Skip to content

Commit 2c562ff

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 142d72d commit 2c562ff

9 files changed

Lines changed: 766 additions & 0 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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 sendMany calls (approve + deposit) and
35+
* returns an operationId that the UI uses for status tracking and recovery.
36+
*
37+
* Uses wallet.sendMany() under the hood so that both custody wallets
38+
* (txRequest creation only) and hot wallets (create + sign + broadcast)
39+
* are handled by the existing infrastructure.
40+
*/
41+
export class DefiVault implements IDefiVault {
42+
private readonly wallet: IWallet;
43+
private readonly bitgo: BitGoBase;
44+
45+
constructor(wallet: IWallet) {
46+
this.wallet = wallet;
47+
this.bitgo = wallet.bitgo;
48+
}
49+
50+
/**
51+
* Deposit an amount of underlying asset into a vault.
52+
*
53+
* Internally issues two sendMany calls (approve + deposit) and returns the
54+
* operationId that links them. If the deposit sendMany fails after
55+
* the approve succeeds, the approve is auto-cancelled (fail-fast).
56+
*
57+
* @param params.vaultId - DeFi-service vault identifier
58+
* @param params.amount - amount in base units of the underlying asset
59+
* @param params.clientIdempotencyKey - optional client idempotency key
60+
* @param params.walletPassphrase - required for hot wallets, omit for custody
61+
*/
62+
async depositToVault(params: DepositToVaultOptions): Promise<DepositResult> {
63+
if (!params.vaultId) {
64+
throw new Error('vaultId is required');
65+
}
66+
if (!params.amount) {
67+
throw new Error('amount is required');
68+
}
69+
70+
// Layer-1 pre-flight: reject if an active deposit already exists for this (wallet, vault)
71+
const activeOps: DefiOperationListResult = await this.bitgo
72+
.get(this.bitgo.microservicesUrl(this.operationsUrl()))
73+
.query({ vaultId: params.vaultId, state: 'active' })
74+
.result();
75+
76+
if (activeOps.items && activeOps.items.length > 0) {
77+
throw new ActiveOperationExistsError(activeOps.items[0].operationId);
78+
}
79+
80+
// Step 1: Approve txRequest via sendMany
81+
const approveResult = await this.wallet.sendMany({
82+
type: 'defiApprove',
83+
defiParams: {
84+
vaultId: params.vaultId,
85+
amount: params.amount,
86+
...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}),
87+
},
88+
...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}),
89+
});
90+
91+
const approveTxRequestId = this.extractTxRequestId(approveResult);
92+
const operationId = this.extractOperationId(approveResult);
93+
94+
if (!operationId) {
95+
throw new Error('operationId not found in approve txRequest response');
96+
}
97+
98+
// Step 2: Deposit txRequest via sendMany
99+
// On failure, auto-cancel the approve txRequest (fail-fast per TDD §6.3.1)
100+
let depositTxRequestId: string;
101+
try {
102+
const depositResult = await this.wallet.sendMany({
103+
type: 'defiDeposit',
104+
defiParams: {
105+
vaultId: params.vaultId,
106+
amount: params.amount,
107+
operationId,
108+
...(params.clientIdempotencyKey ? { clientIdempotencyKey: params.clientIdempotencyKey } : {}),
109+
},
110+
...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}),
111+
});
112+
depositTxRequestId = this.extractTxRequestId(depositResult);
113+
} catch (err) {
114+
// Fail-fast: cancel the approve txRequest before throwing
115+
try {
116+
await this.cancelTxRequest(approveTxRequestId);
117+
} catch {
118+
// Best-effort cancel; the reconciler will clean up if this fails
119+
}
120+
throw err;
121+
}
122+
123+
return {
124+
operationId,
125+
txRequestIds: {
126+
approve: approveTxRequestId,
127+
deposit: depositTxRequestId,
128+
},
129+
};
130+
}
131+
132+
/**
133+
* Resume a partially-completed deposit. Call this when the SDK process died
134+
* between the approve and deposit txRequest creation.
135+
*
136+
* @param params.operationId - the operationId from the original depositToVault call
137+
* @param params.walletPassphrase - required for hot wallets, omit for custody
138+
*/
139+
async resumeDeposit(params: ResumeDepositOptions): Promise<DepositResult> {
140+
if (!params.operationId) {
141+
throw new Error('operationId is required');
142+
}
143+
144+
// Fetch the operation to get the vault and amount details
145+
const operation = await this.getOperation({ operationId: params.operationId });
146+
147+
if (operation.associatedTxRequestId) {
148+
throw new Error('Deposit txRequest already exists for this operation; nothing to resume');
149+
}
150+
151+
if (!operation.txRequestId) {
152+
throw new Error('Approve txRequest not found for this operation; cannot resume');
153+
}
154+
155+
// Issue the deposit txRequest using the existing operation's details
156+
const depositResult = await this.wallet.sendMany({
157+
type: 'defiDeposit',
158+
defiParams: {
159+
vaultId: operation.vaultId,
160+
amount: operation.assetAmount,
161+
operationId: params.operationId,
162+
},
163+
...(params.walletPassphrase ? { walletPassphrase: params.walletPassphrase } : {}),
164+
});
165+
166+
return {
167+
operationId: params.operationId,
168+
txRequestIds: {
169+
approve: operation.txRequestId,
170+
deposit: this.extractTxRequestId(depositResult),
171+
},
172+
};
173+
}
174+
175+
/**
176+
* Get the current state of a DeFi operation.
177+
*
178+
* @param params.operationId - the operation to retrieve
179+
*/
180+
async getOperation(params: GetOperationOptions): Promise<DefiOperation> {
181+
if (!params.operationId) {
182+
throw new Error('operationId is required');
183+
}
184+
185+
return await this.bitgo.get(this.bitgo.microservicesUrl(this.operationUrl(params.operationId))).result();
186+
}
187+
188+
/**
189+
* List operations for a vault filtered by walletId.
190+
*
191+
* @param params.vaultId - vault to list operations for
192+
* @param params.state - optional state filter
193+
* @param params.type - optional type filter (DEPOSIT | WITHDRAW)
194+
* @param params.limit - page size
195+
* @param params.cursor - pagination cursor
196+
*/
197+
async listOperations(params: ListOperationsOptions): Promise<DefiOperationListResult> {
198+
if (!params.vaultId) {
199+
throw new Error('vaultId is required');
200+
}
201+
202+
const query: Record<string, string | number> = {
203+
walletId: this.wallet.id(),
204+
vaultId: params.vaultId,
205+
};
206+
if (params.state) query.state = params.state;
207+
if (params.type) query.type = params.type;
208+
if (params.limit) query.limit = params.limit;
209+
if (params.cursor) query.cursor = params.cursor;
210+
211+
return await this.bitgo
212+
.get(this.bitgo.microservicesUrl(this.vaultOperationsUrl(params.vaultId)))
213+
.query(query)
214+
.result();
215+
}
216+
217+
// ── Internal helpers ────────────────────────────────────────────────
218+
219+
/**
220+
* Extract txRequestId from a sendMany result.
221+
* sendMany returns different shapes depending on wallet type:
222+
* - TSS full: { txRequest: { txRequestId } } or { pendingApproval, txRequest }
223+
* - TSS lite: result from tssUtils.sendTxRequest
224+
*/
225+
private extractTxRequestId(sendManyResult: Record<string, unknown>): string {
226+
const txRequest = sendManyResult.txRequest as Record<string, unknown> | undefined;
227+
if (txRequest?.txRequestId) {
228+
return txRequest.txRequestId as string;
229+
}
230+
if (sendManyResult.txRequestId) {
231+
return sendManyResult.txRequestId as string;
232+
}
233+
throw new Error('txRequestId not found in sendMany response');
234+
}
235+
236+
/**
237+
* Extract operationId from the intent of a sendMany result.
238+
* The WP populates operationId in the intent of the approve txRequest.
239+
*/
240+
private extractOperationId(sendManyResult: Record<string, unknown>): string | undefined {
241+
const txRequest = sendManyResult.txRequest as Record<string, unknown> | undefined;
242+
const intent = txRequest?.intent as Record<string, unknown> | undefined;
243+
return intent?.operationId as string | undefined;
244+
}
245+
246+
private async cancelTxRequest(txRequestId: string): Promise<void> {
247+
await this.bitgo.del(this.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests/' + txRequestId, 2)).result();
248+
}
249+
250+
private operationsUrl(): string {
251+
return `/api/defi-service/v1/wallets/${this.wallet.id()}/operations`;
252+
}
253+
254+
private operationUrl(operationId: string): string {
255+
return `/api/defi-service/v1/operations/${operationId}`;
256+
}
257+
258+
private vaultOperationsUrl(vaultId: string): string {
259+
return `/api/defi-service/v1/vaults/${vaultId}/operations`;
260+
}
261+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
/** Wallet passphrase — required for hot wallets, omit for custody */
13+
walletPassphrase?: string;
14+
}
15+
16+
export interface ResumeDepositOptions {
17+
/** operationId of the partially-completed deposit */
18+
operationId: string;
19+
/** Wallet passphrase — required for hot wallets, omit for custody */
20+
walletPassphrase?: string;
21+
}
22+
23+
export interface GetOperationOptions {
24+
operationId: string;
25+
}
26+
27+
export interface ListOperationsOptions {
28+
vaultId: string;
29+
state?: string;
30+
type?: string;
31+
limit?: number;
32+
cursor?: string;
33+
}
34+
35+
export interface DefiOperation {
36+
operationId: string;
37+
walletId: string;
38+
vaultId: string;
39+
type: 'DEPOSIT' | 'WITHDRAW';
40+
assetAmount: string;
41+
state: string;
42+
txRequestId?: string;
43+
associatedTxRequestId?: string;
44+
createdAt: string;
45+
updatedAt: string;
46+
}
47+
48+
export interface DepositResult {
49+
operationId: string;
50+
txRequestIds: {
51+
approve: string;
52+
deposit: string;
53+
};
54+
}
55+
56+
export interface DefiOperationListResult {
57+
items: DefiOperation[];
58+
nextCursor?: string;
59+
}
60+
61+
export interface IDefiVault {
62+
depositToVault(params: DepositToVaultOptions): Promise<DepositResult>;
63+
resumeDeposit(params: ResumeDepositOptions): Promise<DepositResult>;
64+
getOperation(params: GetOperationOptions): Promise<DefiOperation>;
65+
listOperations(params: ListOperationsOptions): Promise<DefiOperationListResult>;
66+
}
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/utils/mpcUtils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AddKeychainOptions, Keychain, KeyType, WebauthnKeyEncryptionInfo } from
99
import { encryptText, getBitgoGpgPubKey } from './opengpgUtils';
1010
import {
1111
IntentRecipient,
12+
PopulatedDefiIntent,
1213
PopulatedIntent,
1314
PrebuildTransactionWithIntentOptions,
1415
TokenTransferRecipientParams,
@@ -183,6 +184,8 @@ export abstract class MpcUtils {
183184
'transferOfferWithdrawn',
184185
'bridgeFunds',
185186
'cantonCommand',
187+
'defi-approve',
188+
'defi-deposit',
186189
].includes(params.intentType)
187190
) {
188191
assert(params.recipients, `'recipients' is a required parameter for ${params.intentType} intent`);
@@ -276,6 +279,22 @@ export abstract class MpcUtils {
276279
feeOptions: params.feeOptions,
277280
feeToken: params.feeToken,
278281
};
282+
case 'defi-approve':
283+
case 'defi-deposit': {
284+
assert(params.defiParams, `'defiParams' is required for ${params.intentType} intent`);
285+
const defiIntent: PopulatedDefiIntent = {
286+
intentType: params.intentType,
287+
vaultId: params.defiParams.vaultId,
288+
amount: params.defiParams.amount,
289+
};
290+
if (params.defiParams.operationId) {
291+
defiIntent.operationId = params.defiParams.operationId;
292+
}
293+
if (params.defiParams.clientIdempotencyKey) {
294+
defiIntent.clientIdempotencyKey = params.defiParams.clientIdempotencyKey;
295+
}
296+
return defiIntent as unknown as PopulatedIntent;
297+
}
279298
default:
280299
throw new Error(`Unsupported intent type ${params.intentType}`);
281300
}

0 commit comments

Comments
 (0)