Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/abstract-lightning/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ export interface TransactionParams extends BaseTransactionParams {
allowExternalChangeAddress?: boolean;
changeAddress?: string;
rbfTxIds?: string[];
qr?: boolean;
}

export interface ParseTransactionOptions<TNumber extends number | bigint = number> extends BaseParseTransactionOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,32 @@ export async function verifyTransaction<TNumber extends number | bigint>(
}

assertValidTransaction(psbt, descriptorMap, params.txParams.recipients ?? [], coin.name);
Comment thread
davidkaplanbitgo marked this conversation as resolved.
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ export async function verifyTransaction<TNumber extends bigint | number>(
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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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;

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();
const externalScript = Buffer.from(descriptorOther.atDerivationIndex(0).scriptPubkey());
const externalAddress = toExtendedAddressFormat(externalScript, 'tbtc');

await assert.rejects(
verifyTransaction(
coin,
{
txParams: {
qr: true,
recipients: [{ address: externalAddress, amount: '400000' }],
},
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);
});
});
113 changes: 113 additions & 0 deletions modules/abstract-utxo/test/unit/verifyTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion modules/bitgo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion modules/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion modules/passkey-crypto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-coin-flrp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-coin-sol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/BuildParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 4 additions & 15 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading