-
Notifications
You must be signed in to change notification settings - Fork 303
feat(sdk-core): add wallet.defi DeFi vault orchestration methods #8912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thoughts on this?
Suggested change
|
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||
|
|
||||||||||||||||
| // ── 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(); | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this endpoint exist today in platform? |
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this endpoint should also be be wallet.
Suggested change
|
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private vaultOperationsUrl(vaultId: string): string { | ||||||||||||||||
| return `/api/defi-service/v1/vaults/${vaultId}/operations`; | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| 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>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './iDefiVault'; | ||
| export { DefiVault, ActiveOperationExistsError } from './defiVault'; |
There was a problem hiding this comment.
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?