From 252ec7194a32e08f2eb182f72befe044487ac86b Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Mon, 14 Jul 2025 14:40:00 +0000 Subject: [PATCH 1/7] Better support for ECDSA signatures in ABI arguments --- packages/cashscript/src/Argument.ts | 16 +++--- .../cashscript/test/e2e/HodlVault.test.ts | 50 +++++++++++++++++++ .../cashscript/test/fixture/PriceOracle.ts | 8 ++- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/cashscript/src/Argument.ts b/packages/cashscript/src/Argument.ts index 915fe84d..5913e82f 100644 --- a/packages/cashscript/src/Argument.ts +++ b/packages/cashscript/src/Argument.ts @@ -59,16 +59,20 @@ export function encodeFunctionArgument(argument: FunctionArgument, typeStr: stri throw Error(`Value for type ${type} should be a Uint8Array or hex string`); } - // Redefine SIG as a bytes65 so it is included in the size checks below - // Note that ONLY Schnorr signatures are accepted + // Redefine SIG as a bytes65 (Schnorr) or bytes71, bytes72 (ECDSA) if (type === PrimitiveType.SIG && argument.byteLength !== 0) { - type = new BytesType(65); + if (![65, 71, 72].includes(argument.byteLength)) { + throw new TypeError(`bytes${argument.byteLength}`, type); + } + type = new BytesType(argument.byteLength); } - // Redefine DATASIG as a bytes64 so it is included in the size checks below - // Note that ONLY Schnorr signatures are accepted + // Redefine DATASIG as a bytes64 (Schnorr) or bytes70 (ECDSA) so it is included in the size checks below if (type === PrimitiveType.DATASIG && argument.byteLength !== 0) { - type = new BytesType(64); + if (![64, 70].includes(argument.byteLength)) { + throw new TypeError(`bytes${argument.byteLength}`, type); + } + type = new BytesType(argument.byteLength); } // Bounded bytes types require a correctly sized argument diff --git a/packages/cashscript/test/e2e/HodlVault.test.ts b/packages/cashscript/test/e2e/HodlVault.test.ts index f9ef0a92..af88689c 100644 --- a/packages/cashscript/test/e2e/HodlVault.test.ts +++ b/packages/cashscript/test/e2e/HodlVault.test.ts @@ -5,6 +5,8 @@ import { ElectrumNetworkProvider, Network, TransactionBuilder, + SignatureAlgorithm, + HashType, } from '../../src/index.js'; import { alicePriv, @@ -16,6 +18,7 @@ import { gatherUtxos, getTxOutputs } from '../test-util.js'; import { FailedRequireError } from '../../src/Errors.js'; import artifact from '../fixture/hodl_vault.artifact.js'; import { randomUtxo } from '../../src/utils.js'; +import { placeholder } from '@cashscript/utils'; describe('HodlVault', () => { const provider = process.env.TESTS_USE_MOCKNET @@ -94,5 +97,52 @@ describe('HodlVault', () => { const txOutputs = getTxOutputs(tx); expect(txOutputs).toEqual(expect.arrayContaining([{ to, amount }])); }); + + it('should succeed when price is high enough, ECDSA sig and datasig', async () => { + // given + const message = oracle.createMessage(100000n, 30000n); + const oracleSig = oracle.signMessage(message, SignatureAlgorithm.ECDSA); + const to = hodlVault.address; + const amount = 10000n; + const { utxos, changeAmount } = gatherUtxos(await hodlVault.getUtxos(), { amount, fee: 2000n }); + + const signatureTemplate = new SignatureTemplate(alicePriv, HashType.SIGHASH_ALL, SignatureAlgorithm.ECDSA); + + // when + const tx = await new TransactionBuilder({ provider }) + .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, oracleSig, message)) + .addOutput({ to: to, amount: amount }) + .addOutput({ to: to, amount: changeAmount }) + .setLocktime(100_000) + .send(); + + // then + const txOutputs = getTxOutputs(tx); + expect(txOutputs).toEqual(expect.arrayContaining([{ to, amount }])); + }); + + it('should fail to accept wrong signature lengths', async () => { + // given + const message = oracle.createMessage(100000n, 30000n); + const oracleSig = oracle.signMessage(message, SignatureAlgorithm.ECDSA); + const to = hodlVault.address; + const amount = 10000n; + const { utxos, changeAmount } = gatherUtxos(await hodlVault.getUtxos(), { amount, fee: 2000n }); + + expect(() => new TransactionBuilder({ provider }) + .addInputs(utxos, hodlVault.unlock.spend(placeholder(100), oracleSig, message)) + .addOutput({ to: to, amount: amount }) + .addOutput({ to: to, amount: changeAmount }) + .setLocktime(100_000) + .send()).toThrow("Found type 'bytes100' where type 'sig' was expected"); + + const signatureTemplate = new SignatureTemplate(alicePriv, HashType.SIGHASH_ALL, SignatureAlgorithm.ECDSA); + expect(() => new TransactionBuilder({ provider }) + .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, placeholder(100), message)) + .addOutput({ to: to, amount: amount }) + .addOutput({ to: to, amount: changeAmount }) + .setLocktime(100_000) + .send()).toThrow("Found type 'bytes100' where type 'datasig' was expected"); + }); }); }); diff --git a/packages/cashscript/test/fixture/PriceOracle.ts b/packages/cashscript/test/fixture/PriceOracle.ts index eb174099..f8643ac4 100644 --- a/packages/cashscript/test/fixture/PriceOracle.ts +++ b/packages/cashscript/test/fixture/PriceOracle.ts @@ -1,5 +1,6 @@ import { padMinimallyEncodedVmNumber, flattenBinArray, secp256k1 } from '@bitauth/libauth'; import { encodeInt, sha256 } from '@cashscript/utils'; +import { SignatureAlgorithm } from '../../src/index.js'; export class PriceOracle { constructor(public privateKey: Uint8Array) {} @@ -12,8 +13,11 @@ export class PriceOracle { return flattenBinArray([encodedBlockHeight, encodedBchUsdPrice]); } - signMessage(message: Uint8Array): Uint8Array { - const signature = secp256k1.signMessageHashSchnorr(this.privateKey, sha256(message)); + signMessage(message: Uint8Array, signatureAlgorithm: SignatureAlgorithm = SignatureAlgorithm.SCHNORR): Uint8Array { + const signature = signatureAlgorithm === SignatureAlgorithm.SCHNORR ? + secp256k1.signMessageHashSchnorr(this.privateKey, sha256(message)) : + secp256k1.signMessageHashDER(this.privateKey, sha256(message)); + if (typeof signature === 'string') throw new Error(); return signature; } From 8a1e1125f03f49033e25feb9c110b0eca1d539a5 Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Sat, 6 Sep 2025 12:53:59 +0000 Subject: [PATCH 2/7] Add bytes70 to ecdsa signature length check --- packages/cashscript/src/Argument.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cashscript/src/Argument.ts b/packages/cashscript/src/Argument.ts index 5913e82f..7550176c 100644 --- a/packages/cashscript/src/Argument.ts +++ b/packages/cashscript/src/Argument.ts @@ -59,9 +59,9 @@ export function encodeFunctionArgument(argument: FunctionArgument, typeStr: stri throw Error(`Value for type ${type} should be a Uint8Array or hex string`); } - // Redefine SIG as a bytes65 (Schnorr) or bytes71, bytes72 (ECDSA) + // Redefine SIG as a bytes65 (Schnorr) or bytes70, bytes71, bytes72 (ECDSA) if (type === PrimitiveType.SIG && argument.byteLength !== 0) { - if (![65, 71, 72].includes(argument.byteLength)) { + if (![65, 70, 71, 72].includes(argument.byteLength)) { throw new TypeError(`bytes${argument.byteLength}`, type); } type = new BytesType(argument.byteLength); From e467e57affe86a3b849414d3c6d8675d46b0d8f6 Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Thu, 11 Sep 2025 10:57:42 +0000 Subject: [PATCH 3/7] Add extra tests to further increase coverage --- packages/cashscript/test/e2e/HodlVault.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/cashscript/test/e2e/HodlVault.test.ts b/packages/cashscript/test/e2e/HodlVault.test.ts index af88689c..e7a7012f 100644 --- a/packages/cashscript/test/e2e/HodlVault.test.ts +++ b/packages/cashscript/test/e2e/HodlVault.test.ts @@ -129,6 +129,7 @@ describe('HodlVault', () => { const amount = 10000n; const { utxos, changeAmount } = gatherUtxos(await hodlVault.getUtxos(), { amount, fee: 2000n }); + // sig: improper length expect(() => new TransactionBuilder({ provider }) .addInputs(utxos, hodlVault.unlock.spend(placeholder(100), oracleSig, message)) .addOutput({ to: to, amount: amount }) @@ -136,6 +137,15 @@ describe('HodlVault', () => { .setLocktime(100_000) .send()).toThrow("Found type 'bytes100' where type 'sig' was expected"); + // sig: proper length but malformed + await expect(new TransactionBuilder({ provider }) + .addInputs(utxos, hodlVault.unlock.spend(placeholder(70), oracleSig, message)) + .addOutput({ to: to, amount: amount }) + .addOutput({ to: to, amount: changeAmount }) + .setLocktime(100_000) + .send()).rejects.toThrow(); + + // datasig: improper length const signatureTemplate = new SignatureTemplate(alicePriv, HashType.SIGHASH_ALL, SignatureAlgorithm.ECDSA); expect(() => new TransactionBuilder({ provider }) .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, placeholder(100), message)) @@ -143,6 +153,14 @@ describe('HodlVault', () => { .addOutput({ to: to, amount: changeAmount }) .setLocktime(100_000) .send()).toThrow("Found type 'bytes100' where type 'datasig' was expected"); + + // datasig: proper length but malformed + await expect(new TransactionBuilder({ provider }) + .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, placeholder(64), message)) + .addOutput({ to: to, amount: amount }) + .addOutput({ to: to, amount: changeAmount }) + .setLocktime(100_000) + .send()).rejects.toThrow(); }); }); }); From 4d10da69e54f09b0365e8ad465a8987789683334 Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Thu, 11 Sep 2025 11:43:00 +0000 Subject: [PATCH 4/7] Add a test with precomputed ecdsa signature --- .../cashscript/test/e2e/HodlVault.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/cashscript/test/e2e/HodlVault.test.ts b/packages/cashscript/test/e2e/HodlVault.test.ts index e7a7012f..2777ea79 100644 --- a/packages/cashscript/test/e2e/HodlVault.test.ts +++ b/packages/cashscript/test/e2e/HodlVault.test.ts @@ -121,6 +121,37 @@ describe('HodlVault', () => { expect(txOutputs).toEqual(expect.arrayContaining([{ to, amount }])); }); + it('should succeed with precomputed ECDSA signature', async () => { + const cleanProvider = new MockNetworkProvider(); + const contract = new Contract(artifact, [alicePub, oraclePub, 99000n, 30000n], { provider: cleanProvider }); + cleanProvider.addUtxo(contract.address, { + satoshis: 100000n, + txid: '11'.repeat(32), + vout: 0, + }); + // given + const message = oracle.createMessage(100000n, 30000n); + const oracleSig = oracle.signMessage(message, SignatureAlgorithm.ECDSA); + const to = contract.address; + const amount = 10000n; + const { utxos, changeAmount } = gatherUtxos(await contract.getUtxos(), { amount, fee: 2000n }); + + // @ts-ignore + const signature = '3045022100aa004a425c0c911594c0333164f990c760991b7f84272f35d98c9c6617d9c53602207dfe4729224d4e61496dff11963982cf79f05d623a6e4004b5f50b7cefa7175241'; + + // when + const tx = await new TransactionBuilder({ provider: cleanProvider }) + .addInputs(utxos, contract.unlock.spend(signature, oracleSig, message)) + .addOutput({ to: to, amount: amount }) + .addOutput({ to: to, amount: changeAmount }) + .setLocktime(100_000) + .send(); + + // then + const txOutputs = getTxOutputs(tx); + expect(txOutputs).toEqual(expect.arrayContaining([{ to, amount }])); + }); + it('should fail to accept wrong signature lengths', async () => { // given const message = oracle.createMessage(100000n, 30000n); From 0ebac8cc35f8ed7f3420a27e5bfe651977655099 Mon Sep 17 00:00:00 2001 From: mainnet-pat Date: Thu, 11 Sep 2025 11:54:02 +0000 Subject: [PATCH 5/7] Add all signature lengths in SIG and DATASIG length checks --- packages/cashscript/src/Argument.ts | 8 ++++---- packages/cashscript/test/e2e/HodlVault.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cashscript/src/Argument.ts b/packages/cashscript/src/Argument.ts index 7550176c..31e7ca8a 100644 --- a/packages/cashscript/src/Argument.ts +++ b/packages/cashscript/src/Argument.ts @@ -59,17 +59,17 @@ export function encodeFunctionArgument(argument: FunctionArgument, typeStr: stri throw Error(`Value for type ${type} should be a Uint8Array or hex string`); } - // Redefine SIG as a bytes65 (Schnorr) or bytes70, bytes71, bytes72 (ECDSA) + // Redefine SIG as a bytes65 (Schnorr) or bytes71, bytes72, bytes73 (ECDSA) if (type === PrimitiveType.SIG && argument.byteLength !== 0) { - if (![65, 70, 71, 72].includes(argument.byteLength)) { + if (![65, 71, 72, 73].includes(argument.byteLength)) { throw new TypeError(`bytes${argument.byteLength}`, type); } type = new BytesType(argument.byteLength); } - // Redefine DATASIG as a bytes64 (Schnorr) or bytes70 (ECDSA) so it is included in the size checks below + // Redefine DATASIG as a bytes64 (Schnorr) or bytes70, bytes71, bytes72 (ECDSA) so it is included in the size checks below if (type === PrimitiveType.DATASIG && argument.byteLength !== 0) { - if (![64, 70].includes(argument.byteLength)) { + if (![64, 70, 71, 72].includes(argument.byteLength)) { throw new TypeError(`bytes${argument.byteLength}`, type); } type = new BytesType(argument.byteLength); diff --git a/packages/cashscript/test/e2e/HodlVault.test.ts b/packages/cashscript/test/e2e/HodlVault.test.ts index 2777ea79..1fcbf438 100644 --- a/packages/cashscript/test/e2e/HodlVault.test.ts +++ b/packages/cashscript/test/e2e/HodlVault.test.ts @@ -170,7 +170,7 @@ describe('HodlVault', () => { // sig: proper length but malformed await expect(new TransactionBuilder({ provider }) - .addInputs(utxos, hodlVault.unlock.spend(placeholder(70), oracleSig, message)) + .addInputs(utxos, hodlVault.unlock.spend(placeholder(71), oracleSig, message)) .addOutput({ to: to, amount: amount }) .addOutput({ to: to, amount: changeAmount }) .setLocktime(100_000) From 9bdab17ccbae08b5c949e78cf182cc0f61351187 Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 23 Sep 2025 10:54:00 +0200 Subject: [PATCH 6/7] Small updates to tests & explicit zero handling for sigs --- packages/cashscript/src/Argument.ts | 12 ++--- .../cashscript/test/e2e/HodlVault.test.ts | 47 +++++++++++-------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/packages/cashscript/src/Argument.ts b/packages/cashscript/src/Argument.ts index 31e7ca8a..9871ac99 100644 --- a/packages/cashscript/src/Argument.ts +++ b/packages/cashscript/src/Argument.ts @@ -59,17 +59,17 @@ export function encodeFunctionArgument(argument: FunctionArgument, typeStr: stri throw Error(`Value for type ${type} should be a Uint8Array or hex string`); } - // Redefine SIG as a bytes65 (Schnorr) or bytes71, bytes72, bytes73 (ECDSA) - if (type === PrimitiveType.SIG && argument.byteLength !== 0) { - if (![65, 71, 72, 73].includes(argument.byteLength)) { + // Redefine SIG as a bytes65 (Schnorr) or bytes71, bytes72, bytes73 (ECDSA) or bytes0 (for NULLFAIL) + if (type === PrimitiveType.SIG) { + if (![0, 65, 71, 72, 73].includes(argument.byteLength)) { throw new TypeError(`bytes${argument.byteLength}`, type); } type = new BytesType(argument.byteLength); } - // Redefine DATASIG as a bytes64 (Schnorr) or bytes70, bytes71, bytes72 (ECDSA) so it is included in the size checks below - if (type === PrimitiveType.DATASIG && argument.byteLength !== 0) { - if (![64, 70, 71, 72].includes(argument.byteLength)) { + // Redefine DATASIG as a bytes64 (Schnorr) or bytes70, bytes71, bytes72 (ECDSA) or bytes0 (for NULLFAIL) + if (type === PrimitiveType.DATASIG) { + if (![0, 64, 70, 71, 72].includes(argument.byteLength)) { throw new TypeError(`bytes${argument.byteLength}`, type); } type = new BytesType(argument.byteLength); diff --git a/packages/cashscript/test/e2e/HodlVault.test.ts b/packages/cashscript/test/e2e/HodlVault.test.ts index b17dbe49..922cc200 100644 --- a/packages/cashscript/test/e2e/HodlVault.test.ts +++ b/packages/cashscript/test/e2e/HodlVault.test.ts @@ -14,7 +14,7 @@ import { oracle, oraclePub, } from '../fixture/vars.js'; -import { gatherUtxos, getTxOutputs } from '../test-util.js'; +import { gatherUtxos, getTxOutputs, itOrSkip } from '../test-util.js'; import { FailedRequireError } from '../../src/Errors.js'; import artifact from '../fixture/hodl_vault.artifact.js'; import { randomUtxo } from '../../src/utils.js'; @@ -122,7 +122,8 @@ describe('HodlVault', () => { expect(txOutputs).toEqual(expect.arrayContaining([{ to, amount }])); }); - it('should succeed with precomputed ECDSA signature', async () => { + itOrSkip(!Boolean(process.env.TESTS_USE_CHIPNET), 'should succeed with precomputed ECDSA signature', async () => { + // given const cleanProvider = new MockNetworkProvider(); const contract = new Contract(artifact, [alicePub, oraclePub, 99000n, 30000n], { provider: cleanProvider }); cleanProvider.addUtxo(contract.address, { @@ -130,14 +131,11 @@ describe('HodlVault', () => { txid: '11'.repeat(32), vout: 0, }); - // given const message = oracle.createMessage(100000n, 30000n); const oracleSig = oracle.signMessage(message, SignatureAlgorithm.ECDSA); const to = contract.address; const amount = 10000n; const { utxos, changeAmount } = gatherUtxos(await contract.getUtxos(), { amount, fee: 2000n }); - - // @ts-ignore const signature = '3045022100aa004a425c0c911594c0333164f990c760991b7f84272f35d98c9c6617d9c53602207dfe4729224d4e61496dff11963982cf79f05d623a6e4004b5f50b7cefa7175241'; // when @@ -161,38 +159,49 @@ describe('HodlVault', () => { const amount = 10000n; const { utxos, changeAmount } = gatherUtxos(await hodlVault.getUtxos(), { amount, fee: 2000n }); - // sig: improper length - expect(() => new TransactionBuilder({ provider }) - .addInputs(utxos, hodlVault.unlock.spend(placeholder(100), oracleSig, message)) + // sig: unlocker should throw when given an improper length + expect(() => hodlVault.unlock.spend(placeholder(100), oracleSig, message)).toThrow("Found type 'bytes100' where type 'sig' was expected"); + + // sig: unlocker should not throw when given a proper length, but transaction should fail on invalid sig + // Note that this fails with "FailedTransactionEvaluationError" because an invalid signature encoding is NOT a failed + // require statement + await expect(new TransactionBuilder({ provider }) + .addInputs(utxos, hodlVault.unlock.spend(placeholder(71), oracleSig, message)) .addOutput({ to: to, amount: amount }) .addOutput({ to: to, amount: changeAmount }) .setLocktime(100_000) - .send()).toThrow("Found type 'bytes100' where type 'sig' was expected"); + .send()).rejects.toThrow('HodlVault.cash:27 Error in transaction at input 0 in contract HodlVault.cash at line 27'); - // sig: proper length but malformed + // sig: unlocker should not throw when given an empty byte array, but transaction should fail on require statement + // Note that this fails with "FailedRequireError" because a zero-length signature IS a failed require statement await expect(new TransactionBuilder({ provider }) - .addInputs(utxos, hodlVault.unlock.spend(placeholder(71), oracleSig, message)) + .addInputs(utxos, hodlVault.unlock.spend(placeholder(0), oracleSig, message)) .addOutput({ to: to, amount: amount }) .addOutput({ to: to, amount: changeAmount }) .setLocktime(100_000) - .send()).rejects.toThrow(); + .send()).rejects.toThrow('HodlVault.cash:27 Require statement failed at input 0 in contract HodlVault.cash at line 27'); - // datasig: improper length + // datasig: unlocker should throw when given an improper length const signatureTemplate = new SignatureTemplate(alicePriv, HashType.SIGHASH_ALL, SignatureAlgorithm.ECDSA); - expect(() => new TransactionBuilder({ provider }) - .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, placeholder(100), message)) + expect(() => hodlVault.unlock.spend(signatureTemplate, placeholder(100), message)).toThrow("Found type 'bytes100' where type 'datasig' was expected"); + + // datasig: unlocker should not throw when given a proper length, but transaction should fail on invalid sig + // TODO: This somehow fails with "FailedRequireError" instead of "FailedTransactionEvaluationError", check why + await expect(new TransactionBuilder({ provider }) + .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, placeholder(64), message)) .addOutput({ to: to, amount: amount }) .addOutput({ to: to, amount: changeAmount }) .setLocktime(100_000) - .send()).toThrow("Found type 'bytes100' where type 'datasig' was expected"); + .send()).rejects.toThrow('HodlVault.cash:26 Require statement failed at input 0 in contract HodlVault.cash at line 26'); - // datasig: proper length but malformed + // datasig: unlocker should not throw when given an empty byte array, but transaction should fail on require statement + // Note that this fails with "FailedRequireError" because a zero-length signature IS a failed require statement await expect(new TransactionBuilder({ provider }) - .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, placeholder(64), message)) + .addInputs(utxos, hodlVault.unlock.spend(signatureTemplate, placeholder(0), message)) .addOutput({ to: to, amount: amount }) .addOutput({ to: to, amount: changeAmount }) .setLocktime(100_000) - .send()).rejects.toThrow(); + .send()).rejects.toThrow('HodlVault.cash:26 Require statement failed at input 0 in contract HodlVault.cash at line 26'); }); }); }); From a875fd1dbb6304cf189a5da8a9d734136ce2e36b Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 23 Sep 2025 10:56:48 +0200 Subject: [PATCH 7/7] Update release notes --- website/docs/releases/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/releases/release-notes.md b/website/docs/releases/release-notes.md index 9576a66d..7533629c 100644 --- a/website/docs/releases/release-notes.md +++ b/website/docs/releases/release-notes.md @@ -10,6 +10,7 @@ title: Release Notes - :boom: **BREAKING**: Remove deprecated "old" transaction builder (`contract.functions`). - :boom: **BREAKING**: No longer seed the MockNetworkProvider with any test UTXOs. - :sparkles: Add a configurable `vmTarget` option to `MockNetworkProvider`. +- :sparkles: Add support for ECDSA signatures in contract unlockers for `sig` and `datasig` parameters. - :hammer_and_wrench: Improve libauth template generation. ## v0.11.5