From 053bfc256a39be0716bf1bdba59ded8976fe8a02 Mon Sep 17 00:00:00 2001 From: Sachin Roy Date: Mon, 13 Jul 2026 13:37:11 +0530 Subject: [PATCH] feat(sdk-core): add withdrawFromVault() to DefiVault DEFI-237 --- examples/ts/defi-vault-deposit.ts | 62 +++++++ examples/ts/defi-vault-withdraw.ts | 63 +++++++ modules/sdk-core/src/bitgo/defi/defiVault.ts | 43 ++++- modules/sdk-core/src/bitgo/defi/iDefiVault.ts | 17 +- modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 13 +- .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 4 +- .../src/bitgo/utils/tss/recipientUtils.ts | 1 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 1 - modules/sdk-core/src/bitgo/wallet/wallet.ts | 20 ++- .../test/unit/bitgo/defi/defiVault.ts | 162 ++++++++++++++---- .../unit/bitgo/utils/tss/recipientUtils.ts | 1 + 11 files changed, 342 insertions(+), 45 deletions(-) create mode 100644 examples/ts/defi-vault-deposit.ts create mode 100644 examples/ts/defi-vault-withdraw.ts diff --git a/examples/ts/defi-vault-deposit.ts b/examples/ts/defi-vault-deposit.ts new file mode 100644 index 0000000000..a5382ad538 --- /dev/null +++ b/examples/ts/defi-vault-deposit.ts @@ -0,0 +1,62 @@ +/** + * Deposit into a DeFi ERC-4626 vault on staging. + * + * Usage: + * STAGING_ACCESS_TOKEN= \ + * STAGING_WALLET_ID= \ + * STAGING_WALLET_PASSPHRASE= \ + * DEFI_VAULT_ID= \ + * DEFI_DEPOSIT_AMOUNT= \ + * npx ts-node examples/ts/defi-vault-deposit.ts + * + * Copyright 2026, BitGo, Inc. All Rights Reserved. + */ +import { BitGo } from 'bitgo'; + +require('dotenv').config({ path: '../../.env' }); + +const config = { + accessToken: '', + env: 'staging', + walletId: '', + vaultId: 'tbaseeth-usdc-test', + amount: 1000000, // 1 USDC + passphrase: '', + coin: 'tbaseeth', + otp: '000000', +}; + +const bitgoTest = new BitGo({ + env: 'staging', +}); + +//bitgo.register('tbaseeth', TethLikeCoin.createInstance); + +async function main() { + console.log('Connecting to staging...'); + bitgoTest.authenticateWithAccessToken({ accessToken: config.accessToken }); + //await bitgoTest.unlock({ otp: config.otp, duration: 3600 }); + const wallet = await bitgoTest.coin('tbaseeth').wallets().get({ id: config.walletId }); + console.log('Wallet ID :', wallet.id()); + console.log('Vault ID :', config.vaultId); + console.log('Amount :', config.amount, '(base units)'); + + console.log('\nStarting deposit...'); + const result = await wallet.defi.depositToVault({ + vaultId: config.vaultId, + amount: config.amount.toString(), + ...(config.passphrase ? { walletPassphrase: config.passphrase } : {}), + }); + + console.log('\nDeposit complete:'); + console.log(' operationId :', result.operationId); + console.log(' approve txRequestId :', result.txRequestIds.approve); + console.log(' deposit txRequestId :', result.txRequestIds.deposit); + console.log('\nFull result:', JSON.stringify(result, null, 2)); +} + +main().catch((e) => { + console.error('Error:', e.message); + if (e.stack) console.error(e.stack); + process.exit(1); +}); diff --git a/examples/ts/defi-vault-withdraw.ts b/examples/ts/defi-vault-withdraw.ts new file mode 100644 index 0000000000..2e208fae50 --- /dev/null +++ b/examples/ts/defi-vault-withdraw.ts @@ -0,0 +1,63 @@ +/** + * Withdraw vault shares from a DeFi ERC-4626 vault on staging. + * + * Run the deposit script first, then use this to withdraw. + * + * Usage: + * STAGING_ACCESS_TOKEN= \ + * STAGING_WALLET_ID= \ + * STAGING_WALLET_PASSPHRASE= \ + * DEFI_VAULT_ID= \ + * DEFI_WITHDRAW_AMOUNT= \ + * npx ts-node examples/ts/defi-vault-withdraw.ts + * + * Copyright 2026, BitGo, Inc. All Rights Reserved. + */ +import { BitGo } from 'bitgo'; + +require('dotenv').config({ path: '../../.env' }); + +const config = { + accessToken: '', + env: 'staging', + walletId: '', + vaultId: 'tbaseeth-usdc-test', + amount: 1000000, // vault share base units + passphrase: '', + coin: 'tbaseeth', + otp: '000000', +}; + +const bitgoTest = new BitGo({ + env: 'staging', +}); + +//bitgo.register('tbaseeth', TethLikeCoin.createInstance); + +async function main() { + console.log('Connecting to staging...'); + bitgoTest.authenticateWithAccessToken({ accessToken: config.accessToken }); + //await bitgoTest.unlock({ otp: config.otp, duration: 3600 }); + const wallet = await bitgoTest.coin('tbaseeth').wallets().get({ id: config.walletId }); + console.log('Wallet ID :', wallet.id()); + console.log('Vault ID :', config.vaultId); + console.log('Amount :', config.amount, '(vault share base units)'); + + console.log('\nStarting withdrawal...'); + const result = await wallet.defi.withdrawFromVault({ + vaultId: config.vaultId, + amount: config.amount.toString(), + ...(config.passphrase ? { walletPassphrase: config.passphrase } : {}), + }); + + console.log('\nWithdrawal complete:'); + console.log(' operationId :', result.operationId); + console.log(' txRequestId :', result.txRequestId); + console.log('\nFull result:', JSON.stringify(result, null, 2)); +} + +main().catch((e) => { + console.error('Error:', e.message); + if (e.stack) console.error(e.stack); + process.exit(1); +}); diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts index 55dcb69b3b..ec9641ec75 100644 --- a/modules/sdk-core/src/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -15,6 +15,8 @@ import { IDefiVault, ListOperationsOptions, ResumeDepositOptions, + WithdrawFromVaultOptions, + WithdrawResult, } from './iDefiVault'; import { IWallet } from '../wallet'; import { BitGoBase } from '../bitgoBase'; @@ -76,7 +78,6 @@ export class DefiVault implements IDefiVault { * * @param params.vaultId - DeFi-service vault identifier * @param params.amount - amount in base units of the underlying asset - * @param params.clientIdempotencyKey - optional client idempotency key * @param params.walletPassphrase - required for hot wallets, omit for custody */ async depositToVault(params: DepositToVaultOptions): Promise { @@ -139,7 +140,6 @@ export class DefiVault implements IDefiVault { defiParams: { vaultId: params.vaultId, amount: params.amount, - ...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}), }, ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), }); @@ -158,7 +158,6 @@ export class DefiVault implements IDefiVault { vaultId: params.vaultId, amount: params.amount, operationId, - ...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}), }, ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), }); @@ -254,6 +253,44 @@ export class DefiVault implements IDefiVault { return await this.bitgo.get(this.bitgo.microservicesUrl(this.operationsUrl())).query(query).result(); } + /** + * Withdraw vault shares from a DeFi vault. + * + * Issues a single sendMany call (defiWithdraw) and returns the operationId + * and txRequestId. The state machine for withdrawal is simpler than deposit: + * CREATED → WITHDRAW_TX_REQUESTED → WITHDRAW_SIGNED → WITHDRAW_CONFIRMED → COMPLETED + * + * @param params.vaultId - DeFi-service vault identifier + * @param params.amount - amount in base units of the vault share token + * @param params.walletPassphrase - required for hot wallets, omit for custody + */ + async withdrawFromVault(params: WithdrawFromVaultOptions): Promise { + if (!params.vaultId) { + throw new Error('vaultId is required'); + } + if (!params.amount) { + throw new Error('amount is required'); + } + + const withdrawResult = await this.wallet.sendMany({ + type: 'defiWithdraw', + defiParams: { + vaultId: params.vaultId, + amount: params.amount, + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + }); + + const txRequestId = this.extractTxRequestId(withdrawResult); + const operationId = this.extractOperationId(withdrawResult); + + if (!operationId) { + throw new Error('operationId not found in withdraw txRequest response'); + } + + return { operationId, txRequestId }; + } + // ── Internal helpers ──────────────────────────────────────────────── /** diff --git a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts index 1959756689..4ec641c590 100644 --- a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts +++ b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts @@ -9,8 +9,6 @@ export interface DepositToVaultOptions { vaultId: string; /** Amount in base units of the underlying asset */ amount: string; - /** Optional client-supplied idempotency key */ - clientIdempotencyKey?: string; /** Wallet passphrase — required for hot wallets, omit for custody */ walletPassphrase?: string; } @@ -68,10 +66,25 @@ export interface DefiOperationListResult { nextCursor?: string; } +export interface WithdrawFromVaultOptions { + /** DeFi-service vault identifier */ + vaultId: string; + /** Amount in base units of the vault share token */ + amount: string; + /** Wallet passphrase — required for hot wallets, omit for custody */ + walletPassphrase?: string; +} + +export interface WithdrawResult { + operationId: string; + txRequestId: string; +} + export interface IDefiVault { depositToVault(params: DepositToVaultOptions): Promise; resumeDeposit(params: ResumeDepositOptions): Promise; getOperation(params: GetOperationOptions): Promise; listOperations(params: ListOperationsOptions): Promise; getVaultConfig(params: GetVaultConfigOptions): Promise; + withdrawFromVault(params: WithdrawFromVaultOptions): Promise; } diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index c89fb3b8b1..e827d29668 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -221,6 +221,7 @@ export abstract class MpcUtils { 'cantonParticipantOnboardingRequest', 'defi-approve', 'defi-deposit', + 'defi-withdraw', ].includes(params.intentType) ) { assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`); @@ -322,9 +323,15 @@ export abstract class MpcUtils { vaultId: params.defiParams.vaultId, amount: params.defiParams.amount, ...(params.defiParams.operationId && { operationId: params.defiParams.operationId }), - ...(params.defiParams.clientIdempotencyKey && { - clientIdempotencyKey: params.defiParams.clientIdempotencyKey, - }), + }; + } + case 'defi-withdraw': { + assert(params.defiParams, `'defiParams' is required for ${params.intentType} intent`); + return { + ...baseIntent, + vaultId: params.defiParams.vaultId, + // DefiWithdrawIntent uses shareTokenAmount (vault shares, base units) + shareTokenAmount: params.defiParams.amount, }; } default: diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 96c3be43ad..1fdcd917a4 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -287,8 +287,9 @@ interface IntentOptionsBase { export interface DefiIntentFields { vaultId?: string; amount?: { value: string; symbol: string } | string; + /** Vault share token amount for defi-withdraw intent (base units) */ + shareTokenAmount?: { value: string; symbol: string } | string; operationId?: string; - clientIdempotencyKey?: string; } /** DeFi-specific intent parameters (input container for defiParams). */ @@ -296,7 +297,6 @@ export interface DefiIntentParams { vaultId: string; amount: string; operationId?: string; - clientIdempotencyKey?: string; } export interface IntentOptionsForMessage extends IntentOptionsBase { diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index 9ff3f6cf55..f28cbac47f 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -30,6 +30,7 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ // DeFi vault operations — recipients/calldata built server-side from defiParams 'defiApprove', 'defiDeposit', + 'defiWithdraw', // Smart contract invocations with no explicit SDK-level recipients 'contractCall', diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 02e420441a..f719cfac2b 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -942,7 +942,6 @@ export interface SendManyOptions extends PrebuildAndSignTransactionOptions { amount?: string | number; actionType?: string; operationId?: string; - clientIdempotencyKey?: string; }; } diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index a8c1952cf5..68b79f1948 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -51,6 +51,7 @@ import { TokenType, TxRequest, } from '../utils'; +import { decodeWithCodec } from '../utils/codecs'; import { postWithCodec } from '../utils/postWithCodec'; import { txParamsFromIntent } from '../utils/tss/baseTSSUtils'; import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa'; @@ -4445,7 +4446,6 @@ export class Wallet implements IWallet { defiParams: params.defiParams as { vaultId: string; amount: string; - clientIdempotencyKey?: string; }, }, apiVersion, @@ -4461,13 +4461,29 @@ export class Wallet implements IWallet { vaultId: string; amount: string; operationId?: string; - clientIdempotencyKey?: string; }, }, apiVersion, params.preview ); break; + case 'defiWithdraw': { + const defiWithdrawParams = decodeWithCodec( + t.type({ vaultId: t.string, amount: t.string }), + params.defiParams, + 'defiWithdraw.defiParams' + ); + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'defi-withdraw', + defiParams: defiWithdrawParams, + }, + apiVersion, + params.preview + ); + break; + } default: throw new Error(`transaction type not supported: ${params.type}`); } diff --git a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts index 21aa745d28..4e91d40699 100644 --- a/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -353,41 +353,16 @@ describe('DefiVault', function () { mockBitGo.del.called.should.be.false(); }); - it('should pass clientIdempotencyKey and walletPassphrase when provided', async function () { - mockBitGo.get.returns(mockRequest(makeMorphoVault('vlt-galaxy-usdc'))); - - const operationId = 'op-uuid-789'; - - const sendManyStub = sinon.stub(wallet, 'sendMany'); - sendManyStub.onFirstCall().resolves({ - txRequest: { - txRequestId: 'txreq-approve-3', - intent: { intentType: 'defi-approve' }, - transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], - }, - }); - sendManyStub.onSecondCall().resolves({ - txRequest: { - txRequestId: 'txreq-deposit-3', - intent: { intentType: 'defi-deposit' }, - transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], - }, + it('should throw if vaultId is missing', async function () { + await assert.rejects(() => defiVault.depositToVault({ vaultId: '', amount: '1000000' }), { + message: 'vaultId is required', }); + }); - await defiVault.depositToVault({ - vaultId: 'vlt-galaxy-usdc', - amount: '1000000', - clientIdempotencyKey: 'idem-key-123', - walletPassphrase: 'test-passphrase', + it('should throw if amount is missing', async function () { + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-1', amount: '' }), { + message: 'amount is required', }); - - const approveArgs: any = sendManyStub.firstCall.args[0]; - approveArgs.defiParams.clientIdempotencyKey.should.equal('idem-key-123'); - approveArgs.walletPassphrase.should.equal('test-passphrase'); - - const depositArgs: any = sendManyStub.secondCall.args[0]; - depositArgs.defiParams.clientIdempotencyKey.should.equal('idem-key-123'); - depositArgs.walletPassphrase.should.equal('test-passphrase'); }); }); @@ -544,6 +519,129 @@ describe('DefiVault', function () { }); }); + describe('withdrawFromVault', function () { + it('should call sendMany with defiWithdraw and return operationId + txRequestId', async function () { + const operationId = 'op-withdraw-1'; + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: 'txreq-withdraw-1', + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + + const result = await defiVault.withdrawFromVault({ + vaultId: 'vlt-galaxy-usdc', + amount: '500000', + }); + + result.operationId.should.equal(operationId); + result.txRequestId.should.equal('txreq-withdraw-1'); + + sendManyStub.calledOnce.should.be.true(); + const args: any = sendManyStub.firstCall.args[0]; + args.type.should.equal('defiWithdraw'); + args.defiParams.vaultId.should.equal('vlt-galaxy-usdc'); + args.defiParams.amount.should.equal('500000'); + }); + + it('should extract operationId from lite apiVersion coinSpecific', async function () { + const operationId = 'op-withdraw-lite'; + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: 'txreq-withdraw-lite', + unsignedTxs: [{ coinSpecific: { operationId } }], + }, + }); + + const result = await defiVault.withdrawFromVault({ vaultId: 'vlt-galaxy-usdc', amount: '500000' }); + + result.operationId.should.equal(operationId); + result.txRequestId.should.equal('txreq-withdraw-lite'); + }); + + it('should throw when operationId is absent from the withdraw txRequest response', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: 'txreq-withdraw-missing', + transactions: [{ unsignedTx: { coinSpecific: {} } }], + }, + }); + + await assert.rejects(() => defiVault.withdrawFromVault({ vaultId: 'vlt-galaxy-usdc', amount: '500000' }), { + message: 'operationId not found in withdraw txRequest response', + }); + }); + + it('should pass walletPassphrase when provided', async function () { + const operationId = 'op-withdraw-opts'; + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: 'txreq-withdraw-opts', + transactions: [{ unsignedTx: { coinSpecific: { operationId } } }], + }, + }); + + await defiVault.withdrawFromVault({ + vaultId: 'vlt-galaxy-usdc', + amount: '500000', + walletPassphrase: 'test-passphrase', + }); + + const args: any = sendManyStub.firstCall.args[0]; + args.walletPassphrase.should.equal('test-passphrase'); + }); + + it('should throw if vaultId is missing', async function () { + await assert.rejects(() => defiVault.withdrawFromVault({ vaultId: '', amount: '500000' }), { + message: 'vaultId is required', + }); + }); + + it('should throw if amount is missing', async function () { + await assert.rejects(() => defiVault.withdrawFromVault({ vaultId: 'vlt-1', amount: '' }), { + message: 'amount is required', + }); + }); + + describe('prebuildTransactionTxRequests defiWithdraw defiParams validation', function () { + it('should throw when defiParams is missing', async function () { + await assert.rejects( + () => (wallet as any).prebuildTransactionTxRequests({ type: 'defiWithdraw' }), + /defiWithdraw\.defiParams/ + ); + }); + + it('should throw when vaultId is not a string', async function () { + await assert.rejects( + () => + (wallet as any).prebuildTransactionTxRequests({ + type: 'defiWithdraw', + defiParams: { vaultId: 123, amount: '500000' }, + }), + /defiWithdraw\.defiParams/ + ); + }); + + it('should throw when amount is not a string', async function () { + await assert.rejects( + () => + (wallet as any).prebuildTransactionTxRequests({ + type: 'defiWithdraw', + defiParams: { vaultId: 'vlt-1', amount: 500000 }, + }), + /defiWithdraw\.defiParams/ + ); + }); + }); + }); + describe('wallet.defi getter', function () { it('should return a DefiVault instance', function () { const defi = wallet.defi; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts index 9e668cfafe..7c0b99d6b0 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -29,6 +29,7 @@ describe('recipientUtils', function () { 'customTx', 'defiApprove', 'defiDeposit', + 'defiWithdraw', 'contractCall', // Staking 'delegate',