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
261 changes: 261 additions & 0 deletions modules/sdk-core/src/bitgo/defi/defiVault.ts
Original file line number Diff line number Diff line change
@@ -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<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: 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');
}
Comment on lines +94 to +96
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: 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;
Comment on lines +115 to +120
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
* @param params.walletPassphrase - required for hot wallets, omit for custody
*/
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 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<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 +202 to +215
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 ────────────────────────────────────────────────

/**
* 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, unknown>): string {
const txRequest = sendManyResult.txRequest as Record<string, unknown> | 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, unknown>): string | undefined {
const txRequest = sendManyResult.txRequest as Record<string, unknown> | undefined;
const intent = txRequest?.intent as Record<string, unknown> | undefined;
return intent?.operationId as string | undefined;
}

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 +254 to +255
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`;
}
}
66 changes: 66 additions & 0 deletions modules/sdk-core/src/bitgo/defi/iDefiVault.ts
Original file line number Diff line number Diff line change
@@ -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<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
19 changes: 19 additions & 0 deletions modules/sdk-core/src/bitgo/utils/mpcUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AddKeychainOptions, Keychain, KeyType, WebauthnKeyEncryptionInfo } from
import { encryptText, getBitgoGpgPubKey } from './opengpgUtils';
import {
IntentRecipient,
PopulatedDefiIntent,
PopulatedIntent,
PrebuildTransactionWithIntentOptions,
TokenTransferRecipientParams,
Expand Down Expand Up @@ -183,6 +184,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`);
Expand Down Expand Up @@ -276,6 +279,22 @@ export abstract class MpcUtils {
feeOptions: params.feeOptions,
feeToken: params.feeToken,
};
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 as unknown as PopulatedIntent;
}
default:
throw new Error(`Unsupported intent type ${params.intentType}`);
}
Expand Down
Loading
Loading