From 1c8f6012d01f460d692003fe33b45c2791038bf6 Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Tue, 18 Aug 2026 01:25:10 +0700 Subject: [PATCH] test(bitcoin-wallet-snap): repair the integration suite and cover template output order The integration suite has not run since the migration into this monorepo. run-integration.sh passed jest.integration.config.js while the file is jest.integration.config.mjs, and the config declared no transform, so it fell back to the preset's babel-jest and required @babel/preset-env, which is not installed. Point the script at the real filename and use the same ts-jest transform the unit config uses. With the suite running for the first time, 53 of 60 tests failed. The assertions still expected the v1 keyring envelope, { pending: false, result }, which the snap stopped returning in 2.0.1, and one assertion required the funding transaction to be the only transaction on a regtest chain that the other suites broadcast to. Add a regression test for template output order: a template whose middle output belongs to the wallet must keep both its position and its value. Before the ordering fix that output was moved to the end of the transaction and given the excess. Assert output order on the existing fillPsbt test too, which only checked that a string came back. --- .../integration-test/keyring-request.test.ts | 167 +++++++++++------- .../integration-test/keyring.test.ts | 26 +-- .../integration-test/psbt-utils.ts | 123 +++++++++++++ .../integration-test/run-integration.sh | 2 +- .../jest.integration.config.mjs | 3 + 5 files changed, 242 insertions(+), 79 deletions(-) create mode 100644 packages/bitcoin-wallet-snap/integration-test/psbt-utils.ts diff --git a/packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index b698dbe56..bddccc111 100644 --- a/packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -8,6 +8,11 @@ import { Caip19Asset } from '../src/handlers/caip'; import type { FillPsbtResponse } from '../src/handlers/KeyringRequestHandler'; import { BlockchainTestUtils } from './blockchain-utils'; import { MNEMONIC, ORIGIN } from './constants'; +import { buildTemplatePsbt, readOutputs } from './psbt-utils'; + +const DEPOSIT_SCRIPT = + '5120e44fd4d762ab7db99520bf8cc1b44658404c7626bae50b7d78041d4337bb98b8'; +const OP_RETURN_SCRIPT = '6a0568656c6c6f'; const ACCOUNT_INDEX = 3; const submitRequestMethod = 'keyring_submitRequest'; @@ -132,24 +137,20 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ - pending: false, - result: [ - { - address: 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', - derivationIndex: 0, - outpoint: expect.any(String), - scriptPubkey: - 'OP_0 OP_PUSHBYTES_20 82932f60427f769dfab2f449c91b4d9e94a8edb4', - scriptPubkeyHex: '001482932f60427f769dfab2f449c91b4d9e94a8edb4', - value: '1000000000', - }, - ], - }); + expect(response).toRespondWith([ + { + address: 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + derivationIndex: 0, + outpoint: expect.any(String), + scriptPubkey: + 'OP_0 OP_PUSHBYTES_20 82932f60427f769dfab2f449c91b4d9e94a8edb4', + scriptPubkeyHex: '001482932f60427f769dfab2f449c91b4d9e94a8edb4', + value: '1000000000', + }, + ]); - const utxos = ( - response.response as { result: { result: { outpoint: string }[] } } - ).result.result; + const utxos = (response.response as { result: { outpoint: string }[] }) + .result; response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -169,10 +170,7 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ - pending: false, - result: utxos[0], - }); + expect(response).toRespondWith(utxos[0]); }); it('publicDescriptor', async () => { @@ -190,11 +188,9 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ - pending: false, - result: - "wpkh([27f9035f/84'/1'/0']tpubDCkv2fHDfPg5ok9EPv6CDozH72rvY2jgEPm79szMeBwCBwUf2T6n5nLrWFfhuuD48SgzrELezoiyDM9KbZaVen4wuuGwrqQANDhzB7E8yDh/0/*)#sx899xk6", - }); + expect(response).toRespondWith( + "wpkh([27f9035f/84'/1'/0']tpubDCkv2fHDfPg5ok9EPv6CDozH72rvY2jgEPm79szMeBwCBwUf2T6n5nLrWFfhuuD48SgzrELezoiyDM9KbZaVen4wuuGwrqQANDhzB7E8yDh/0/*)#sx899xk6", + ); }); }); @@ -236,11 +232,8 @@ describe('KeyringRequestHandler', () => { const result = await response; expect(result).toRespondWith({ - pending: false, - result: { - psbt: SIGNED_PSBT, - txid: null, - }, + psbt: SIGNED_PSBT, + txid: null, }); }); @@ -275,11 +268,8 @@ describe('KeyringRequestHandler', () => { const result = await response; expect(result).toRespondWith({ - pending: false, - result: { - psbt: expect.any(String), // non deterministic - txid: null, - }, + psbt: expect.any(String), // non deterministic + txid: null, }); }); @@ -314,12 +304,9 @@ describe('KeyringRequestHandler', () => { const result = await response; expect(result).toRespondWith({ - pending: false, - result: { - psbt: expect.any(String), // non deterministic - txid: expect.any(String), - canBeMalleable: false, - }, + psbt: expect.any(String), // non deterministic + txid: expect.any(String), + canBeMalleable: false, }); // Regression for issue #597: after broadcasting a partial-spend tx @@ -466,11 +453,69 @@ describe('KeyringRequestHandler', () => { }); expect(response).toRespondWith({ - pending: false, - result: { - psbt: expect.any(String), // non deterministic - }, + psbt: expect.any(String), // the change amount is not deterministic + }); + + const { psbt } = (response.response as { result: FillPsbtResponse }) + .result; + const templateOutputs = readOutputs(TEMPLATE_PSBT); + + // the last template output belongs to the wallet, so it becomes the drain + // output and takes the excess: assert the order, not its value + expect( + readOutputs(psbt) + .slice(0, templateOutputs.length) + .map((output) => output.scriptHex), + ).toStrictEqual(templateOutputs.map((output) => output.scriptHex)); + }); + + it('keeps a wallet-owned output in its template position', async () => { + const utxosResponse = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { method: AccountCapability.ListUtxos }, + } as KeyringRequest, }); + const ourScriptHex = ( + utxosResponse.response as { result: { scriptPubkeyHex: string }[] } + ).result[0]?.scriptPubkeyHex as string; + + const templateOutputs = [ + { scriptHex: DEPOSIT_SCRIPT, value: 20000 }, + { scriptHex: ourScriptHex, value: 1000 }, + { scriptHex: OP_RETURN_SCRIPT, value: 0 }, + ]; + + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.FillPsbt, + params: { + account: { address: account.address }, + psbt: buildTemplatePsbt(templateOutputs), + feeRate: 3, + }, + }, + } as KeyringRequest, + }); + + const { psbt } = (response.response as { result: FillPsbtResponse }) + .result; + const builtOutputs = readOutputs(psbt); + + expect(builtOutputs.slice(0, 3)).toStrictEqual(templateOutputs); + expect(builtOutputs.length).toBeGreaterThan(3); }); it('fails if invalid PSBT', async () => { @@ -526,10 +571,7 @@ describe('KeyringRequestHandler', () => { }); expect(response).toRespondWith({ - pending: false, - result: { - fee: '632', - }, + fee: '632', }); }); @@ -596,9 +638,7 @@ describe('KeyringRequestHandler', () => { const signResult = await signResponse; - const { result } = ( - signResult.response as { result: { result: FillPsbtResponse } } - ).result; + const { result } = signResult.response as { result: FillPsbtResponse }; const response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -619,11 +659,8 @@ describe('KeyringRequestHandler', () => { }); expect(response).toRespondWith({ - pending: false, - result: { - txid: expect.any(String), - canBeMalleable: false, - }, + txid: expect.any(String), + canBeMalleable: false, }); }); @@ -687,11 +724,8 @@ describe('KeyringRequestHandler', () => { const result = await response; expect(result).toRespondWith({ - pending: false, - result: { - txid: expect.any(String), - canBeMalleable: false, - }, + txid: expect.any(String), + canBeMalleable: false, }); }); @@ -749,11 +783,8 @@ describe('KeyringRequestHandler', () => { const result = await response; expect(result).toRespondWith({ - pending: false, - result: { - signature: - 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4=', - }, + signature: + 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4=', }); }); }); diff --git a/packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index ff7e3745f..4f6f18dbc 100644 --- a/packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -353,10 +353,14 @@ describe('Keyring', () => { }, }); - expect(response).toRespondWith({ - data: [{ ...FUNDING_TX, account: accoundId }], - next: null, - }); + const { data, next } = ( + response.response as { + result: { data: unknown[]; next: string | null }; + } + ).result; + + expect(data).toContainEqual({ ...FUNDING_TX, account: accoundId }); + expect(next).toBeNull(); }); it('gets an account balance', async () => { @@ -369,12 +373,14 @@ describe('Keyring', () => { }, }); - expect(response).toRespondWith({ - [Caip19Asset.Regtest]: { - amount: '500', - unit: CurrencyUnit.Regtest, - }, - }); + const balance = ( + response.response as { + result: Record; + } + ).result[Caip19Asset.Regtest]; + + expect(balance?.unit).toBe(CurrencyUnit.Regtest); + expect(Number(balance?.amount)).toBeGreaterThanOrEqual(500); }); it.each([ diff --git a/packages/bitcoin-wallet-snap/integration-test/psbt-utils.ts b/packages/bitcoin-wallet-snap/integration-test/psbt-utils.ts new file mode 100644 index 000000000..6d5b5acca --- /dev/null +++ b/packages/bitcoin-wallet-snap/integration-test/psbt-utils.ts @@ -0,0 +1,123 @@ +/* eslint-disable no-restricted-globals */ + +export type TemplateOutput = { scriptHex: string; value: number }; + +const PSBT_MAGIC = '70736274ff'; +const GLOBAL_UNSIGNED_TX = '0100'; + +const varInt = (value: number): Buffer => { + if (value < 0xfd) { + return Buffer.from([value]); + } + const buffer = Buffer.alloc(3); + buffer.writeUInt8(0xfd, 0); + buffer.writeUInt16LE(value, 1); + return buffer; +}; + +const uInt32 = (value: number): Buffer => { + const buffer = Buffer.alloc(4); + buffer.writeUInt32LE(value, 0); + return buffer; +}; + +const uInt64 = (value: number): Buffer => { + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64LE(BigInt(value), 0); + return buffer; +}; + +/** + * Builds a base64 PSBT holding only outputs, the shape bridge and swap providers + * return: no inputs, no change, for the wallet to fill. + * + * @param outputs - The outputs to place, in order. + * @returns The base64 encoded PSBT. + */ +export const buildTemplatePsbt = (outputs: TemplateOutput[]): string => { + const unsignedTx = Buffer.concat([ + uInt32(2), + varInt(0), + varInt(outputs.length), + ...outputs.flatMap((output) => { + const script = Buffer.from(output.scriptHex, 'hex'); + return [uInt64(output.value), varInt(script.length), script]; + }), + uInt32(0), + ]); + + return Buffer.concat([ + Buffer.from(PSBT_MAGIC, 'hex'), + Buffer.from(GLOBAL_UNSIGNED_TX, 'hex'), + varInt(unsignedTx.length), + unsignedTx, + Buffer.from([0x00]), + ...outputs.map(() => Buffer.from([0x00])), + ]).toString('base64'); +}; + +/** + * Reads the outputs of a PSBT's unsigned transaction, in transaction order. + * + * @param psbtBase64 - The base64 encoded PSBT. + * @returns The outputs, in the order they appear in the transaction. + */ +export const readOutputs = (psbtBase64: string): TemplateOutput[] => { + const psbt = Buffer.from(psbtBase64, 'base64'); + let offset = PSBT_MAGIC.length / 2; + + const readVarInt = (): number => { + const first = psbt.readUInt8(offset); + offset += 1; + if (first < 0xfd) { + return first; + } + if (first === 0xfd) { + const value = psbt.readUInt16LE(offset); + offset += 2; + return value; + } + const value = psbt.readUInt32LE(offset); + offset += 4; + return value; + }; + + // global map: find the unsigned transaction record + for (;;) { + const keyLength = readVarInt(); + if (keyLength === 0) { + throw new Error('PSBT has no unsigned transaction'); + } + const keyType = psbt.readUInt8(offset); + offset += keyLength; + const valueLength = readVarInt(); + if (keyType === 0x00) { + break; + } + offset += valueLength; + } + + offset += 4; // version + const inputCount = readVarInt(); + for (let index = 0; index < inputCount; index++) { + offset += 36; // previous outpoint + const scriptSigLength = readVarInt(); + offset += scriptSigLength; + offset += 4; // sequence + } + + const outputCount = readVarInt(); + const outputs: TemplateOutput[] = []; + for (let index = 0; index < outputCount; index++) { + const value = Number(psbt.readBigUInt64LE(offset)); + offset += 8; + const scriptLength = readVarInt(); + outputs.push({ + scriptHex: psbt.subarray(offset, offset + scriptLength).toString('hex'), + value, + }); + offset += scriptLength; + } + + return outputs; +}; diff --git a/packages/bitcoin-wallet-snap/integration-test/run-integration.sh b/packages/bitcoin-wallet-snap/integration-test/run-integration.sh index 1fcd07f35..629bd26dd 100755 --- a/packages/bitcoin-wallet-snap/integration-test/run-integration.sh +++ b/packages/bitcoin-wallet-snap/integration-test/run-integration.sh @@ -30,7 +30,7 @@ docker exec esplora bash /init-esplora.sh echo "Running integration tests..." set +e -jest --config jest.integration.config.js +jest --config jest.integration.config.mjs TEST_EXIT_CODE=$? set -e exit $TEST_EXIT_CODE diff --git a/packages/bitcoin-wallet-snap/jest.integration.config.mjs b/packages/bitcoin-wallet-snap/jest.integration.config.mjs index c36c839e2..e42012c10 100644 --- a/packages/bitcoin-wallet-snap/jest.integration.config.mjs +++ b/packages/bitcoin-wallet-snap/jest.integration.config.mjs @@ -4,6 +4,9 @@ */ const config = { preset: '@metamask/snaps-jest', + transform: { + '^.+\\.(t|j)sx?$': 'ts-jest', + }, testMatch: ['**/integration-test/**/*.test.ts'], };