Skip to content

Commit

Permalink
docs(blockapis): improve documentation for UtxoApi test suite
Browse files Browse the repository at this point in the history
Issue: BTC-1084
  • Loading branch information
OttoAllmendinger committed Apr 12, 2024
1 parent ee1341e commit b650728
Show file tree
Hide file tree
Showing 2 changed files with 228 additions and 0 deletions.
58 changes: 58 additions & 0 deletions modules/blockapis/test/UtxoApi.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
/**
* This test suite iterates over a set of backends and test methods.
*
* For each backend and method, we test that the backend can fetch the expected results.
*
* We then compare the normalized results from all backends to make sure they are the same.
*
* We then make sure that the results from all backends are the same.
*/

import 'mocha';
import * as assert from 'assert';
import { BlockchairApi, BlockstreamApi, UtxoApi, CachingHttpClient } from '../src';
Expand Down Expand Up @@ -34,13 +44,30 @@ function getTestTransactionIds(coinName: string): string[] {

type MethodArguments = unknown[];

/**
* A test case for a UtxoApi method.
*/
class TestCase<T> {
/**
* @param coinName - coin to test
* @param methodName - method to test
* @param args - method arguments
*/
constructor(public coinName: string, public methodName: keyof UtxoApi, public args: unknown[]) {}

/**
* Call the method on the given API.
* @param api
*/
func(api: UtxoApi) {
return (api[this.methodName] as any)(...this.args);
}

/**
* Get the fixture for this test case.
* @param api
* @param defaultValue
*/
async getFixture(api: UtxoApi, defaultValue?: T): Promise<T> {
const filename = [
'UtxoApi',
Expand All @@ -53,6 +80,10 @@ class TestCase<T> {
return await getFixture(`${__dirname}/fixtures/${filename}`, defaultValue);
}

/**
* Get the fixture, but with the API-specific fields removed.
* @param api
*/
async getFixtureNormal(api: UtxoApi): Promise<T> {
if (this.methodName === 'getTransactionStatus') {
// remove api-specific fields
Expand All @@ -78,6 +109,9 @@ class TestCase<T> {
return false;
}

/**
* @return a human-readable title for this test case.
*/
title(): string {
function elide(s: string, len: number) {
return s.length > len ? `${s.slice(0, len)}...` : s;
Expand All @@ -86,10 +120,18 @@ class TestCase<T> {
}
}

/**
* @param name
* @return a new CachingHttpClient that reads from the given fixture directory.
*/
function getHttpClient(name: string): CachingHttpClient {
return new CachingHttpClient(`${__dirname}/fixtures/responses/` + name, { isHttpEnabled: isHttpEnabled() });
}

/**
* @param coinName
* @return a list of APIs to test.
*/
function getApis(coinName: string): UtxoApi[] {
if (coinName === 'tbtc') {
return [
Expand All @@ -101,6 +143,10 @@ function getApis(coinName: string): UtxoApi[] {
return [];
}

/**
* @param coinName
* @return a list of test cases for the given coin.
*/
function getTestCases(coinName: string): TestCase<unknown>[] {
function getArguments(coinName: string, methodName: keyof UtxoApi): MethodArguments[] {
switch (methodName) {
Expand Down Expand Up @@ -130,6 +176,12 @@ function getTestCases(coinName: string): TestCase<unknown>[] {
);
}

/**
* Set up fetch tests for the given API.
*
* @param api
* @param coinName
*/
function runTestFetch(api: UtxoApi, coinName: string) {
getTestCases(coinName).forEach((testCase) => {
describe(`${api.constructor.name} ${testCase.title()}`, function () {
Expand All @@ -146,6 +198,12 @@ function runTestFetch(api: UtxoApi, coinName: string) {
});
}

/**
* Set up comparison tests for the given API.
*
* @param api
* @param coinName
*/
function runTestCompare(api: UtxoApi, coinName: string) {
getTestCases(coinName).forEach((testCase) => {
describe(`method ${testCase.title()}`, function () {
Expand Down
170 changes: 170 additions & 0 deletions modules/utxo-lib/src/bitgo/wallet/psbt/rootNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import * as assert from 'assert';
import { UtxoPsbt } from '../../UtxoPsbt';
import { isTriple, Triple } from '../../types';
import { BIP32Factory, BIP32Interface } from 'bip32';
import { ecc as eccLib } from '../../../noble_ecc';
import { ParsedScriptType2Of3 } from '../../parseInput';
import { Network } from '../../../networks';
import { createOutputScript2of3, ScriptType2Of3 } from '../../outputScripts';
import { getPsbtInputScriptType } from '../Psbt';
import { createTransactionFromBuffer } from '../../transaction';

/**
* Retrieves the root BIP32 keys from a PSBT's global xpubs.
* Asserts that there are exactly three keys (or none), as expected for a 2-of-3 multisig setup.
* @param psbt - The PSBT to extract root nodes from.
* @returns An array of the three root BIP32 keys, or undefined if none are present.
*/
export function getMultiSigRootNodes(psbt: UtxoPsbt): Triple<BIP32Interface> | undefined {
const bip32s = psbt.data.globalMap.globalXpub?.map((xpub) =>
BIP32Factory(eccLib).fromBase58(bs58check.encode(xpub.extendedPubkey))
);
assert(!bip32s || isTriple(bip32s), `Invalid globalXpubs in PSBT. Expected 3 or none. Got ${bip32s?.length}`);
return bip32s;
}

/**
* Maps a parsed 2-of-3 script type to its corresponding ScriptType2Of3 array.
* Used to handle the various script types a PSBT input or output can be part of.
* @param parsedScriptType - The parsed script type from a PSBT input/output.
* @returns An array of ScriptType2Of3 based on the parsedScriptType.
*/
export function toScriptType2Of3s(parsedScriptType: ParsedScriptType2Of3): ScriptType2Of3[] {
return parsedScriptType === 'taprootScriptPathSpend'
? ['p2trMusig2', 'p2tr']
: parsedScriptType === 'taprootKeyPathSpend'
? ['p2trMusig2']
: [parsedScriptType];
}

/**
* Checks if a specific permutation of public keys matches the provided scriptPubKey.
* Utilizes script type conversion and script creation to verify the match.
* @param params - An object containing the required parameters for script matching.
* @returns True if the scriptPubKey matches the constructed script for the given permutation and type.
*/
function matchesScript({
publicKeys,
perm,
scriptPubKey,
parsedScriptType,
network,
}: {
publicKeys: Buffer[];
perm: Triple<number>;
scriptPubKey: Buffer;
parsedScriptType: ParsedScriptType2Of3;
network: Network;
}): boolean {
const orderedPublicKeys: Triple<Buffer> = [publicKeys[perm[0]], publicKeys[perm[1]], publicKeys[perm[2]]];
const scriptTypes = toScriptType2Of3s(parsedScriptType);
return scriptTypes.some((scriptType) =>
createOutputScript2of3(orderedPublicKeys, scriptType, network).scriptPubKey.equals(scriptPubKey)
);
}

/**
* Determines the correct order of public keys for a 2-of-3 multisig input based on the scriptPubKey.
* It iterates through all possible permutations to find a match.
* @param publicKeys - The public keys involved in the multisig setup.
* @param scriptPubKey - The scriptPubKey from the PSBT input to match against.
* @param parsedScriptType - The type of script the PSBT input is using.
* @param network - The Bitcoin network the PSBT is for.
* @param ordered - Specifies if the provided public keys are already ordered.
* @returns An ordered array of indices representing the correct order of public keys.
*/
function determineAssertedOrder({
publicKeys,
scriptPubKey,
parsedScriptType,
network,
ordered,
}: {
publicKeys: Triple<Buffer>;
scriptPubKey: Buffer;
parsedScriptType: ParsedScriptType2Of3;
network: Network;
ordered: boolean;
}): Triple<number> {
const permutations: Array<Triple<number>> = ordered
? [[0, 1, 2]]
: [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
];

const order = permutations.find((perm) =>
matchesScript({ publicKeys, perm, scriptPubKey, parsedScriptType, network })
);
assert(order, 'Could not determine order of public keys of multi sig input');
return order;
}

/**
* Extracts multi-sig related information from a PSBT, necessary for root node ordering.
* This includes script type, scriptPubKey, and derivation path for the first non-p2shP2pk input.
* @param psbt - The PSBT to extract information from.
* @returns An object containing the extracted details or undefined if not found.
*/
function getMultiSigDetailsForSortRootNodes(psbt: UtxoPsbt): {
parsedScriptType: ParsedScriptType2Of3;
scriptPubKey: Buffer;
derivationPath: string;
} {
const txInputs = psbt.txInputs;
for (let i = 0; i < psbt.data.inputs.length; i++) {
const input = psbt.data.inputs[i];
const parsedScriptType = getPsbtInputScriptType(input);
if (parsedScriptType === 'p2shP2pk') {
continue;
}

const prevOutIndex = txInputs[i].index;
const scriptPubKey =
input.witnessUtxo?.script ??
(input.nonWitnessUtxo
? createTransactionFromBuffer(input.nonWitnessUtxo, psbt.network, { amountType: 'bigint' }).outs[prevOutIndex]
.script
: undefined);
assert(scriptPubKey, 'Input scriptPubKey can not be found');

const bip32Dv = input?.bip32Derivation ?? input?.tapBip32Derivation;
assert(bip32Dv?.length, 'Input Bip32Derivation can not be found');
const derivationPath = bip32Dv[0].path;

return { parsedScriptType, scriptPubKey, derivationPath };
}
throw new Error('No multi sig input found');
}

/**
* Orders the root nodes (BIP32 keys) canonically based on a PSBT's multi-sig script.
* This is used to ensure consistent ordering of keys across PSBT operations.
* @param psbt - The PSBT to order root nodes for.
* @param rootNodes - Optionally, a specific set of root nodes to order if no PSBT globalXpub is provided.
* @param ordered - Specifies if the provided rootNodes are already ordered.
* @returns An ordered array of Triple<BIP32Interface>, representing the canonically ordered root nodes.
*/
export function getAssertedCanonicalOrderedRootNodes(psbt: UtxoPsbt): Triple<BIP32Interface> {
const unorderedRootNodes = getMultiSigRootNodes(psbt);
assert(unorderedRootNodes, 'Either rootNodes or PSBT globalXpub must be provided');

const { parsedScriptType, scriptPubKey, derivationPath } = getMultiSigDetailsForSortRootNodes(psbt);

const publicKeys = unorderedRootNodes.map(
(rootNode) => rootNode.derivePath(derivationPath).publicKey
) as Triple<Buffer>;

const order = determineAssertedOrder({
publicKeys,
scriptPubKey,
parsedScriptType,
network: psbt.network,
ordered: false,
});
return order.map((i) => unorderedRootNodes[i]) as Triple<BIP32Interface>;
}

0 comments on commit b650728

Please sign in to comment.