Skip to content

Commit

Permalink
feat: implement random strategy for utxo sorting
Browse files Browse the repository at this point in the history
  • Loading branch information
peter-sanderson committed Oct 22, 2024
1 parent d3a4fe5 commit e6c9977
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 4 deletions.
4 changes: 2 additions & 2 deletions packages/connect/src/constants/utxo.ts
Original file line number Diff line number Diff line change
@@ -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';
39 changes: 39 additions & 0 deletions packages/utxo-lib/src/compose/sorting/randomSortingStrategy.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
2 changes: 2 additions & 0 deletions packages/utxo-lib/src/compose/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TransactionInputOutputSortingStrategy, SortingStrategy> = {
bip69: bip69SortingStrategy,
none: noneSortingStrategy,
random: randomSortingStrategy,
};

export function createTransaction<Input extends ComposeInput, Change extends ComposeChangeAddress>(
Expand Down
2 changes: 1 addition & 1 deletion packages/utxo-lib/src/types/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions packages/utxo-lib/tests/__fixtures__/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
25 changes: 24 additions & 1 deletion packages/utxo-lib/tests/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit e6c9977

Please sign in to comment.