@dfm-fi/agent 0.2.2059 → 0.2.2084

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,7 +86,7 @@ running. Write tools also require `DFM_AGENT_WRITE_ENABLED=true`.
86
86
  | `report_bug` | support | Send a bug report to the DFM team. Moves no funds. |
87
87
  | `onboard` | onboarding | Headless agent: runs the steps below in order, stops at the first missing one and prints the funding step. |
88
88
  | `create_identity_wallet` | onboarding | The agent's own Solana identity keypair (SIWS sign-in); reused if present, never a second. |
89
- | `set_profile` | onboarding | Display name, @username, bio, avatar. No image file? A deterministic default avatar is generated. |
89
+ | `set_profile` | onboarding | Display name, bio, avatar (no DFM @username; the only @ is a linked X). No image file? A deterministic default avatar is generated. |
90
90
  | `create_app_wallet` | onboarding | An app wallet owned by the agent's local P-256 key. |
91
91
  | `create_api_key` | onboarding | Mint a `dfm_` key (read-only unless `DFM_ALLOW_WRITE_KEY_MINT=1`), stored 0600, never shown in chat. |
92
92
  | `launch_vault` | write | Launch a fund (2–10 assets, weights sum to 10,000 bps). |
@@ -78,7 +78,7 @@ export declare class DfmApiClient {
78
78
  * null. The session wallet must equal `user` (or be the vault admin), else 403.
79
79
  * Returns `null` when the user has no open escrow.
80
80
  */
81
- getZapV2Status(vaultAddress: string, user: string): Promise<ZapV2Status | null>;
81
+ getZapV2Status(vaultAddress: string, user: string, escrowSlot?: number): Promise<ZapV2Status | null>;
82
82
  /**
83
83
  * `GET /portfolio` (auth) — the API reads the wallet from the JWT; there is NO
84
84
  * portfolio-by-arbitrary-address endpoint. So this returns the SESSION
@@ -287,7 +287,7 @@ export declare class DfmApiClient {
287
287
  expirySecs?: number;
288
288
  }): Promise<ZapV2PrepareResponse>;
289
289
  /** `POST /vaults/:address/zap-in-v2/close/prepare` — CPI mint_in_kind_v2. */
290
- prepareZapInCloseV2(vaultAddress: string): Promise<ZapV2PrepareResponse>;
290
+ prepareZapInCloseV2(vaultAddress: string, escrowSlot?: number): Promise<ZapV2PrepareResponse>;
291
291
  /**
292
292
  * `POST /tx/submit` (#18) — hand a SIGNED prepared tx back to the backend so
293
293
  * its premium RPC broadcasts it. Used when no client RPC is configured (the
@@ -305,13 +305,13 @@ export declare class DfmApiClient {
305
305
  expirySecs?: number;
306
306
  }): Promise<ZapV2PrepareResponse>;
307
307
  /** `POST /vaults/:address/zap-out-v2/close/prepare` — exit fee, pay net USDC. */
308
- prepareZapOutCloseV2(vaultAddress: string): Promise<ZapV2PrepareResponse>;
308
+ prepareZapOutCloseV2(vaultAddress: string, escrowSlot?: number): Promise<ZapV2PrepareResponse>;
309
309
  /**
310
310
  * `POST /vaults/:address/zap-v2/cancel/prepare` — return the escrow's current
311
311
  * USDC + per-asset balances to the test wallet as-is (no swaps, no oracle),
312
312
  * then close it. 404 server-side when no escrow is open. Single v0 tx.
313
313
  */
314
- prepareZapCancelV2(vaultAddress: string): Promise<ZapV2SingleTxResponse>;
314
+ prepareZapCancelV2(vaultAddress: string, escrowSlot?: number): Promise<ZapV2SingleTxResponse>;
315
315
  /**
316
316
  * `POST /vaults/:address/zap-v2/update-envelope/prepare` — LOOSEN the pinned
317
317
  * per-leg floors + slippage (and optionally extend expiry) so a stalled escrow
@@ -219,10 +219,13 @@ export class DfmApiClient {
219
219
  * null. The session wallet must equal `user` (or be the vault admin), else 403.
220
220
  * Returns `null` when the user has no open escrow.
221
221
  */
222
- async getZapV2Status(vaultAddress, user) {
222
+ async getZapV2Status(vaultAddress, user, escrowSlot) {
223
223
  requireBase58(vaultAddress, 'vault address');
224
224
  requireBase58(user, 'user wallet');
225
- return this.authRequest(`/vaults/${vaultAddress}/zap-v2/status/${user}`);
225
+ // A wallet can hold up to 8 escrows per fund; without a slot the API
226
+ // returns the oldest open one.
227
+ const q = escrowSlot === undefined ? '' : `?slot=${escrowSlot}`;
228
+ return this.authRequest(`/vaults/${vaultAddress}/zap-v2/status/${user}${q}`);
226
229
  }
227
230
  /**
228
231
  * `GET /portfolio` (auth) — the API reads the wallet from the JWT; there is NO
@@ -490,9 +493,9 @@ export class DfmApiClient {
490
493
  return this.authRequest(`/vaults/${vaultAddress}/zap-in-v2/open/prepare`, { method: 'POST', body: JSON.stringify({ usdcAmount: usdcAmountRaw, ...opts }) });
491
494
  }
492
495
  /** `POST /vaults/:address/zap-in-v2/close/prepare` — CPI mint_in_kind_v2. */
493
- async prepareZapInCloseV2(vaultAddress) {
496
+ async prepareZapInCloseV2(vaultAddress, escrowSlot) {
494
497
  requireBase58(vaultAddress, 'vault address');
495
- return this.authRequest(`/vaults/${vaultAddress}/zap-in-v2/close/prepare`, { method: 'POST', body: JSON.stringify({}) });
498
+ return this.authRequest(`/vaults/${vaultAddress}/zap-in-v2/close/prepare`, { method: 'POST', body: JSON.stringify(escrowSlot === undefined ? {} : { escrowSlot }) });
496
499
  }
497
500
  /**
498
501
  * `POST /tx/submit` (#18) — hand a SIGNED prepared tx back to the backend so
@@ -518,18 +521,18 @@ export class DfmApiClient {
518
521
  return this.authRequest(`/vaults/${vaultAddress}/zap-out-v2/open/prepare`, { method: 'POST', body: JSON.stringify({ shares: sharesRaw, ...opts }) });
519
522
  }
520
523
  /** `POST /vaults/:address/zap-out-v2/close/prepare` — exit fee, pay net USDC. */
521
- async prepareZapOutCloseV2(vaultAddress) {
524
+ async prepareZapOutCloseV2(vaultAddress, escrowSlot) {
522
525
  requireBase58(vaultAddress, 'vault address');
523
- return this.authRequest(`/vaults/${vaultAddress}/zap-out-v2/close/prepare`, { method: 'POST', body: JSON.stringify({}) });
526
+ return this.authRequest(`/vaults/${vaultAddress}/zap-out-v2/close/prepare`, { method: 'POST', body: JSON.stringify(escrowSlot === undefined ? {} : { escrowSlot }) });
524
527
  }
525
528
  /**
526
529
  * `POST /vaults/:address/zap-v2/cancel/prepare` — return the escrow's current
527
530
  * USDC + per-asset balances to the test wallet as-is (no swaps, no oracle),
528
531
  * then close it. 404 server-side when no escrow is open. Single v0 tx.
529
532
  */
530
- async prepareZapCancelV2(vaultAddress) {
533
+ async prepareZapCancelV2(vaultAddress, escrowSlot) {
531
534
  requireBase58(vaultAddress, 'vault address');
532
- return this.authRequest(`/vaults/${vaultAddress}/zap-v2/cancel/prepare`, { method: 'POST', body: JSON.stringify({}) });
535
+ return this.authRequest(`/vaults/${vaultAddress}/zap-v2/cancel/prepare`, { method: 'POST', body: JSON.stringify(escrowSlot === undefined ? {} : { escrowSlot }) });
533
536
  }
534
537
  /**
535
538
  * `POST /vaults/:address/zap-v2/update-envelope/prepare` — LOOSEN the pinned
@@ -419,15 +419,16 @@ server.registerTool('zap_status', {
419
419
  inputSchema: {
420
420
  vault: z.string().regex(BASE58, 'Invalid base58 vault address'),
421
421
  user: z.string().regex(BASE58, 'Invalid base58 user wallet').optional(),
422
+ escrowSlot: z.number().int().min(0).max(7).optional().describe('Escrow slot 0-7 (from zap_status). A wallet can hold several escrows per fund; omit for the oldest open one.'),
422
423
  },
423
424
  annotations: { readOnlyHint: true, openWorldHint: true },
424
- }, async ({ vault, user }) => {
425
+ }, async ({ vault, user, escrowSlot }) => {
425
426
  try {
426
427
  const identity = await resolveWalletIdentity();
427
428
  const escrowOwner = resolveZapStatusWallet(identity, user);
428
429
  if (!escrowOwner)
429
430
  return errText('zap_status could not resolve this agent wallet');
430
- const res = await api.getZapV2Status(vault, escrowOwner);
431
+ const res = await api.getZapV2Status(vault, escrowOwner, escrowSlot);
431
432
  return text(res ?? { escrow: null, note: 'No open zap-v2 escrow for this vault+user.' });
432
433
  }
433
434
  catch (e) {
@@ -643,7 +644,7 @@ server.registerTool('create_identity_wallet', {
643
644
  });
644
645
  server.registerTool('set_profile', {
645
646
  title: 'Set my DFM profile',
646
- description: 'Set this agent\'s public DFM profile: displayName (required), optional @username and bio. The avatar is the ' +
647
+ description: 'Set this agent\'s public DFM profile: displayName (required) and optional bio; there is no DFM @username. The avatar is the ' +
647
648
  'image the OPERATOR put at DFM_AGENT_AVATAR_PATH or ~/.config/dfm/avatar.{png,jpg,jpeg,webp,gif} (≤ 2 MB, ' +
648
649
  'checked locally) — the model cannot point it at any other file. With no such file a deterministic default ' +
649
650
  'avatar (an identicon of the identity public key) is generated and uploaded, so no image is required; the ' +
@@ -651,16 +652,14 @@ server.registerTool('set_profile', {
651
652
  'the SIWS sign-in (identity wallet). Moves no funds.',
652
653
  inputSchema: {
653
654
  displayName: z.string().min(1).max(32),
654
- username: z.string().regex(/^@?[a-zA-Z0-9_]{3,20}$/).optional().describe('@handle, 3-20 chars a-z 0-9 _'),
655
655
  bio: z.string().max(160).optional(),
656
656
  uploadAvatar: z.boolean().optional().describe('default true: upload the operator image, or the generated default when there is none'),
657
657
  },
658
658
  annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
659
- }, async ({ displayName, username, bio, uploadAvatar }) => {
659
+ }, async ({ displayName, bio, uploadAvatar }) => {
660
660
  try {
661
661
  const profile = await onboardApi.updateProfile({
662
662
  displayName,
663
- ...(username ? { username: username.replace(/^@/, '') } : {}),
664
663
  ...(bio !== undefined ? { bio } : {}),
665
664
  });
666
665
  let avatar = { uploaded: false };
@@ -676,7 +675,7 @@ server.registerTool('set_profile', {
676
675
  : {}),
677
676
  };
678
677
  }
679
- return text({ profile: { displayName: profile.displayName, username: profile.username, bio: profile.bio }, avatar });
678
+ return text({ profile: { displayName: profile.displayName, bio: profile.bio }, avatar });
680
679
  }
681
680
  catch (e) {
682
681
  return errText(`set_profile failed: ${e.message}`);
@@ -1218,19 +1217,20 @@ server.registerTool('zap_v2_cancel', {
1218
1217
  'just want your funds back. Returns the held balances + the cancel signature.',
1219
1218
  inputSchema: {
1220
1219
  vault: z.string().regex(BASE58, 'Invalid base58 vault address'),
1220
+ escrowSlot: z.number().int().min(0).max(7).optional().describe('Escrow slot 0-7 (from zap_status). A wallet can hold several escrows per fund; omit for the oldest open one.'),
1221
1221
  confirmPhrase: confirmPhraseField,
1222
1222
  },
1223
1223
  annotations: { title: 'DFM zap-v2 cancel', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1224
1224
  },
1225
1225
  // REVIEW: real-money path — parent must review before enabling.
1226
- async ({ vault, confirmPhrase }) => {
1226
+ async ({ vault, escrowSlot, confirmPhrase }) => {
1227
1227
  const gate = confirmGate('zap_v2_cancel', confirmPhrase);
1228
1228
  if (!gate.ok)
1229
1229
  return gate.result;
1230
1230
  const write = writeGate();
1231
1231
  if (!write.ok)
1232
1232
  return write.result;
1233
- return keylessWrite('zap_v2_cancel', { vault });
1233
+ return keylessWrite('zap_v2_cancel', { vault, escrowSlot });
1234
1234
  });
1235
1235
  server.registerTool('zap_v2_update_envelope', {
1236
1236
  title: 'Loosen pinned floors on a stalled zap-v2 escrow',
@@ -1241,6 +1241,7 @@ server.registerTool('zap_v2_update_envelope', {
1241
1241
  'slippage and ≤1000 (onchain monotonic-loosening). Optionally extend expiry. ' +
1242
1242
  'Returns the exact loosening applied (old→new). If it still will not fill, cancel.',
1243
1243
  inputSchema: {
1244
+ escrowSlot: z.number().int().min(0).max(7).optional().describe('Escrow slot 0-7 (from zap_status). A wallet can hold several escrows per fund; omit for the oldest open one.'),
1244
1245
  vault: z.string().regex(BASE58, 'Invalid base58 vault address'),
1245
1246
  loosenPct: z.number().min(0).max(50).optional(),
1246
1247
  newSlippageBps: z.number().int().min(0).max(1000).optional(),
@@ -1250,14 +1251,14 @@ server.registerTool('zap_v2_update_envelope', {
1250
1251
  annotations: { title: 'DFM zap-v2 update-envelope', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1251
1252
  },
1252
1253
  // REVIEW: real-money path — parent must review before enabling.
1253
- async ({ vault, loosenPct, newSlippageBps, extendSecs, confirmPhrase }) => {
1254
+ async ({ vault, loosenPct, newSlippageBps, extendSecs, escrowSlot, confirmPhrase }) => {
1254
1255
  const gate = confirmGate('zap_v2_update_envelope', confirmPhrase);
1255
1256
  if (!gate.ok)
1256
1257
  return gate.result;
1257
1258
  const write = writeGate();
1258
1259
  if (!write.ok)
1259
1260
  return write.result;
1260
- return keylessWrite('zap_v2_update_envelope', { vault, loosenPct, newSlippageBps, extendSecs });
1261
+ return keylessWrite('zap_v2_update_envelope', { vault, loosenPct, newSlippageBps, extendSecs, escrowSlot });
1261
1262
  });
1262
1263
  // ─── WRITE TOOL: update_vault_assets (config-only, same semantics in both lanes) ─
1263
1264
  server.registerTool('update_vault_assets', {
@@ -32,7 +32,6 @@ export declare function onboardingApi(call: SessionFetch): {
32
32
  }>;
33
33
  updateProfile: (dto: {
34
34
  displayName: string;
35
- username?: string;
36
35
  bio?: string;
37
36
  }) => Promise<Record<string, unknown>>;
38
37
  uploadAvatar: (bytes: Buffer, filename: string, mime: string) => Promise<Record<string, unknown>>;
package/dist/types.d.ts CHANGED
@@ -71,6 +71,8 @@ export interface VaultDetailResponse {
71
71
  */
72
72
  export interface ZapV2Status {
73
73
  escrowPda: string;
74
+ /** Escrow slot 0..7 — a wallet can hold several escrows per fund. */
75
+ escrowSlot?: number;
74
76
  vault: string;
75
77
  user: string;
76
78
  mode: number;
@@ -111,6 +113,8 @@ export interface ZapV2PrepareResponse {
111
113
  lastValidBlockHeight: number;
112
114
  computeUnits: number;
113
115
  escrowPda: string;
116
+ /** Escrow slot (0..7) the tx acts on. */
117
+ escrowSlot?: number;
114
118
  /** zap-out OPEN additionally returns these (redeem economics). */
115
119
  minUsdcOut?: string;
116
120
  expectedUsdc?: string;
@@ -44,7 +44,7 @@ export interface ZapFlowResult {
44
44
  * #61 — 'complete' = escrow closed/settled (shares minted / USDC paid).
45
45
  * 'pending' = the poll window elapsed but the escrow is STILL OPEN and
46
46
  * self-healing (the backend orchestrator keeps cranking) — this is NOT a
47
- * failure; call the same tool again (same args) to RESUME polling to close.
47
+ * failure; call again with the escrow's `escrowSlot` to RESUME polling to close.
48
48
  */
49
49
  settlement: 'complete' | 'pending';
50
50
  openSignatures: string[];
@@ -75,6 +75,7 @@ export declare function runDeposit(config: AgentConfig, api: DfmApiClient, user:
75
75
  slippageBps?: number;
76
76
  expirySecs?: number;
77
77
  poll?: Partial<PollOptions>;
78
+ escrowSlot?: number;
78
79
  }): Promise<ZapFlowResult>;
79
80
  /**
80
81
  * Drive a full REDEEM (shares → basket → USDC). `sharesRaw` is 6-dp raw units.
@@ -84,6 +85,7 @@ export declare function runRedeem(config: AgentConfig, api: DfmApiClient, user:
84
85
  slippageBps?: number;
85
86
  expirySecs?: number;
86
87
  poll?: Partial<PollOptions>;
88
+ escrowSlot?: number;
87
89
  }): Promise<ZapFlowResult>;
88
90
  export {};
89
91
  //# sourceMappingURL=zap-v2-flow.d.ts.map
@@ -28,21 +28,22 @@ async function submitOpen(config, prepared, kind, api) {
28
28
  catch (e) {
29
29
  if (e instanceof BlockhashExpiredError) {
30
30
  throw new Error(`The ${kind} OPEN tx did not confirm before its blockhash expired. Check zap_status: ` +
31
- `if an escrow now EXISTS, the open landed — poll it then close (re-run this ${kind} call ` +
32
- `once an escrow is open will resume it); if NO escrow exists, nothing happened and it is ` +
31
+ `if an escrow now EXISTS in slot ${prepared.escrowSlot ?? 0}, the open landed — re-run this ${kind} ` +
32
+ `with escrowSlot=${prepared.escrowSlot ?? 0} to resume it; if NO escrow exists, nothing happened and it is ` +
33
33
  `safe to re-run the ${kind}. Original: ${e.message}`);
34
34
  }
35
35
  // Backend broadcast the OPEN but it did NOT confirm inside the API window. The
36
36
  // open pulls funds + Anchor-`init`s the escrow, so it MAY still land — a blind
37
37
  // re-run could double-pull before the escrow becomes visible. Do NOT re-run
38
38
  // immediately: verify the broadcast signature on-chain first, THEN use
39
- // zap_status (the deposit/redeem resume-guard keys off an existing escrow, so a
39
+ // zap_status (resume = re-run with the escrowSlot of this open, so a
40
40
  // re-run is only safe once that escrow is visible OR the open is confirmed dead).
41
41
  if (e instanceof BroadcastUnconfirmedError) {
42
42
  throw new Error(`The ${kind} OPEN tx ${e.signature} was broadcast but did NOT confirm in time — it may ` +
43
43
  `still land. Do NOT blindly re-run (the open pulls funds + creates the escrow; a duplicate ` +
44
44
  `could double-pull). First verify ${e.signature} onchain: if it LANDED, an escrow now ` +
45
- `exists — re-run this ${kind} to resume (poll → close); if it truly DROPPED (sig not found), ` +
45
+ `exists — re-run this ${kind} with escrowSlot=${prepared.escrowSlot ?? 0} to resume (poll → close); ` +
46
+ `a re-run WITHOUT it opens a second ${kind}. If it truly DROPPED (sig not found), ` +
46
47
  `it is safe to re-run the ${kind}. Original: ${e.message}`);
47
48
  }
48
49
  throw e;
@@ -110,12 +111,12 @@ async function closeWithRetry(config, api, prepareClose, confirmClosed, attempts
110
111
  * surface a resume-able `pending` instead of a misleading hard error (a redeem
111
112
  * that out-runs the poll window is still self-healing, not failed).
112
113
  */
113
- async function pollUntilReady(api, vault, user, poll) {
114
+ async function pollUntilReady(api, vault, user, poll, escrowSlot) {
114
115
  const deadline = Date.now() + poll.timeoutSecs * 1000;
115
116
  let last = null;
116
117
  let sawOpen = false; // have we observed the escrow OPEN at least once this poll?
117
118
  while (Date.now() < deadline) {
118
- last = await api.getZapV2Status(vault, user);
119
+ last = await api.getZapV2Status(vault, user, escrowSlot);
119
120
  if (!last) {
120
121
  // A null read is ambiguous: (a) the escrow SETTLED + closed (the backend
121
122
  // keeper finished it — the #61 success case), OR (b) a freshly-opened escrow
@@ -139,10 +140,13 @@ async function pollUntilReady(api, vault, user, poll) {
139
140
  return { ready: false, reason: 'timeout', status: last };
140
141
  }
141
142
  /** Build the non-fatal `pending` ZapFlowResult (#61) — escrow still open / self-healing. */
142
- function pendingResult(kind, vault, escrowPda, openSignatures, pr) {
143
+ function pendingResult(kind, vault, escrowPda, openSignatures, pr, escrowSlot) {
143
144
  const legsFilled = pr.status?.legsFilled ?? 0;
144
145
  const legsTotal = pr.status?.legsTotal ?? 0;
145
146
  const verb = kind === 'redeem' ? 'redeem' : 'deposit';
147
+ // Without the slot a repeat call OPENS A NEW escrow (several per fund are
148
+ // allowed) — resuming this one needs it.
149
+ const again = `call ${verb} again with escrowSlot=${escrowSlot ?? 0}`;
146
150
  const why = pr.reason === 'gone'
147
151
  ? `The escrow is no longer open — most likely it SETTLED (the backend keeper closed it ` +
148
152
  `after cranking the legs) or was cancelled. Verify with get_portfolio / your balances ` +
@@ -150,10 +154,10 @@ function pendingResult(kind, vault, escrowPda, openSignatures, pr) {
150
154
  : pr.reason === 'expired'
151
155
  ? `The escrow passed its expiry with ${legsFilled}/${legsTotal} legs filled. The backend ` +
152
156
  `stall-recovery may STILL settle it automatically (it loosens floors + extends expiry) — ` +
153
- `call ${verb} again (same args) to resume polling. If it stays stuck, use ` +
157
+ `${again} to resume polling. If it stays stuck, use ` +
154
158
  `zap_v2_update_envelope (loosen floors) or zap_v2_cancel (recover holdings).`
155
159
  : `Still cranking: ${legsFilled}/${legsTotal} legs filled. The escrow is OPEN and ` +
156
- `self-healing — this is NOT a failure. Call ${verb} again (same args) to RESUME polling ` +
160
+ `self-healing — this is NOT a failure. To RESUME polling, ${again} ` +
157
161
  `to completion (consider a larger pollTimeoutSecs), or inspect with zap_status.`;
158
162
  return {
159
163
  ok: true,
@@ -175,49 +179,50 @@ function pendingResult(kind, vault, escrowPda, openSignatures, pr) {
175
179
  */
176
180
  export async function runDeposit(config, api, user, vault, usdcAmountRaw, opts) {
177
181
  const poll = { ...DEFAULT_POLL, ...(opts?.poll ?? {}) };
178
- // 0. RESUME GUARD — if an escrow is ALREADY open for this (vault, user), don't
179
- // re-open (the escrow PDA is Anchor-`init` → a second open reverts, and we'd
180
- // risk double-pulling USDC). Resume it: poll → close. This makes the deposit
181
- // call idempotent against a prior open whose close never landed.
182
- const existing = await api.getZapV2Status(vault, user);
182
+ // 0. RESUME only when asked: a wallet may hold several escrows per fund, so a
183
+ // plain call always OPENS a new one. `opts.escrowSlot` resumes that escrow
184
+ // (poll → close) — the idempotent retry after a pending/unconfirmed open.
185
+ const existing = opts?.escrowSlot === undefined ? null : await api.getZapV2Status(vault, user, opts.escrowSlot);
186
+ if (opts?.escrowSlot !== undefined && !existing) {
187
+ throw new Error(`No open escrow in slot ${opts.escrowSlot} for this vault+wallet — nothing to resume.`);
188
+ }
183
189
  let openSignatures = [];
184
190
  let escrowPda;
191
+ let escrowSlot = opts?.escrowSlot;
185
192
  // Set when resuming an open escrow whose pinned input amount differs from the
186
193
  // amount this call requested (the requested amount is NOT honored on resume).
187
194
  let resumedPinnedAmount = null;
188
195
  if (existing) {
189
196
  if (existing.mode !== 0) {
190
- throw new Error(`An open REDEEM escrow already exists for this vault+wallet — cancel or finish it ` +
191
- `(zap_v2_cancel / redeem) before depositing.`);
197
+ throw new Error(`Slot ${opts?.escrowSlot} holds a REDEEM escrow, not a deposit.`);
192
198
  }
193
199
  escrowPda = existing.escrowPda;
194
200
  // The existing escrow's input amount was PINNED at its open; resuming
195
201
  // completes THAT amount and ignores the `usdcAmountRaw` just requested.
196
- // Surface the mismatch loudly so a caller acting on the requested size
197
- // isn't misled into thinking a different amount was deposited.
198
202
  if (existing.inputAmount && existing.inputAmount !== usdcAmountRaw) {
199
203
  resumedPinnedAmount = existing.inputAmount;
200
204
  }
201
205
  }
202
206
  else {
203
- // 1. OPEN — pull USDC into escrow, pin the plan.
207
+ // 1. OPEN — pull USDC into escrow, pin the plan (the API picks a free slot).
204
208
  const open = await api.prepareZapInOpenV2(vault, usdcAmountRaw, {
205
209
  slippageBps: opts?.slippageBps,
206
210
  expirySecs: opts?.expirySecs,
207
211
  });
212
+ escrowSlot = open.escrowSlot;
208
213
  openSignatures = await submitOpen(config, open, 'deposit', api);
209
214
  escrowPda = open.escrowPda;
210
215
  }
211
216
  // 2. POLL — wait for the backend orchestrator to crank every USDC→asset leg.
212
217
  // #61: on timeout/expiry/auto-settle, return a resume-able `pending` rather
213
218
  // than throwing — the escrow keeps self-healing.
214
- const pr = await pollUntilReady(api, vault, user, poll);
219
+ const pr = await pollUntilReady(api, vault, user, poll, escrowSlot);
215
220
  if (!pr.ready)
216
- return pendingResult('deposit', vault, escrowPda, openSignatures, pr);
221
+ return pendingResult('deposit', vault, escrowPda, openSignatures, pr, escrowSlot);
217
222
  const ready = pr.status;
218
223
  // 3. CLOSE — mint_in_kind_v2, forward shares, close escrow. Retries on
219
224
  // blockhash expiry (escrow stays READY between attempts).
220
- const closeSignatures = await closeWithRetry(config, api, () => api.prepareZapInCloseV2(vault), async () => !(await api.getZapV2Status(vault, user)));
225
+ const closeSignatures = await closeWithRetry(config, api, () => api.prepareZapInCloseV2(vault, escrowSlot), async () => !(await api.getZapV2Status(vault, user, escrowSlot)));
221
226
  return {
222
227
  ok: true,
223
228
  vault,
@@ -242,19 +247,21 @@ export async function runDeposit(config, api, user, vault, usdcAmountRaw, opts)
242
247
  */
243
248
  export async function runRedeem(config, api, user, vault, sharesRaw, opts) {
244
249
  const poll = { ...DEFAULT_POLL, ...(opts?.poll ?? {}) };
245
- // 0. RESUME GUARD — mirror the deposit path: if a redeem escrow is already
246
- // open, resume it rather than re-burning shares into a second escrow.
247
- const existing = await api.getZapV2Status(vault, user);
250
+ // 0. RESUME only when asked (see runDeposit): a plain call opens a new escrow.
251
+ const existing = opts?.escrowSlot === undefined ? null : await api.getZapV2Status(vault, user, opts.escrowSlot);
252
+ if (opts?.escrowSlot !== undefined && !existing) {
253
+ throw new Error(`No open escrow in slot ${opts.escrowSlot} for this vault+wallet — nothing to resume.`);
254
+ }
248
255
  let openSignatures = [];
249
256
  let escrowPda;
257
+ let escrowSlot = opts?.escrowSlot;
250
258
  // Set when resuming an open escrow whose pinned share amount differs from
251
259
  // this call. The existing escrow wins; opening it already burned shares and
252
260
  // fixed `sharesIn`, so the new request cannot resize the in-flight redeem.
253
261
  let resumedPinnedShares = null;
254
262
  if (existing) {
255
263
  if (existing.mode !== 1) {
256
- throw new Error(`An open DEPOSIT escrow already exists for this vault+wallet — finish or cancel it ` +
257
- `(deposit / zap_v2_cancel) before redeeming.`);
264
+ throw new Error(`Slot ${opts?.escrowSlot} holds a DEPOSIT escrow, not a redeem.`);
258
265
  }
259
266
  escrowPda = existing.escrowPda;
260
267
  if (existing.sharesIn && existing.sharesIn !== sharesRaw) {
@@ -267,19 +274,20 @@ export async function runRedeem(config, api, user, vault, sharesRaw, opts) {
267
274
  slippageBps: opts?.slippageBps,
268
275
  expirySecs: opts?.expirySecs,
269
276
  });
277
+ escrowSlot = open.escrowSlot;
270
278
  openSignatures = await submitOpen(config, open, 'redeem', api);
271
279
  escrowPda = open.escrowPda;
272
280
  }
273
281
  // 2. POLL — wait for the orchestrator to crank every asset→USDC leg.
274
282
  // #61: a slow/retrying leg (e.g. ORCA→USDC) can self-heal over many minutes;
275
283
  // on timeout/expiry/auto-settle return a resume-able `pending`, not an error.
276
- const pr = await pollUntilReady(api, vault, user, poll);
284
+ const pr = await pollUntilReady(api, vault, user, poll, escrowSlot);
277
285
  if (!pr.ready)
278
- return pendingResult('redeem', vault, escrowPda, openSignatures, pr);
286
+ return pendingResult('redeem', vault, escrowPda, openSignatures, pr, escrowSlot);
279
287
  const ready = pr.status;
280
288
  // 3. CLOSE — apply exit fee, pay net USDC, close escrow. Retries on blockhash
281
289
  // expiry (escrow stays READY between attempts).
282
- const closeSignatures = await closeWithRetry(config, api, () => api.prepareZapOutCloseV2(vault), async () => !(await api.getZapV2Status(vault, user)));
290
+ const closeSignatures = await closeWithRetry(config, api, () => api.prepareZapOutCloseV2(vault, escrowSlot), async () => !(await api.getZapV2Status(vault, user, escrowSlot)));
283
291
  return {
284
292
  ok: true,
285
293
  vault,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfm-fi/agent",
3
- "version": "0.2.2059",
3
+ "version": "0.2.2084",
4
4
  "description": "DFM v2 MCP server — drive the full fund launch → deposit → redeem (+ manage) lifecycle from an AI agent against the live API.",
5
5
  "homepage": "https://app.dfm.finance/docs/agent",
6
6
  "mcpName": "io.github.DFM-Finance/dfm-agent",