diff --git a/contrib/teamcity/build-configurations.yml b/contrib/teamcity/build-configurations.yml --- a/contrib/teamcity/build-configurations.yml +++ b/contrib/teamcity/build-configurations.yml @@ -65,6 +65,8 @@ # the mock-chronik-client library, otherwise unset # - DEPENDS_ECASH_LIB_WASM: "true" if these tests require ecash-lib-wasm # to be built, otherwise unset + # - DEPENDS_CHRONIK_CLIENT: "true" if these tests require chronik-client + # to be build, otherwise unset js-mocha: artifacts: coverage.tar.gz: coverage.tar.gz @@ -97,6 +99,14 @@ ./build-wasm.sh fi + if [ -z "${DEPENDS_CHRONIK_CLIENT+x}" ] ; then + echo "Test does not depend on chronik-client" + else + echo "Test depends on chronik-client. Building TypeScript..." + pushd "${TOPLEVEL}/modules/chronik-client" + npm ci + fi + pushd "${TOPLEVEL}/${JS_PROJECT_ROOT}" MOCHA_JUNIT_DIR="test_results" @@ -158,6 +168,23 @@ PROJECT_NAME="$(basename ${JS_PROJECT_ROOT})" TEST_SUITE_NAME="$(project_to_suite ${PROJECT_NAME})" + # Build ecash-lib-wasm for ecash-lib's WebAssembly part + if [ -z "${DEPENDS_ECASH_LIB_WASM+x}" ] ; then + echo "Test does not depend on ecash-lib-wasm, skipping" + else + echo "Test depends on ecash-lib-wasm. Building WebAssembly..." + pushd "${TOPLEVEL}/modules/ecash-lib-wasm" + ./build-wasm.sh + fi + + if [ -z "${DEPENDS_CHRONIK_CLIENT+x}" ] ; then + echo "Test does not depend on chronik-client" + else + echo "Test depends on chronik-client. Building TypeScript..." + pushd "${TOPLEVEL}/modules/chronik-client" + npm ci + fi + pushd "${TOPLEVEL}/${JS_PROJECT_ROOT}" MOCHA_JUNIT_DIR="test_results" @@ -836,9 +863,26 @@ JS_PROJECT_ROOT: modules/ecash-lib RUN_NPM_BUILD: "true" DEPENDS_ECASH_LIB_WASM: "true" + DEPENDS_CHRONIK_CLIENT: "true" templates: - js-mocha + ecash-lib-integration-tests: + cmake_flags: + - '-DBUILD_BITCOIN_CHRONIK=ON' + targets: + - - all + runOnDiffRegex: + - chronik/ + - modules/chronik-client/ + - modules/ecash-lib/ + env: + JS_PROJECT_ROOT: modules/ecash-lib + DEPENDS_ECASH_LIB_WASM: "true" + DEPENDS_CHRONIK_CLIENT: "true" + templates: + - js-mocha-integration-tests + chronik-client-integration-tests: cmake_flags: - '-DBUILD_BITCOIN_CHRONIK=ON' diff --git a/modules/ecash-lib/package-lock.json b/modules/ecash-lib/package-lock.json --- a/modules/ecash-lib/package-lock.json +++ b/modules/ecash-lib/package-lock.json @@ -1,18 +1,20 @@ { "name": "ecash-lib", - "version": "0.1.0rc", + "version": "0.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ecash-lib", - "version": "0.1.0rc", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", "@types/chai": "^4.3.14", "@types/mocha": "^10.0.6", + "@types/node": "^20.12.7", "chai": "^5.1.0", + "chronik-client": "file:../chronik-client", "eslint-plugin-header": "^3.1.1", "mocha": "^10.4.0", "mocha-junit-reporter": "^2.2.1", @@ -24,6 +26,40 @@ "typescript-eslint": "^7.6.0" } }, + "../chronik-client": { + "version": "0.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ws": "^8.2.1", + "axios": "^1.6.3", + "ecashaddrjs": "^1.5.6", + "isomorphic-ws": "^4.0.1", + "protobufjs": "^6.8.8", + "ws": "^8.3.0" + }, + "devDependencies": { + "@types/chai": "^4.2.22", + "@types/chai-as-promised": "^7.1.4", + "@types/mocha": "^9.0.0", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "eslint": "^8.37.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-header": "^3.1.1", + "mocha": "^9.1.3", + "mocha-junit-reporter": "^2.2.0", + "mocha-suppress-logs": "^0.3.1", + "prettier": "^2.5.1", + "prettier-plugin-organize-imports": "^2.3.4", + "ts-node": "^10.4.0", + "ts-proto": "^1.92.1", + "typedoc": "^0.22.10", + "typescript": "^4.5.2" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -1236,7 +1272,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1869,6 +1904,15 @@ "node": ">=12" } }, + "node_modules/chai/node_modules/check-error": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.0.0.tgz", + "integrity": "sha512-tjLAOBHKVxtPoHe/SA7kNOMvhCRdCJ3vETdeY0RuAc9popf+hyaSV6ZEg9hr4cpWF7jmo/JSWEnLDrnijS9Tog==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1906,15 +1950,6 @@ "node": "*" } }, - "node_modules/check-error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.0.0.tgz", - "integrity": "sha512-tjLAOBHKVxtPoHe/SA7kNOMvhCRdCJ3vETdeY0RuAc9popf+hyaSV6ZEg9hr4cpWF7jmo/JSWEnLDrnijS9Tog==", - "dev": true, - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -1942,6 +1977,10 @@ "fsevents": "~2.3.2" } }, + "node_modules/chronik-client": { + "resolved": "../chronik-client", + "link": true + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -4530,8 +4569,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/update-browserslist-db": { "version": "1.0.13", @@ -5571,7 +5609,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dev": true, - "peer": true, "requires": { "undici-types": "~5.26.4" } @@ -5986,6 +6023,14 @@ "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" + }, + "dependencies": { + "check-error": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.0.0.tgz", + "integrity": "sha512-tjLAOBHKVxtPoHe/SA7kNOMvhCRdCJ3vETdeY0RuAc9popf+hyaSV6ZEg9hr4cpWF7jmo/JSWEnLDrnijS9Tog==", + "dev": true + } } }, "chalk": { @@ -6015,12 +6060,6 @@ "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", "dev": true }, - "check-error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.0.0.tgz", - "integrity": "sha512-tjLAOBHKVxtPoHe/SA7kNOMvhCRdCJ3vETdeY0RuAc9popf+hyaSV6ZEg9hr4cpWF7jmo/JSWEnLDrnijS9Tog==", - "dev": true - }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -6037,6 +6076,36 @@ "readdirp": "~3.6.0" } }, + "chronik-client": { + "version": "file:../chronik-client", + "requires": { + "@types/chai": "^4.2.22", + "@types/chai-as-promised": "^7.1.4", + "@types/mocha": "^9.0.0", + "@types/ws": "^8.2.1", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "axios": "^1.6.3", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "ecashaddrjs": "^1.5.6", + "eslint": "^8.37.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-header": "^3.1.1", + "isomorphic-ws": "^4.0.1", + "mocha": "^9.1.3", + "mocha-junit-reporter": "^2.2.0", + "mocha-suppress-logs": "^0.3.1", + "prettier": "^2.5.1", + "prettier-plugin-organize-imports": "^2.3.4", + "protobufjs": "^6.8.8", + "ts-node": "^10.4.0", + "ts-proto": "^1.92.1", + "typedoc": "^0.22.10", + "typescript": "^4.5.2", + "ws": "^8.3.0" + } + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -7942,8 +8011,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "peer": true + "dev": true }, "update-browserslist-db": { "version": "1.0.13", diff --git a/modules/ecash-lib/package.json b/modules/ecash-lib/package.json --- a/modules/ecash-lib/package.json +++ b/modules/ecash-lib/package.json @@ -7,6 +7,7 @@ "scripts": { "build": "tsc && cp -r ./src/ffi ./dist", "test": "mocha --import=tsx ./src/*.test.ts ./src/**/*.test.ts", + "integration-tests": "mocha --import=tsx ./tests/*.test.ts", "coverage": "nyc npm run test", "junit": "npm run test --reporter mocha-junit-reporter" }, @@ -32,6 +33,7 @@ "@types/mocha": "^10.0.6", "@types/node": "^20.12.7", "chai": "^5.1.0", + "chronik-client": "file:../chronik-client", "eslint-plugin-header": "^3.1.1", "mocha": "^10.4.0", "mocha-junit-reporter": "^2.2.1", diff --git a/modules/ecash-lib/src/index.ts b/modules/ecash-lib/src/index.ts --- a/modules/ecash-lib/src/index.ts +++ b/modules/ecash-lib/src/index.ts @@ -9,6 +9,7 @@ export * from './script.js'; export * from './sigHashType.js'; export * from './tx.js'; +export * from './txBuilder.js'; export * from './unsignedTx.js'; export * from './io/bytes.js'; export * from './io/hex.js'; diff --git a/modules/ecash-lib/src/script.ts b/modules/ecash-lib/src/script.ts --- a/modules/ecash-lib/src/script.ts +++ b/modules/ecash-lib/src/script.ts @@ -55,6 +55,11 @@ return new ScriptOpIter(new Bytes(this.bytecode)); } + /** Create a deep copy of this Script */ + public copy(): Script { + return new Script(new Uint8Array(this.bytecode)); + } + /** * Find the n-th OP_CODESEPARATOR (0-based) and cut out the bytecode * following it. Required for signing BIP143 scripts that have an diff --git a/modules/ecash-lib/src/tx.test.ts b/modules/ecash-lib/src/tx.test.ts --- a/modules/ecash-lib/src/tx.test.ts +++ b/modules/ecash-lib/src/tx.test.ts @@ -22,6 +22,41 @@ 'efbeadde000078563412', ); + // inputs have defaults + checkTx( + new Tx({ + version: 1, + inputs: [ + { + prevOut: { + txid: '0123456789abcdef99887766554433220000000000000000f1e2d3c4b5a69788', + outIdx: 0xdeadbeef, + }, + }, + { + prevOut: { + txid: new Uint8Array([...Array(32).keys()]), + outIdx: 0x76757473, + }, + }, + ], + outputs: [], + locktime: 0, + }), + '01000000' + // version + '02' + // num inputs + '8897a6b5c4d3e2f100000000000000002233445566778899efcdab8967452301' + + 'efbeadde' + // 0th input outpoint + '00' + // script + 'ffffffff' + // sequence + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f' + + '73747576' + // 1st input outpoint + '00' + // script + 'ffffffff' + // sequence + '00' + // num outputs + '00000000', // locktime + ); + checkTx( new Tx({ version: 0xfacefeed, diff --git a/modules/ecash-lib/src/tx.ts b/modules/ecash-lib/src/tx.ts --- a/modules/ecash-lib/src/tx.ts +++ b/modules/ecash-lib/src/tx.ts @@ -9,6 +9,16 @@ import { WriterLength } from './io/writerlength.js'; import { Script } from './script.js'; +/** + * Default value for nSequence of inputs if left undefined; this opts out of + * BIP68 relative lock-time, and if all inputs have this value, nLockTime is + * disabled, too. + * + * This is chosen as the default as it's the default in the node too, + * see CTxIn in /src/primitives/transaction.h. + **/ +export const DEFAULT_SEQUENCE = 0xffffffff; + /** COutPoint, pointing to a coin being spent. */ export interface OutPoint { /** @@ -24,10 +34,10 @@ export interface TxInput { /** Points to an output being spent. */ prevOut: OutPoint; - /** scriptSig unlocking the output. */ - script: Script; - /** nSequence. */ - sequence: number; + /** scriptSig unlocking the output, defaults to the empty Script. */ + script?: Script; + /** nSequence, defaults to 0xffffffff if unspecified. */ + sequence?: number; /** Sign data required to sign an input */ signData?: SignData; } @@ -118,8 +128,8 @@ /** Write a TxInput to a Writer */ export function writeTxInput(input: TxInput, writer: Writer): void { writeOutPoint(input.prevOut, writer); - input.script.writeWithSize(writer); - writer.putU32(input.sequence); + (input.script ?? new Script()).writeWithSize(writer); + writer.putU32(input.sequence ?? DEFAULT_SEQUENCE); } /** Write a TxOutput to a Writer */ @@ -127,3 +137,31 @@ writer.putU64(output.value); output.script.writeWithSize(writer); } + +/** Create a deep copy of the TxInput */ +export function copyTxInput(input: TxInput): TxInput { + return { + prevOut: { + txid: + typeof input.prevOut.txid === 'string' + ? input.prevOut.txid + : new Uint8Array(input.prevOut.txid), + outIdx: input.prevOut.outIdx, + }, + script: input.script?.copy(), + sequence: input.sequence, + signData: input.signData && { + value: input.signData.value, + outputScript: input.signData.outputScript?.copy(), + redeemScript: input.signData.redeemScript?.copy(), + }, + }; +} + +/** Create a deep copy of the TxOutput */ +export function copyTxOutput(output: TxOutput): TxOutput { + return { + value: output.value, + script: output.script.copy(), + }; +} diff --git a/modules/ecash-lib/src/txBuilder.ts b/modules/ecash-lib/src/txBuilder.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/src/txBuilder.ts @@ -0,0 +1,240 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { Ecc, EccDummy } from './ecc.js'; +import { sha256d } from './hash.js'; +import { WriterBytes } from './io/writerbytes.js'; +import { pushBytesOp } from './op.js'; +import { Script } from './script.js'; +import { SigHashType } from './sigHashType.js'; +import { Tx, TxInput, TxOutput, copyTxInput, copyTxOutput } from './tx.js'; +import { UnsignedTx, UnsignedTxInput } from './unsignedTx.js'; + +/** + * Function that contains all the required data to sign a given `input` and + * return the scriptSig. + * + * Use it by attaching a `Signatory` to a TxBuilderInput, e.g. like this for a + * P2PKH input: + * ```ts + * new TxBuilder({ + * inputs: [{ + * input: { prevOut: ... }, + * signatory: P2PKHSignatory(sk, pk, ALL_BIP143), + * }], + * ... + * }) + * ``` + **/ +export type Signatory = (ecc: Ecc, input: UnsignedTxInput) => Script; + +/** Builder input that bundles all the data required to sign a TxInput */ +export interface TxBuilderInput { + input: TxInput; + signatory?: Signatory; +} + +/** + * Output that can either be: + * - `TxOutput`: A full output with a fixed sats amount + * - `Script`: A Script which will receive the leftover sats after fees. + * Leftover usually is the change the sender gets back from providing more + * sats than needed. + */ +export type TxBuilderOutput = TxOutput | Script; + +/** Class that can be used to build and sign txs. */ +export class TxBuilder { + /** nVersion of the resulting Tx */ + public version: number; + /** Inputs that will be signed by the buider */ + public inputs: TxBuilderInput[]; + /** + * Outputs of the tx, can specify a single leftover (i.e. change) output as + * a Script. + **/ + public outputs: TxBuilderOutput[]; + /** nLockTime of the resulting Tx */ + public locktime: number; + + public constructor(params?: { + version?: number; + inputs?: TxBuilderInput[]; + outputs?: TxBuilderOutput[]; + locktime?: number; + }) { + this.version = params?.version ?? 1; + this.inputs = params?.inputs ?? []; + this.outputs = params?.outputs ?? []; + this.locktime = params?.locktime ?? 0; + } + + /** Calculte sum of all sats coming in, or `undefined` if some unknown. */ + private inputSum(): bigint | undefined { + let inputSum = 0n; + for (const input of this.inputs) { + if (input.input.signData === undefined) { + return undefined; + } + inputSum += BigInt(input.input.signData.value); + } + return inputSum; + } + + private prepareOutputs(): { + fixedOutputSum: bigint; + leftoverIdx: number | undefined; + outputs: TxOutput[]; + } { + let fixedOutputSum = 0n; + let leftoverIdx: number | undefined = undefined; + let outputs: TxOutput[] = new Array(this.outputs.length); + for (let idx = 0; idx < this.outputs.length; ++idx) { + const builderOutput = this.outputs[idx]; + if (builderOutput instanceof Script) { + if (leftoverIdx !== undefined) { + throw 'Multiple leftover outputs, can at most use one'; + } + leftoverIdx = idx; + outputs[idx] = { + value: 0, // placeholder + script: builderOutput.copy(), + }; + } else { + fixedOutputSum += BigInt(builderOutput.value); + outputs[idx] = copyTxOutput(builderOutput); + } + } + return { fixedOutputSum, leftoverIdx, outputs }; + } + + /** Sign the tx built by this builder and return a Tx */ + public sign(ecc: Ecc, feePerKb?: number, dustLimit?: number): Tx { + const { fixedOutputSum, leftoverIdx, outputs } = this.prepareOutputs(); + const inputs = this.inputs.map(input => copyTxInput(input.input)); + if (leftoverIdx !== undefined) { + const inputSum = this.inputSum(); + if (inputSum === undefined) { + throw new Error( + 'Using a leftover output requires setting SignData.value for all inputs', + ); + } + if (feePerKb === undefined) { + throw new Error( + 'Using a leftover output requires setting feePerKb', + ); + } + if (dustLimit === undefined) { + throw new Error( + 'Using a leftover output requires setting dustLimit', + ); + } + const dummyUnsignedTx = UnsignedTx.dummyFromTx( + new Tx({ + version: this.version, + inputs, + outputs, + locktime: this.locktime, + }), + ); + for (let inputIdx = 0; inputIdx < this.inputs.length; ++inputIdx) { + const signatory = this.inputs[inputIdx].signatory; + const input = inputs[inputIdx]; + if (signatory !== undefined) { + // Must use dummy here because ECDSA sigs could be too small + // for fee calc + input.script = signatory( + new EccDummy(), + new UnsignedTxInput({ + inputIdx, + unsignedTx: dummyUnsignedTx, + }), + ); + } + } + let txSize = dummyUnsignedTx.tx.serSize(); + let txFee = calcTxFee(txSize, feePerKb); + const leftoverValue = inputSum - (fixedOutputSum + txFee); + if (leftoverValue < dustLimit) { + // inputs cannot pay for a dust leftover -> remove & recalc + outputs.splice(leftoverIdx, 1); + dummyUnsignedTx.tx.outputs = outputs; + txSize = dummyUnsignedTx.tx.serSize(); + txFee = calcTxFee(txSize, feePerKb); + } else { + outputs[leftoverIdx].value = leftoverValue; + } + if (inputSum < fixedOutputSum + txFee) { + throw new Error( + `Insufficient input value (${inputSum}): Can only pay for ${ + inputSum - fixedOutputSum + } fees, but ${txFee} required`, + ); + } + } + const unsignedTx = UnsignedTx.fromTx( + new Tx({ + version: this.version, + inputs, + outputs, + locktime: this.locktime, + }), + ); + for (let inputIdx = 0; inputIdx < this.inputs.length; ++inputIdx) { + const signatory = this.inputs[inputIdx].signatory; + const input = inputs[inputIdx]; + if (signatory !== undefined) { + input.script = signatory( + ecc, + new UnsignedTxInput({ + inputIdx, + unsignedTx, + }), + ); + } + } + return unsignedTx.tx; + } +} + +/** Calculate the required tx fee for the given txSize and feePerKb, + * rounding up */ +export function calcTxFee(txSize: number, feePerKb: number): bigint { + return (BigInt(txSize) * BigInt(feePerKb) + 999n) / 1000n; +} + +/** Append the sighash flags to the signature */ +export function flagSignature( + sig: Uint8Array, + sigHashFlags: SigHashType, +): Uint8Array { + const writer = new WriterBytes(sig.length + 1); + writer.putBytes(sig); + writer.putU8(sigHashFlags.toInt() & 0xff); + return writer.data; +} + +/** Signatory for a P2PKH input. Always uses Schnorr signatures */ +export const P2PKHSignatory = ( + sk: Uint8Array, + pk: Uint8Array, + sigHashType: SigHashType, +) => { + return (ecc: Ecc, input: UnsignedTxInput): Script => { + const preimage = input.sigHashPreimage(sigHashType); + const sighash = sha256d(preimage.bytes); + const sig = flagSignature(ecc.schnorrSign(sk, sighash), sigHashType); + return Script.p2pkhSpend(pk, sig); + }; +}; + +/** Signatory for a P2PK input. Always uses Schnorr signatures */ +export const P2PKSignatory = (sk: Uint8Array, sigHashType: SigHashType) => { + return (ecc: Ecc, input: UnsignedTxInput): Script => { + const preimage = input.sigHashPreimage(sigHashType); + const sighash = sha256d(preimage.bytes); + const sig = flagSignature(ecc.schnorrSign(sk, sighash), sigHashType); + return Script.fromOps([pushBytesOp(sig)]); + }; +}; diff --git a/modules/ecash-lib/src/unsignedTx.ts b/modules/ecash-lib/src/unsignedTx.ts --- a/modules/ecash-lib/src/unsignedTx.ts +++ b/modules/ecash-lib/src/unsignedTx.ts @@ -13,7 +13,14 @@ SigHashTypeOutputs, SigHashTypeVariant, } from './sigHashType.js'; -import { SignData, Tx, TxInput, writeOutPoint, writeTxOutput } from './tx.js'; +import { + DEFAULT_SEQUENCE, + SignData, + Tx, + TxInput, + writeOutPoint, + writeTxOutput, +} from './tx.js'; /** An unsigned tx, which helps us build the sighash preimage we need to sign */ export class UnsignedTx { @@ -154,7 +161,7 @@ writeOutPoint(input.prevOut, writer); scriptCode.writeWithSize(writer); writer.putU64(signData.value); - writer.putU32(input.sequence); + writer.putU32(input.sequence ?? DEFAULT_SEQUENCE); writer.putBytes(hashOutputs); writer.putU32(tx.locktime); writer.putU32(sigHashType.toInt()); @@ -213,7 +220,7 @@ function writeSequences(tx: Tx, writer: Writer) { for (const input of tx.inputs) { - writer.putU32(input.sequence); + writer.putU32(input.sequence ?? DEFAULT_SEQUENCE); } } diff --git a/modules/ecash-lib/tests/txBuilder.test.ts b/modules/ecash-lib/tests/txBuilder.test.ts new file mode 100644 --- /dev/null +++ b/modules/ecash-lib/tests/txBuilder.test.ts @@ -0,0 +1,681 @@ +// Copyright (c) 2024 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import { expect, use } from 'chai'; +import { ChildProcess, spawn } from 'node:child_process'; +import { EventEmitter, once } from 'node:events'; +import fs from 'node:fs/promises'; + +import { ChronikClientNode } from 'chronik-client'; + +import { + ALL_ANYONECANPAY_BIP143, + ALL_BIP143, + Ecc, + EccWasm, + NONE_ANYONECANPAY_BIP143, + NONE_BIP143, + OP_1, + OP_CHECKSIG, + OP_CHECKSIGVERIFY, + OP_CODESEPARATOR, + OP_RETURN, + OP_REVERSEBYTES, + OutPoint, + P2PKHSignatory, + P2PKSignatory, + SINGLE_ANYONECANPAY_BIP143, + SINGLE_BIP143, + Script, + Tx, + TxBuilder, + TxInput, + UnsignedTxInput, + WriterBytes, + flagSignature, + fromHex, + initWasm, + pushBytesOp, + sha256d, + shaRmd160, + toHex, +} from '../src/index.js'; + +const NUM_COINS = 500; +const COIN_VALUE = 100000; +const OP_TRUE_SCRIPT = Script.fromOps([OP_1]); +const OP_TRUE_SCRIPT_SIG = Script.fromOps([ + pushBytesOp(OP_TRUE_SCRIPT.bytecode), +]); +// Like OP_TRUE_SCRIPT but much bigger to avoid undersize +const ANYONE_SCRIPT = Script.fromOps([pushBytesOp(fromHex('01'.repeat(100)))]); +const ANYONE_SCRIPT_SIG = Script.fromOps([pushBytesOp(ANYONE_SCRIPT.bytecode)]); + +const SIG_HASH_TYPES = [ + ALL_BIP143, + ALL_ANYONECANPAY_BIP143, + NONE_BIP143, + NONE_ANYONECANPAY_BIP143, + SINGLE_BIP143, + SINGLE_ANYONECANPAY_BIP143, +]; + +describe('TxBuilder', () => { + let testRunner: ChildProcess; + let chronik: ChronikClientNode; + let ecc: Ecc; + let coinsTxid: string; + let outputIdx: number = 0; + + function getOutpoint(): OutPoint { + return { + txid: coinsTxid, + outIdx: outputIdx++, // use value, then increment + }; + } + + async function sendToScript( + value: number, + script: Script, + ): Promise { + const setupTx = new Tx({ + inputs: [ + { + prevOut: getOutpoint(), + script: ANYONE_SCRIPT_SIG, + sequence: 0xffffffff, + }, + ], + outputs: [{ value, script }], + }); + return (await chronik.broadcastTx(setupTx.ser())).txid; + } + + before(async () => { + const statusEvent = new EventEmitter(); + + testRunner = spawn( + 'python3', + [ + 'test/functional/test_runner.py', + // Place the setup in the python file + 'setup_scripts/ecash-lib_base', + ], + { + stdio: ['ipc'], + // Needs to be set dynamically and the Bitcoin ABC + // node has to be built first. + cwd: process.env.BUILD_DIR || '.', + }, + ); + // Redirect stdout so we can see the messages from the test runner + testRunner?.stdout?.pipe(process.stdout); + + testRunner.on('error', function (error) { + console.log('Test runner error, aborting: ' + error); + testRunner.kill(); + process.exit(-1); + }); + + testRunner.on('exit', function (code, signal) { + // The test runner failed, make sure to propagate the error + if (code !== null && code !== undefined && code != 0) { + console.log('Test runner completed with code ' + code); + process.exit(code); + } + + // The test runner was aborted by a signal, make sure to return an + // error + if (signal !== null && signal !== undefined) { + console.log('Test runner aborted by signal ' + signal); + process.exit(-2); + } + + // In all other cases, let the test return its own status as + // expected + }); + + testRunner.on('spawn', function () { + console.log('Test runner started'); + }); + + testRunner.on('message', function (message: any) { + if (message && message.test_info && message.test_info.chronik) { + console.log( + 'Setting chronik url to ', + message.test_info.chronik, + ); + chronik = new ChronikClientNode(message.test_info.chronik); + } + + if (message && message.status) { + statusEvent.emit(message.status); + } + }); + + // Can't use `fetch` for local file so we have to read it using `fs` + await initWasm(fs.readFile('./src/ffi/ecash_lib_wasm_bg.wasm')); + ecc = new EccWasm(); + + // We got the coins, can fan out now + await once(statusEvent, 'ready'); + + const opTrueScriptHash = shaRmd160(OP_TRUE_SCRIPT.bytecode); + const utxo = ( + await chronik.script('p2sh', toHex(opTrueScriptHash)).utxos() + ).utxos[0]; + const anyoneScriptHash = shaRmd160(ANYONE_SCRIPT.bytecode); + const anyoneP2sh = Script.p2sh(anyoneScriptHash); + const tx = new Tx({ + inputs: [ + { + prevOut: utxo.outpoint, + script: OP_TRUE_SCRIPT_SIG, + sequence: 0xffffffff, + }, + ], + }); + for (let i = 0; i < NUM_COINS; ++i) { + tx.outputs.push({ + value: COIN_VALUE, + script: anyoneP2sh, + }); + } + tx.outputs.push({ + value: 0, + script: Script.fromOps([OP_RETURN]), + }); + tx.outputs[tx.outputs.length - 1].value = + utxo.value - NUM_COINS * COIN_VALUE - tx.serSize(); + + coinsTxid = (await chronik.broadcastTx(tx.ser())).txid; + }); + + after(() => { + testRunner.send('stop'); + }); + + it('TxBuilder P2PKH Wallet', async () => { + // Setup simple single-address P2PKH wallet + const sk = fromHex( + '112233445566778899001122334455667788990011223344556677889900aabb', + ); + const pk = ecc.derivePubkey(sk); + const pkh = shaRmd160(pk); + const p2pkh = Script.p2pkh(pkh); + + // Recipient script + const recipientPkh = fromHex( + '0123456789012345678901234567890123456789', + ); + const recipientScript = Script.p2pkh(recipientPkh); + + // Send some UTXOs to the wallet + await sendToScript(90000, p2pkh); + await sendToScript(90000, p2pkh); + + const utxos = await chronik.script('p2pkh', toHex(pkh)).utxos(); + expect(utxos.utxos.length).to.equal(2); + + const txBuild = new TxBuilder({ + // Use all UTXOs of the wallet as input + inputs: utxos.utxos.map(utxo => ({ + input: { + prevOut: utxo.outpoint, + signData: { + value: utxo.value, + outputScript: p2pkh, + }, + }, + signatory: P2PKHSignatory(sk, pk, ALL_BIP143), + })), + outputs: [ + // Recipient using a TxOutput + { value: 120000, script: recipientScript }, + // Leftover change back to wallet + p2pkh, + ], + }); + const spendTx = txBuild.sign(ecc, 1000, 546); + const txid = (await chronik.broadcastTx(spendTx.ser())).txid; + + // Now have 1 UTXO change in the wallet + const newUtxos = await chronik.script('p2pkh', toHex(pkh)).utxos(); + expect(newUtxos.utxos).to.deep.equal([ + { + outpoint: { + txid, + outIdx: 1, + }, + blockHeight: -1, + isCoinbase: false, + value: 90000 * 2 - 120000 - spendTx.serSize(), + isFinal: false, + }, + ]); + }); + + it('TxBuilder P2PKH', async () => { + const sk = fromHex( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ); + const pk = ecc.derivePubkey(sk); + const pkh = shaRmd160(pk); + const p2pkh = Script.p2pkh(pkh); + + for (const sigHashType of SIG_HASH_TYPES) { + const txid = await sendToScript(90000, p2pkh); + const txBuild = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + txid, + outIdx: 0, + }, + signData: { + value: 90000, + outputScript: p2pkh, + }, + }, + signatory: P2PKHSignatory(sk, pk, sigHashType), + }, + ], + outputs: [p2pkh], + }); + const spendTx = txBuild.sign(ecc, 1000, 546); + await chronik.broadcastTx(spendTx.ser()); + } + }); + + it('TxBuilder P2PK', async () => { + const sk = fromHex( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ); + const pk = ecc.derivePubkey(sk); + const p2pk = Script.fromOps([pushBytesOp(pk), OP_CHECKSIG]); + + for (const sigHashType of SIG_HASH_TYPES) { + const txid = await sendToScript(90000, p2pk); + const txBuild = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + txid, + outIdx: 0, + }, + signData: { + value: 90000, + outputScript: p2pk, + }, + }, + signatory: P2PKSignatory(sk, sigHashType), + }, + ], + outputs: [p2pk], + }); + const spendTx = txBuild.sign(ecc, 1000, 546); + await chronik.broadcastTx(spendTx.ser()); + } + }); + + it('TxBuilder P2PK ECDSA', async () => { + const sk = fromHex( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ); + const pk = ecc.derivePubkey(sk); + const p2pk = Script.fromOps([pushBytesOp(pk), OP_CHECKSIG]); + + for (const sigHashType of SIG_HASH_TYPES) { + const txid = await sendToScript(90000, p2pk); + const txBuild = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + txid, + outIdx: 0, + }, + sequence: 0x12345678, + signData: { + value: 90000, + outputScript: p2pk, + }, + }, + signatory: ( + ecc: Ecc, + input: UnsignedTxInput, + ): Script => { + const preimage = input.sigHashPreimage(sigHashType); + const sighash = sha256d(preimage.bytes); + const sig = flagSignature( + ecc.ecdsaSign(sk, sighash), + sigHashType, + ); + return Script.fromOps([pushBytesOp(sig)]); + }, + }, + ], + outputs: [p2pk], + }); + const spendTx = txBuild.sign(ecc, 1000, 546); + await chronik.broadcastTx(spendTx.ser()); + } + }); + + it('TxBuilder P2SH with reversed signature OP_CHECKSIG', async () => { + const sk = fromHex( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + ); + const pk = ecc.derivePubkey(sk); + const redeemScript = Script.fromOps([ + OP_REVERSEBYTES, + pushBytesOp(pk), + OP_CHECKSIG, + ]); + const p2sh = Script.p2sh(shaRmd160(redeemScript.bytecode)); + + for (const sigHashType of SIG_HASH_TYPES) { + const txid = await sendToScript(90000, p2sh); + const txBuild = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + txid, + outIdx: 0, + }, + signData: { + value: 90000, + redeemScript, + }, + }, + signatory: ( + ecc: Ecc, + input: UnsignedTxInput, + ): Script => { + const preimage = input.sigHashPreimage(sigHashType); + const sighash = sha256d(preimage.bytes); + const sig = flagSignature( + ecc.schnorrSign(sk, sighash), + sigHashType, + ); + sig.reverse(); + return Script.fromOps([ + pushBytesOp(sig), + pushBytesOp(preimage.redeemScript.bytecode), + ]); + }, + }, + ], + outputs: [p2sh], + }); + const spendTx = txBuild.sign(ecc, 1000, 546); + await chronik.broadcastTx(spendTx.ser()); + } + }); + + it('TxBuilder OP_CODESEPERATOR', async () => { + const sk1 = fromHex('11'.repeat(32)); + const pk1 = ecc.derivePubkey(sk1); + const sk2 = fromHex('22'.repeat(32)); + const pk2 = ecc.derivePubkey(sk2); + const sk3 = fromHex('33'.repeat(32)); + const pk3 = ecc.derivePubkey(sk3); + const redeemScript = Script.fromOps([ + OP_CODESEPARATOR, + pushBytesOp(pk1), + OP_CODESEPARATOR, + OP_CHECKSIGVERIFY, + OP_CODESEPARATOR, + pushBytesOp(pk2), + OP_CHECKSIGVERIFY, + OP_CODESEPARATOR, + pushBytesOp(pk3), + OP_CHECKSIG, + OP_CODESEPARATOR, + ]); + const p2sh = Script.p2sh(shaRmd160(redeemScript.bytecode)); + + for (const sigHashType of SIG_HASH_TYPES) { + const txid = await sendToScript(90000, p2sh); + const txBuild = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + txid, + outIdx: 0, + }, + sequence: 0x98765432, + signData: { + value: 90000, + redeemScript, + }, + }, + signatory: ( + ecc: Ecc, + input: UnsignedTxInput, + ): Script => { + const sks = [sk1, sk2, sk3]; + const sigs = [...Array(3).keys()].map(i => { + // Sign with nCodesep = 1, 2, 3 + const preimage = input.sigHashPreimage( + sigHashType, + i + 1, + ); + return flagSignature( + ecc.schnorrSign( + sks[i], + sha256d(preimage.bytes), + ), + sigHashType, + ); + }); + return Script.fromOps([ + pushBytesOp(sigs[2]), + pushBytesOp(sigs[1]), + pushBytesOp(sigs[0]), + pushBytesOp(redeemScript.bytecode), + ]); + }, + }, + ], + outputs: [p2sh], + }); + const spendTx = txBuild.sign(ecc, 1000, 546); + await chronik.broadcastTx(spendTx.ser()); + } + }); + + it('TxBuilder leftover calculation', async () => { + const sk1 = fromHex('11'.repeat(32)); + const pk1 = ecc.derivePubkey(sk1); + const sk2 = fromHex('22'.repeat(32)); + const pk2 = ecc.derivePubkey(sk2); + const redeemScript = Script.fromOps([ + pushBytesOp(pk1), + OP_CHECKSIGVERIFY, + pushBytesOp(pk2), + OP_CHECKSIG, + ]); + const p2sh = Script.p2sh(shaRmd160(redeemScript.bytecode)); + const txid = await sendToScript(90000, p2sh); + const txBuild = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + txid, + outIdx: 0, + }, + signData: { + value: 90000, + redeemScript, + }, + }, + signatory: (ecc: Ecc, input: UnsignedTxInput): Script => { + const sks = [sk1, sk2]; + const sigs = [...Array(2).keys()].map(i => { + const preimage = input.sigHashPreimage(ALL_BIP143); + return flagSignature( + ecc.schnorrSign( + sks[i], + sha256d(preimage.bytes), + ), + ALL_BIP143, + ); + }); + return Script.fromOps([ + pushBytesOp(sigs[1]), + pushBytesOp(sigs[0]), + pushBytesOp(redeemScript.bytecode), + ]); + }, + }, + ], + outputs: [ + { + value: 20000, + script: Script.p2pkh(shaRmd160(pk1)), + }, + Script.p2pkh(shaRmd160(pk2)), + { + value: 30000, + script: Script.p2pkh(shaRmd160(pk2)), + }, + ], + }); + + // 0sats/kB (not broadcast) + let spendTx = txBuild.sign(ecc, 0, 546); + expect(spendTx.outputs[1].value).to.equal(40000n); + + // 1ksats/kB + spendTx = txBuild.sign(ecc, 1000, 546); + await chronik.broadcastTx(spendTx.ser()); + expect(spendTx.outputs[1].value).to.equal( + BigInt(40000 - spendTx.serSize()), + ); + + // 10ksats/kB + txBuild.inputs[0].input.prevOut.txid = await sendToScript(90000, p2sh); + spendTx = txBuild.sign(ecc, 10000, 546); + await chronik.broadcastTx(spendTx.ser()); + expect(spendTx.outputs[1].value).to.equal( + BigInt(40000 - 10 * spendTx.serSize()), + ); + + // 100ksats/kB + txBuild.inputs[0].input.prevOut.txid = await sendToScript(90000, p2sh); + spendTx = txBuild.sign(ecc, 100000, 546); + await chronik.broadcastTx(spendTx.ser()); + expect(spendTx.outputs[1].value).to.equal( + BigInt(40000 - 100 * spendTx.serSize()), + ); + + // 120ksats/kB, deletes leftover output + txBuild.inputs[0].input.prevOut.txid = await sendToScript(90000, p2sh); + spendTx = txBuild.sign(ecc, 120000, 546); + await chronik.broadcastTx(spendTx.ser()); + expect(spendTx.outputs.length).to.equal(2); + + // 100ksats/kB with a 5000 dust limit deletes leftover too + txBuild.inputs[0].input.prevOut.txid = await sendToScript(90000, p2sh); + spendTx = txBuild.sign(ecc, 100000, /*dustLimit=*/ 5000); + await chronik.broadcastTx(spendTx.ser()); + expect(spendTx.outputs.length).to.equal(2); + + // 1000ksats/kB does't have sufficient sats even without leftover + txBuild.inputs[0].input.prevOut.txid = await sendToScript(90000, p2sh); + expect(() => txBuild.sign(ecc, 1000000, 546)).to.throw( + `Insufficient input value (90000): Can only pay for 40000 fees, ` + + `but ${spendTx.serSize() * 1000} required`, + ); + }); + + it('TxBuilder leftover with 0xFD outputs', async () => { + const sk = fromHex('11'.repeat(32)); + const pk = ecc.derivePubkey(sk); + const pkh = shaRmd160(pk); + const p2pkh = Script.p2pkh(pkh); + const sk2 = fromHex('22'.repeat(32)); + const pk2 = ecc.derivePubkey(sk2); + const txBuild = new TxBuilder(); + for (let i = 0; i < 2; ++i) { + txBuild.inputs.push({ + input: { + prevOut: { + txid: await sendToScript(90000, p2pkh), + outIdx: 0, + }, + signData: { + value: 90000, + outputScript: p2pkh, + }, + }, + signatory: P2PKHSignatory(sk, pk, ALL_BIP143), + }); + } + txBuild.outputs.push(Script.p2pkh(shaRmd160(pk2))); + const txSize = 8896; + const extraOutput = { + value: 90000 * 2 - (txSize + 252 * 546), + script: p2pkh, + }; + txBuild.outputs.push(extraOutput); + for (let i = 0; i < 251; ++i) { + txBuild.outputs.push({ value: 546, script: p2pkh }); + } + expect(txBuild.outputs.length).to.equal(253); + let spendTx = txBuild.sign(ecc, 1000, 546); + expect(spendTx.serSize()).to.equal(txSize); + expect(spendTx.outputs[0].value).to.equal(BigInt(546)); + + // If we remove the leftover output from the tx, we also remove 2 extra + // bytes from the VARSIZE of the output, because 253 requires 3 bytes to + // encode and 252 requires just 1 byte to encode. + const p2pkhSize = 8 + 1 + 25; + const smallerSize = txSize - p2pkhSize - 2; + // We can add 2 extra sats for the VARSIZE savings and it's handled fine + extraOutput.value += 546 + p2pkhSize + 2; + spendTx = txBuild.sign(ecc, 1000, 546); + expect(spendTx.serSize()).to.equal(smallerSize); + expect(spendTx.outputs.length).to.equal(252); + + // Adding 1 extra sat -> fails -> showing that the previous tx was exact + extraOutput.value += 1; + expect(() => txBuild.sign(ecc, 1000, 546)).to.throw( + `Insufficient input value (180000): Can only pay for ` + + `${smallerSize - 1} fees, but ${smallerSize} required`, + ); + }); + + it('TxBuilder leftover failure', async () => { + const txBuild = new TxBuilder({ + inputs: [ + { + input: { + prevOut: { + txid: new Uint8Array(32), + outIdx: 0, + }, + }, + }, + ], + outputs: [new Script()], + }); + expect(() => txBuild.sign(ecc, 1000, 545)).to.throw( + 'Using a leftover output requires setting SignData.value for all inputs', + ); + txBuild.inputs[0].input.signData = { value: 1234 }; + expect(() => txBuild.sign(ecc, 1000)).to.throw( + 'Using a leftover output requires setting dustLimit', + ); + expect(() => txBuild.sign(ecc)).to.throw( + 'Using a leftover output requires setting feePerKb', + ); + }); +}); diff --git a/test/functional/setup_scripts/ecash-lib_base.py b/test/functional/setup_scripts/ecash-lib_base.py new file mode 100644 --- /dev/null +++ b/test/functional/setup_scripts/ecash-lib_base.py @@ -0,0 +1,35 @@ +# Copyright (c) 2024 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +""" +Setup script to test the ecash-lib js library +""" + +import pathmagic # noqa +from setup_framework import SetupFramework +from test_framework.address import ADDRESS_ECREG_P2SH_OP_TRUE, ADDRESS_ECREG_UNSPENDABLE + + +class EcashLibSetup(SetupFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + self.extra_args = [["-chronik"]] + self.ipc_timeout = 10 + + def skip_test_if_missing_module(self): + self.skip_if_no_chronik() + + def run_test(self): + node = self.nodes[0] + mocktime = 1300000000 + node.setmocktime(mocktime) + + self.generatetoaddress(node, 1, ADDRESS_ECREG_P2SH_OP_TRUE)[0] + self.generatetoaddress(node, 100, ADDRESS_ECREG_UNSPENDABLE) + + yield True + + +if __name__ == "__main__": + EcashLibSetup().main()