From 31e6eec610fa661a3cbad46c7359cfd94cec42f5 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 3496d442e0..7b7a47bbd6 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -279,6 +279,7 @@ export interface TransactionParams extends BaseTransactionParams { rbfTxIds?: string[]; /** Parameters for bridging intents (e.g. BTC -> sBTC peg-in), present when `type === 'bridging'`. */ bridgingParams?: BridgingParams; + 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 9d313cf5b6..14ad5f7f19 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts @@ -127,6 +127,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 c368366bc2..35b58a9568 100644 --- a/modules/abstract-utxo/test/unit/verifyTransaction.ts +++ b/modules/abstract-utxo/test/unit/verifyTransaction.ts @@ -465,6 +465,119 @@ describe('Verify Transaction', function () { coinMock.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 577d3da4b3..a635b2057b 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 38b454c422..384f7b1205 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -274,6 +274,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 bc2e6b671b61a552602d9a18cc2962137c484435 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Mon, 1 Jun 2026 11:16:30 -0400 Subject: [PATCH 2/2] feat: bump public typers to 6.22 T1-3418 --- modules/abstract-lightning/package.json | 2 +- modules/bitgo/package.json | 2 +- modules/express/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 | 8 ++++---- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index fb634d5ce9..edc508d166 100644 --- a/modules/abstract-lightning/package.json +++ b/modules/abstract-lightning/package.json @@ -39,7 +39,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.21.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-core": "^37.3.0", "@bitgo/statics": "^58.43.0", "@bitgo/utxo-lib": "^11.22.1", diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 524696c102..11a629d5ce 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -140,7 +140,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.21.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-opensslbytes": "^2.1.0", "@bitgo/sdk-test": "^9.1.46", "@openpgp/web-stream-tools": "0.0.14", diff --git a/modules/express/package.json b/modules/express/package.json index 514ad0767a..94fc652fd3 100644 --- a/modules/express/package.json +++ b/modules/express/package.json @@ -60,7 +60,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.21.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-lib-mpc": "^10.14.0", "@bitgo/sdk-test": "^9.1.46", "@types/argparse": "^1.0.36", diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index 53ff304b9c..d5f2db13d9 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -47,7 +47,7 @@ "@bitgo/sdk-test": "^9.1.46" }, "dependencies": { - "@bitgo/public-types": "6.21.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-core": "^37.3.0", "@bitgo/secp256k1": "^1.11.0", "@bitgo/statics": "^58.43.0", diff --git a/modules/sdk-coin-sol/package.json b/modules/sdk-coin-sol/package.json index ddf2a906f6..88a4d0d8c2 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.21.0", + "@bitgo/public-types": "6.22.0", "@bitgo/sdk-core": "^37.3.0", "@bitgo/sdk-lib-mpc": "^10.14.0", "@bitgo/statics": "^58.43.0", diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index e65abc35b4..d0d4994808 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.21.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 9e0fe876bd..c83e8186c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1063,10 +1063,10 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" -"@bitgo/public-types@6.21.0": - version "6.21.0" - resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.21.0.tgz#54120da43349514cb5f7c98013883fd25e40fdbb" - integrity sha512-Y5pVLb81bGUY4jZWvp92AZzpdCmI1qUpDbMOsPEdwMxwlFNwj8gHCjtbW3sNpXpr09m3v9IXUZxdNBdjeGs9/w== +"@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"