Skip to content

Commit 7cc82ac

Browse files
committed
feat: add bridgingParams support for BTC-to-sBTC bridging
Add bridgingParams to the BTC transaction build pipeline for cross-chain BTC-to-sBTC bridging. The parameter flows through all wallet types: non-TSS (hot/cold via BuildParams whitelist), TSS (via new 'bridging' case in prebuild switch), and custodial. Shared types (SbtcBridgingParams, BridgingParams) defined once in iWallet.ts and reused across sdk-core and express typed routes. bridgingParams is conditionally included in populateIntent only for intentType === 'bridging'. Ticket: CSHLD-906
1 parent 676e584 commit 7cc82ac

9 files changed

Lines changed: 227 additions & 1 deletion

File tree

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1710,6 +1710,45 @@ describe('V2 Wallet:', function () {
17101710
postProcessStub.restore();
17111711
});
17121712

1713+
it('should pass bridgingParams to tx/build for non-TSS BTC wallet', async function () {
1714+
const bridgingParams = {
1715+
sbtc: {
1716+
amount: 100000,
1717+
stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
1718+
maxFee: 5000,
1719+
lockTime: 144,
1720+
},
1721+
};
1722+
const expectedBuildParams = {
1723+
type: 'bridging',
1724+
txFormat: 'psbt',
1725+
recipients: [],
1726+
bridgingParams,
1727+
changeAddressType: ['p2trMusig2', 'p2wsh', 'p2shP2wsh', 'p2sh', 'p2tr'],
1728+
};
1729+
1730+
const scope = nock(bgUrl)
1731+
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/build`, expectedBuildParams)
1732+
.query({})
1733+
.reply(200, {});
1734+
const blockHeightStub = sinon.stub(basecoin, 'getLatestBlockHeight').resolves(100);
1735+
const postProcessStub = sinon.stub(basecoin, 'postProcessPrebuild').resolves({});
1736+
await wallet.prebuildTransaction({
1737+
type: 'bridging',
1738+
txFormat: 'psbt',
1739+
recipients: [],
1740+
bridgingParams,
1741+
});
1742+
postProcessStub.should.have.been.calledOnceWith({
1743+
blockHeight: 100,
1744+
wallet: wallet,
1745+
buildParams: expectedBuildParams,
1746+
});
1747+
scope.done();
1748+
blockHeightStub.restore();
1749+
postProcessStub.restore();
1750+
});
1751+
17131752
it('prebuild should call build but not getLatestBlockHeight for account coins', async function () {
17141753
['txrp', 'txlm', 'teth'].forEach(async function (coin) {
17151754
const accountcoin = bitgo.coin(coin);
@@ -2468,8 +2507,22 @@ describe('V2 Wallet:', function () {
24682507
multisigType: 'tss',
24692508
};
24702509

2510+
const btcWalletData = {
2511+
id: '5b34252f1bf349930e34020a11111111',
2512+
coin: 'tbtc',
2513+
keys: [
2514+
'598f606cd8fc24710d2ebad89dce86c2',
2515+
'598f606cc8e43aef09fcb785221d9dd2',
2516+
'5935d59cf660764331bafcade1855fd7',
2517+
],
2518+
multisigType: 'tss',
2519+
coinSpecific: {},
2520+
type: 'hot',
2521+
};
2522+
24712523
const tssSolWallet = new Wallet(bitgo, tsol, walletData);
24722524
const tssAdaWallet = new Wallet(bitgo, tada, adaWalletData);
2525+
const tssBtcWallet = new Wallet(bitgo, basecoin, btcWalletData);
24732526

24742527
let tssEthWallet = new Wallet(bitgo, bitgo.coin('teth'), ethWalletData);
24752528
const tssPolygonWallet = new Wallet(bitgo, bitgo.coin('tpolygon'), polygonWalletData);
@@ -3536,6 +3589,35 @@ describe('V2 Wallet:', function () {
35363589
args[1]!.should.equal('full');
35373590
});
35383591

3592+
it('should call prebuildTxWithIntent with the correct params for bridging on BTC wallet', async function () {
3593+
const bridgingParams = {
3594+
sbtc: {
3595+
amount: 100000,
3596+
stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
3597+
maxFee: 5000,
3598+
lockTime: 144,
3599+
},
3600+
};
3601+
3602+
const prebuildTxWithIntent = sandbox.stub(ECDSAUtils.EcdsaUtils.prototype, 'prebuildTxWithIntent');
3603+
prebuildTxWithIntent.resolves(txRequestFull);
3604+
3605+
await tssBtcWallet.prebuildTransaction({
3606+
reqId,
3607+
type: 'bridging',
3608+
recipients: [],
3609+
bridgingParams,
3610+
txFormat: 'psbt',
3611+
});
3612+
3613+
sinon.assert.calledOnce(prebuildTxWithIntent);
3614+
const args = prebuildTxWithIntent.args[0];
3615+
args[0]!.intentType.should.equal('bridging');
3616+
args[0]!.bridgingParams!.should.deepEqual(bridgingParams);
3617+
args[0]!.recipients!.should.deepEqual([]);
3618+
args[1]!.should.equal('full');
3619+
});
3620+
35393621
it('should call prebuildTxWithIntent with the correct params for Export', async function () {
35403622
const recipients = [
35413623
{
@@ -3915,6 +3997,37 @@ describe('V2 Wallet:', function () {
39153997
intent.should.have.property('recipients', undefined);
39163998
});
39173999

4000+
it('populate intent should include bridgingParams only for bridging intent type on BTC', async function () {
4001+
const tbtc = bitgo.coin('tbtc');
4002+
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, tbtc);
4003+
const bridgingParams = {
4004+
sbtc: {
4005+
amount: 100000,
4006+
stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
4007+
maxFee: 5000,
4008+
lockTime: 144,
4009+
},
4010+
};
4011+
4012+
// bridgingParams should be included when intentType is 'bridging'
4013+
const bridgingIntent = mpcUtils.populateIntent(tbtc, {
4014+
reqId,
4015+
intentType: 'bridging',
4016+
recipients: [{ address: 'tb1qexample', amount: '100000' }],
4017+
bridgingParams,
4018+
});
4019+
bridgingIntent.bridgingParams!.should.deepEqual(bridgingParams);
4020+
4021+
// bridgingParams should NOT be included for other intent types
4022+
const paymentIntent = mpcUtils.populateIntent(tbtc, {
4023+
reqId,
4024+
intentType: 'payment',
4025+
recipients: [{ address: 'tb1qexample', amount: '100000' }],
4026+
bridgingParams,
4027+
});
4028+
paymentIntent.should.not.have.property('bridgingParams');
4029+
});
4030+
39184031
it('populate intent should return valid export intent for EVM cross-chain', async function () {
39194032
const mpcUtils = new ECDSAUtils.EcdsaUtils(bitgo, bitgo.coin('hteth'));
39204033
const feeOptions = {

modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
SignedTransactionRequestResponse,
1010
EIP1559,
1111
} from './coinSignTx';
12+
import { BridgingParamsCodec } from './sendmany';
1213

1314
/**
1415
* Request parameters for prebuild and sign transaction
@@ -384,6 +385,8 @@ export const PrebuildAndSignTransactionBody = {
384385
eip1559: optional(EIP1559),
385386
/** Gas limit */
386387
gasLimit: optional(t.number),
388+
/** Parameters for bridging transactions (e.g., BTC to sBTC). Used with type: 'bridging'. */
389+
bridgingParams: optional(BridgingParamsCodec),
387390
/** Low fee transaction ID for CPFP */
388391
lowFeeTxid: optional(t.string),
389392
/** Receive address */

modules/express/src/typedRoutes/api/v2/sendmany.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,27 @@ export const TokenEnablement = t.intersection([
105105
}),
106106
]);
107107

108+
/**
109+
* sBTC bridging parameters codec
110+
*/
111+
export const SbtcBridgingParamsCodec = t.type({
112+
/** Amount in satoshis to bridge */
113+
amount: t.union([t.number, t.string]),
114+
/** Stacks recipient address */
115+
stacksRecipient: t.string,
116+
/** Maximum fee in satoshis */
117+
maxFee: t.union([t.number, t.string]),
118+
/** Lock time for the bridging transaction */
119+
lockTime: t.number,
120+
});
121+
122+
/**
123+
* Bridging parameters codec for cross-chain bridging transactions (e.g., BTC to sBTC).
124+
*/
125+
export const BridgingParamsCodec = t.partial({
126+
sbtc: SbtcBridgingParamsCodec,
127+
});
128+
108129
/**
109130
* Request body for sending to multiple recipients (v2)
110131
*
@@ -344,6 +365,9 @@ export const SendManyRequestBody = {
344365
/** Array of tokens to enable on the wallet */
345366
enableTokens: optional(t.array(TokenEnablement)),
346367

368+
/** Parameters for bridging transactions (e.g., BTC to sBTC). Used with type: 'bridging'. */
369+
bridgingParams: optional(BridgingParamsCodec),
370+
347371
/** Low fee transaction ID (for CPFP - Child Pays For Parent) */
348372
lowFeeTxid: optional(t.string),
349373

modules/sdk-core/src/bitgo/utils/mpcUtils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export abstract class MpcUtils {
183183
'transferOfferWithdrawn',
184184
'bridgeFunds',
185185
'cantonCommand',
186+
'bridging',
186187
].includes(params.intentType)
187188
) {
188189
assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`);
@@ -222,6 +223,7 @@ export abstract class MpcUtils {
222223
recipients: intentRecipients,
223224
tokenName: params.tokenName,
224225
isTestTransaction: params.isTestTransaction,
226+
...(params.intentType === 'bridging' && params.bridgingParams ? { bridgingParams: params.bridgingParams } : {}),
225227
};
226228

227229
if (baseCoin.isEVM() && baseCoin.supportsTss()) {

modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Key, SerializedKeyPair } from 'openpgp';
22
import { EncryptionVersion, IEncryptionSession, IRequestTracer } from '../../../api';
33
import { type ITransactionRecipient, KeychainsTriplet, ParsedTransaction, TransactionParams } from '../../baseCoin';
44
import { ApiKeyShare, Keychain, WebauthnKeyEncryptionInfo } from '../../keychain';
5-
import { ApiVersion, Memo, WalletType } from '../../wallet';
5+
import { ApiVersion, BridgingParams, Memo, WalletType } from '../../wallet';
66
import { EDDSA, GShare, Signature, SignShare } from '../../../account-lib/mpc/tss';
77
import { Signature as EcdsaSignature } from '../../../account-lib/mpc/tss/ecdsa/types';
88
import { KeyShare } from './ecdsa';
@@ -354,6 +354,10 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase
354354
feeToken?: string;
355355
/** Canton-specific params for the cantonCommand intent. */
356356
cantonCommandParams?: CantonCommandParams;
357+
/**
358+
* Parameters for bridging transactions (e.g., BTC to sBTC).
359+
*/
360+
bridgingParams?: BridgingParams;
357361
}
358362
export interface IntentRecipient {
359363
address: {
@@ -437,6 +441,10 @@ export interface PopulatedIntent extends PopulatedIntentBase {
437441
amount?: { value: string; symbol: string };
438442
/** Canton-specific params serialized into the cantonCommand intent payload. */
439443
cantonCommandParams?: CantonCommandParams;
444+
/**
445+
* Parameters for bridging transactions (e.g., BTC to sBTC).
446+
*/
447+
bridgingParams?: BridgingParams;
440448
}
441449

442450
export type TxRequestState =

modules/sdk-core/src/bitgo/wallet/BuildParams.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export const BuildParams = t.exact(
132132
aptosCustomTransactionParams: t.unknown,
133133
isTestTransaction: t.unknown,
134134
feeToken: t.unknown,
135+
// Bridging parameters for cross-chain operations (e.g., BTC to sBTC)
136+
bridgingParams: t.unknown,
135137
}),
136138
])
137139
);

modules/sdk-core/src/bitgo/wallet/iWallet.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,23 @@ export type NftBalance = BaseBalance & {
124124

125125
export type ApiVersion = 'lite' | 'full';
126126

127+
/** Parameters for sBTC bridging (BTC to sBTC). */
128+
export interface SbtcBridgingParams {
129+
/** Amount in satoshis to bridge */
130+
amount: number | string;
131+
/** Stacks recipient address */
132+
stacksRecipient: string;
133+
/** Maximum fee in satoshis */
134+
maxFee: number | string;
135+
/** Lock time for the bridging transaction */
136+
lockTime: number;
137+
}
138+
139+
/** Parameters for cross-chain bridging transactions. */
140+
export interface BridgingParams {
141+
sbtc?: SbtcBridgingParams;
142+
}
143+
127144
export interface PrebuildTransactionOptions {
128145
reqId?: IRequestTracer;
129146
recipients?: {
@@ -267,6 +284,11 @@ export interface PrebuildTransactionOptions {
267284
* When specified, fees will be deducted in this token instead of the native currency.
268285
*/
269286
feeToken?: string;
287+
/**
288+
* Parameters for bridging transactions (e.g., BTC to sBTC).
289+
* Used with type: 'bridging' for cross-chain bridging operations.
290+
*/
291+
bridgingParams?: BridgingParams;
270292
}
271293

272294
export interface PrebuildAndSignTransactionOptions extends PrebuildTransactionOptions, WalletSignTransactionOptions {

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4162,6 +4162,21 @@ export class Wallet implements IWallet {
41624162
params.preview
41634163
);
41644164
break;
4165+
case 'bridging':
4166+
txRequest = await this.tssUtils!.prebuildTxWithIntent(
4167+
{
4168+
reqId,
4169+
intentType: 'bridging',
4170+
sequenceId: params.sequenceId,
4171+
comment: params.comment,
4172+
recipients: params.recipients || [],
4173+
bridgingParams: params.bridgingParams,
4174+
feeOptions,
4175+
},
4176+
apiVersion,
4177+
params.preview
4178+
);
4179+
break;
41654180
case 'bridgeFunds':
41664181
txRequest = await this.tssUtils!.prebuildTxWithIntent(
41674182
{

modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,41 @@ describe('BuildParams', function () {
3737
}
3838
);
3939
});
40+
41+
it('should whitelist bridgingParams', function () {
42+
const bridgingParams = {
43+
sbtc: {
44+
amount: 100000,
45+
stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
46+
maxFee: 5000,
47+
lockTime: 144,
48+
},
49+
};
50+
assert.deepStrictEqual(
51+
BuildParams.encode({
52+
type: 'bridging',
53+
txFormat: 'psbt',
54+
recipients: [],
55+
bridgingParams,
56+
} as any),
57+
{
58+
type: 'bridging',
59+
txFormat: 'psbt',
60+
recipients: [],
61+
bridgingParams,
62+
}
63+
);
64+
});
65+
66+
it('should strip unknown params while keeping bridgingParams', function () {
67+
assert.deepStrictEqual(
68+
BuildParams.encode({
69+
bridgingParams: { sbtc: { amount: 50000, stacksRecipient: 'SP123', maxFee: 1000, lockTime: 100 } },
70+
unknownField: 'should be stripped',
71+
} as any),
72+
{
73+
bridgingParams: { sbtc: { amount: 50000, stacksRecipient: 'SP123', maxFee: 1000, lockTime: 100 } },
74+
}
75+
);
76+
});
4077
});

0 commit comments

Comments
 (0)