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..a1feb85250 --- /dev/null +++ b/modules/sdk-core/src/bitgo/defi/defiVault.ts @@ -0,0 +1,231 @@ +/** + * @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 txRequest-create calls (approve + deposit) and + * returns an operationId that the UI uses for status tracking and recovery. + */ +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 creates two txRequests (approve + deposit) and returns the + * operationId that links them. If the deposit txRequest creation 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 + */ + 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: Create approve txRequest + const approveIntent = { + intentType: 'defi-approve', + vaultId: params.vaultId, + amount: params.amount, + ...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}), + }; + + const approveTxRequest = await this.createTxRequest(approveIntent); + const operationId = (approveTxRequest.intent as Record)?.operationId as string; + const approveTxRequestId = approveTxRequest.txRequestId; + + if (!operationId) { + throw new Error('operationId not found in approve txRequest response'); + } + + // Step 2: Create deposit txRequest + // On failure, auto-cancel the approve txRequest (fail-fast per TDD §6.3.1) + let depositTxRequestId: string; + try { + const depositIntent = { + intentType: 'defi-deposit', + vaultId: params.vaultId, + amount: params.amount, + operationId, + ...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}), + }; + + const depositTxRequest = await this.createTxRequest(depositIntent); + depositTxRequestId = depositTxRequest.txRequestId; + } 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 + */ + 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 depositIntent = { + intentType: 'defi-deposit', + vaultId: operation.vaultId, + amount: operation.assetAmount, + operationId: params.operationId, + }; + + const depositTxRequest = await this.createTxRequest(depositIntent); + + return { + operationId: params.operationId, + txRequestIds: { + approve: operation.txRequestId, + deposit: depositTxRequest.txRequestId, + }, + }; + } + + /** + * 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 ──────────────────────────────────────────────── + + private async createTxRequest(intent: Record): Promise<{ txRequestId: string; intent: unknown }> { + return await this.bitgo + .post(this.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests', 2)) + .send({ intent }) + .result(); + } + + 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..b40f48620a --- /dev/null +++ b/modules/sdk-core/src/bitgo/defi/iDefiVault.ts @@ -0,0 +1,62 @@ +/** + * @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; +} + +export interface ResumeDepositOptions { + /** operationId of the partially-completed deposit */ + operationId: 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/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..6a67297879 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 */ 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..1bf0dc02df --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/defi/defiVault.ts @@ -0,0 +1,343 @@ +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 create approve and deposit txRequests on happy path', async function () { + const operationId = 'op-uuid-123'; + const approveResponse = { + txRequestId: 'txreq-approve-1', + intent: { intentType: 'defi-approve', operationId }, + }; + const depositResponse = { + txRequestId: 'txreq-deposit-1', + intent: { intentType: 'defi-deposit', operationId }, + }; + + // Pre-flight: no active operations + const preflightReq = mockRequest({ items: [] }); + mockBitGo.get.onFirstCall().returns(preflightReq); + + // Approve txRequest + const approveReq = mockRequest(approveResponse); + mockBitGo.post.onFirstCall().returns(approveReq); + + // Deposit txRequest + const depositReq = mockRequest(depositResponse); + mockBitGo.post.onSecondCall().returns(depositReq); + + 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 approve intent was sent correctly + approveReq.send.calledOnce.should.be.true(); + const approvePayload = approveReq.send.firstCall.args[0]; + approvePayload.intent.intentType.should.equal('defi-approve'); + approvePayload.intent.vaultId.should.equal('vlt-galaxy-usdc'); + approvePayload.intent.amount.should.equal('1000000'); + + // Verify deposit intent was sent correctly + depositReq.send.calledOnce.should.be.true(); + const depositPayload = depositReq.send.firstCall.args[0]; + depositPayload.intent.intentType.should.equal('defi-deposit'); + depositPayload.intent.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 txRequest creation fails', async function () { + const operationId = 'op-uuid-456'; + const approveResponse = { + txRequestId: 'txreq-approve-2', + intent: { intentType: 'defi-approve', operationId }, + }; + + // Pre-flight: no active operations + const preflightReq = mockRequest({ items: [] }); + mockBitGo.get.returns(preflightReq); + + // Approve txRequest succeeds + const approveReq = mockRequest(approveResponse); + mockBitGo.post.onFirstCall().returns(approveReq); + + // Deposit txRequest fails + const depositReq = { + send: sinon.stub().returnsThis(), + query: sinon.stub().returnsThis(), + result: sinon.stub().rejects(new Error('deposit creation failed')), + }; + mockBitGo.post.onSecondCall().returns(depositReq); + + // 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 when provided', async function () { + const operationId = 'op-uuid-789'; + const approveResponse = { + txRequestId: 'txreq-approve-3', + intent: { intentType: 'defi-approve', operationId }, + }; + const depositResponse = { + txRequestId: 'txreq-deposit-3', + intent: { intentType: 'defi-deposit', operationId }, + }; + + const preflightReq = mockRequest({ items: [] }); + mockBitGo.get.returns(preflightReq); + + const approveReq = mockRequest(approveResponse); + mockBitGo.post.onFirstCall().returns(approveReq); + + const depositReq = mockRequest(depositResponse); + mockBitGo.post.onSecondCall().returns(depositReq); + + await defiVault.depositToVault({ + vaultId: 'vlt-galaxy-usdc', + amount: '1000000', + clientIdempotencyKey: 'idem-key-123', + }); + + const approvePayload = approveReq.send.firstCall.args[0]; + approvePayload.intent.clientIdempotencyKey.should.equal('idem-key-123'); + + const depositPayload = depositReq.send.firstCall.args[0]; + depositPayload.intent.clientIdempotencyKey.should.equal('idem-key-123'); + }); + }); + + describe('resumeDeposit', function () { + it('should issue the deposit txRequest 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 txRequest + const depositResponse = { + txRequestId: 'txreq-deposit-resume', + intent: { intentType: 'defi-deposit', operationId: 'op-resume-1' }, + }; + const depositReq = mockRequest(depositResponse); + mockBitGo.post.returns(depositReq); + + 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'); + }); + + 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(); + }); + }); +});