Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement random strategy for utxo sorting #14993

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions packages/connect/e2e/__fixtures__/composeTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export default {
feePerByte: '1',
max: undefined,
totalSpent: '6639',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDWITNESS', sequence: 0xffffffff }],
outputs: [
{ amount: '2787', script_type: 'PAYTOWITNESS' },
Expand Down Expand Up @@ -125,6 +126,7 @@ export default {
feePerByte: '2.176056338028169',
max: undefined,
totalSpent: '6806',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDWITNESS', sequence: 1 }],
outputs: [
{ amount: '6497', script_type: 'PAYTOADDRESS' },
Expand Down Expand Up @@ -159,6 +161,7 @@ export default {
feePerByte: '2.176056338028169',
max: undefined,
totalSpent: '6806',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDWITNESS', sequence: 1 }],
outputs: [
{ amount: '6497', script_type: 'PAYTOADDRESS' },
Expand Down Expand Up @@ -210,10 +213,11 @@ export default {
bytes: 142,
fee: '142',
totalSpent: '1142',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ amount: '9426', script_type: 'SPENDWITNESS' }],
outputs: [
{ amount: '1000', script_type: 'PAYTOADDRESS' },
{ amount: '8284', script_type: 'PAYTOWITNESS' },
{ amount: '1000', script_type: 'PAYTOADDRESS' },
],
},
],
Expand Down Expand Up @@ -270,14 +274,15 @@ export default {
bytes: 278,
fee: '278',
totalSpent: '16678',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [
{ amount: '7086' },
{ amount: '9426' },
{ amount: '309896' }, // NOTE: this utxo is used because required utxo is not enough to cover fee
{ amount: '9426' },
{ amount: '7086' },
],
outputs: [
{ amount: '16400', script_type: 'PAYTOADDRESS' },
{ amount: '309730', script_type: 'PAYTOWITNESS' },
{ amount: '16400', script_type: 'PAYTOADDRESS' },
],
},
],
Expand Down Expand Up @@ -329,6 +334,7 @@ export default {
feePerByte: '1',
max: '9315',
totalSpent: '9426',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDWITNESS' }],
outputs: [{ amount: '9315', script_type: 'PAYTOADDRESS' }],
},
Expand All @@ -354,14 +360,15 @@ export default {
fee: '226000',
max: undefined,
totalSpent: '100226000',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDADDRESS' }],
outputs: [
{ amount: '399774000', script_type: 'PAYTOADDRESS' },
{
address: 'DDn7UV1CrqVefzwrHyw7H2zEZZKqfzR2ZD',
amount: '100000000',
script_type: 'PAYTOADDRESS',
},
{ amount: '399774000', script_type: 'PAYTOADDRESS' },
],
},
],
Expand All @@ -386,14 +393,15 @@ export default {
fee: '1226000', // NOTE: +0.01 DOGE per dust limit output
max: undefined,
totalSpent: '1326000',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDADDRESS' }],
outputs: [
{ amount: '498674000', script_type: 'PAYTOADDRESS' },
{
address: 'DDn7UV1CrqVefzwrHyw7H2zEZZKqfzR2ZD',
amount: '100000',
script_type: 'PAYTOADDRESS',
},
{ amount: '498674000', script_type: 'PAYTOADDRESS' },
],
},
],
Expand Down Expand Up @@ -423,16 +431,17 @@ export default {
fee: '1000000', // NOTE: +0.01 DOGE per dust limit output + ~0.08 DOGE dust limit change
max: undefined,
totalSpent: '500000000',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDADDRESS' }],
outputs: [
{
address: 'DDn7UV1CrqVefzwrHyw7H2zEZZKqfzR2ZD',
amount: '200000000',
amount: '299000000',
script_type: 'PAYTOADDRESS',
},
{
address: 'DDn7UV1CrqVefzwrHyw7H2zEZZKqfzR2ZD',
amount: '299000000',
amount: '200000000',
script_type: 'PAYTOADDRESS',
},
],
Expand All @@ -459,6 +468,7 @@ export default {
fee: '192000',
max: '499808000',
totalSpent: '500000000',
// Order of inputs & outputs is important as we mock randomness in test to make it deterministic
inputs: [{ script_type: 'SPENDADDRESS' }],
outputs: [{ amount: '499808000', script_type: 'PAYTOADDRESS' }],
},
Expand Down
2 changes: 1 addition & 1 deletion packages/connect/e2e/karma.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const path = require('path');
const webpack = require('webpack');

module.exports = config => {
const singleRun = process.env.KARMA_SINGLE_RUN === 'false' ? false : true;
const singleRun = process.env.KARMA_SINGLE_RUN !== 'false';

config.set({
basePath: path.resolve(__dirname, '../..'), // NOTE: "[monorepo-root]/packages", to have access to other packages
Expand Down
28 changes: 28 additions & 0 deletions packages/connect/e2e/tests/device/methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ import {

let controller: ReturnType<typeof getController> | undefined;

// After the removal bip69, we sort inputs and outputs randomly
// So we need to mock the source of randomness for all tests, so the fixtures are deterministic.

// However, we run those test both in Node.js and in browser environment,
// so we need to mock the source of randomness in both environments

// This is mock of randomnes for Karma (web environment)
if (typeof window !== 'undefined') {
window.crypto.getRandomValues = array => {
if (array instanceof Uint32Array) {
array[0] = 4;
}

return array;
};
}

// In Karma web environment, there is no `jest`, so we fake one
if (typeof jest === 'undefined') {
globalThis.jest = { mock: () => undefined } as any;
}

// Jest.mock() MUST be called in global scope, if we put it into condition it won't work.
jest.mock('@trezor/utils', () => ({
...jest.requireActual('@trezor/utils'),
getRandomInt: (min: number, max: number) => min + (4 % max), // 4 is truly random number, I rolled the dice
}));

const getFixtures = () => {
const includedMethods = process.env.TESTS_INCLUDED_METHODS;
const excludedMethods = process.env.TESTS_EXCLUDED_METHODS;
Expand Down
2 changes: 1 addition & 1 deletion 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';

export const DEFAULT_SORTING_STRATEGY: TransactionInputOutputSortingStrategy = 'bip69';
export const DEFAULT_SORTING_STRATEGY: TransactionInputOutputSortingStrategy = 'random';
37 changes: 37 additions & 0 deletions packages/utxo-lib/src/compose/sorting/randomSortingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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];
peter-sanderson marked this conversation as resolved.
Show resolved Hide resolved
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
74 changes: 74 additions & 0 deletions packages/utxo-lib/tests/__fixtures__/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Fixture = {
changeAddress: AnyComposeRequest['changeAddress'] & { path?: number[] | string };
};
result: AnyComposeResult;
randomIntSequence?: number[];
};

export const composeTxFixture: Fixture[] = [
Expand Down Expand Up @@ -524,6 +525,79 @@ export const composeTxFixture: Fixture[] = [
type: 'final',
},
},
{
description:
'sorts the inputs randomly and puts change at random place between user-defined outputs' +
'when sortingStrategy=random',
randomIntSequence: [
1, // For outputs (so change output is inserted at index 1 after the first output)
1, // Shuffling inputs (Fisher-Yates swap last, the 3rd with second)
0, // Shuffling inputs (Fisher-Yates swap second with the first)
],
peter-sanderson marked this conversation as resolved.
Show resolved Hide resolved
request: {
changeAddress: { address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT' },
dustThreshold: 546,
feeRate: '10',
sortingStrategy: 'random',
outputs: [
{
address: '1BitcoinEaterAddressDontSendf59kuE',
amount: '100000',
type: 'payment',
},
{
address: '1LetUsDestroyBitcoinTogether398Nrg',
amount: '150000',
type: 'payment',
},
],
utxos: [
{
...UTXO,
vout: 3,
},
{
...UTXO,
vout: 2,
},
{
...UTXO,
vout: 1,
},
],
},
result: {
bytes: 556,
fee: '5560',
feePerByte: '10',
max: undefined,
totalSpent: '255560',
inputs: [
{ ...UTXO, vout: 1 },
{ ...UTXO, vout: 3 },
{ ...UTXO, vout: 2 },
],
outputs: [
{
address: '1BitcoinEaterAddressDontSendf59kuE',
amount: '100000',
type: 'payment',
},
{
address: '1CrwjoKxvdbAnPcGzYjpvZ4no4S71neKXT',
amount: '50443',
type: 'change',
},
{
address: '1LetUsDestroyBitcoinTogether398Nrg',
amount: '150000',
type: 'payment',
},
],
outputsPermutation: [0, 2, 1],
type: 'final',
},
},
{
description: 'builds a p2sh tx with two same value outputs (mixed p2sh + p2pkh) and change',
request: {
Expand Down
22 changes: 21 additions & 1 deletion packages/utxo-lib/tests/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,33 @@ import { verifyTxBytes } from './compose.utils';
import { composeTxFixture } from './__fixtures__/compose';
import { fixturesCrossCheck } from './__fixtures__/compose.crosscheck';

import { getRandomInt } from '@trezor/utils';

jest.mock('@trezor/utils', () => ({
...jest.requireActual('@trezor/utils'),
getRandomInt: jest.fn(),
}));

const mockRandomInt = (randomIntSequence: number[] | undefined) => {
let fakeRandomIndex = 0;
(getRandomInt as jest.Mock).mockImplementation(() => {
if (randomIntSequence === undefined || fakeRandomIndex >= randomIntSequence.length) {
throw new Error(`Not enough random numbers provided (i: ${fakeRandomIndex})`);
}

return randomIntSequence?.[fakeRandomIndex++];
});
};

describe(composeTx.name, () => {
composeTxFixture.forEach(f => {
const network = f.request.network ?? NETWORKS.bitcoin;
const request = { ...f.request, network };
const result = { ...f.result };

it(f.description, () => {
mockRandomInt(f.randomIntSequence);

const tx = composeTx(request);
expect(tx).toEqual(result);

Expand Down Expand Up @@ -53,7 +73,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}`, () => {
Lemonexe marked this conversation as resolved.
Show resolved Hide resolved
it(`${key} ${f.description}`, () => {
const tx = composeTx({
...f.request,
network: NETWORKS.bitcoin,
Expand Down
Loading