Skip to content
Draft
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
231 changes: 231 additions & 0 deletions modules/sdk-core/src/bitgo/defi/defiVault.ts
Original file line number Diff line number Diff line change
@@ -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<DepositResult> {
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<string, unknown>)?.operationId as string;
const approveTxRequestId = approveTxRequest.txRequestId;

if (!operationId) {
throw new Error('operationId not found in approve txRequest response');
}
Comment on lines +87 to +89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we do cleanup if the operationId is not present in the 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;
Comment on lines +107 to +112
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts on this?

Suggested change
try {
await this.cancelTxRequest(approveTxRequestId);
} catch {
// Best-effort cancel; the reconciler will clean up if this fails
}
throw err;
await this.cancelTxRequest(approveTxRequestId);

}

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<DepositResult> {
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<DefiOperation> {
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<DefiOperationListResult> {
if (!params.vaultId) {
throw new Error('vaultId is required');
}

const query: Record<string, string | number> = {
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();
}
Comment on lines +192 to +205
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vaultId redundant in the request
It's present in both URL path and query string.


// ── Internal helpers ────────────────────────────────────────────────

private async createTxRequest(intent: Record<string, unknown>): 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<void> {
await this.bitgo.del(this.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests/' + txRequestId, 2)).result();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this endpoint exist today in platform?
DELETE /txrequests/{id}

}

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}`;
Comment on lines +224 to +225
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this endpoint should also be be wallet.
without wallet scope, in the resumeDeposit() flow, it can potentially fetch operation from foreign wallet.

Suggested change
private operationUrl(operationId: string): string {
return `/api/defi-service/v1/operations/${operationId}`;
private operationUrl(operationId: string): string {
return `/api/defi-service/v1/wallets/${this.wallet.id()}/operations/${operationId}`;

}

private vaultOperationsUrl(vaultId: string): string {
return `/api/defi-service/v1/vaults/${vaultId}/operations`;
}
}
62 changes: 62 additions & 0 deletions modules/sdk-core/src/bitgo/defi/iDefiVault.ts
Original file line number Diff line number Diff line change
@@ -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<DepositResult>;
resumeDeposit(params: ResumeDepositOptions): Promise<DepositResult>;
getOperation(params: GetOperationOptions): Promise<DefiOperation>;
listOperations(params: ListOperationsOptions): Promise<DefiOperationListResult>;
}
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/defi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './iDefiVault';
export { DefiVault, ActiveOperationExistsError } from './defiVault';
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1163,6 +1164,7 @@ export interface IWallet {
remove(params?: Record<string, never>): Promise<any>;
toJSON(): WalletData;
createLightningInvoice(params: CreateLightningInvoiceParams): Promise<LightningInvoiceResponse>;
readonly defi: IDefiVault;
toTradingAccount(): ITradingAccount;
toStakingWallet(): IStakingWallet;
toGoStakingWallet(): IGoStakingWallet;
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[];

Expand Down Expand Up @@ -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
*/
Expand Down
Loading
Loading