From 0f066240b77918c8a34e5f0e9a3c5302a9bd97cf Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Wed, 27 May 2026 12:49:45 -0400 Subject: [PATCH 1/2] feat(sdk-core,abstract-utxo): add qr param and client-side output validation Add the `qr` build parameter to the SDK and enforce client-side verification that quantum-resistant sweep transactions only contain wallet-internal outputs sdk-core: - Add `qr: t.boolean` to `BuildParamsUTXO` io-ts codec - Add `qr?: boolean` to `PrebuildTransactionOptions` interface abstract-utxo: - Add `qr?: boolean` to `TransactionParams` interface - Fixed-script verifyTransaction: when qr is true, reject any transaction with explicit or implicit external outputs - Descriptor verifyTransaction: same enforcement for descriptor wallets, throwing TxIntentMismatchError on external outputs - 7 new tests covering both verification paths Ticket: T1-3418 Co-authored-by: Cursor --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 1 + .../descriptor/verifyTransaction.ts | 27 ++++- .../fixedScript/verifyTransaction.ts | 11 ++ .../descriptor/verifyTransactionQr.ts | 93 ++++++++++++++ .../test/unit/verifyTransaction.ts | 113 ++++++++++++++++++ .../sdk-core/src/bitgo/wallet/BuildParams.ts | 1 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 1 + 7 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 8c84f8db46..26a2778435 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -276,6 +276,7 @@ export interface TransactionParams extends BaseTransactionParams { allowExternalChangeAddress?: boolean; changeAddress?: string; rbfTxIds?: string[]; + qr?: boolean; } export interface ParseTransactionOptions extends BaseParseTransactionOptions { diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 7f81479a90..301918238d 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -94,7 +94,32 @@ export async function verifyTransaction( ); } - assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], coin.name); + const parsedOutputs = toBaseParsedTransactionOutputsFromPsbt( + psbt, + descriptorMap, + params.txParams.recipients ?? [], + coin.name + ); + + if (params.txParams.qr) { + const allExternalOutputs = [...parsedOutputs.explicitExternalOutputs, ...parsedOutputs.implicitExternalOutputs]; + if (allExternalOutputs.length > 0) { + const txExplanation = await TxIntentMismatchError.tryGetTxExplanation( + coin as unknown as IBaseCoin, + params.txPrebuild + ); + throw new TxIntentMismatchError( + 'quantum-resistant sweep transactions must only contain wallet-internal outputs', + params.reqId, + [params.txParams], + params.txPrebuild.txHex, + txExplanation + ); + } + return true; + } + + assertExpectedOutputDifference(parsedOutputs); return true; } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts index c7a2f9ab5d..50b8b1d2e5 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -126,6 +126,17 @@ export async function verifyTransaction( debug('successfully verified user public key and custom change key signatures'); } + if (txParams.qr) { + const allExternalOutputs = [ + ...parsedTransaction.explicitExternalOutputs, + ...parsedTransaction.implicitExternalOutputs, + ]; + if (allExternalOutputs.length > 0) { + throwTxMismatch('quantum-resistant sweep transactions must only contain wallet-internal outputs'); + } + return true; + } + const missingOutputs = parsedTransaction.missingOutputs; if (missingOutputs.length !== 0) { // there are some outputs in the recipients list that have not made it into the actual transaction diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts new file mode 100644 index 0000000000..14c8481489 --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts @@ -0,0 +1,93 @@ +import assert from 'assert'; + +import * as testutils from '@bitgo/wasm-utxo/testutils'; + +import { verifyTransaction } from '../../../../src/transaction/descriptor/verifyTransaction'; +import { getUtxoCoin } from '../../util'; + +const { getDefaultXPubs, getDescriptor, getDescriptorMap, mockPsbt } = testutils.descriptor; + +describe('descriptor verifyTransaction - quantum-resistant sweep', function () { + const coin = getUtxoCoin('tbtc'); + + const xpubsSelf = getDefaultXPubs('a'); + const xpubsOther = getDefaultXPubs('b'); + const descriptorSelf = getDescriptor('Wsh2Of3', xpubsSelf); + const descriptorOther = getDescriptor('Wsh2Of3', xpubsOther); + const descriptorMap = getDescriptorMap('Wsh2Of3', xpubsSelf); + + function buildPsbtAllInternal() { + return mockPsbt( + [ + { descriptor: descriptorSelf, index: 0 }, + { descriptor: descriptorSelf, index: 1, id: { vout: 1 } }, + ], + [ + { descriptor: descriptorSelf, index: 0, value: BigInt(4e5) }, + { descriptor: descriptorSelf, index: 1, value: BigInt(4e5) }, + ] + ); + } + + function buildPsbtWithExternal() { + return mockPsbt( + [ + { descriptor: descriptorSelf, index: 0 }, + { descriptor: descriptorSelf, index: 1, id: { vout: 1 } }, + ], + [ + { descriptor: descriptorOther, index: 0, value: BigInt(4e5), external: true }, + { descriptor: descriptorSelf, index: 0, value: BigInt(4e5) }, + ] + ); + } + + it('should reject when external outputs exist and qr is true', async function () { + const psbt = buildPsbtWithExternal(); + + await assert.rejects( + verifyTransaction( + coin, + { + txParams: { qr: true, recipients: [] }, + txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, + wallet: {} as any, + }, + descriptorMap + ), + /quantum-resistant sweep transactions must only contain wallet-internal outputs/ + ); + }); + + it('should pass when all outputs are internal and qr is true', async function () { + const psbt = buildPsbtAllInternal(); + + const result = await verifyTransaction( + coin, + { + txParams: { qr: true, recipients: [] }, + txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, + wallet: {} as any, + }, + descriptorMap + ); + + assert.strictEqual(result, true); + }); + + it('should not apply qr check when qr is not set (internal-only outputs pass normally)', async function () { + const psbt = buildPsbtAllInternal(); + + const result = await verifyTransaction( + coin, + { + txParams: { recipients: [] }, + txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, + wallet: {} as any, + }, + descriptorMap + ); + + assert.strictEqual(result, true); + }); +}); diff --git a/modules/abstract-utxo/test/unit/verifyTransaction.ts b/modules/abstract-utxo/test/unit/verifyTransaction.ts index b7eabebf13..695883ec87 100644 --- a/modules/abstract-utxo/test/unit/verifyTransaction.ts +++ b/modules/abstract-utxo/test/unit/verifyTransaction.ts @@ -305,6 +305,119 @@ describe('Verify Transaction', function () { bitcoinMock.restore(); }); + describe('quantum-resistant sweep (qr: true)', function () { + it('should reject when explicit external outputs are present', async () => { + const coinMock = sinon.stub(coin, 'parseTransaction').resolves({ + keychains: {} as any, + keySignatures: {}, + outputs: [], + missingOutputs: [], + explicitExternalOutputs: [{ address: 'external_addr', amount: '5000' }], + implicitExternalOutputs: [], + changeOutputs: [{ address: 'change_addr', amount: '4000' }], + explicitExternalSpendAmount: 5000, + implicitExternalSpendAmount: 0, + needsCustomChangeKeySignatureVerification: false, + }); + + await assert.rejects( + coin.verifyTransaction({ + txParams: { walletPassphrase: passphrase, qr: true }, + txPrebuild: {}, + wallet: unsignedSendingWallet as any, + verification: {}, + }), + /quantum-resistant sweep transactions must only contain wallet-internal outputs/ + ); + + coinMock.restore(); + }); + + it('should reject when implicit external outputs are present', async () => { + const coinMock = sinon.stub(coin, 'parseTransaction').resolves({ + keychains: {} as any, + keySignatures: {}, + outputs: [], + missingOutputs: [], + explicitExternalOutputs: [], + implicitExternalOutputs: [{ address: 'paygo_addr', amount: '100' }], + changeOutputs: [{ address: 'change_addr', amount: '9900' }], + explicitExternalSpendAmount: 0, + implicitExternalSpendAmount: 100, + needsCustomChangeKeySignatureVerification: false, + }); + + await assert.rejects( + coin.verifyTransaction({ + txParams: { walletPassphrase: passphrase, qr: true }, + txPrebuild: {}, + wallet: unsignedSendingWallet as any, + verification: {}, + }), + /quantum-resistant sweep transactions must only contain wallet-internal outputs/ + ); + + coinMock.restore(); + }); + + it('should pass when all outputs are internal (change only)', async () => { + const coinMock = sinon.stub(coin, 'parseTransaction').resolves({ + keychains: {} as any, + keySignatures: {}, + outputs: [{ address: 'change_addr', amount: '10000' }], + missingOutputs: [], + explicitExternalOutputs: [], + implicitExternalOutputs: [], + changeOutputs: [{ address: 'change_addr', amount: '10000' }], + explicitExternalSpendAmount: 0, + implicitExternalSpendAmount: 0, + needsCustomChangeKeySignatureVerification: false, + }); + + const result = await coin.verifyTransaction({ + txParams: { walletPassphrase: passphrase, qr: true }, + txPrebuild: {}, + wallet: unsignedSendingWallet as any, + verification: {}, + }); + + assert.strictEqual(result, true); + + coinMock.restore(); + }); + + it('should not apply qr check when qr is not set', async () => { + const coinMock = sinon.stub(coin, 'parseTransaction').resolves({ + keychains: {} as any, + keySignatures: {}, + outputs: [], + missingOutputs: [], + explicitExternalOutputs: [{ address: 'external_addr', amount: '5000' }], + implicitExternalOutputs: [], + changeOutputs: [], + explicitExternalSpendAmount: 5000, + implicitExternalSpendAmount: 0, + needsCustomChangeKeySignatureVerification: false, + }); + + const bitcoinMock = sinon + .stub(coin, 'createTransactionFromHex') + .returns({ ins: [] } as unknown as utxolib.bitgo.UtxoTransaction); + + const result = await coin.verifyTransaction({ + txParams: { walletPassphrase: passphrase }, + txPrebuild: { txHex: '00' }, + wallet: unsignedSendingWallet as any, + verification: {}, + }); + + assert.strictEqual(result, true); + + coinMock.restore(); + bitcoinMock.restore(); + }); + }); + it('should work with bigint amounts', async () => { // need a coin that uses bigint const bigintCoin = getUtxoCoin('tdoge'); diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 0d6ea7964d..3b4a1815a8 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -38,6 +38,7 @@ export const BuildParamsUTXO = t.partial({ rbfTxIds: t.array(t.string), isReplaceableByFee: t.boolean, messages: t.array(Bip322Message), + qr: t.boolean, }); export const BuildParamsStacks = t.partial({ diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index f98e985d7d..38a44d4800 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -256,6 +256,7 @@ export interface PrebuildTransactionOptions { }; txRequestId?: string; isTestTransaction?: boolean; + qr?: boolean; transferOfferId?: string; /** * Amount for intents that use a top-level amount instead of recipients (e.g. bridgeFunds). From e7a5b023b7aeecdb817ea22b4962bcbe5523dde1 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Wed, 27 May 2026 12:49:51 -0400 Subject: [PATCH 2/2] chore: bump @bitgo/public-types to 6.22.0 Bump public-types across all consuming packages to pick up the qr field on TxSendBody. Ticket: T1-3418 --- modules/abstract-lightning/package.json | 2 +- .../descriptor/verifyTransaction.ts | 1 + .../descriptor/verifyTransactionQr.ts | 8 +++++++- modules/bitgo/package.json | 2 +- modules/express/package.json | 2 +- modules/passkey-crypto/package.json | 2 +- modules/sdk-coin-flrp/package.json | 2 +- modules/sdk-coin-sol/package.json | 2 +- modules/sdk-core/package.json | 2 +- yarn.lock | 19 ++++--------------- 10 files changed, 19 insertions(+), 23 deletions(-) diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index a0cd491f90..49eb9edfca 100644 --- a/modules/abstract-lightning/package.json +++ b/modules/abstract-lightning/package.json @@ -39,7 +39,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.17.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-core": "^37.2.0", "@bitgo/statics": "^58.42.0", "@bitgo/utxo-lib": "^11.22.1", diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index 301918238d..7c8b3a09d7 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -94,6 +94,7 @@ export async function verifyTransaction( ); } + assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], coin.name); const parsedOutputs = toBaseParsedTransactionOutputsFromPsbt( psbt, descriptorMap, diff --git a/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts index 14c8481489..00f86979a0 100644 --- a/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts +++ b/modules/abstract-utxo/test/unit/transaction/descriptor/verifyTransactionQr.ts @@ -3,6 +3,7 @@ import assert from 'assert'; import * as testutils from '@bitgo/wasm-utxo/testutils'; import { verifyTransaction } from '../../../../src/transaction/descriptor/verifyTransaction'; +import { toExtendedAddressFormat } from '../../../../src/transaction/recipient'; import { getUtxoCoin } from '../../util'; const { getDefaultXPubs, getDescriptor, getDescriptorMap, mockPsbt } = testutils.descriptor; @@ -44,12 +45,17 @@ describe('descriptor verifyTransaction - quantum-resistant sweep', function () { it('should reject when external outputs exist and qr is true', async function () { const psbt = buildPsbtWithExternal(); + const externalScript = Buffer.from(descriptorOther.atDerivationIndex(0).scriptPubkey()); + const externalAddress = toExtendedAddressFormat(externalScript, 'tbtc'); await assert.rejects( verifyTransaction( coin, { - txParams: { qr: true, recipients: [] }, + txParams: { + qr: true, + recipients: [{ address: externalAddress, amount: '400000' }], + }, txPrebuild: { txHex: Buffer.from(psbt.serialize()).toString('hex') }, wallet: {} as any, }, diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index a6f829499f..2ce528374e 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -140,7 +140,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.17.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-opensslbytes": "^2.1.0", "@bitgo/sdk-test": "^9.1.45", "@openpgp/web-stream-tools": "0.0.14", diff --git a/modules/express/package.json b/modules/express/package.json index 7e5193a064..412a0d109c 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -60,7 +60,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.17.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-lib-mpc": "^10.14.0", "@bitgo/sdk-test": "^9.1.45", "@types/argparse": "^1.0.36", diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index 10b7bd386d..e2fe96f349 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -35,7 +35,7 @@ "access": "public" }, "dependencies": { - "@bitgo/public-types": "6.1.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-core": "^37.2.0" }, "devDependencies": { diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index 5f6350ca4b..d0f0f5ec0e 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -47,7 +47,7 @@ "@bitgo/sdk-test": "^9.1.45" }, "dependencies": { - "@bitgo/public-types": "6.17.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-core": "^37.2.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.42.0", diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index b3021543cb..e0a060f3b5 100644 --- a/modules/sdk-coin-sol/package.json +++ b/modules/sdk-coin-sol/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@bitgo/logger": "^1.4.0", - "@bitgo/public-types": "6.17.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-core": "^37.2.0", "@bitgo/sdk-lib-mpc": "^10.14.0", "@bitgo/statics": "^58.42.0", diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index da6a1d77b3..cf50932598 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.17.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-lib-mpc": "^10.14.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/sjcl": "^1.1.0", diff --git a/yarn.lock b/yarn.lock index 9e47216ef1..267f50c0e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1052,21 +1052,10 @@ "@scure/base" "1.1.5" micro-eth-signer "0.7.2" -"@bitgo/public-types@6.1.0": - version "6.1.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.1.0.tgz#7c3949a0ae4de706b3d6a748ab07669a330e3fad" - integrity sha512-k+3cYvcSzpaqBcBO3saZkwfsazE3JY9WC321WX76fAYFTt6v6Q71pyUSCH41dTEZz9KGi79DwicCnpKsREw8eg== - dependencies: - fp-ts "^2.0.0" - io-ts "npm:@bitgo-forks/io-ts@2.1.4" - io-ts-types "^0.5.16" - monocle-ts "^2.3.13" - newtype-ts "^0.3.5" - -"@bitgo/public-types@6.17.0": - version "6.17.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.17.0.tgz#9e87418d55926d512610edeb2c46c811dcb09f65" - integrity sha512-/7JlGvxNsLx4qZQe5iQVoFoI2vKv1eElJKiiQM8NsARTqX76duYzJeYmiKiFvTERGK+np/gx29ijyBYw40KyGA== +"@bitgo/public-types@6.22.0": + version "6.22.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.22.0.tgz#bbef2866c9b2d35e4a6179f7c400abc4f419d0ec" + integrity sha512-FueZVrrAKfevkoC9/TtKQLq5S19PzKfsNSj+0uHt1rEoKJ5vS1Icf/M/8pIwYVR11Kn3mjWzqbYJrJUZI/3FHQ== dependencies: fp-ts "^2.0.0" io-ts "npm:@bitgo-forks/io-ts@2.1.4"