From e10a9cde41e4eb4d1e3ea99ef3ea3bcc8dfc97d2 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 5 Mar 2026 11:24:00 +0800 Subject: [PATCH 1/2] feat: expose sortingStrategy parameter in witness entry point Allow callers to control output ordering by passing sortingStrategy ('bip69', 'none', or 'random') to the witness coinSelect function. Defaults to 'random' for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- test/fixtures/witness.js | 284 +++++++++++++++++++++++++++++++++++++++ test/witness.js | 16 ++- witness.d.ts | 1 + witness.js | 5 +- 4 files changed, 303 insertions(+), 3 deletions(-) diff --git a/test/fixtures/witness.js b/test/fixtures/witness.js index 0f9d083..103dbc7 100644 --- a/test/fixtures/witness.js +++ b/test/fixtures/witness.js @@ -508,6 +508,290 @@ const fixtures = [ outputsPermutation: [0, 1] }, shouldThrow: false + }, + { + description: 'sortingStrategy none - preserves original input/output order', + feeRate: 10, + sortingStrategy: 'none', + inputs: [ + { + txId: 'bbbb_second_in_bip69', + vout: 0, + value: 5000, + amount: '5000', + confirmations: 200, + own: true, + coinbase: false, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + required: true + }, + { + txId: 'aaaa_first_in_bip69', + vout: 0, + value: 3000, + amount: '3000', + confirmations: 300, + own: true, + coinbase: false, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/1", + required: true + } + ], + outputs: [ + { + type: 'payment', + address: 'tb1quawu6eyfuechu3qhdeejnrzne9y7shr08u8zzt', + value: 1000, + amount: '1000' + } + ], + network: testnet, + changeAddress: { + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0" + }, + txType: 'p2wpkh', + expected: { + type: 'final', + fee: '2090', + feePerByte: '10', + bytes: 209, + max: undefined, + totalSpent: '3090', + inputs: [ + { + txId: 'bbbb_second_in_bip69', + vout: 0, + value: 5000, + confirmations: 200, + own: true, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + coinbase: false, + amount: '5000', + required: true + }, + { + txId: 'aaaa_first_in_bip69', + vout: 0, + value: 3000, + confirmations: 300, + own: true, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/1", + coinbase: false, + amount: '3000', + required: true + } + ], + outputs: [ + { + type: 'payment', + address: 'tb1quawu6eyfuechu3qhdeejnrzne9y7shr08u8zzt', + value: 1000, + amount: '1000' + }, + { + type: 'change', + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + amount: '4910' + } + ], + outputsPermutation: [0, 1] + }, + shouldThrow: false + }, + { + description: 'sortingStrategy bip69 - sorts inputs by txid and outputs by value', + feeRate: 10, + sortingStrategy: 'bip69', + inputs: [ + { + txid: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + vout: 0, + value: 5000, + amount: '5000', + confirmations: 200, + own: true, + coinbase: false, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + required: true + }, + { + txid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + vout: 0, + value: 3000, + amount: '3000', + confirmations: 300, + own: true, + coinbase: false, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/1", + required: true + } + ], + outputs: [ + { + type: 'payment', + address: 'tb1quawu6eyfuechu3qhdeejnrzne9y7shr08u8zzt', + value: 1000, + amount: '1000' + } + ], + network: testnet, + changeAddress: { + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0" + }, + txType: 'p2wpkh', + expected: { + type: 'final', + fee: '2090', + feePerByte: '10', + bytes: 209, + max: undefined, + totalSpent: '3090', + inputs: [ + { + txid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + vout: 0, + value: 3000, + confirmations: 300, + own: true, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/1", + coinbase: false, + amount: '3000', + required: true + }, + { + txid: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + vout: 0, + value: 5000, + confirmations: 200, + own: true, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + coinbase: false, + amount: '5000', + required: true + } + ], + outputs: [ + { + type: 'payment', + address: 'tb1quawu6eyfuechu3qhdeejnrzne9y7shr08u8zzt', + value: 1000, + amount: '1000' + }, + { + type: 'change', + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + amount: '4910' + } + ], + outputsPermutation: [0, 1] + }, + shouldThrow: false + }, + { + description: 'sortingStrategy random (default) - produces valid result with shuffled order', + feeRate: 10, + inputs: [ + { + txId: 'bbbb_second_in_bip69', + vout: 0, + value: 5000, + amount: '5000', + confirmations: 200, + own: true, + coinbase: false, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + required: true + }, + { + txId: 'aaaa_first_in_bip69', + vout: 0, + value: 3000, + amount: '3000', + confirmations: 300, + own: true, + coinbase: false, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/1", + required: true + } + ], + outputs: [ + { + type: 'payment', + address: 'tb1quawu6eyfuechu3qhdeejnrzne9y7shr08u8zzt', + value: 1000, + amount: '1000' + } + ], + network: testnet, + changeAddress: { + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0" + }, + txType: 'p2wpkh', + expected: { + type: 'final', + fee: '2090', + feePerByte: '10', + bytes: 209, + max: undefined, + totalSpent: '3090', + inputs: [ + { + txId: 'bbbb_second_in_bip69', + vout: 0, + value: 5000, + confirmations: 200, + own: true, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + coinbase: false, + amount: '5000', + required: true + }, + { + txId: 'aaaa_first_in_bip69', + vout: 0, + value: 3000, + confirmations: 300, + own: true, + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/1", + coinbase: false, + amount: '3000', + required: true + } + ], + outputs: [ + { + type: 'payment', + address: 'tb1quawu6eyfuechu3qhdeejnrzne9y7shr08u8zzt', + value: 1000, + amount: '1000' + }, + { + type: 'change', + address: 'tb1qul5mzh5phe7xqyqek0nl42hflfrn7ugxck59jd', + path: "m/84'/1'/0'/0/0", + amount: '4910' + } + ], + outputsPermutation: [0, 1] + }, + shouldThrow: false } ] diff --git a/test/witness.js b/test/witness.js index 859fcf1..1c79cc0 100644 --- a/test/witness.js +++ b/test/witness.js @@ -25,7 +25,8 @@ fixtures.forEach(function (f) { feeRate: f.feeRate, network: f.network, changeAddress: f.changeAddress, - txType: f.txType + txType: f.txType, + sortingStrategy: f.sortingStrategy }) const compareOutputs = (actual, expected) => { @@ -63,6 +64,19 @@ fixtures.forEach(function (f) { t.same(actual.totalSpent, f.expected.totalSpent) t.ok(compareOutputs(actual.inputs, f.expected.inputs), 'inputs are the same') t.ok(compareOutputs(actual.outputs, f.expected.outputs), 'outputs are the same') + + // For deterministic strategies, also verify ordering + if (f.sortingStrategy === 'none' || f.sortingStrategy === 'bip69') { + var txIdKey = f.sortingStrategy === 'bip69' ? 'txid' : 'txId' + t.same( + actual.inputs.map(function (i) { return i[txIdKey] }), + f.expected.inputs.map(function (i) { return i[txIdKey] }), + 'input order matches expected for ' + f.sortingStrategy + ' strategy' + ) + t.same(actual.outputsPermutation, f.expected.outputsPermutation, + 'outputsPermutation matches for ' + f.sortingStrategy + ' strategy') + } + t.end() } }) diff --git a/witness.d.ts b/witness.d.ts index 32e0a1f..d31227d 100644 --- a/witness.d.ts +++ b/witness.d.ts @@ -51,6 +51,7 @@ export interface ICoinSelectParams { txType: IPaymentType baseFee?: number; dustThreshold?: number; + sortingStrategy?: 'bip69' | 'none' | 'random'; } export interface ICoinSelectResult { diff --git a/witness.js b/witness.js index 52d622d..c330502 100644 --- a/witness.js +++ b/witness.js @@ -8,13 +8,14 @@ module.exports = function coinSelect ({ network, txType, baseFee = 0, - dustThreshold = 546 + dustThreshold = 546, + sortingStrategy = 'random' }) { const result = composeTx({ utxos, outputs, feeRate, - sortingStrategy: 'random', + sortingStrategy, txType, dustThreshold, changeAddress, From e7f61f8124f71e5cb8b186fe36b8dc0a7c3bd260 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 5 Mar 2026 11:39:08 +0800 Subject: [PATCH 2/2] chore: bump version to 3.1.17 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a22f76a..f387287 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/coinselect", - "version": "3.1.16", + "version": "3.1.17", "description": "A transaction input selection module for bitcoin.", "keywords": [ "coinselect",