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
77 changes: 77 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,83 @@ describe('V2 Wallet:', function () {
});
});

describe('Custody sBTC bridging/withdraw params pass-through', function () {
const bridgingParams = {
sbtc: {
amount: 100000,
stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
maxFee: 5000,
lockTime: 144,
},
};
const sbtcWithdrawParams = {
amount: '100000',
btcAddress: '2N9Ego9KidiZR8tMP82g6RaggQtcbR9zNzH',
maxFee: '5000',
};

afterEach(function () {
nock.cleanAll();
});

it('should pass bridgingParams through sendMany to tx/initiate for custodial wallets', async function () {
const custodialWallet = new Wallet(bitgo, bitgo.coin('tbtc'), {
id: '5b34252f1bf349930e34020a',
coin: 'tbtc',
type: 'custodial',
keys: ['5b3424f91bf349930e340175'],
});

const initiatePath = `/api/v2/${custodialWallet.coin()}/wallet/${custodialWallet.id()}/tx/initiate`;
const response = nock(bgUrl)
.post(initiatePath, _.matches({ type: 'bridging', bridgingParams }))
.reply(200, { status: 'accepted', txRequestId: 'mock-tx-request-id' });

const result = await custodialWallet.sendMany({ type: 'bridging', bridgingParams });
response.isDone().should.be.true();
(result as any).status.should.equal('accepted');
});

it('should pass sbtcWithdrawParams through sendMany to tx/initiate for custodial wallets', async function () {
const custodialWallet = new Wallet(bitgo, bitgo.coin('tbtc'), {
id: '5b34252f1bf349930e34020a',
coin: 'tbtc',
type: 'custodial',
keys: ['5b3424f91bf349930e340175'],
});

const initiatePath = `/api/v2/${custodialWallet.coin()}/wallet/${custodialWallet.id()}/tx/initiate`;
const response = nock(bgUrl)
.post(initiatePath, _.matches({ sbtcWithdrawParams }))
.reply(200, { status: 'accepted', txRequestId: 'mock-tx-request-id' });

const result = await custodialWallet.sendMany({ sbtcWithdrawParams } as any);
response.isDone().should.be.true();
(result as any).status.should.equal('accepted');
});

it('should not forward bridgingParams/sbtcWithdrawParams to tx/send for non-custodial wallets', async function () {
const hotWallet = new Wallet(bitgo, bitgo.coin('tbtc'), {
id: '5b34252f1bf349930e34020a',
coin: 'tbtc',
keys: ['5b3424f91bf349930e340175'],
});

const prebuildAndSignStub = sinon
.stub(hotWallet, 'prebuildAndSignTransaction')
.resolves({ txHex: 'deadbeef' } as any);

const sendPath = `/api/v2/${hotWallet.coin()}/wallet/${hotWallet.id()}/tx/send`;
const response = nock(bgUrl)
.post(sendPath, (body) => !('bridgingParams' in body) && !('sbtcWithdrawParams' in body))
.reply(200, { status: 'accepted' });

await hotWallet.sendMany({ type: 'bridging', bridgingParams, sbtcWithdrawParams } as any);
response.isDone().should.be.true();
prebuildAndSignStub.restore();
});
});

describe('Transaction prebuilds', function () {
let ethWallet;

Expand Down
13 changes: 13 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/BuildParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ export const BuildParamsSbtc = t.partial({
sbtcDepositParams: t.unknown,
});

/** Parameters for sBTC bridging (BTC to sBTC). Mirrors iWallet.ts's SbtcBridgingParams. */
export const SbtcBridgingParams = t.type({
amount: t.union([t.number, t.string]),
stacksRecipient: t.string,
maxFee: t.union([t.number, t.string]),
lockTime: t.number,
});

/** Parameters for cross-chain bridging transactions. Mirrors iWallet.ts's BridgingParams. */
export const BridgingParams = t.partial({
sbtc: SbtcBridgingParams,
});

export const BuildParamsOffchain = t.partial({
idfSignedTimestamp: t.unknown,
idfVersion: t.unknown,
Expand Down
21 changes: 18 additions & 3 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import { postWithCodec } from '../utils/postWithCodec';
import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa';
import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa';
import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest';
import { buildParamKeys, BuildParams } from './BuildParams';
import { buildParamKeys, BuildParams, SbtcWithdrawParams, BridgingParams } from './BuildParams';
import {
AccelerateTransactionOptions,
AddressesByBalanceOptions,
Expand Down Expand Up @@ -148,6 +148,15 @@ type ManageUnspents = 'consolidate' | 'fanout';

const whitelistedSendParams = TxSendBody.type.types.flatMap((t) => Object.keys(t.props));

// 'bridgingParams' and 'sbtcWithdrawParams' are appended locally (scoped to /tx/initiate only)
// because @bitgo/public-types' TxSendBody codec doesn't declare them yet. Custody (custodial,
// non-TSS) sBTC mint/burn goes through sendMany -> initiateTransaction -> POST /tx/initiate,
// which builds and signs server-side; without this, the fields would be picked off params but
// then encoded away by TxSendBody's t.exact. Drop this local addition once TxSendBody adds the
// fields upstream. Not added to whitelistedSendParams itself, since sendTransaction (/tx/send)
// is for already-built/signed transactions and should not forward these build-time-only fields.
const whitelistedInitiateParams = [...whitelistedSendParams, 'bridgingParams', 'sbtcWithdrawParams'];

export enum ManageUnspentsOptions {
BUILD_ONLY,
BUILD_SIGN_SEND,
Expand Down Expand Up @@ -5339,13 +5348,19 @@ export class Wallet implements IWallet {
// extract the whitelisted params from the top level, in case
// other invalid params are present that would fail encoding
// and fall back to the body params
const whitelistedParams = this.baseCoin.preprocessBuildParams(_.pick(params, whitelistedSendParams));
const whitelistedParams = this.baseCoin.preprocessBuildParams(_.pick(params, whitelistedInitiateParams));
const reqTracer = reqId || new RequestTracer();
this.bitgo.setRequestTracer(reqTracer);
// bridgingParams/sbtcWithdrawParams are added to this local intersection because
// @bitgo/public-types' TxSendBody codec (t.exact) doesn't declare them yet, and t.exact
// reconstructs the encoded body from only its declared props — dropping anything else.
return postWithCodec(
this.bitgo,
this.baseCoin.url('/wallet/' + this.id() + '/tx/initiate'),
TxSendBody,
t.intersection([
TxSendBody,
t.partial({ bridgingParams: BridgingParams, sbtcWithdrawParams: SbtcWithdrawParams }),
]),
whitelistedParams
).result();
}
Expand Down
42 changes: 41 additions & 1 deletion modules/sdk-core/test/unit/bitgo/wallet/BuildParams.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import * as assert from 'assert';
import { BuildParams, buildParamKeys, AttestationPayload } from '../../../../src/bitgo/wallet/BuildParams';
import {
BuildParams,
buildParamKeys,
AttestationPayload,
BridgingParams,
SbtcBridgingParams,
SbtcWithdrawParams,
} from '../../../../src/bitgo/wallet/BuildParams';

describe('BuildParams', function () {
it('enforces codec', function () {
Expand Down Expand Up @@ -106,4 +113,37 @@ describe('BuildParams', function () {
assert.strictEqual(AttestationPayload.is(valid), true);
assert.strictEqual(AttestationPayload.is({ ...valid, signature: undefined }), false);
});

it('SbtcBridgingParams codec requires all four fields', function () {
const valid = {
amount: 100000,
stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
maxFee: 5000,
lockTime: 144,
};
assert.strictEqual(SbtcBridgingParams.is(valid), true);
assert.strictEqual(SbtcBridgingParams.is({ ...valid, amount: undefined }), false);
// amount/maxFee accept string or number, per iWallet.ts's SbtcBridgingParams
assert.strictEqual(SbtcBridgingParams.is({ ...valid, amount: '100000', maxFee: '5000' }), true);
});

it('BridgingParams codec validates the nested sbtc shape', function () {
const valid = {
sbtc: {
amount: 100000,
stacksRecipient: 'SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7',
maxFee: 5000,
lockTime: 144,
},
};
assert.strictEqual(BridgingParams.is(valid), true);
assert.strictEqual(BridgingParams.is({}), true); // sbtc is optional
assert.strictEqual(BridgingParams.is({ sbtc: { amount: 100000 } }), false); // incomplete nested shape
});

it('SbtcWithdrawParams codec accepts partial string fields', function () {
assert.strictEqual(SbtcWithdrawParams.is({ amount: '100000', btcAddress: 'mtbtcaddr', maxFee: '5000' }), true);
assert.strictEqual(SbtcWithdrawParams.is({}), true);
assert.strictEqual(SbtcWithdrawParams.is({ amount: 100000 }), false); // must be string, not number
});
});
Loading