Skip to content

Commit 8bc40d5

Browse files
authored
feat(abstract-utxo): verify the mint amount with external change amount for sbtc mint txs
2 parents b7c91fb + 2c3b12a commit 8bc40d5

3 files changed

Lines changed: 179 additions & 1 deletion

File tree

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ParseTransactionOptions as BaseParseTransactionOptions,
2828
PrecreateBitGoOptions,
2929
PresignTransactionOptions,
30+
BridgingParams,
3031
RequestTracer,
3132
SignedTransaction,
3233
TxIntentMismatchError,
@@ -276,6 +277,8 @@ export interface TransactionParams extends BaseTransactionParams {
276277
allowExternalChangeAddress?: boolean;
277278
changeAddress?: string;
278279
rbfTxIds?: string[];
280+
/** Parameters for bridging intents (e.g. BTC -> sBTC peg-in), present when `type === 'bridging'`. */
281+
bridgingParams?: BridgingParams;
279282
}
280283

281284
export interface ParseTransactionOptions<TNumber extends number | bigint = number> extends BaseParseTransactionOptions {

modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export async function verifyTransaction<TNumber extends bigint | number>(
6565
throw new Error('should not have unspents in txInfo for psbt');
6666
}
6767
const disableNetworking = !!verification.disableNetworking;
68+
const isBridging = txParams.type === 'bridging';
6869
const parsedTransaction: ParsedTransaction<TNumber> = await coin.parseTransaction<TNumber>({
6970
txParams,
7071
txPrebuild,
@@ -160,7 +161,21 @@ export async function verifyTransaction<TNumber extends bigint | number>(
160161

161162
// There are two instances where we will get into this point here
162163
if (nonChangeAmount.gt(payAsYouGoLimit)) {
163-
if (isPsbt && parsedTransaction.customChange) {
164+
if (isBridging) {
165+
// The implicit external output is the bridge deposit address (see note above); it has no
166+
// recipient to match against, so instead verify the total implicit external spend equals the
167+
// intended bridge amount from bridgingParams.
168+
const bridgeAmount = txParams.bridgingParams?.sbtc?.amount;
169+
if (bridgeAmount === undefined) {
170+
throwTxMismatch('bridging transaction is missing bridgingParams.sbtc.amount');
171+
} else if (!nonChangeAmount.eq(bridgeAmount.toString())) {
172+
throwTxMismatch(
173+
`bridging output amount (${nonChangeAmount.toString()}) does not match intended bridge amount (${bridgeAmount})`
174+
);
175+
} else {
176+
debug('verified bridging output amount matches bridgingParams.sbtc.amount');
177+
}
178+
} else if (isPsbt && parsedTransaction.customChange) {
164179
// In the case that we have a custom change address on a wallet and we are building the transaction
165180
// with a PSBT, we do not have the metadata to verify the address from the custom change wallet, nor
166181
// can we fetch that information from the other wallet because we may not have the credentials. Therefore,

modules/abstract-utxo/test/unit/verifyTransaction.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,166 @@ describe('Verify Transaction', function () {
305305
bitcoinMock.restore();
306306
});
307307

308+
it('should verify a bridging transaction whose implicit external output matches the bridge amount', async () => {
309+
// Bridging intents (e.g. BTC -> sBTC peg-in) carry no recipients; the single external output
310+
// is the bridge deposit address computed server-side. With explicitExternalSpendAmount 0 the
311+
// paygo limit is 0, so any implicit external output would normally be rejected as an
312+
// "unintended external recipient". For type: 'bridging' we instead verify that the implicit
313+
// external spend equals bridgingParams.sbtc.amount.
314+
const coinMock = sinon.stub(coin, 'parseTransaction').resolves({
315+
keychains: {} as any,
316+
keySignatures: {},
317+
outputs: [],
318+
missingOutputs: [],
319+
explicitExternalOutputs: [],
320+
implicitExternalOutputs: [
321+
{
322+
address: 'sbtc_deposit_address',
323+
amount: '22000',
324+
},
325+
],
326+
changeOutputs: [],
327+
explicitExternalSpendAmount: 0,
328+
implicitExternalSpendAmount: 22000,
329+
needsCustomChangeKeySignatureVerification: false,
330+
});
331+
332+
const bitcoinMock = sinon
333+
.stub(coin, 'createTransactionFromHex')
334+
.returns({ ins: [] } as unknown as utxolib.bitgo.UtxoTransaction);
335+
336+
const result = await coin.verifyTransaction({
337+
txParams: {
338+
walletPassphrase: passphrase,
339+
type: 'bridging',
340+
bridgingParams: { sbtc: { amount: '22000', stacksRecipient: 'SM1X', maxFee: '1000', lockTime: 100 } },
341+
},
342+
txPrebuild: {
343+
txHex: '00',
344+
},
345+
wallet: unsignedSendingWallet as any,
346+
verification: {},
347+
});
348+
349+
assert.strictEqual(result, true);
350+
351+
coinMock.restore();
352+
bitcoinMock.restore();
353+
});
354+
355+
it('should reject a bridging transaction whose implicit external output does not match the bridge amount', async () => {
356+
const coinMock = sinon.stub(coin, 'parseTransaction').resolves({
357+
keychains: {} as any,
358+
keySignatures: {},
359+
outputs: [],
360+
missingOutputs: [],
361+
explicitExternalOutputs: [],
362+
implicitExternalOutputs: [
363+
{
364+
address: 'sbtc_deposit_address',
365+
amount: '50000',
366+
},
367+
],
368+
changeOutputs: [],
369+
explicitExternalSpendAmount: 0,
370+
implicitExternalSpendAmount: 50000,
371+
needsCustomChangeKeySignatureVerification: false,
372+
});
373+
374+
await assert.rejects(
375+
coin.verifyTransaction({
376+
txParams: {
377+
walletPassphrase: passphrase,
378+
type: 'bridging',
379+
bridgingParams: { sbtc: { amount: '22000', stacksRecipient: 'SM1X', maxFee: '1000', lockTime: 100 } },
380+
},
381+
txPrebuild: {
382+
txHex: '00',
383+
},
384+
wallet: unsignedSendingWallet as any,
385+
verification: {},
386+
}),
387+
/bridging output amount \(50000\) does not match intended bridge amount \(22000\)/
388+
);
389+
390+
coinMock.restore();
391+
});
392+
393+
it('should reject a bridging transaction that is missing bridgingParams.sbtc.amount', async () => {
394+
const coinMock = sinon.stub(coin, 'parseTransaction').resolves({
395+
keychains: {} as any,
396+
keySignatures: {},
397+
outputs: [],
398+
missingOutputs: [],
399+
explicitExternalOutputs: [],
400+
implicitExternalOutputs: [
401+
{
402+
address: 'sbtc_deposit_address',
403+
amount: '22000',
404+
},
405+
],
406+
changeOutputs: [],
407+
explicitExternalSpendAmount: 0,
408+
implicitExternalSpendAmount: 22000,
409+
needsCustomChangeKeySignatureVerification: false,
410+
});
411+
412+
await assert.rejects(
413+
coin.verifyTransaction({
414+
txParams: {
415+
walletPassphrase: passphrase,
416+
type: 'bridging',
417+
},
418+
txPrebuild: {
419+
txHex: '00',
420+
},
421+
wallet: unsignedSendingWallet as any,
422+
verification: {},
423+
}),
424+
/bridging transaction is missing bridgingParams.sbtc.amount/
425+
);
426+
427+
coinMock.restore();
428+
});
429+
430+
it('should still reject the same implicit external output when the intent is not bridging', async () => {
431+
// Same shape as the bridging test above, but without type: 'bridging' the implicit external
432+
// output is treated as an unintended external recipient and rejected.
433+
const coinMock = sinon.stub(coin, 'parseTransaction').resolves({
434+
keychains: {} as any,
435+
keySignatures: {},
436+
outputs: [],
437+
missingOutputs: [],
438+
explicitExternalOutputs: [],
439+
implicitExternalOutputs: [
440+
{
441+
address: 'sbtc_deposit_address',
442+
amount: '22000',
443+
},
444+
],
445+
changeOutputs: [],
446+
explicitExternalSpendAmount: 0,
447+
implicitExternalSpendAmount: 22000,
448+
needsCustomChangeKeySignatureVerification: false,
449+
});
450+
451+
await assert.rejects(
452+
coin.verifyTransaction({
453+
txParams: {
454+
walletPassphrase: passphrase,
455+
},
456+
txPrebuild: {
457+
txHex: '00',
458+
},
459+
wallet: unsignedSendingWallet as any,
460+
verification: {},
461+
}),
462+
/prebuild attempts to spend to unintended external recipients/
463+
);
464+
465+
coinMock.restore();
466+
});
467+
308468
it('should work with bigint amounts', async () => {
309469
// need a coin that uses bigint
310470
const bigintCoin = getUtxoCoin('tdoge');

0 commit comments

Comments
 (0)