From 3dace574c0e2872620bea9318114a7aa74b48624 Mon Sep 17 00:00:00 2001 From: Peter Sanderson Date: Thu, 17 Oct 2024 11:08:37 +0200 Subject: [PATCH] feat: introduce sortingStrategy into utxo-lib so we can have more strategies (bip69, none, and later the random) feat: refactor utxo-lib so it impements multiple sorting strategies (one strategy per file) --- .../compose/sorting/bip69SortingStrategy.ts | 39 ++++++ .../src/compose/sorting/convertOutput.ts | 23 ++++ .../compose/sorting/noneSortingStrategy.ts | 18 +++ .../src/compose/sorting/sortingStrategy.ts | 18 +++ packages/utxo-lib/src/compose/transaction.ts | 82 +++-------- packages/utxo-lib/src/types/compose.ts | 31 ++++- .../utxo-lib/tests/__fixtures__/compose.ts | 127 +++++++++++++++++- 7 files changed, 272 insertions(+), 66 deletions(-) create mode 100644 packages/utxo-lib/src/compose/sorting/bip69SortingStrategy.ts create mode 100644 packages/utxo-lib/src/compose/sorting/convertOutput.ts create mode 100644 packages/utxo-lib/src/compose/sorting/noneSortingStrategy.ts create mode 100644 packages/utxo-lib/src/compose/sorting/sortingStrategy.ts diff --git a/packages/utxo-lib/src/compose/sorting/bip69SortingStrategy.ts b/packages/utxo-lib/src/compose/sorting/bip69SortingStrategy.ts new file mode 100644 index 00000000000..2cb8bb1270e --- /dev/null +++ b/packages/utxo-lib/src/compose/sorting/bip69SortingStrategy.ts @@ -0,0 +1,39 @@ +import { SortingStrategy } from './sortingStrategy'; +import { CoinSelectOutputFinal, ComposeInput } from '../../types'; +import { convertOutput } from './convertOutput'; + +function inputComparator(a: ComposeInput, b: ComposeInput) { + return Buffer.from(a.txid, 'hex').compare(Buffer.from(b.txid, 'hex')) || a.vout - b.vout; +} + +function outputComparator(a: CoinSelectOutputFinal, b: CoinSelectOutputFinal) { + return ( + a.value.cmp(b.value) || + (Buffer.isBuffer(a.script) && Buffer.isBuffer(b.script) + ? a.script.compare(b.script) + : a.script.length - b.script.length) + ); +} + +export const bip69SortingStrategy: SortingStrategy = ({ result, request, convertedInputs }) => { + const defaultPermutation: number[] = []; + const convertedOutputs = result.outputs.map((output, index) => { + defaultPermutation.push(index); + if (request.outputs[index]) { + return convertOutput(output, request.outputs[index]); + } + + return convertOutput(output, { type: 'change', ...request.changeAddress }); + }); + + const permutation = defaultPermutation.sort((a, b) => + outputComparator(result.outputs[a], result.outputs[b]), + ); + const sortedOutputs = permutation.map(index => convertedOutputs[index]); + + return { + inputs: convertedInputs.sort(inputComparator), + outputs: sortedOutputs, + outputsPermutation: permutation, + }; +}; diff --git a/packages/utxo-lib/src/compose/sorting/convertOutput.ts b/packages/utxo-lib/src/compose/sorting/convertOutput.ts new file mode 100644 index 00000000000..9972d363885 --- /dev/null +++ b/packages/utxo-lib/src/compose/sorting/convertOutput.ts @@ -0,0 +1,23 @@ +import { CoinSelectOutputFinal, ComposeChangeAddress, ComposeFinalOutput } from '../../types'; + +export const convertOutput = ( + selectedOutput: CoinSelectOutputFinal, + composeOutput: ComposeFinalOutput | ({ type: 'change' } & ComposeChangeAddress), +) => { + if (composeOutput.type === 'change') { + return { + ...composeOutput, + amount: selectedOutput.value.toString(), + }; + } + + if (composeOutput.type === 'opreturn') { + return composeOutput; + } + + return { + ...composeOutput, + type: 'payment' as const, + amount: selectedOutput.value.toString(), + }; +}; diff --git a/packages/utxo-lib/src/compose/sorting/noneSortingStrategy.ts b/packages/utxo-lib/src/compose/sorting/noneSortingStrategy.ts new file mode 100644 index 00000000000..6cb7cbf648a --- /dev/null +++ b/packages/utxo-lib/src/compose/sorting/noneSortingStrategy.ts @@ -0,0 +1,18 @@ +import { SortingStrategy } from './sortingStrategy'; +import { convertOutput } from './convertOutput'; + +export const noneSortingStrategy: SortingStrategy = ({ result, request, convertedInputs }) => { + const convertedOutputs = result.outputs.map((output, index) => { + if (request.outputs[index]) { + return convertOutput(output, request.outputs[index]); + } + + return convertOutput(output, { type: 'change', ...request.changeAddress }); + }); + + return { + inputs: convertedInputs, + outputs: convertedOutputs, + outputsPermutation: Array.from(convertedOutputs.keys()), + }; +}; diff --git a/packages/utxo-lib/src/compose/sorting/sortingStrategy.ts b/packages/utxo-lib/src/compose/sorting/sortingStrategy.ts new file mode 100644 index 00000000000..f3a79fcd61e --- /dev/null +++ b/packages/utxo-lib/src/compose/sorting/sortingStrategy.ts @@ -0,0 +1,18 @@ +import { + CoinSelectSuccess, + ComposeChangeAddress, + ComposedTransaction, + ComposeFinalOutput, + ComposeInput, + ComposeRequest, +} from '../../types'; + +type SortingStrategyParams = { + request: ComposeRequest; + result: CoinSelectSuccess; + convertedInputs: Input[]; +}; + +export type SortingStrategy = ( + params: SortingStrategyParams, +) => ComposedTransaction; diff --git a/packages/utxo-lib/src/compose/transaction.ts b/packages/utxo-lib/src/compose/transaction.ts index 6c00ac64da3..5e1eebaf800 100644 --- a/packages/utxo-lib/src/compose/transaction.ts +++ b/packages/utxo-lib/src/compose/transaction.ts @@ -5,76 +5,36 @@ import { ComposeChangeAddress, ComposeFinalOutput, ComposedTransaction, - CoinSelectOutputFinal, + TransactionInputOutputSortingStrategy, } from '../types'; - -function convertOutput( - selectedOutput: CoinSelectOutputFinal, - composeOutput: ComposeFinalOutput | ({ type: 'change' } & ComposeChangeAddress), -) { - if (composeOutput.type === 'change') { - return { - ...composeOutput, - amount: selectedOutput.value.toString(), - }; +import { noneSortingStrategy } from './sorting/noneSortingStrategy'; +import { SortingStrategy } from './sorting/sortingStrategy'; +import { bip69SortingStrategy } from './sorting/bip69SortingStrategy'; + +const resolveSortingStrategyWithBackCompatibility = < + Input extends ComposeInput, + Change extends ComposeChangeAddress, +>( + request: ComposeRequest, +): TransactionInputOutputSortingStrategy => { + if (request.sortingStrategy === undefined) { + return request.skipPermutation === true ? 'none' : 'bip69'; } - if (composeOutput.type === 'opreturn') { - return composeOutput; - } + return request.sortingStrategy; +}; - return { - ...composeOutput, - type: 'payment' as const, - amount: selectedOutput.value.toString(), - }; -} - -function inputComparator(a: ComposeInput, b: ComposeInput) { - return Buffer.from(a.txid, 'hex').compare(Buffer.from(b.txid, 'hex')) || a.vout - b.vout; -} - -function outputComparator(a: CoinSelectOutputFinal, b: CoinSelectOutputFinal) { - return ( - a.value.cmp(b.value) || - (Buffer.isBuffer(a.script) && Buffer.isBuffer(b.script) - ? a.script.compare(b.script) - : a.script.length - b.script.length) - ); -} +const strategyMap: Record = { + bip69: bip69SortingStrategy, + none: noneSortingStrategy, +}; export function createTransaction( request: ComposeRequest, result: CoinSelectSuccess, ): ComposedTransaction { + const sortingStrategy = resolveSortingStrategyWithBackCompatibility(request); const convertedInputs = result.inputs.map(input => request.utxos[input.i]); - const defaultPermutation: number[] = []; - const convertedOutputs = result.outputs.map((output, index) => { - defaultPermutation.push(index); - if (request.outputs[index]) { - return convertOutput(output, request.outputs[index]); - } - - return convertOutput(output, { type: 'change', ...request.changeAddress }); - }); - - if (request.skipPermutation) { - return { - inputs: convertedInputs, - outputs: convertedOutputs, - outputsPermutation: defaultPermutation, - }; - } - - const permutation = defaultPermutation.sort((a, b) => - outputComparator(result.outputs[a], result.outputs[b]), - ); - const sortedOutputs = permutation.map(index => convertedOutputs[index]); - - return { - inputs: convertedInputs.sort(inputComparator), - outputs: sortedOutputs, - outputsPermutation: permutation, - }; + return strategyMap[sortingStrategy]({ result, request, convertedInputs }); } diff --git a/packages/utxo-lib/src/types/compose.ts b/packages/utxo-lib/src/types/compose.ts index e16078bc425..81ab6e0f597 100644 --- a/packages/utxo-lib/src/types/compose.ts +++ b/packages/utxo-lib/src/types/compose.ts @@ -68,11 +68,35 @@ export interface ComposeChangeAddress { address: string; } -export interface ComposeRequest< +export type TransactionInputOutputSortingStrategy = + // BIP69 sorting + | 'bip69' + + // Inputs are randomized, outputs are kept as they were provided in the request, + // and change is randomly placed somewhere between outputs + // | 'random' // Todo: will be implemented later in https://github.com/trezor/trezor-suite/issues/10765 + + // It keeps the inputs and outputs as they were provided in the request. + // This is useful for RBF transactions where the order of inputs and outputs must be preserved. + | 'none'; + +type SortingStrategyPropsWithCompatibility = + | { + /** @deprecated use `sortingStrategy=none` instead if you want to keep order of inputs and outputs */ + skipPermutation?: boolean; // Do not sort inputs/outputs and preserve the given order. Handy for RBF. + sortingStrategy?: undefined; + } + | { + /** @deprecated use `sortingStrategy=none` instead if you want to keep order of inputs and outputs */ + skipPermutation?: undefined; + sortingStrategy?: TransactionInputOutputSortingStrategy; + }; + +export type ComposeRequest< Input extends ComposeInput, Output extends ComposeOutput, Change extends ComposeChangeAddress, -> { +> = { txType?: CoinSelectPaymentType; utxos: Input[]; // all inputs outputs: Output[]; // all outputs @@ -84,8 +108,7 @@ export interface ComposeRequest< baseFee?: number; // DOGE or RBF base fee floorBaseFee?: boolean; // DOGE floor base fee to the nearest integer skipUtxoSelection?: boolean; // use custom utxo selection, without algorithm - skipPermutation?: boolean; // Do not sort inputs/outputs and preserve the given order. Handy for RBF. -} +} & SortingStrategyPropsWithCompatibility; type ComposedTransactionOutputs = T extends ComposeOutputSendMax ? Omit & ComposeOutputPayment // NOTE: replace ComposeOutputSendMax (no amount) with ComposeOutputPayment (with amount) diff --git a/packages/utxo-lib/tests/__fixtures__/compose.ts b/packages/utxo-lib/tests/__fixtures__/compose.ts index 1d797653c52..149c55fd1bc 100644 --- a/packages/utxo-lib/tests/__fixtures__/compose.ts +++ b/packages/utxo-lib/tests/__fixtures__/compose.ts @@ -438,6 +438,57 @@ export default [ type: 'final', }, }, + { + description: 'sorts the inputs according to BIP69 when sortingStrategy=bip69', + request: { + changeAddress: { address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT' }, + dustThreshold: 546, + sortingStrategy: 'bip69', + feeRate: '10', + outputs: [ + { + address: '1BitcoinEaterAddressDontSendf59kuE', + amount: '150000', + type: 'payment', + }, + ], + utxos: [ + { + ...UTXO, + vout: 2, + }, + { + ...UTXO, + vout: 1, + }, + ], + }, + result: { + bytes: 374, + fee: '3740', + feePerByte: '10', + max: undefined, + totalSpent: '153740', + inputs: [ + { ...UTXO, vout: 1 }, + { ...UTXO, vout: 2 }, + ], + outputs: [ + { + address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT', + amount: '50262', + type: 'change', + }, + { + address: '1BitcoinEaterAddressDontSendf59kuE', + amount: '150000', + type: 'payment', + }, + ], + outputsPermutation: [1, 0], + type: 'final', + }, + }, { description: 'builds a p2sh tx with two same value outputs (mixed p2sh + p2pkh) and change', request: { @@ -1009,7 +1060,8 @@ export default [ }, }, { - description: 'skip inputs/outputs permutation', + description: + 'skip inputs/outputs permutation when skipPermutation=true (compatibility API)', request: { changeAddress: { address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT' }, dustThreshold: 546, @@ -1081,6 +1133,79 @@ export default [ type: 'final', }, }, + { + description: 'skip inputs/outputs permutation when sortingStrategy=none', + request: { + changeAddress: { address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT' }, + dustThreshold: 546, + feeRate: '10', + sortingStrategy: 'none', + outputs: [ + { + address: '1BitcoinEaterAddressDontSendf59kuE', + amount: '70000', + type: 'payment', + }, + ], + utxos: [ + { + txid: 'a4dc0ffeee', + vout: 0, + amount: '65291', + coinbase: false, + confirmations: 60, + own: false, + }, + { + txid: 'b4dc0ffeee', + vout: 1, + amount: '55291', + coinbase: false, + confirmations: 50, + own: false, + }, + ], + }, + result: { + bytes: 374, + fee: '3740', + feePerByte: '10', + max: undefined, + totalSpent: '73740', + inputs: [ + { + txid: 'a4dc0ffeee', + vout: 0, + amount: '65291', + coinbase: false, + confirmations: 60, + own: false, + }, + { + txid: 'b4dc0ffeee', + vout: 1, + amount: '55291', + coinbase: false, + confirmations: 50, + own: false, + }, + ], + outputs: [ + { + address: '1BitcoinEaterAddressDontSendf59kuE', + amount: '70000', + type: 'payment', + }, + { + amount: '46842', + address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT', + type: 'change', + }, + ], + outputsPermutation: [0, 1], + type: 'final', + }, + }, { description: 'builds a Dogecoin tx with change and both input and one of the outputs above MAX_SAFE_INTEGER',