-
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,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'); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // 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
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 | ||||||||||||||||
| * @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
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 ──────────────────────────────────────────────── | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * 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(); | ||||||||||||||||
|
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
+254
to
+255
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,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>; | ||
| } |
| 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?