diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index cb62f470b9..54ba202ab7 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -158,6 +158,8 @@ export const BuildParams = t.exact( validToBlock: t.unknown, messageKey: t.unknown, stakingParams: t.unknown, + // PoX-5 (UTXO) unstaking, e.g. { pox5: { branch: 'early-exit', principalPreimage } } + unstakingParams: t.unknown, stakingOptions: t.unknown, unstakingOptions: t.unknown, eip1559: t.unknown, diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 107962047e..fdf54b1770 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -241,6 +241,10 @@ export interface PrebuildTransactionOptions { custodianTransactionId?: string; apiVersion?: ApiVersion; stakingParams?: unknown; + /** + * PoX-5 unstaking parameters, e.g. `{ pox5: { branch: 'early-exit', principalPreimage } }`. + */ + unstakingParams?: unknown; /** * If set to false, sweep all funds including the required minimums for address(es). E.g. Polkadot (DOT) requires 1 DOT minimum. */ diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 3a37110801..2eebc77efe 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -160,6 +160,11 @@ type ManageUnspents = 'consolidate' | 'fanout'; const whitelistedSendParams = TxSendBody.type.types.flatMap((t) => Object.keys(t.props)); +// TxSendBody in @bitgo/public-types does not declare unstakingParams yet, so /tx/initiate would +// drop it. Extend locally until the codec has it. +const TxInitiateBody = t.intersection([TxSendBody, t.partial({ unstakingParams: t.unknown })]); +const whitelistedInitiateParams = [...whitelistedSendParams, 'unstakingParams']; + export enum ManageUnspentsOptions { BUILD_ONLY, BUILD_SIGN_SEND, @@ -5782,17 +5787,17 @@ export class Wallet implements IWallet { ).result(); } - private initiateTransaction(params: TxSendBody, reqId?: IRequestTracer) { + private initiateTransaction(params: t.TypeOf, reqId?: IRequestTracer) { // 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); return postWithCodec( this.bitgo, this.baseCoin.url('/wallet/' + this.id() + '/tx/initiate'), - TxSendBody, + TxInitiateBody, whitelistedParams ).result(); } diff --git a/modules/sdk-core/test/unit/bitgo/wallet/custodialInitiate.ts b/modules/sdk-core/test/unit/bitgo/wallet/custodialInitiate.ts new file mode 100644 index 0000000000..783827f519 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/custodialInitiate.ts @@ -0,0 +1,56 @@ +import * as assert from 'assert'; +import sinon from 'sinon'; +import { Wallet } from '../../../../src'; + +describe('Wallet - custodial tx/initiate', function () { + const unstakingParams = { + pox5: { branch: 'early-exit', principalPreimage: '00'.repeat(32) }, + }; + const recipients = [{ address: 'tb1qexample', amount: 'max' }]; + + let wallet: Wallet; + let mockBitGo: any; + let sendStub: sinon.SinonStub; + + beforeEach(function () { + sendStub = sinon.stub().returns({ result: sinon.stub().resolves({ status: 'accepted' }) }); + mockBitGo = { + post: sinon.stub().returns({ send: sendStub }), + setRequestTracer: sinon.stub(), + }; + const mockBaseCoin = { + url: sinon.stub().callsFake((path: string) => `/tbtcstxsignet${path}`), + checkRecipient: sinon.stub(), + preprocessBuildParams: sinon.stub().callsFake((params: unknown) => params), + getExtraPrebuildParams: sinon.stub().resolves({}), + supportsTss: sinon.stub().returns(false), + }; + wallet = new Wallet(mockBitGo, mockBaseCoin as any, { + id: 'test-wallet-id', + keys: ['user-key'], + type: 'custodial', + multisigType: 'onchain', + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('forwards unstakingParams from sendMany to the tx/initiate request body', async function () { + await wallet.sendMany({ recipients, txFormat: 'psbt', noSplitChange: true, unstakingParams } as any); + + assert.strictEqual(mockBitGo.post.callCount, 1); + assert.strictEqual(mockBitGo.post.firstCall.args[0], '/tbtcstxsignet/wallet/test-wallet-id/tx/initiate'); + const body = sendStub.firstCall.args[0]; + assert.deepStrictEqual(body.unstakingParams, unstakingParams); + assert.deepStrictEqual(body.recipients, recipients); + assert.strictEqual(body.txFormat, 'psbt'); + }); + + it('does not add unstakingParams when the caller did not set it', async function () { + await wallet.sendMany({ recipients } as any); + + assert.ok(!('unstakingParams' in sendStub.firstCall.args[0])); + }); +});