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'], };