Skip to content

Commit

Permalink
feat: introduce sortingStrategy into utxo-lib so we can have more str…
Browse files Browse the repository at this point in the history
…ategies (bip69, none, and later the random)

feat: refactor utxo-lib so it impements multiple sorting strategies (one strategy per file)
  • Loading branch information
peter-sanderson committed Oct 21, 2024
1 parent 49f7737 commit 3dace57
Show file tree
Hide file tree
Showing 7 changed files with 272 additions and 66 deletions.
39 changes: 39 additions & 0 deletions packages/utxo-lib/src/compose/sorting/bip69SortingStrategy.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
23 changes: 23 additions & 0 deletions packages/utxo-lib/src/compose/sorting/convertOutput.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
};
18 changes: 18 additions & 0 deletions packages/utxo-lib/src/compose/sorting/noneSortingStrategy.ts
Original file line number Diff line number Diff line change
@@ -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()),
};
};
18 changes: 18 additions & 0 deletions packages/utxo-lib/src/compose/sorting/sortingStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
CoinSelectSuccess,
ComposeChangeAddress,
ComposedTransaction,
ComposeFinalOutput,
ComposeInput,
ComposeRequest,
} from '../../types';

type SortingStrategyParams<Input extends ComposeInput, Change extends ComposeChangeAddress> = {
request: ComposeRequest<Input, ComposeFinalOutput, Change>;
result: CoinSelectSuccess;
convertedInputs: Input[];
};

export type SortingStrategy = <Input extends ComposeInput, Change extends ComposeChangeAddress>(
params: SortingStrategyParams<Input, Change>,
) => ComposedTransaction<Input, ComposeFinalOutput, Change>;
82 changes: 21 additions & 61 deletions packages/utxo-lib/src/compose/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input, ComposeFinalOutput, Change>,
): 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<TransactionInputOutputSortingStrategy, SortingStrategy> = {
bip69: bip69SortingStrategy,
none: noneSortingStrategy,
};

export function createTransaction<Input extends ComposeInput, Change extends ComposeChangeAddress>(
request: ComposeRequest<Input, ComposeFinalOutput, Change>,
result: CoinSelectSuccess,
): ComposedTransaction<Input, ComposeFinalOutput, Change> {
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 });
}
31 changes: 27 additions & 4 deletions packages/utxo-lib/src/types/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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> = T extends ComposeOutputSendMax
? Omit<T, 'type'> & ComposeOutputPayment // NOTE: replace ComposeOutputSendMax (no amount) with ComposeOutputPayment (with amount)
Expand Down
127 changes: 126 additions & 1 deletion packages/utxo-lib/tests/__fixtures__/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down

0 comments on commit 3dace57

Please sign in to comment.