diff --git a/packages/connect/src/constants/utxo.ts b/packages/connect/src/constants/utxo.ts index b069e4580e9..f0f86f46db2 100644 --- a/packages/connect/src/constants/utxo.ts +++ b/packages/connect/src/constants/utxo.ts @@ -1,3 +1,3 @@ -import type { TransactionInputOutputSortingStrategy } from '@trezor/utxo-lib/libDev/src'; +import type { TransactionInputOutputSortingStrategy } from '@trezor/utxo-lib'; -export const DEFAULT_SORTING_STRATEGY: TransactionInputOutputSortingStrategy = 'bip69'; +export const DEFAULT_SORTING_STRATEGY: TransactionInputOutputSortingStrategy = 'random'; diff --git a/packages/utxo-lib/src/compose/sorting/randomSortingStrategy.ts b/packages/utxo-lib/src/compose/sorting/randomSortingStrategy.ts new file mode 100644 index 00000000000..aeb9832a509 --- /dev/null +++ b/packages/utxo-lib/src/compose/sorting/randomSortingStrategy.ts @@ -0,0 +1,39 @@ +import { SortingStrategy } from './sortingStrategy'; +import { convertOutput } from './convertOutput'; +import { arrayShuffle, getRandomInt } from '@trezor/utils'; + +export const randomSortingStrategy: SortingStrategy = ({ result, request, convertedInputs }) => { + const nonChangeOutputPermutation: number[] = []; + const changeOutputPermutation: number[] = []; + + const convertedOutputs = result.outputs.map((output, index) => { + if (request.outputs[index]) { + nonChangeOutputPermutation.push(index); + + return convertOutput(output, request.outputs[index]); + } + + changeOutputPermutation.push(index); + + return convertOutput(output, { type: 'change', ...request.changeAddress }); + }); + + /** + * The goal here is to randomly insert change outputs into the outputs array., + * so you cannot tell what is the change just by the order of the transaction. + */ + const permutation = [...nonChangeOutputPermutation]; + // Min (0) is inclusive, max (permutation.length + 1) is exclusive + // Example: for array [0, 1, 2] the result can be: 0, 1, 2, 3 + const newPositionOfChange = getRandomInt(0, permutation.length + 1); + + permutation.splice(newPositionOfChange, 0, ...changeOutputPermutation); + const sortedOutputs = permutation.map(index => convertedOutputs[index]); + + return { + /** Randomly shuffle inputs to make it harder to fingerprint the Trezor Suite. */ + inputs: arrayShuffle(convertedInputs, { randomInt: getRandomInt }), + outputs: sortedOutputs, + outputsPermutation: permutation, + }; +}; diff --git a/packages/utxo-lib/src/compose/transaction.ts b/packages/utxo-lib/src/compose/transaction.ts index 80af81a6bb9..2596e607c39 100644 --- a/packages/utxo-lib/src/compose/transaction.ts +++ b/packages/utxo-lib/src/compose/transaction.ts @@ -10,10 +10,12 @@ import { import { noneSortingStrategy } from './sorting/noneSortingStrategy'; import { SortingStrategy } from './sorting/sortingStrategy'; import { bip69SortingStrategy } from './sorting/bip69SortingStrategy'; +import { randomSortingStrategy } from './sorting/randomSortingStrategy'; const strategyMap: Record = { bip69: bip69SortingStrategy, none: noneSortingStrategy, + random: randomSortingStrategy, }; export function createTransaction( diff --git a/packages/utxo-lib/src/types/compose.ts b/packages/utxo-lib/src/types/compose.ts index 1060835fe1a..13bb8937459 100644 --- a/packages/utxo-lib/src/types/compose.ts +++ b/packages/utxo-lib/src/types/compose.ts @@ -74,7 +74,7 @@ export type TransactionInputOutputSortingStrategy = // 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 + | 'random' // 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. diff --git a/packages/utxo-lib/tests/__fixtures__/compose.ts b/packages/utxo-lib/tests/__fixtures__/compose.ts index 5b4424b5464..62dc4329804 100644 --- a/packages/utxo-lib/tests/__fixtures__/compose.ts +++ b/packages/utxo-lib/tests/__fixtures__/compose.ts @@ -524,6 +524,59 @@ export const composeTxFixture: Fixture[] = [ type: 'final', }, }, + { + description: + 'sorts the inputs randomly and puts change at random place between user-defined outputs' + + 'when sortingStrategy=random', + request: { + changeAddress: { address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT' }, + dustThreshold: 546, + feeRate: '10', + sortingStrategy: 'random', + 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: 2 }, + { ...UTXO, vout: 1 }, + ], + outputs: [ + { + address: '1BitcoinEaterAddressDontSendf59kuE', + amount: '150000', + type: 'payment', + }, + { + address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT', + amount: '50262', + type: 'change', + }, + ], + outputsPermutation: [0, 1], + type: 'final', + }, + }, { description: 'builds a p2sh tx with two same value outputs (mixed p2sh + p2pkh) and change', request: { diff --git a/packages/utxo-lib/tests/compose.test.ts b/packages/utxo-lib/tests/compose.test.ts index 8120000d9a7..e3a52e2cda4 100644 --- a/packages/utxo-lib/tests/compose.test.ts +++ b/packages/utxo-lib/tests/compose.test.ts @@ -5,6 +5,29 @@ import { verifyTxBytes } from './compose.utils'; import { composeTxFixture } from './__fixtures__/compose'; import { fixturesCrossCheck } from './__fixtures__/compose.crosscheck'; +jest.mock('@trezor/utils', () => { + const actual = jest.requireActual('@trezor/utils'); + + let fakeRandomIndex = 0; + const fakeRandom = [ + 1, // Called from `sortingStrategy=random` test, for outputs (so change output is inserted at index 1 after the first output) + 1, // Called from `sortingStrategy=random` test, when shuffling inputs + ]; + + return { + ...actual, + getRandomInt: () => { + if (fakeRandomIndex >= fakeRandom.length) { + throw new Error( + `getRandomInt called too many times, add more values to fakeRandom`, + ); + } + + return fakeRandom[fakeRandomIndex++]; + }, + }; +}); + describe(composeTx.name, () => { composeTxFixture.forEach(f => { const network = f.request.network ?? NETWORKS.bitcoin; @@ -53,7 +76,7 @@ describe('composeTx addresses cross-check', () => { addrKeys.slice(0, offset).forEach(addressType => { const key = `${txType}-${addressType}` as keyof typeof f.result; - it(`${String(key)} ${f.description}`, () => { + it(`${key} ${f.description}`, () => { const tx = composeTx({ ...f.request, network: NETWORKS.bitcoin,