diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 81506eb49c..46ec407816 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -1710,6 +1710,55 @@ describe('V2 Wallet:', function () { postProcessStub.restore(); }); + it('should pass bridgingParams to tx/build for non-TSS BTC wallet', async function () { + const bridgingParams = { + sbtc: { + amount: 100000, + stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + maxFee: 5000, + lockTime: 144, + }, + }; + const expectedBuildParams = { + type: 'bridging', + txFormat: 'psbt', + recipients: [], + bridgingParams, + changeAddressType: ['p2trMusig2', 'p2wsh', 'p2shP2wsh', 'p2sh', 'p2tr'], + }; + + const scope = nock(bgUrl) + .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/build`, expectedBuildParams) + .query({}) + .reply(200, {}); + const blockHeightStub = sinon.stub(basecoin, 'getLatestBlockHeight').resolves(100); + const postProcessStub = sinon.stub(basecoin, 'postProcessPrebuild').resolves({}); + await wallet.prebuildTransaction({ + type: 'bridging', + txFormat: 'psbt', + recipients: [], + bridgingParams, + }); + postProcessStub.should.have.been.calledOnceWith({ + blockHeight: 100, + wallet: wallet, + buildParams: expectedBuildParams, + }); + scope.done(); + blockHeightStub.restore(); + postProcessStub.restore(); + }); + + it('should throw error when bridgingParams is missing for bridging type', async function () { + await wallet + .prebuildTransaction({ + type: 'bridging', + txFormat: 'psbt', + recipients: [], + }) + .should.be.rejectedWith("'bridgingParams' is required for bridging intent"); + }); + it('prebuild should call build but not getLatestBlockHeight for account coins', async function () { ['txrp', 'txlm', 'teth'].forEach(async function (coin) { const accountcoin = bitgo.coin(coin); diff --git a/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts b/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts index cc8bb4a18d..6c727ffdc5 100644 --- a/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts +++ b/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts @@ -9,6 +9,7 @@ import { SignedTransactionRequestResponse, EIP1559, } from './coinSignTx'; +import { BridgingParamsCodec } from './sendmany'; /** * Request parameters for prebuild and sign transaction @@ -384,6 +385,8 @@ export const PrebuildAndSignTransactionBody = { eip1559: optional(EIP1559), /** Gas limit */ gasLimit: optional(t.number), + /** Parameters for bridging transactions (e.g., BTC to sBTC). Used with type: 'bridging'. */ + bridgingParams: optional(BridgingParamsCodec), /** Low fee transaction ID for CPFP */ lowFeeTxid: optional(t.string), /** Receive address */ diff --git a/modules/express/src/typedRoutes/api/v2/sendmany.ts b/modules/express/src/typedRoutes/api/v2/sendmany.ts index b55a9b918a..f4d3e3cba8 100644 --- a/modules/express/src/typedRoutes/api/v2/sendmany.ts +++ b/modules/express/src/typedRoutes/api/v2/sendmany.ts @@ -105,6 +105,27 @@ export const TokenEnablement = t.intersection([ }), ]); +/** + * sBTC bridging parameters codec + */ +export const SbtcBridgingParamsCodec = t.type({ + /** Amount in satoshis to bridge */ + amount: t.union([t.number, t.string]), + /** Stacks recipient address */ + stacksRecipient: t.string, + /** Maximum fee in satoshis */ + maxFee: t.union([t.number, t.string]), + /** Lock time for the bridging transaction */ + lockTime: t.number, +}); + +/** + * Bridging parameters codec for cross-chain bridging transactions (e.g., BTC to sBTC). + */ +export const BridgingParamsCodec = t.partial({ + sbtc: SbtcBridgingParamsCodec, +}); + /** * Request body for sending to multiple recipients (v2) * @@ -344,6 +365,9 @@ export const SendManyRequestBody = { /** Array of tokens to enable on the wallet */ enableTokens: optional(t.array(TokenEnablement)), + /** Parameters for bridging transactions (e.g., BTC to sBTC). Used with type: 'bridging'. */ + bridgingParams: optional(BridgingParamsCodec), + /** Low fee transaction ID (for CPFP - Child Pays For Parent) */ lowFeeTxid: optional(t.string), diff --git a/modules/express/test/unit/typedRoutes/sendmany.ts b/modules/express/test/unit/typedRoutes/sendmany.ts index e9f537f96e..2525329941 100644 --- a/modules/express/test/unit/typedRoutes/sendmany.ts +++ b/modules/express/test/unit/typedRoutes/sendmany.ts @@ -1,5 +1,10 @@ import * as assert from 'assert'; -import { SendManyResponse, SendManyRequestParams, SendManyRequestBody } from '../../../src/typedRoutes/api/v2/sendmany'; +import { + SendManyResponse, + SendManyRequestParams, + SendManyRequestBody, + BridgingParamsCodec, +} from '../../../src/typedRoutes/api/v2/sendmany'; import { assertDecode } from './common'; import * as t from 'io-ts'; import 'should'; @@ -1192,6 +1197,79 @@ describe('SendMany V2 codec tests', function () { }); }); + describe('BridgingParamsCodec', function () { + const validBridgingParams = { + sbtc: { + amount: 100000, + stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + maxFee: 5000, + lockTime: 144, + }, + }; + + it('should validate valid bridgingParams with numeric amount and maxFee', function () { + const decoded = assertDecode(BridgingParamsCodec, validBridgingParams); + assert.deepStrictEqual(decoded, validBridgingParams); + }); + + it('should validate valid bridgingParams with string amount and maxFee', function () { + const validParams = { + sbtc: { + amount: '100000', + stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + maxFee: '5000', + lockTime: 144, + }, + }; + + const decoded = assertDecode(BridgingParamsCodec, validParams); + assert.deepStrictEqual(decoded, validParams); + }); + + it('should validate empty bridgingParams object', function () { + const decoded = assertDecode(BridgingParamsCodec, {}); + assert.deepStrictEqual(decoded, {}); + }); + + it('should reject sbtc params with missing stacksRecipient', function () { + assert.throws(() => { + assertDecode(BridgingParamsCodec, { + sbtc: { + amount: 100000, + maxFee: 5000, + lockTime: 144, + }, + }); + }); + }); + + it('should reject sbtc params with wrong lockTime type', function () { + assert.throws(() => { + assertDecode(BridgingParamsCodec, { + sbtc: { + amount: 100000, + stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + maxFee: 5000, + lockTime: '144', + }, + }); + }); + }); + + it('should reject sbtc params with wrong amount type', function () { + assert.throws(() => { + assertDecode(BridgingParamsCodec, { + sbtc: { + amount: true, + stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + maxFee: 5000, + lockTime: 144, + }, + }); + }); + }); + }); + describe('SendManyResponse', function () { it('should validate response with status and tx', function () { const validResponse = { diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index 0d6ea7964d..577d3da4b3 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -132,6 +132,8 @@ export const BuildParams = t.exact( aptosCustomTransactionParams: t.unknown, isTestTransaction: t.unknown, feeToken: t.unknown, + // Bridging parameters for cross-chain operations (e.g., BTC to sBTC) + bridgingParams: t.unknown, }), ]) ); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index f98e985d7d..15b6d4c14e 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -124,6 +124,23 @@ export type NftBalance = BaseBalance & { export type ApiVersion = 'lite' | 'full'; +/** Parameters for sBTC bridging (BTC to sBTC). */ +export interface SbtcBridgingParams { + /** Amount in satoshis to bridge */ + amount: number | string; + /** Stacks recipient address */ + stacksRecipient: string; + /** Maximum fee in satoshis */ + maxFee: number | string; + /** Lock time for the bridging transaction */ + lockTime: number; +} + +/** Parameters for cross-chain bridging transactions. */ +export interface BridgingParams { + sbtc?: SbtcBridgingParams; +} + export interface PrebuildTransactionOptions { reqId?: IRequestTracer; recipients?: { @@ -267,6 +284,11 @@ export interface PrebuildTransactionOptions { * When specified, fees will be deducted in this token instead of the native currency. */ feeToken?: string; + /** + * Parameters for bridging transactions (e.g., BTC to sBTC). + * Used with type: 'bridging' for cross-chain bridging operations. + */ + bridgingParams?: BridgingParams; } export interface PrebuildAndSignTransactionOptions extends PrebuildTransactionOptions, WalletSignTransactionOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index c5ccbd4336..61f0e2e56a 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2051,6 +2051,10 @@ export class Wallet implements IWallet { return this.prebuildTransactionTxRequests(params); } + if (params.type === 'bridging') { + assert(params.bridgingParams, "'bridgingParams' is required for bridging intent"); + } + // Whitelist params to build tx const whitelistedParams = this.baseCoin.preprocessBuildParams(_.pick(params, this.prebuildWhitelistedParams())); debug('prebuilding transaction: %O', whitelistedParams); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts b/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts index 926d2c5082..e88da25a62 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts @@ -37,4 +37,41 @@ describe('BuildParams', function () { } ); }); + + it('should whitelist bridgingParams', function () { + const bridgingParams = { + sbtc: { + amount: 100000, + stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7', + maxFee: 5000, + lockTime: 144, + }, + }; + assert.deepStrictEqual( + BuildParams.encode({ + type: 'bridging', + txFormat: 'psbt', + recipients: [], + bridgingParams, + } as any), + { + type: 'bridging', + txFormat: 'psbt', + recipients: [], + bridgingParams, + } + ); + }); + + it('should strip unknown params while keeping bridgingParams', function () { + assert.deepStrictEqual( + BuildParams.encode({ + bridgingParams: { sbtc: { amount: 50000, stacksRecipient: 'SP123', maxFee: 1000, lockTime: 100 } }, + unknownField: 'should be stripped', + } as any), + { + bridgingParams: { sbtc: { amount: 50000, stacksRecipient: 'SP123', maxFee: 1000, lockTime: 100 } }, + } + ); + }); });