diff --git a/modules/sdk-core/src/bitgo/defi/defiVault.ts b/modules/sdk-core/src/bitgo/defi/defiVault.ts new file mode 100644 index 0000000000..b692563e58 --- /dev/null +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -0,0 +1,261 @@ +/** + * @prettier + */ +import { + DefiOperation, + DefiOperationListResult, + DepositResult, + DepositToVaultOptions, + GetOperationOptions, + IDefiVault, + ListOperationsOptions, + ResumeDepositOptions, +} from './iDefiVault'; +import { IWallet } from '../wallet'; +import { BitGoBase } from '../bitgoBase'; + +/** + * Error thrown when a concurrent active deposit already exists for the (wallet, vault) pair. + */ +export class ActiveOperationExistsError extends Error { + public readonly operationId: string; + + constructor(operationId: string) { + super(`An active deposit operation already exists: ${operationId}`); + this.name = 'ActiveOperationExistsError'; + this.operationId = operationId; + } +} + +/** + * Orchestrates ERC-4626 vault deposit and withdraw flows for a wallet. + * + * Exposed as `wallet.defi` on the Wallet class. See TDD §6.3.1 for the full + * design: the SDK sequences two sendMany calls (approve + deposit) and + * returns an operationId that the UI uses for status tracking and recovery. + * + * Uses wallet.sendMany() under the hood so that both custody wallets + * (txRequest creation only) and hot wallets (create + sign + broadcast) + * are handled by the existing infrastructure. + */ +export class DefiVault implements IDefiVault { + private readonly wallet: IWallet; + private readonly bitgo: BitGoBase; + + constructor(wallet: IWallet) { + this.wallet = wallet; + this.bitgo = wallet.bitgo; + } + + /** + * Deposit an amount of underlying asset into a vault. + * + * Internally issues two sendMany calls (approve + deposit) and returns the + * operationId that links them. If the deposit sendMany fails after + * the approve succeeds, the approve is auto-cancelled (fail-fast). + * + * @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 { + if (!params.vaultId) { + throw new Error('vaultId is required'); + } + if (!params.amount) { + throw new Error('amount is required'); + } + + // Layer-1 pre-flight: reject if an active deposit already exists for this (wallet, vault) + const activeOps: DefiOperationListResult = await this.bitgo + .get(this.bitgo.microservicesUrl(this.operationsUrl())) + .query({ vaultId: params.vaultId, state: 'active' }) + .result(); + + if (activeOps.items && activeOps.items.length > 0) { + throw new ActiveOperationExistsError(activeOps.items[0].operationId); + } + + // Step 1: Approve txRequest via sendMany + const approveResult = await this.wallet.sendMany({ + type: 'defiApprove', + defiParams: { + vaultId: params.vaultId, + amount: params.amount, + ...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}), + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + }); + + const approveTxRequestId = this.extractTxRequestId(approveResult); + const operationId = this.extractOperationId(approveResult); + + if (!operationId) { + throw new Error('operationId not found in approve txRequest response'); + } + + // Step 2: Deposit txRequest via sendMany + // On failure, auto-cancel the approve txRequest (fail-fast per TDD §6.3.1) + let depositTxRequestId: string; + try { + const depositResult = await this.wallet.sendMany({ + type: 'defiDeposit', + defiParams: { + vaultId: params.vaultId, + amount: params.amount, + operationId, + ...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}), + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + }); + depositTxRequestId = this.extractTxRequestId(depositResult); + } catch (err) { + // Fail-fast: cancel the approve txRequest before throwing + try { + await this.cancelTxRequest(approveTxRequestId); + } catch { + // Best-effort cancel; the reconciler will clean up if this fails + } + throw err; + } + + return { + operationId, + txRequestIds: { + approve: approveTxRequestId, + deposit: depositTxRequestId, + }, + }; + } + + /** + * Resume a partially-completed deposit. Call this when the SDK process died + * between the approve and deposit txRequest creation. + * + * @param params.operationId - the operationId from the original depositToVault call + * @param params.walletPassphrase - required for hot wallets, omit for custody + */ + async resumeDeposit(params: ResumeDepositOptions): Promise { + if (!params.operationId) { + throw new Error('operationId is required'); + } + + // Fetch the operation to get the vault and amount details + const operation = await this.getOperation({ operationId: params.operationId }); + + if (operation.associatedTxRequestId) { + throw new Error('Deposit txRequest already exists for this operation; nothing to resume'); + } + + if (!operation.txRequestId) { + throw new Error('Approve txRequest not found for this operation; cannot resume'); + } + + // Issue the deposit txRequest using the existing operation's details + const depositResult = await this.wallet.sendMany({ + type: 'defiDeposit', + defiParams: { + vaultId: operation.vaultId, + amount: operation.assetAmount, + operationId: params.operationId, + }, + ...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}), + }); + + return { + operationId: params.operationId, + txRequestIds: { + approve: operation.txRequestId, + deposit: this.extractTxRequestId(depositResult), + }, + }; + } + + /** + * Get the current state of a DeFi operation. + * + * @param params.operationId - the operation to retrieve + */ + async getOperation(params: GetOperationOptions): Promise { + if (!params.operationId) { + throw new Error('operationId is required'); + } + + return await this.bitgo.get(this.bitgo.microservicesUrl(this.operationUrl(params.operationId))).result(); + } + + /** + * List operations for a vault filtered by walletId. + * + * @param params.vaultId - vault to list operations for + * @param params.state - optional state filter + * @param params.type - optional type filter (DEPOSIT | WITHDRAW) + * @param params.limit - page size + * @param params.cursor - pagination cursor + */ + async listOperations(params: ListOperationsOptions): Promise { + if (!params.vaultId) { + throw new Error('vaultId is required'); + } + + const query: Record = { + walletId: this.wallet.id(), + vaultId: params.vaultId, + }; + if (params.state) query.state = params.state; + if (params.type) query.type = params.type; + if (params.limit) query.limit = params.limit; + if (params.cursor) query.cursor = params.cursor; + + return await this.bitgo + .get(this.bitgo.microservicesUrl(this.vaultOperationsUrl(params.vaultId))) + .query(query) + .result(); + } + + // ── Internal helpers ──────────────────────────────────────────────── + + /** + * Extract txRequestId from a sendMany result. + * sendMany returns different shapes depending on wallet type: + * - TSS full: { txRequest: { txRequestId } } or { pendingApproval, txRequest } + * - TSS lite: result from tssUtils.sendTxRequest + */ + private extractTxRequestId(sendManyResult: Record): string { + const txRequest = sendManyResult.txRequest as Record | undefined; + if (txRequest?.txRequestId) { + return txRequest.txRequestId as string; + } + if (sendManyResult.txRequestId) { + return sendManyResult.txRequestId as string; + } + throw new Error('txRequestId not found in sendMany response'); + } + + /** + * Extract operationId from the intent of a sendMany result. + * The WP populates operationId in the intent of the approve txRequest. + */ + private extractOperationId(sendManyResult: Record): string | undefined { + const txRequest = sendManyResult.txRequest as Record | undefined; + const intent = txRequest?.intent as Record | undefined; + return intent?.operationId as string | undefined; + } + + private async cancelTxRequest(txRequestId: string): Promise { + await this.bitgo.del(this.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests/' + txRequestId, 2)).result(); + } + + private operationsUrl(): string { + return `/api/defi-service/v1/wallets/${this.wallet.id()}/operations`; + } + + private operationUrl(operationId: string): string { + return `/api/defi-service/v1/operations/${operationId}`; + } + + private vaultOperationsUrl(vaultId: string): string { + return `/api/defi-service/v1/vaults/${vaultId}/operations`; + } +} diff --git a/modules/sdk-core/src/bitgo/defi/iDefiVault.ts b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts new file mode 100644 index 0000000000..6bc706b77a --- /dev/null +++ b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts @@ -0,0 +1,66 @@ +/** + * @prettier + */ + +export interface DepositToVaultOptions { + /** DeFi-service vault identifier */ + 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; +} + +export interface ResumeDepositOptions { + /** operationId of the partially-completed deposit */ + operationId: string; + /** Wallet passphrase — required for hot wallets, omit for custody */ + walletPassphrase?: string; +} + +export interface GetOperationOptions { + operationId: string; +} + +export interface ListOperationsOptions { + vaultId: string; + state?: string; + type?: string; + limit?: number; + cursor?: string; +} + +export interface DefiOperation { + operationId: string; + walletId: string; + vaultId: string; + type: 'DEPOSIT' | 'WITHDRAW'; + assetAmount: string; + state: string; + txRequestId?: string; + associatedTxRequestId?: string; + createdAt: string; + updatedAt: string; +} + +export interface DepositResult { + operationId: string; + txRequestIds: { + approve: string; + deposit: string; + }; +} + +export interface DefiOperationListResult { + items: DefiOperation[]; + nextCursor?: string; +} + +export interface IDefiVault { + depositToVault(params: DepositToVaultOptions): Promise; + resumeDeposit(params: ResumeDepositOptions): Promise; + getOperation(params: GetOperationOptions): Promise; + listOperations(params: ListOperationsOptions): Promise; +} diff --git a/modules/sdk-core/src/bitgo/defi/index.ts b/modules/sdk-core/src/bitgo/defi/index.ts new file mode 100644 index 0000000000..8569663eeb --- /dev/null +++ b/modules/sdk-core/src/bitgo/defi/index.ts @@ -0,0 +1,2 @@ +export * from './iDefiVault'; +export { DefiVault, ActiveOperationExistsError } from './defiVault'; diff --git a/modules/sdk-core/src/bitgo/index.ts b/modules/sdk-core/src/bitgo/index.ts index c320eee454..610c75d97d 100644 --- a/modules/sdk-core/src/bitgo/index.ts +++ b/modules/sdk-core/src/bitgo/index.ts @@ -8,6 +8,7 @@ export * from './bitcoin'; export * from './bitgoBase'; export * from './config'; export * from './coinFactory'; +export * from './defi'; export * from './ecdh'; export * from './enterprise'; export * from './environments'; diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index fae8909d8f..3f982d050a 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -9,6 +9,7 @@ import { AddKeychainOptions, Keychain, KeyType, WebauthnKeyEncryptionInfo } from import { encryptText, getBitgoGpgPubKey } from './opengpgUtils'; import { IntentRecipient, + PopulatedDefiIntent, PopulatedIntent, PrebuildTransactionWithIntentOptions, TokenTransferRecipientParams, @@ -115,7 +116,10 @@ export abstract class MpcUtils { * @param {PrebuildTransactionWithIntentOptions} params * @returns {Record} */ - populateIntent(baseCoin: IBaseCoin, params: PrebuildTransactionWithIntentOptions): PopulatedIntent { + populateIntent( + baseCoin: IBaseCoin, + params: PrebuildTransactionWithIntentOptions + ): PopulatedIntent | PopulatedDefiIntent { const chain = this.baseCoin.getChain(); if (params.intentType === 'customTx' && baseCoin.getFamily() === 'sol') { @@ -183,6 +187,8 @@ export abstract class MpcUtils { 'transferOfferWithdrawn', 'bridgeFunds', 'cantonCommand', + 'defi-approve', + 'defi-deposit', ].includes(params.intentType) ) { assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`); @@ -269,6 +275,22 @@ export abstract class MpcUtils { feeToken: params.feeToken, nonce: params.nonce, }; + case 'defi-approve': + case 'defi-deposit': { + assert(params.defiParams, `'defiParams' is required for ${params.intentType} intent`); + const defiIntent: PopulatedDefiIntent = { + intentType: params.intentType, + vaultId: params.defiParams.vaultId, + amount: params.defiParams.amount, + }; + if (params.defiParams.operationId) { + defiIntent.operationId = params.defiParams.operationId; + } + if (params.defiParams.clientIdempotencyKey) { + defiIntent.clientIdempotencyKey = params.defiParams.clientIdempotencyKey; + } + return defiIntent; + } default: throw new Error(`Unsupported intent type ${params.intentType}`); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 19682197f4..c3aa90c426 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -280,6 +280,18 @@ interface IntentOptionsBase { custodianMessageId?: string; } +/** DeFi-specific intent parameters for ERC-4626 vault operations. */ +export interface DefiIntentParams { + /** DeFi-service vault identifier. */ + vaultId: string; + /** Amount in base units of the underlying asset. */ + amount: string; + /** Operation ID linking approve and deposit txRequests (defi-deposit only). */ + operationId?: string; + /** Client-supplied idempotency key. */ + clientIdempotencyKey?: string; +} + export interface IntentOptionsForMessage extends IntentOptionsBase { messageRaw: string; messageEncoded?: string; @@ -354,6 +366,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase feeToken?: string; /** Canton-specific params for the cantonCommand intent. */ cantonCommandParams?: CantonCommandParams; + /** DeFi vault intent fields for defi-approve / defi-deposit intents. */ + defiParams?: DefiIntentParams; } export interface IntentRecipient { address: { @@ -439,6 +453,18 @@ export interface PopulatedIntent extends PopulatedIntentBase { cantonCommandParams?: CantonCommandParams; } +/** + * Populated intent shape for DeFi vault operations (defi-approve / defi-deposit). + * Extends PopulatedIntentBase with DeFi-specific fields that the WP expects + * at the top level of the intent payload. + */ +export interface PopulatedDefiIntent extends PopulatedIntentBase { + vaultId: string; + amount: string; + operationId?: string; + clientIdempotencyKey?: string; +} + export type TxRequestState = | 'pendingCommitment' | 'pendingApproval' diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 38b454c422..c5c6d31a78 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -15,6 +15,7 @@ import { import { BitGoBase } from '../bitgoBase'; import { Keychain, KeychainWithEncryptedPrv } from '../keychain'; import { IPendingApproval, PendingApprovalData } from '../pendingApproval'; +import { IDefiVault } from '../defi'; import { IGoStakingWallet, IStakingWallet } from '../staking'; import { ITradingAccount } from '../trading'; import { @@ -1163,6 +1164,7 @@ export interface IWallet { remove(params?: Record): Promise; toJSON(): WalletData; createLightningInvoice(params: CreateLightningInvoiceParams): Promise; + readonly defi: IDefiVault; toTradingAccount(): ITradingAccount; toStakingWallet(): IStakingWallet; toGoStakingWallet(): IGoStakingWallet; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 09d26073d1..e0710a2b10 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -42,6 +42,7 @@ import { import { CreateLightningInvoiceParams, LightningInvoiceResponse } from '../../lightning'; import { getLightningAuthKey } from '../lightning/lightningWalletUtil'; import { IPendingApproval, PendingApproval, PendingApprovals } from '../pendingApproval'; +import { DefiVault } from '../defi'; import { GoStakingWallet, StakingWallet } from '../staking'; import { TradingAccount } from '../trading'; import { getTxRequest } from '../tss'; @@ -166,6 +167,7 @@ export class Wallet implements IWallet { public readonly bitgo: BitGoBase; public readonly baseCoin: IBaseCoin; public _wallet: WalletData; + private _defi?: DefiVault; private readonly tssUtils: EcdsaUtils | EcdsaMPCv2Utils | EddsaUtils | EddsaMPCv2Utils | undefined; private readonly _permissions?: string[]; @@ -3251,6 +3253,16 @@ export class Wallet implements IWallet { return new AddressBook(this._wallet.enterprise, this.bitgo, this); } + /** + * Access DeFi vault operations for this wallet (deposit, withdraw, resume). + */ + get defi(): DefiVault { + if (!this._defi) { + this._defi = new DefiVault(this); + } + return this._defi; + } + /** * Create a staking wallet from this wallet */ @@ -4271,6 +4283,37 @@ export class Wallet implements IWallet { params.preview ); break; + case 'defiApprove': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'defi-approve', + defiParams: params.defiParams as { + vaultId: string; + amount: string; + clientIdempotencyKey?: string; + }, + }, + apiVersion, + params.preview + ); + break; + case 'defiDeposit': + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'defi-deposit', + defiParams: params.defiParams as { + vaultId: string; + amount: string; + operationId?: string; + clientIdempotencyKey?: string; + }, + }, + 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 new file mode 100644 index 0000000000..a474314c6c --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -0,0 +1,346 @@ +import sinon from 'sinon'; +import assert from 'assert'; +import 'should'; +import { ActiveOperationExistsError, DefiVault, Wallet } from '../../../../src'; + +describe('DefiVault', function () { + let wallet: Wallet; + let defiVault: DefiVault; + let mockBitGo: any; + let mockBaseCoin: any; + + // Helper to create a chainable request mock + function mockRequest(result: any) { + return { + send: sinon.stub().returnsThis(), + query: sinon.stub().returnsThis(), + result: sinon.stub().resolves(result), + }; + } + + beforeEach(function () { + mockBitGo = { + post: sinon.stub(), + get: sinon.stub(), + del: sinon.stub(), + url: sinon.stub().callsFake((path: string, version: number) => `https://bitgo.com/api/v${version}${path}`), + microservicesUrl: sinon.stub().callsFake((path: string) => `https://bitgo.com${path}`), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + getFamily: sinon.stub().returns('eth'), + url: sinon.stub(), + keychains: sinon.stub(), + supportsTss: sinon.stub().returns(true), + getMPCAlgorithm: sinon.stub(), + }; + + const mockWalletData = { + id: 'test-wallet-id', + coin: 'eth', + keys: ['user-key', 'backup-key', 'bitgo-key'], + }; + + wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData); + defiVault = wallet.defi; + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('depositToVault', function () { + it('should call sendMany for approve and deposit on happy path', async function () { + const operationId = 'op-uuid-123'; + + // Pre-flight: no active operations + const preflightReq = mockRequest({ items: [] }); + mockBitGo.get.onFirstCall().returns(preflightReq); + + // Mock sendMany for approve and deposit + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-1', + intent: { intentType: 'defi-approve', operationId }, + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { + txRequestId: 'txreq-deposit-1', + intent: { intentType: 'defi-deposit', operationId }, + }, + }); + + const result = await defiVault.depositToVault({ + vaultId: 'vlt-galaxy-usdc', + amount: '1000000', + }); + + result.operationId.should.equal(operationId); + result.txRequestIds.approve.should.equal('txreq-approve-1'); + result.txRequestIds.deposit.should.equal('txreq-deposit-1'); + + // Verify pre-flight was called with correct query + preflightReq.query.calledWith({ vaultId: 'vlt-galaxy-usdc', state: 'active' }).should.be.true(); + + // Verify sendMany was called with correct params for approve + sendManyStub.calledTwice.should.be.true(); + const approveArgs: any = sendManyStub.firstCall.args[0]; + approveArgs.type.should.equal('defiApprove'); + approveArgs.defiParams.vaultId.should.equal('vlt-galaxy-usdc'); + approveArgs.defiParams.amount.should.equal('1000000'); + + // Verify sendMany was called with correct params for deposit + const depositArgs: any = sendManyStub.secondCall.args[0]; + depositArgs.type.should.equal('defiDeposit'); + depositArgs.defiParams.operationId.should.equal(operationId); + }); + + it('should reject when an active operation already exists', async function () { + const preflightReq = mockRequest({ + items: [{ operationId: 'existing-op-id', state: 'APPROVE_TX_REQUESTED' }], + }); + mockBitGo.get.returns(preflightReq); + + await assert.rejects( + () => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), + (err: Error) => { + (err instanceof ActiveOperationExistsError).should.be.true(); + (err as ActiveOperationExistsError).operationId.should.equal('existing-op-id'); + return true; + } + ); + }); + + it('should auto-cancel approve when deposit sendMany fails', async function () { + const operationId = 'op-uuid-456'; + + // Pre-flight: no active operations + const preflightReq = mockRequest({ items: [] }); + mockBitGo.get.returns(preflightReq); + + // Mock sendMany: approve succeeds, deposit fails + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-2', + intent: { intentType: 'defi-approve', operationId }, + }, + }); + sendManyStub.onSecondCall().rejects(new Error('deposit creation failed')); + + // Cancel approve should succeed + const cancelReq = mockRequest(undefined); + mockBitGo.del.returns(cancelReq); + + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-galaxy-usdc', amount: '1000000' }), { + message: 'deposit creation failed', + }); + + // Verify cancel was called + mockBitGo.del.calledOnce.should.be.true(); + }); + + it('should throw if vaultId is missing', async function () { + await assert.rejects(() => defiVault.depositToVault({ vaultId: '', amount: '1000000' }), { + message: 'vaultId is required', + }); + }); + + it('should throw if amount is missing', async function () { + await assert.rejects(() => defiVault.depositToVault({ vaultId: 'vlt-1', amount: '' }), { + message: 'amount is required', + }); + }); + + it('should pass clientIdempotencyKey and walletPassphrase when provided', async function () { + const operationId = 'op-uuid-789'; + + const preflightReq = mockRequest({ items: [] }); + mockBitGo.get.returns(preflightReq); + + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.onFirstCall().resolves({ + txRequest: { + txRequestId: 'txreq-approve-3', + intent: { intentType: 'defi-approve', operationId }, + }, + }); + sendManyStub.onSecondCall().resolves({ + txRequest: { + txRequestId: 'txreq-deposit-3', + intent: { intentType: 'defi-deposit', operationId }, + }, + }); + + await defiVault.depositToVault({ + vaultId: 'vlt-galaxy-usdc', + amount: '1000000', + clientIdempotencyKey: 'idem-key-123', + walletPassphrase: 'test-passphrase', + }); + + 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'); + }); + }); + + describe('resumeDeposit', function () { + it('should issue the deposit sendMany for a partial operation', async function () { + const operation = { + operationId: 'op-resume-1', + walletId: 'test-wallet-id', + vaultId: 'vlt-galaxy-usdc', + type: 'DEPOSIT', + assetAmount: '1000000', + state: 'APPROVE_TX_REQUESTED', + txRequestId: 'txreq-approve-existing', + associatedTxRequestId: undefined, + createdAt: '2026-05-14T07:12:00Z', + updatedAt: '2026-05-14T07:12:00Z', + }; + + // getOperation call + const getOpReq = mockRequest(operation); + mockBitGo.get.returns(getOpReq); + + // deposit sendMany + const sendManyStub = sinon.stub(wallet, 'sendMany'); + sendManyStub.resolves({ + txRequest: { + txRequestId: 'txreq-deposit-resume', + intent: { intentType: 'defi-deposit', operationId: 'op-resume-1' }, + }, + }); + + const result = await defiVault.resumeDeposit({ operationId: 'op-resume-1' }); + + result.operationId.should.equal('op-resume-1'); + result.txRequestIds.approve.should.equal('txreq-approve-existing'); + result.txRequestIds.deposit.should.equal('txreq-deposit-resume'); + + // Verify sendMany was called with correct defiParams + const depositArgs: any = sendManyStub.firstCall.args[0]; + depositArgs.type.should.equal('defiDeposit'); + depositArgs.defiParams.vaultId.should.equal('vlt-galaxy-usdc'); + depositArgs.defiParams.amount.should.equal('1000000'); + depositArgs.defiParams.operationId.should.equal('op-resume-1'); + }); + + it('should throw if deposit txRequest already exists', async function () { + const operation = { + operationId: 'op-already-done', + walletId: 'test-wallet-id', + vaultId: 'vlt-galaxy-usdc', + type: 'DEPOSIT', + assetAmount: '1000000', + state: 'DEPOSIT_TX_REQUESTED', + txRequestId: 'txreq-approve-x', + associatedTxRequestId: 'txreq-deposit-x', + createdAt: '2026-05-14T07:12:00Z', + updatedAt: '2026-05-14T07:12:00Z', + }; + + const getOpReq = mockRequest(operation); + mockBitGo.get.returns(getOpReq); + + await assert.rejects(() => defiVault.resumeDeposit({ operationId: 'op-already-done' }), { + message: 'Deposit txRequest already exists for this operation; nothing to resume', + }); + }); + + it('should throw if operationId is missing', async function () { + await assert.rejects(() => defiVault.resumeDeposit({ operationId: '' }), { message: 'operationId is required' }); + }); + }); + + describe('getOperation', function () { + it('should fetch an operation by ID', async function () { + const operation = { + operationId: 'op-get-1', + walletId: 'test-wallet-id', + vaultId: 'vlt-galaxy-usdc', + type: 'DEPOSIT', + assetAmount: '1000000', + state: 'COMPLETED', + txRequestId: 'txreq-1', + associatedTxRequestId: 'txreq-2', + createdAt: '2026-05-14T07:12:00Z', + updatedAt: '2026-05-14T07:18:00Z', + }; + + const req = mockRequest(operation); + mockBitGo.get.returns(req); + + const result = await defiVault.getOperation({ operationId: 'op-get-1' }); + result.should.deepEqual(operation); + }); + + it('should throw if operationId is missing', async function () { + await assert.rejects(() => defiVault.getOperation({ operationId: '' }), { message: 'operationId is required' }); + }); + }); + + describe('listOperations', function () { + it('should list operations for a vault', async function () { + const listResult = { + items: [ + { + operationId: 'op-1', + walletId: 'test-wallet-id', + vaultId: 'vlt-galaxy-usdc', + type: 'DEPOSIT', + assetAmount: '1000000', + state: 'COMPLETED', + createdAt: '2026-05-14T07:12:00Z', + updatedAt: '2026-05-14T07:18:00Z', + }, + ], + nextCursor: 'cursor-abc', + }; + + const req = mockRequest(listResult); + mockBitGo.get.returns(req); + + const result = await defiVault.listOperations({ + vaultId: 'vlt-galaxy-usdc', + state: 'COMPLETED', + limit: 10, + }); + + result.items.length.should.equal(1); + result.nextCursor!.should.equal('cursor-abc'); + + // Verify query params + const queryArgs = req.query.firstCall.args[0]; + queryArgs.walletId.should.equal('test-wallet-id'); + queryArgs.vaultId.should.equal('vlt-galaxy-usdc'); + queryArgs.state.should.equal('COMPLETED'); + queryArgs.limit.should.equal(10); + }); + + it('should throw if vaultId is missing', async function () { + await assert.rejects(() => defiVault.listOperations({ vaultId: '' }), { message: 'vaultId is required' }); + }); + }); + + describe('wallet.defi getter', function () { + it('should return a DefiVault instance', function () { + const defi = wallet.defi; + (defi instanceof DefiVault).should.be.true(); + }); + + it('should return the same instance on subsequent calls', function () { + const defi1 = wallet.defi; + const defi2 = wallet.defi; + (defi1 === defi2).should.be.true(); + }); + }); +});