Skip to content

Commit ef017e9

Browse files
hitansh-madanclaude
andcommitted
feat(sdk-core): add wallet.defi DeFi vault orchestration methods
Add SDK-side orchestration for Galaxy × Morpho ERC-4626 vault deposits. Introduces wallet.defi.depositToVault() which sequences approve + deposit txRequests with fail-fast auto-cancel, plus resumeDeposit() for recovery, and getOperation()/listOperations() for status tracking. Ticket: CGD-1533 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 291a6fb commit ef017e9

7 files changed

Lines changed: 653 additions & 0 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* @prettier
3+
*/
4+
import {
5+
DefiOperation,
6+
DefiOperationListResult,
7+
DepositResult,
8+
DepositToVaultOptions,
9+
GetOperationOptions,
10+
IDefiVault,
11+
ListOperationsOptions,
12+
ResumeDepositOptions,
13+
} from './iDefiVault';
14+
import { IWallet } from '../wallet';
15+
import { BitGoBase } from '../bitgoBase';
16+
17+
/**
18+
* Error thrown when a concurrent active deposit already exists for the (wallet, vault) pair.
19+
*/
20+
export class ActiveOperationExistsError extends Error {
21+
public readonly operationId: string;
22+
23+
constructor(operationId: string) {
24+
super(`An active deposit operation already exists: ${operationId}`);
25+
this.name = 'ActiveOperationExistsError';
26+
this.operationId = operationId;
27+
}
28+
}
29+
30+
/**
31+
* Orchestrates ERC-4626 vault deposit and withdraw flows for a wallet.
32+
*
33+
* Exposed as `wallet.defi` on the Wallet class. See TDD §6.3.1 for the full
34+
* design: the SDK sequences two txRequest-create calls (approve + deposit) and
35+
* returns an operationId that the UI uses for status tracking and recovery.
36+
*/
37+
export class DefiVault implements IDefiVault {
38+
private readonly wallet: IWallet;
39+
private readonly bitgo: BitGoBase;
40+
41+
constructor(wallet: IWallet) {
42+
this.wallet = wallet;
43+
this.bitgo = wallet.bitgo;
44+
}
45+
46+
/**
47+
* Deposit an amount of underlying asset into a vault.
48+
*
49+
* Internally creates two txRequests (approve + deposit) and returns the
50+
* operationId that links them. If the deposit txRequest creation fails after
51+
* the approve succeeds, the approve is auto-cancelled (fail-fast).
52+
*
53+
* @param params.vaultId - DeFi-service vault identifier
54+
* @param params.amount - amount in base units of the underlying asset
55+
* @param params.clientIdempotencyKey - optional client idempotency key
56+
*/
57+
async depositToVault(params: DepositToVaultOptions): Promise<DepositResult> {
58+
if (!params.vaultId) {
59+
throw new Error('vaultId is required');
60+
}
61+
if (!params.amount) {
62+
throw new Error('amount is required');
63+
}
64+
65+
// Layer-1 pre-flight: reject if an active deposit already exists for this (wallet, vault)
66+
const activeOps: DefiOperationListResult = await this.bitgo
67+
.get(this.bitgo.microservicesUrl(this.operationsUrl()))
68+
.query({ vaultId: params.vaultId, state: 'active' })
69+
.result();
70+
71+
if (activeOps.items && activeOps.items.length > 0) {
72+
throw new ActiveOperationExistsError(activeOps.items[0].operationId);
73+
}
74+
75+
// Step 1: Create approve txRequest
76+
const approveIntent = {
77+
intentType: 'defi-approve',
78+
vaultId: params.vaultId,
79+
amount: params.amount,
80+
...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}),
81+
};
82+
83+
const approveTxRequest = await this.createTxRequest(approveIntent);
84+
const operationId = (approveTxRequest.intent as Record<string, unknown>)?.operationId as string;
85+
const approveTxRequestId = approveTxRequest.txRequestId;
86+
87+
if (!operationId) {
88+
throw new Error('operationId not found in approve txRequest response');
89+
}
90+
91+
// Step 2: Create deposit txRequest
92+
// On failure, auto-cancel the approve txRequest (fail-fast per TDD §6.3.1)
93+
let depositTxRequestId: string;
94+
try {
95+
const depositIntent = {
96+
intentType: 'defi-deposit',
97+
vaultId: params.vaultId,
98+
amount: params.amount,
99+
operationId,
100+
...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}),
101+
};
102+
103+
const depositTxRequest = await this.createTxRequest(depositIntent);
104+
depositTxRequestId = depositTxRequest.txRequestId;
105+
} catch (err) {
106+
// Fail-fast: cancel the approve txRequest before throwing
107+
try {
108+
await this.cancelTxRequest(approveTxRequestId);
109+
} catch {
110+
// Best-effort cancel; the reconciler will clean up if this fails
111+
}
112+
throw err;
113+
}
114+
115+
return {
116+
operationId,
117+
txRequestIds: {
118+
approve: approveTxRequestId,
119+
deposit: depositTxRequestId,
120+
},
121+
};
122+
}
123+
124+
/**
125+
* Resume a partially-completed deposit. Call this when the SDK process died
126+
* between the approve and deposit txRequest creation.
127+
*
128+
* @param params.operationId - the operationId from the original depositToVault call
129+
*/
130+
async resumeDeposit(params: ResumeDepositOptions): Promise<DepositResult> {
131+
if (!params.operationId) {
132+
throw new Error('operationId is required');
133+
}
134+
135+
// Fetch the operation to get the vault and amount details
136+
const operation = await this.getOperation({ operationId: params.operationId });
137+
138+
if (operation.associatedTxRequestId) {
139+
throw new Error('Deposit txRequest already exists for this operation; nothing to resume');
140+
}
141+
142+
if (!operation.txRequestId) {
143+
throw new Error('Approve txRequest not found for this operation; cannot resume');
144+
}
145+
146+
// Issue the deposit txRequest using the existing operation's details
147+
const depositIntent = {
148+
intentType: 'defi-deposit',
149+
vaultId: operation.vaultId,
150+
amount: operation.assetAmount,
151+
operationId: params.operationId,
152+
};
153+
154+
const depositTxRequest = await this.createTxRequest(depositIntent);
155+
156+
return {
157+
operationId: params.operationId,
158+
txRequestIds: {
159+
approve: operation.txRequestId,
160+
deposit: depositTxRequest.txRequestId,
161+
},
162+
};
163+
}
164+
165+
/**
166+
* Get the current state of a DeFi operation.
167+
*
168+
* @param params.operationId - the operation to retrieve
169+
*/
170+
async getOperation(params: GetOperationOptions): Promise<DefiOperation> {
171+
if (!params.operationId) {
172+
throw new Error('operationId is required');
173+
}
174+
175+
return await this.bitgo.get(this.bitgo.microservicesUrl(this.operationUrl(params.operationId))).result();
176+
}
177+
178+
/**
179+
* List operations for a vault filtered by walletId.
180+
*
181+
* @param params.vaultId - vault to list operations for
182+
* @param params.state - optional state filter
183+
* @param params.type - optional type filter (DEPOSIT | WITHDRAW)
184+
* @param params.limit - page size
185+
* @param params.cursor - pagination cursor
186+
*/
187+
async listOperations(params: ListOperationsOptions): Promise<DefiOperationListResult> {
188+
if (!params.vaultId) {
189+
throw new Error('vaultId is required');
190+
}
191+
192+
const query: Record<string, string | number> = {
193+
walletId: this.wallet.id(),
194+
vaultId: params.vaultId,
195+
};
196+
if (params.state) query.state = params.state;
197+
if (params.type) query.type = params.type;
198+
if (params.limit) query.limit = params.limit;
199+
if (params.cursor) query.cursor = params.cursor;
200+
201+
return await this.bitgo
202+
.get(this.bitgo.microservicesUrl(this.vaultOperationsUrl(params.vaultId)))
203+
.query(query)
204+
.result();
205+
}
206+
207+
// ── Internal helpers ────────────────────────────────────────────────
208+
209+
private async createTxRequest(intent: Record<string, unknown>): Promise<{ txRequestId: string; intent: unknown }> {
210+
return await this.bitgo
211+
.post(this.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests', 2))
212+
.send({ intent })
213+
.result();
214+
}
215+
216+
private async cancelTxRequest(txRequestId: string): Promise<void> {
217+
await this.bitgo.del(this.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests/' + txRequestId, 2)).result();
218+
}
219+
220+
private operationsUrl(): string {
221+
return `/api/defi-service/v1/wallets/${this.wallet.id()}/operations`;
222+
}
223+
224+
private operationUrl(operationId: string): string {
225+
return `/api/defi-service/v1/operations/${operationId}`;
226+
}
227+
228+
private vaultOperationsUrl(vaultId: string): string {
229+
return `/api/defi-service/v1/vaults/${vaultId}/operations`;
230+
}
231+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @prettier
3+
*/
4+
5+
export interface DepositToVaultOptions {
6+
/** DeFi-service vault identifier */
7+
vaultId: string;
8+
/** Amount in base units of the underlying asset */
9+
amount: string;
10+
/** Optional client-supplied idempotency key */
11+
clientIdempotencyKey?: string;
12+
}
13+
14+
export interface ResumeDepositOptions {
15+
/** operationId of the partially-completed deposit */
16+
operationId: string;
17+
}
18+
19+
export interface GetOperationOptions {
20+
operationId: string;
21+
}
22+
23+
export interface ListOperationsOptions {
24+
vaultId: string;
25+
state?: string;
26+
type?: string;
27+
limit?: number;
28+
cursor?: string;
29+
}
30+
31+
export interface DefiOperation {
32+
operationId: string;
33+
walletId: string;
34+
vaultId: string;
35+
type: 'DEPOSIT' | 'WITHDRAW';
36+
assetAmount: string;
37+
state: string;
38+
txRequestId?: string;
39+
associatedTxRequestId?: string;
40+
createdAt: string;
41+
updatedAt: string;
42+
}
43+
44+
export interface DepositResult {
45+
operationId: string;
46+
txRequestIds: {
47+
approve: string;
48+
deposit: string;
49+
};
50+
}
51+
52+
export interface DefiOperationListResult {
53+
items: DefiOperation[];
54+
nextCursor?: string;
55+
}
56+
57+
export interface IDefiVault {
58+
depositToVault(params: DepositToVaultOptions): Promise<DepositResult>;
59+
resumeDeposit(params: ResumeDepositOptions): Promise<DepositResult>;
60+
getOperation(params: GetOperationOptions): Promise<DefiOperation>;
61+
listOperations(params: ListOperationsOptions): Promise<DefiOperationListResult>;
62+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './iDefiVault';
2+
export { DefiVault, ActiveOperationExistsError } from './defiVault';

modules/sdk-core/src/bitgo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './bitcoin';
88
export * from './bitgoBase';
99
export * from './config';
1010
export * from './coinFactory';
11+
export * from './defi';
1112
export * from './ecdh';
1213
export * from './enterprise';
1314
export * from './environments';

modules/sdk-core/src/bitgo/wallet/iWallet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { BitGoBase } from '../bitgoBase';
1616
import { Keychain, KeychainWithEncryptedPrv } from '../keychain';
1717
import { IPendingApproval, PendingApprovalData } from '../pendingApproval';
18+
import { IDefiVault } from '../defi';
1819
import { IGoStakingWallet, IStakingWallet } from '../staking';
1920
import { ITradingAccount } from '../trading';
2021
import {
@@ -1163,6 +1164,7 @@ export interface IWallet {
11631164
remove(params?: Record<string, never>): Promise<any>;
11641165
toJSON(): WalletData;
11651166
createLightningInvoice(params: CreateLightningInvoiceParams): Promise<LightningInvoiceResponse>;
1167+
readonly defi: IDefiVault;
11661168
toTradingAccount(): ITradingAccount;
11671169
toStakingWallet(): IStakingWallet;
11681170
toGoStakingWallet(): IGoStakingWallet;

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
import { CreateLightningInvoiceParams, LightningInvoiceResponse } from '../../lightning';
4343
import { getLightningAuthKey } from '../lightning/lightningWalletUtil';
4444
import { IPendingApproval, PendingApproval, PendingApprovals } from '../pendingApproval';
45+
import { DefiVault } from '../defi';
4546
import { GoStakingWallet, StakingWallet } from '../staking';
4647
import { TradingAccount } from '../trading';
4748
import { getTxRequest } from '../tss';
@@ -166,6 +167,7 @@ export class Wallet implements IWallet {
166167
public readonly bitgo: BitGoBase;
167168
public readonly baseCoin: IBaseCoin;
168169
public _wallet: WalletData;
170+
private _defi?: DefiVault;
169171
private readonly tssUtils: EcdsaUtils | EcdsaMPCv2Utils | EddsaUtils | EddsaMPCv2Utils | undefined;
170172
private readonly _permissions?: string[];
171173

@@ -3251,6 +3253,16 @@ export class Wallet implements IWallet {
32513253
return new AddressBook(this._wallet.enterprise, this.bitgo, this);
32523254
}
32533255

3256+
/**
3257+
* Access DeFi vault operations for this wallet (deposit, withdraw, resume).
3258+
*/
3259+
get defi(): DefiVault {
3260+
if (!this._defi) {
3261+
this._defi = new DefiVault(this);
3262+
}
3263+
return this._defi;
3264+
}
3265+
32543266
/**
32553267
* Create a staking wallet from this wallet
32563268
*/

0 commit comments

Comments
 (0)