@temporalabs/treasury 0.1.2 → 0.1.4

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.
Files changed (83) hide show
  1. package/README.md +45 -13
  2. package/THIRD_PARTY_NOTICES.connect-page.md +494 -24
  3. package/THIRD_PARTY_NOTICES.md +579 -6
  4. package/dist/cli.js +28 -5
  5. package/dist/cli.js.map +1 -1
  6. package/dist/client.d.ts +50 -209
  7. package/dist/client.js +66 -11
  8. package/dist/client.js.map +1 -1
  9. package/dist/config/earn.d.ts +16 -1
  10. package/dist/config/earn.js +16 -1
  11. package/dist/config/earn.js.map +1 -1
  12. package/dist/connect/ack.d.ts +88 -1
  13. package/dist/connect/ack.js +177 -6
  14. package/dist/connect/ack.js.map +1 -1
  15. package/dist/connect/commands.d.ts +30 -0
  16. package/dist/connect/commands.js +846 -88
  17. package/dist/connect/commands.js.map +1 -1
  18. package/dist/connect/config.d.ts +7 -5
  19. package/dist/connect/config.js +8 -6
  20. package/dist/connect/config.js.map +1 -1
  21. package/dist/connect/gate-solana.d.ts +8 -0
  22. package/dist/connect/gate-solana.js +147 -0
  23. package/dist/connect/gate-solana.js.map +1 -0
  24. package/dist/connect/http.d.ts +17 -2
  25. package/dist/connect/http.js +45 -13
  26. package/dist/connect/http.js.map +1 -1
  27. package/dist/connect/page.d.ts +1 -1
  28. package/dist/connect/page.js +2 -2
  29. package/dist/connect/page.js.map +1 -1
  30. package/dist/connect/session.d.ts +40 -1
  31. package/dist/connect/session.js +243 -2
  32. package/dist/connect/session.js.map +1 -1
  33. package/dist/connect/solana.d.ts +112 -0
  34. package/dist/connect/solana.js +370 -0
  35. package/dist/connect/solana.js.map +1 -0
  36. package/dist/connect-page.js +835 -671
  37. package/dist/disclosures.d.ts +7 -4
  38. package/dist/disclosures.js +7 -4
  39. package/dist/disclosures.js.map +1 -1
  40. package/dist/earn/commands.js +127 -54
  41. package/dist/earn/commands.js.map +1 -1
  42. package/dist/earn/solana.d.ts +278 -0
  43. package/dist/earn/solana.js +316 -0
  44. package/dist/earn/solana.js.map +1 -0
  45. package/dist/guide.d.ts +15 -0
  46. package/dist/guide.js +50 -0
  47. package/dist/guide.js.map +1 -0
  48. package/dist/links.js +2 -0
  49. package/dist/links.js.map +1 -1
  50. package/dist/position.d.ts +3 -2
  51. package/dist/position.js +6 -3
  52. package/dist/position.js.map +1 -1
  53. package/dist/quote.js +2 -2
  54. package/dist/quote.js.map +1 -1
  55. package/dist/redact.js +7 -1
  56. package/dist/redact.js.map +1 -1
  57. package/dist/registry-schema.d.ts +79 -2
  58. package/dist/registry-schema.js +77 -1
  59. package/dist/registry-schema.js.map +1 -1
  60. package/dist/registry.d.ts +21 -1
  61. package/dist/registry.js +48 -10
  62. package/dist/registry.js.map +1 -1
  63. package/dist/solana/constants.d.ts +49 -0
  64. package/dist/solana/constants.js +50 -0
  65. package/dist/solana/constants.js.map +1 -0
  66. package/dist/solana/kvault.d.ts +99 -0
  67. package/dist/solana/kvault.js +192 -0
  68. package/dist/solana/kvault.js.map +1 -0
  69. package/dist/solana/layout.d.ts +143 -0
  70. package/dist/solana/layout.js +209 -0
  71. package/dist/solana/layout.js.map +1 -0
  72. package/dist/solana/rpc.d.ts +24 -0
  73. package/dist/solana/rpc.js +66 -0
  74. package/dist/solana/rpc.js.map +1 -0
  75. package/dist/solana/vault.d.ts +153 -0
  76. package/dist/solana/vault.js +322 -0
  77. package/dist/solana/vault.js.map +1 -0
  78. package/dist/treasury.mjs +37410 -27965
  79. package/dist/version.js +5 -10
  80. package/dist/version.js.map +1 -1
  81. package/llms.txt +637 -0
  82. package/package.json +14 -8
  83. package/registry/vaults.json +113 -0
@@ -22,22 +22,27 @@ import { CHAIN_INFO, chains, makePublicClient, rpcUrlFromEnv, isSupportedChainId
22
22
  import { erc4626Abi } from "../abi/erc4626.js";
23
23
  import { explorerTxUrl, linksFor } from "../links.js";
24
24
  import { redactEndpoints } from "../redact.js";
25
- import { resolveVault } from "../registry.js";
26
- import { acknowledgementText, checkAck, issueAck, summarize } from "./ack.js";
27
- import { connectPort, privyAppId } from "./config.js";
25
+ import { resolveAnyVault, resolveVault } from "../registry.js";
26
+ import { isAddress as isSolanaAddress } from "@solana/kit";
27
+ import { parseAmount, formatAmount } from "../units.js";
28
+ import { makeSolanaRpc } from "../solana/rpc.js";
29
+ import { readVault, tokenBalance } from "../solana/vault.js";
30
+ import { associatedTokenAccount } from "../solana/kvault.js";
31
+ import { solanaRpcUrlFromEnv } from "../client.js";
32
+ import { buildForIntent, prepareConfirmSolana, runConfirmSolana, runConnectSolana } from "./solana.js";
33
+ import { acknowledgementText, callsDigest, checkAck, checkAckDigest, checkPlanAck, checkPlanLeg, issueAck, issueAckForDigest, issuePlan, planAcknowledgementText, PLAN_MAX_LEGS, solanaIntentDigest, summarize, summarizeSolana, planExpired, } from "./ack.js";
34
+ import { connectPorts, privyAppId } from "./config.js";
28
35
  import { admit } from "./gate.js";
29
- import { serveOnce } from "./http.js";
36
+ import { newSecret, serveOnce } from "./http.js";
30
37
  import { openBrowser } from "./open.js";
31
38
  import { PAGE_CSP, readPageBundle, shell } from "./page.js";
32
- import { clearPendingAck, clearSession, readPendingAck, readSession, writePendingAck, writeSession } from "./session.js";
39
+ import { clearPendingAck, clearSession, clearSolanaSession, readPendingAck, readPlan, readSession, readSolanaSession, writePendingAck, writePlan, writeSession, writeSolanaSession, } from "./session.js";
33
40
  import { verifyLanded } from "./verify.js";
34
- const addressArg = z
35
- .string()
36
- .refine((s) => isAddress(s), "must be an EVM address")
37
- .transform((s) => getAddress(s));
41
+ /** An address in either family's form; each command checks the family its vault needs. */
42
+ const addressArg = z.string().refine((s) => isAddress(s) || isSolanaAddress(s), "must be an EVM address (0x…), or a Solana address for a vault on Solana");
38
43
  const amountArg = z.string().regex(/^\d+(\.\d+)?$/, 'plain decimal amount of the vault\'s asset (USDC; USDG on Robinhood Chain), e.g. "25" or "12.5"');
39
44
  const vaultArg = z.string().optional().describe("the vault's ERC-20 ticker, as `earn vaults` lists them; omit for the default vault of `chain`");
40
- const chainArg = z.string().optional().describe('which chain: "base", "arbitrum" or "robinhood"; omit for the default chain, or when `vault` names one');
45
+ const chainArg = z.string().optional().describe('which chain: "base", "arbitrum", "robinhood", "ethereum", "monad" or "solana"; omit for the default chain, or when `vault` names one');
41
46
  const receiverArg = addressArg.describe("where the money lands — it must be the CONNECTED account (this page pays no one else), and it comes from the operator's own message, never filled in by an agent");
42
47
  const ackArg = z
43
48
  .string()
@@ -47,6 +52,12 @@ const ackArg = z
47
52
  /** One sign-in or one confirmation flow at a time, each whole flow bounded under a shell tool's 10-minute limit. */
48
53
  const CONNECT_TTL_MS = 9 * 60_000;
49
54
  const CONFIRM_TTL_MS = 9 * 60_000;
55
+ /**
56
+ * Below this much page time left, `connect plan --run` starts no new leg: the next run carries on instead. A deposit's
57
+ * chain waits alone can take three minutes (the approval's receipt, the allowance showing, the deposit's receipt, a
58
+ * minute each), so this leaves the operator two more to answer the wallet.
59
+ */
60
+ const LEG_MIN_MS = 5 * 60_000;
50
61
  export function signInMessage(a) {
51
62
  return [
52
63
  `${new URL(a.origin).host} wants you to sign in with your Ethereum account:`,
@@ -86,7 +97,7 @@ export async function runConnect(deps = {}) {
86
97
  html: (s) => shell(s, "Connect to Open Agent Treasury"),
87
98
  csp: PAGE_CSP,
88
99
  assets: assets(),
89
- port: connectPort(),
100
+ ports: connectPorts(),
90
101
  info: () => ({ mode: "connect", appId: privyAppId() }),
91
102
  challenge: (address, origin, nonce) => signInMessage({ address, origin, nonce, issuedAt: new Date().toISOString() }),
92
103
  accept: async (body, ctx) => {
@@ -111,9 +122,12 @@ export async function runConnect(deps = {}) {
111
122
  writeSession(result.value);
112
123
  return { result, url: handle.url, opened };
113
124
  }
114
- export async function runConfirm(calls, session, deps = {}) {
115
- // The gate runs here, on exactly the calls this flow will hand the wallet, so no caller can pass
116
- // a check for one batch and the calls of another. It throws before any page opens.
125
+ /**
126
+ * The confirm flow without its page: the gate runs here, on exactly the calls the page will hand the
127
+ * wallet, so no caller can pass a check for one batch and the calls of another. It throws before any
128
+ * page opens. `deadline`: when the page closes, so every chain read stops waiting by then.
129
+ */
130
+ export async function prepareConfirm(calls, session, deps, deadline) {
117
131
  const admitted = admit(calls, session.account);
118
132
  const chainId = calls[0].chainId;
119
133
  if (!isSupportedChainId(chainId))
@@ -122,9 +136,6 @@ export async function runConfirm(calls, session, deps = {}) {
122
136
  const client = deps.client ? deps.client(chainId) : makePublicClient(chainId, rpcUrlFromEnv(chainId));
123
137
  const done = [];
124
138
  let stopReason;
125
- const ttlMs = deps.ttlMs ?? CONFIRM_TTL_MS;
126
- // Every chain read stops waiting when the flow's time limit is up, so the command returns inside it.
127
- const deadline = Date.now() + ttlMs;
128
139
  // The chain head when the flow starts: a receipt mined in it or before it cannot be from this flow.
129
140
  // Read beside the balances below; a read that fails or is slow leaves no floor, never blocks the flow.
130
141
  const notBeforeRead = client.getBlockNumber
@@ -170,37 +181,12 @@ export async function runConfirm(calls, session, deps = {}) {
170
181
  ]);
171
182
  const verifyOpts = { ...deps.verifyOpts, deadline, ...(notBefore !== undefined ? { notBefore } : {}) };
172
183
  const deposit = admitted.find((a) => a.kind === "deposit");
173
- const handle = await serveOnce({
174
- mode: "confirm",
175
- ttlMs,
176
- ...(deps.cancelGraceMs !== undefined ? { cancelGraceMs: deps.cancelGraceMs } : {}),
177
- timeoutReason: () => done.length
178
- ? `the ${Math.round(ttlMs / 60_000)}-minute limit ran out after ${done.length} of ${calls.length} transactions were reported`
179
- : `nothing happened in the browser within ${Math.round(ttlMs / 60_000)} minutes`,
180
- // The page reports a hash it could not hand over (its POST failed after the wallet sent):
181
- // put it on record, against the step the page names, so the command reports it unchecked
182
- // instead of losing it. A hash that names no step is still recorded, as such.
183
- onReject: (body) => {
184
- const hash = body["hash"];
185
- if (!wellFormedHash(hash) || known(hash))
186
- return;
187
- const i = body["index"];
188
- const call = typeof i === "number" && Number.isInteger(i) && i >= 0 && i < calls.length ? calls[i] : undefined;
189
- done.push({
190
- step: call?.step ?? 0,
191
- description: call?.description ?? "a transaction the page reported for no known step",
192
- hash,
193
- verified: "unverified",
194
- detail: "the page reported this transaction but could not hand it over for checking; look it up before retrying anything",
195
- });
196
- },
197
- html: (s) => shell(s, "Confirm — Open Agent Treasury"),
198
- csp: PAGE_CSP,
199
- assets: assets(),
200
- port: connectPort(),
184
+ return {
185
+ chainId,
186
+ calls,
187
+ done,
188
+ stopReason: () => stopReason,
201
189
  info: () => ({
202
- mode: "confirm",
203
- appId: privyAppId(),
204
190
  account,
205
191
  walletType: session.walletType,
206
192
  chainId,
@@ -219,12 +205,27 @@ export async function runConfirm(calls, session, deps = {}) {
219
205
  to: c.to,
220
206
  data: c.data,
221
207
  value: c.value,
222
- vault: { symbol: admitted[i].vault.symbol, name: admitted[i].vault.name, address: admitted[i].vault.address, asset: admitted[i].vault.asset.symbol, links: linksFor(admitted[i].vault) },
208
+ vault: { symbol: admitted[i].vault.symbol, name: admitted[i].vault.name, address: admitted[i].vault.address, asset: admitted[i].vault.asset.symbol, assetAddress: admitted[i].vault.asset.address, links: linksFor(admitted[i].vault) },
223
209
  })),
224
210
  }),
225
- accept: async (body) => {
226
- const index = body["index"];
211
+ // The page reports a hash it could not hand over (its POST failed after the wallet sent):
212
+ // put it on record, against the step the page names, so the command reports it unchecked
213
+ // instead of losing it. A hash that names no step is still recorded, as such.
214
+ onReject: (body) => {
227
215
  const hash = body["hash"];
216
+ if (!wellFormedHash(hash) || known(hash))
217
+ return;
218
+ const i = body["index"];
219
+ const call = typeof i === "number" && Number.isInteger(i) && i >= 0 && i < calls.length ? calls[i] : undefined;
220
+ done.push({
221
+ step: call?.step ?? 0,
222
+ description: call?.description ?? "a transaction the page reported for no known step",
223
+ hash,
224
+ verified: "unverified",
225
+ detail: "the page reported this transaction but could not hand it over for checking; look it up before retrying anything",
226
+ });
227
+ },
228
+ accept: async (index, hash) => {
228
229
  if (index !== done.length)
229
230
  return { ok: false, reason: `expected step ${done.length + 1}` };
230
231
  if (!wellFormedHash(hash))
@@ -251,19 +252,45 @@ export async function runConfirm(calls, session, deps = {}) {
251
252
  if (verdict.verified !== "matched" && verdict.verified !== "extra_transfer") {
252
253
  // Stop at the first step that did not land as confirmed: the next one depends on it.
253
254
  stopReason = `step ${call.step} (${call.description}) is ${verdict.verified}: ${verdict.detail ?? "it did not land as confirmed"}`;
254
- return { ok: true, final: true, value: done, reply: { stop: true, verdict } };
255
+ return { ok: true, kind: "stop", reply: { stop: true, verdict } };
255
256
  }
256
257
  if (index + 1 === calls.length)
257
- return { ok: true, final: true, value: done, reply: { finished: true, verdict } };
258
+ return { ok: true, kind: "finished", reply: { finished: true, verdict } };
258
259
  if (!(await allowanceVisible(index + 1))) {
259
260
  stopReason = "the approval landed, but its allowance is not visible on this RPC yet; the deposit was not sent";
260
- return { ok: true, final: true, value: done, reply: { stop: true, reason: stopReason } };
261
+ return { ok: true, kind: "stop", reply: { stop: true, reason: stopReason } };
261
262
  }
262
- return { ok: true, final: false, reply: { next: index + 1, verdict } };
263
+ return { ok: true, kind: "next", reply: { next: index + 1, verdict } };
264
+ },
265
+ };
266
+ }
267
+ export async function runConfirm(calls, session, deps = {}) {
268
+ const ttlMs = deps.ttlMs ?? CONFIRM_TTL_MS;
269
+ const core = await prepareConfirm(calls, session, deps, Date.now() + ttlMs);
270
+ const { done } = core;
271
+ const handle = await serveOnce({
272
+ mode: "confirm",
273
+ ttlMs,
274
+ ...(deps.cancelGraceMs !== undefined ? { cancelGraceMs: deps.cancelGraceMs } : {}),
275
+ timeoutReason: () => done.length
276
+ ? `the ${Math.round(ttlMs / 60_000)}-minute limit ran out after ${done.length} of ${calls.length} transactions were reported`
277
+ : `nothing happened in the browser within ${Math.round(ttlMs / 60_000)} minutes`,
278
+ onReject: core.onReject,
279
+ html: (s) => shell(s, "Confirm — Open Agent Treasury"),
280
+ csp: PAGE_CSP,
281
+ assets: assets(),
282
+ ports: connectPorts(),
283
+ info: () => ({ mode: "confirm", appId: privyAppId(), ...core.info() }),
284
+ accept: async (body) => {
285
+ const r = await core.accept(body["index"], body["hash"]);
286
+ if (!r.ok)
287
+ return r;
288
+ return r.kind === "next" ? { ok: true, final: false, reply: r.reply } : { ok: true, final: true, value: done, reply: r.reply };
263
289
  },
264
290
  });
265
291
  const opened = (deps.open ?? announce)(handle.url);
266
292
  const result = await handle.done;
293
+ const stopReason = core.stopReason();
267
294
  return { result, url: handle.url, opened, done, total: calls.length, ...(stopReason ? { stopReason } : {}) };
268
295
  }
269
296
  const notConnected = () => new Error("no wallet is connected — run `treasury connect wallet` first, and let the operator sign in");
@@ -306,29 +333,222 @@ export function buildConnectCommands(deps = {}) {
306
333
  }),
307
334
  };
308
335
  };
309
- register("connect_status", { title: "Which wallet is connected", description: "The connected account and how it signed in (`external` browser wallet, or `embedded` Privy wallet from an email or social login), or `disconnected`. Reads a local file only.", inputSchema: {} }, async () => {
336
+ register("connect_status", {
337
+ title: "Which wallet is connected",
338
+ description: "The connected account and how it signed in (`external` browser wallet, or `embedded` Privy wallet from an email or social login), or `disconnected`. `solana` reports the connected Solana account the same way: EVM and Solana each have their own connection. Reads local files only.",
339
+ inputSchema: {},
340
+ }, async () => {
310
341
  const s = readSession();
311
- return JSON.stringify(s ? { status: "connected", ...s } : { status: "disconnected", next_step: "run `treasury connect wallet` to let the operator sign in" }, null, 2);
342
+ const sol = readSolanaSession();
343
+ return JSON.stringify({
344
+ ...(s ? { status: "connected", ...s } : { status: "disconnected", next_step: "run `treasury connect wallet` to let the operator sign in" }),
345
+ solana: sol ? { status: "connected", ...sol } : { status: "disconnected", next_step: "run `treasury connect wallet --chain solana` for a vault on Solana" },
346
+ }, null, 2);
312
347
  });
313
348
  register("connect_wallet", {
314
349
  title: "Connect a wallet",
315
- description: "Opens the sign-in page in the operator's browser: one Connect button, then Privy's window with email, Google, Apple, X and browser wallets (MetaMask, Rabby, Coinbase Wallet). The operator signs a free sign-in message; nothing is spent. Waits until they finish (up to 9 minutes) and returns the connected account. Replaces any earlier connection.",
316
- inputSchema: {},
317
- }, async () => {
350
+ description: "Opens the sign-in page in the operator's browser: one Connect button, then Privy's window with email, Google, Apple, X and browser wallets (MetaMask, Rabby, Coinbase Wallet). The operator signs a free sign-in message; nothing is spent. Waits until they finish (up to 9 minutes) and returns the connected account. Replaces any earlier connection. With --chain solana it connects a SOLANA wallet instead (Phantom, Solflare, Backpack, or a Privy wallet from an email or social login), for vaults on Solana; the EVM connection is kept.",
351
+ inputSchema: { chain: z.string().optional().describe('"solana" to connect a Solana wallet; omit for an EVM wallet') },
352
+ }, async ({ chain }) => {
353
+ if (chain !== undefined && chain !== "" && chain !== "solana")
354
+ throw new Error(`--chain takes "solana" (a Solana wallet) or nothing (an EVM wallet, used on every EVM chain); got "${chain}"`);
355
+ if (chain === "solana") {
356
+ const r = await runConnectSolana(solanaDeps());
357
+ if (!r.result.ok)
358
+ return Promise.reject(new Error(`not connected: ${r.result.reason}`));
359
+ writeSolanaSession(r.result.value);
360
+ return JSON.stringify({ status: "connected", chain: "solana", ...r.result.value, opened: r.opened }, null, 2);
361
+ }
318
362
  const r = await runConnect(deps);
319
363
  if (!r.result.ok)
320
364
  return Promise.reject(new Error(`not connected: ${r.result.reason}`));
321
365
  return JSON.stringify({ status: "connected", ...r.result.value, opened: r.opened }, null, 2);
322
366
  });
323
- register("connect_disconnect", { title: "Forget the connected wallet", description: "Deletes the local connection record. It moves nothing and revokes nothing in the wallet itself.", inputSchema: {} }, async () => JSON.stringify({ status: "disconnected", hadConnection: clearSession() }, null, 2));
367
+ register("connect_disconnect", {
368
+ title: "Forget the connected wallet",
369
+ description: "Deletes the local connection record, the EVM and the Solana one. It moves nothing and revokes nothing in the wallet itself.",
370
+ inputSchema: {},
371
+ }, async () => JSON.stringify({ status: "disconnected", hadConnection: clearSession(), hadSolanaConnection: clearSolanaSession() }, null, 2));
324
372
  const now = deps.now ?? (() => new Date());
373
+ /** What the Solana flows need from this file: the same page, port, policy, app and time limits. */
374
+ const solanaDeps = () => ({
375
+ open: deps.open ?? announce,
376
+ ttlMs: deps.ttlMs ?? CONFIRM_TTL_MS,
377
+ ...(deps.cancelGraceMs !== undefined ? { cancelGraceMs: deps.cancelGraceMs } : {}),
378
+ ...(deps.verifyOpts ? { verifyOpts: deps.verifyOpts } : {}),
379
+ ...(deps.solanaRpc ? { rpc: deps.solanaRpc } : {}),
380
+ html: shell,
381
+ csp: PAGE_CSP,
382
+ assets: assets(),
383
+ ports: connectPorts(),
384
+ appId: privyAppId(),
385
+ });
386
+ const notConnectedSolana = () => new Error("no Solana wallet is connected — run `treasury connect wallet --chain solana` first, and let the operator sign in");
387
+ /** A transaction as a plan keeps it: the hash, the verdict, and what else the wallet moved with it. */
388
+ const planTx = (t) => ({ hash: t.hash, verified: t.verified, ...(t.alsoMoved?.length ? { alsoMoved: t.alsoMoved } : {}) });
389
+ /** Records a leg's progress on the plan it belongs to, unless a newer plan has replaced it meanwhile. */
390
+ const markLeg = (code, index, update) => {
391
+ const cur = readPlan();
392
+ if (!cur || cur.code !== code)
393
+ return;
394
+ cur.legs[index] = { ...cur.legs[index], ...update };
395
+ writePlan(cur);
396
+ };
397
+ /**
398
+ * Runs leg `index` of `plan` through `run` (the existing confirm flow), recording it as running before the
399
+ * page opens and as its outcome after, and adds where the plan stands to the result. A leg that throws, or
400
+ * does not complete, ends the plan.
401
+ */
402
+ const runPlanLeg = async (plan, index, run) => {
403
+ markLeg(plan.code, index, { status: "running" });
404
+ let opened = false;
405
+ const open = (url) => ((opened = true), (deps.open ?? announce)(url));
406
+ let text;
407
+ try {
408
+ text = await run(open);
409
+ }
410
+ catch (e) {
411
+ const message = redactEndpoints(e instanceof Error ? e.message : String(e));
412
+ if (!opened) {
413
+ // No page opened, so nothing could be sent: the leg stays pending and the plan stands.
414
+ markLeg(plan.code, index, { status: "pending" });
415
+ throw new Error(`${message} (no page opened and nothing was sent; leg ${plan.legs[index].leg} of the plan can run again)`);
416
+ }
417
+ markLeg(plan.code, index, { status: "not_reported", reason: message });
418
+ throw e;
419
+ }
420
+ const r = JSON.parse(text);
421
+ const status = r.status === "completed" ? "completed" : r.status === "stopped" ? "stopped" : "not_reported";
422
+ markLeg(plan.code, index, { status, txs: (r.txs ?? []).map(planTx), ...(r.reason ? { reason: r.reason } : {}) });
423
+ const leg = plan.legs[index];
424
+ const next = plan.legs[index + 1];
425
+ const next_step = status !== "completed"
426
+ ? `The plan ended at leg ${leg.leg} of ${plan.legs.length}: the legs after it will not run. ${r.next_step}`
427
+ : next
428
+ ? `Leg ${leg.leg} of ${plan.legs.length} completed. Report its hashes to the operator, then run leg ${next.leg} without asking again (their yes covered every leg): ${next.command}`
429
+ : `Leg ${leg.leg} of ${plan.legs.length} completed, and with it the whole plan. Report every leg's hashes to the operator (\`connect plan --status\` lists them).`;
430
+ return JSON.stringify({ ...r, plan: { code: plan.code, leg: leg.leg, of: plan.legs.length }, next_step }, null, 2);
431
+ };
432
+ /** A Solana confirm flow's result, in the same shape as `outcome`'s. */
433
+ const solanaOutcome = (session, r) => {
434
+ const txs = r.result.ok ? r.result.value : r.done;
435
+ const all = txs.length === r.total && txs.every((t) => t.verified === "matched" || t.verified === "extra_transfer");
436
+ const status = r.result.ok ? (all ? "completed" : "stopped") : txs.length ? "stopped" : "not_reported";
437
+ const reason = (r.result.ok ? r.stopReason : r.result.reason) ?? (status === "stopped" ? "the flow ended before the transaction was confirmed" : undefined);
438
+ return JSON.stringify({
439
+ status,
440
+ chain: "solana",
441
+ chainId: null,
442
+ account: session.account,
443
+ opened: r.opened,
444
+ ...(reason ? { reason } : {}),
445
+ calls_total: r.total,
446
+ txs,
447
+ next_step: status === "completed"
448
+ ? "The transaction landed as confirmed. Report its signature to the operator; any `extra_transfer` is money the wallet moved besides the call — say so."
449
+ : status === "stopped"
450
+ ? "Stopped before the transaction landed as confirmed. Do not retry blindly: read `reason` and the transaction's `verified` and `detail`, check the account with `earn balance --chain solana`, and tell the operator."
451
+ : "No transaction was reported to this command. That is not proof that none was sent: if the wallet showed a confirmation, check the account with `earn balance --chain solana` before any retry. Tell the operator why (reason).",
452
+ }, null, 2);
453
+ };
454
+ /**
455
+ * The Solana counterpart of `confirmInWallet`: the same two runs. The acknowledgement covers what the
456
+ * operator asked for (`acked`: account, vault, action, raw amount), because the transaction itself is built
457
+ * when the operator confirms. `intent` gives the shares and amount to build from; it runs again after the
458
+ * acknowledgement is accepted, so a withdrawal by amount burns the shares worth that amount at that moment.
459
+ */
460
+ const confirmSolana = async (acked, amountText, session, mode, intent) => {
461
+ const digest = solanaIntentDigest(session.account, acked.vault.address, acked.kind, acked.raw);
462
+ if (mode.kind === "describe" || (mode.kind === "ack" && mode.ack === undefined)) {
463
+ // Resolves the request (for a withdrawal by amount, the shares it burns now), so one that cannot be priced
464
+ // fails before the operator is asked. It is NOT the gate: no transaction is built or simulated here. The
465
+ // --ack run builds and simulates it (buildForIntent) before any page opens.
466
+ await intent();
467
+ const summary = summarizeSolana(acked.kind === "deposit" ? "deposit" : "withdraw", acked.vault, amountText, session.account);
468
+ if (mode.kind === "describe")
469
+ return { digest, summary };
470
+ const pending = issueAckForDigest(digest, now());
471
+ writePendingAck(pending);
472
+ return JSON.stringify({
473
+ status: "needs_acknowledgement",
474
+ opened: false,
475
+ action: acked.kind === "deposit" ? "deposit" : "withdraw",
476
+ amount: summary.amount,
477
+ chain: "solana",
478
+ chainId: null,
479
+ vault: summary.vault,
480
+ account: session.account,
481
+ receiver: session.account,
482
+ acknowledgement: acknowledgementText(summary),
483
+ ack: pending.code,
484
+ expiresAtIso: pending.expiresAtIso,
485
+ next_step: `Post \`acknowledgement\` to the operator word for word, as it is, and wait for their reply. Only an explicit yes they give after reading it is an acknowledgement: not a "go" from before they saw it, and not your own reading of the conversation. On yes, run this same command again with --ack ${pending.code} added, and the signing page opens. On anything else, stop. The code works once, for exactly this ${acked.kind === "deposit" ? "deposit" : "withdrawal"}, until ${pending.expiresAtIso}.`,
486
+ }, null, 2);
487
+ }
488
+ if (mode.kind === "plan") {
489
+ const plan = readPlan();
490
+ const index = checkPlanLeg(mode.code, mode.leg, plan, digest, now());
491
+ const recordedElsewhere = (sig) => {
492
+ const file = readPlan();
493
+ return file?.code === plan.code && file.legs.some((l) => l.txs?.some((t) => t.hash === sig));
494
+ };
495
+ return runPlanLeg(plan, index, async (open) => solanaOutcome(session, await runConfirmSolana(await intent(), session, { ...solanaDeps(), open, recordedElsewhere })));
496
+ }
497
+ checkAckDigest(mode.ack, readPendingAck(), digest, now());
498
+ clearPendingAck();
499
+ return solanaOutcome(session, await runConfirmSolana(await intent(), session, solanaDeps()));
500
+ };
501
+ /** A Solana withdrawal's shares: `shares_exact` verbatim, or the shares worth `amount_usdc`, rounded up. */
502
+ const solanaWithdrawShares = async (vault, amount_usdc, shares_exact) => {
503
+ if (shares_exact !== undefined)
504
+ return parseAmount(shares_exact, vault.shareDecimals, "shares_exact");
505
+ const v = await readVault(deps.solanaRpc ?? makeSolanaRpc(solanaRpcUrlFromEnv()), vault);
506
+ const amount = parseAmount(amount_usdc, vault.asset.decimals, `withdrawal amount (${vault.asset.symbol})`);
507
+ if (v.holdings.netAum === 0n)
508
+ throw new Error(`${vault.symbol} holds nothing to withdraw`);
509
+ return (amount * v.state.sharesIssued + v.holdings.netAum - 1n) / v.holdings.netAum;
510
+ };
511
+ /**
512
+ * A Solana deposit or withdrawal as it is acknowledged (`acked`: the action and its raw amount; the digest adds the
513
+ * account and vault) and the intent to build from. One derivation for a single run, a plan's leg run and `--run`.
514
+ */
515
+ const solanaRequest = (action, v, a) => {
516
+ if (action === "deposit") {
517
+ const raw = parseAmount(a.amount_usdc, v.asset.decimals, `deposit amount (${v.asset.symbol})`);
518
+ if (raw === 0n)
519
+ throw new Error("a deposit of 0");
520
+ const intent = { kind: "deposit", vault: v, raw };
521
+ return { acked: { kind: "deposit", vault: v, raw }, amountText: `${formatAmount(raw, v.asset.decimals)} ${v.asset.symbol}`, intent: async () => intent };
522
+ }
523
+ const intent = async () => {
524
+ const raw = await solanaWithdrawShares(v, a.all ? undefined : a.amount_usdc, a.all ? a.shares_exact : undefined);
525
+ if (raw === 0n)
526
+ throw new Error("a withdrawal of 0");
527
+ return { kind: "withdraw", vault: v, raw, ...(a.all ? {} : { assets: parseAmount(a.amount_usdc, v.asset.decimals, `withdrawal amount (${v.asset.symbol})`) }) };
528
+ };
529
+ if (a.all) {
530
+ const shares = parseAmount(a.shares_exact, v.shareDecimals, "shares_exact");
531
+ return { acked: { kind: "withdraw", vault: v, raw: shares }, amountText: `${formatAmount(shares, v.shareDecimals)} ${v.symbol} shares`, intent };
532
+ }
533
+ const assets = parseAmount(a.amount_usdc, v.asset.decimals, `withdrawal amount (${v.asset.symbol})`);
534
+ return { acked: { kind: "withdraw-assets", vault: v, raw: assets }, amountText: `${formatAmount(assets, v.asset.decimals)} ${v.asset.symbol}`, intent };
535
+ };
325
536
  /**
326
537
  * Without `ack`: gate-check the calls, record them as pending, and return the acknowledgement for
327
538
  * the operator; nothing opens. With `ack`: open the confirm page only for the calls the operator
328
- * acknowledged, spending the acknowledgement first so it can never open a second page.
539
+ * acknowledged, spending the acknowledgement first so it can never open a second page. As a plan's leg:
540
+ * the same page, for the calls that leg was acknowledged as.
329
541
  */
330
- const confirmInWallet = async (action, calls, session, receiver, ack) => {
542
+ const confirmInWallet = async (action, calls, session, receiver, mode) => {
331
543
  const account = session.account;
544
+ if (mode.kind === "describe")
545
+ return { digest: callsDigest(calls, account), summary: summarize(action, calls, admit(calls, account), receiver) };
546
+ if (mode.kind === "plan") {
547
+ const plan = readPlan();
548
+ const index = checkPlanLeg(mode.code, mode.leg, plan, callsDigest(calls, account), now());
549
+ return runPlanLeg(plan, index, async (open) => outcome(calls[0].chainId, session.account, await runConfirm(calls, session, { ...deps, open })));
550
+ }
551
+ const ack = mode.ack;
332
552
  if (ack === undefined) {
333
553
  const summary = summarize(action, calls, admit(calls, account), receiver);
334
554
  const pending = issueAck(calls, account, now());
@@ -351,49 +571,75 @@ export function buildConnectCommands(deps = {}) {
351
571
  }
352
572
  checkAck(ack, readPendingAck(), calls, account, now());
353
573
  // Spent before the page opens, so a flow that fails to start leaves nothing to reuse. Two
354
- // concurrent runs could both pass the check; only one page opens because the port is fixed.
574
+ // concurrent runs could both pass the check; only one page opens, because the page lock admits one.
355
575
  clearPendingAck();
356
576
  const r = await runConfirm(calls, session, deps);
357
577
  return outcome(calls[0].chainId, session.account, r);
358
578
  };
359
- register("connect_deposit", {
360
- title: "Deposit through the connected wallet",
361
- description: "Builds the approve + deposit for the CONNECTED account and checks every call against the registry. Runs twice. Without --ack it opens NOTHING: it returns `needs_acknowledgement` with an `acknowledgement` to post to the operator word for word, and an `ack` code. Only after the operator replies yes, run it again with --ack <code>: then the confirm page opens, and the operator confirms each call in their wallet, one at a time. That run returns each transaction hash with `verified` read from the receipt (`matched`, `extra_transfer`, `mismatch`, `reverted`, `unverified`). Run `earn quote --direction deposit` first.",
362
- inputSchema: { vault: vaultArg, chain: chainArg, amount_usdc: amountArg, receiver: receiverArg, ack: ackArg },
363
- }, async ({ vault: symbol, chain, amount_usdc, receiver, ack }) => {
579
+ /** `--ack`, or `--plan` with `--leg`, never both: which authorisation this run uses. */
580
+ const modeOf = (a) => {
581
+ if (a.plan !== undefined || a.leg !== undefined) {
582
+ if (a.ack !== undefined)
583
+ throw new Error("give --ack (a single acknowledgement) or --plan with --leg (a leg of an acknowledged plan), not both");
584
+ if (a.plan === undefined || a.leg === undefined)
585
+ throw new Error("--plan and --leg go together: the plan's code and the leg's number, as `connect plan` gave them");
586
+ return { kind: "plan", code: a.plan, leg: a.leg };
587
+ }
588
+ return { kind: "ack", ack: a.ack };
589
+ };
590
+ const asText = (r) => {
591
+ if (typeof r !== "string")
592
+ throw new Error("internal: a run returned a description");
593
+ return r;
594
+ };
595
+ const depositLeg = async ({ vault: symbol, chain, amount_usdc, receiver: rawReceiver }, mode) => {
596
+ const target = resolveAnyVault(symbol, chain);
597
+ if (target.family === "solana") {
598
+ const session = readSolanaSession();
599
+ if (!session)
600
+ throw notConnectedSolana();
601
+ if (rawReceiver !== session.account)
602
+ throw new Error(`refused: --receiver ${rawReceiver} is not the connected Solana account ${session.account}; on Solana the shares can only go to the account that deposits`);
603
+ const r = solanaRequest("deposit", target.vault, { amount_usdc });
604
+ return confirmSolana(r.acked, r.amountText, session, mode, r.intent);
605
+ }
364
606
  const session = readSession();
365
607
  if (!session)
366
608
  throw notConnected();
609
+ const receiver = isAddress(rawReceiver) ? getAddress(rawReceiver) : rawReceiver;
367
610
  if (receiver !== session.account)
368
611
  throw new Error(`refused: --receiver ${receiver} is not the connected account ${session.account}; this page pays only the connected account (use \`earn prepare_deposit\` with the operator's own signer for anything else)`);
369
612
  const vault = resolveVault(symbol, chain);
370
- return confirmInWallet("deposit", buildDeposit(vault, { assetsHuman: amount_usdc, receiver: receiver, account: session.account }), session, receiver, ack);
371
- });
372
- register("connect_withdraw", {
373
- title: "Withdraw through the connected wallet",
374
- description: "Builds the withdrawal for the CONNECTED account (the asset to that same account) and checks it against the registry. Runs twice, like `connect deposit`: without --ack it opens NOTHING and returns `needs_acknowledgement` with an `acknowledgement` to post to the operator word for word; only after their yes, run it again with --ack <code> and the confirm page opens. Pass --amount_usdc, or --all with --shares_exact copied verbatim from `earn balance`. Run `earn quote --direction withdraw` first. The second run returns the hash with `verified` read from the receipt.",
375
- inputSchema: {
376
- vault: vaultArg,
377
- chain: chainArg,
378
- receiver: receiverArg,
379
- amount_usdc: amountArg.optional(),
380
- all: z.boolean().optional(),
381
- shares_exact: z.string().regex(/^\d+(\.\d+)?$/).optional(),
382
- ack: ackArg,
383
- },
384
- }, async ({ vault: symbol, chain, receiver, amount_usdc, all, shares_exact, ack }) => {
613
+ return confirmInWallet("deposit", buildDeposit(vault, { assetsHuman: amount_usdc, receiver: receiver, account: session.account }), session, receiver, mode);
614
+ };
615
+ const withdrawLeg = async ({ vault: symbol, chain, receiver: rawReceiver, amount_usdc, all, shares_exact }, mode) => {
616
+ if (all && amount_usdc)
617
+ throw new Error("give --amount_usdc or --all with --shares_exact, not both");
618
+ if (!all && shares_exact)
619
+ throw new Error("--shares_exact goes with --all; for an amount in the asset, use --amount_usdc alone");
620
+ if (all && !shares_exact)
621
+ throw new Error("--all requires --shares_exact (copy it verbatim from `earn balance`)");
622
+ if (!all && !amount_usdc)
623
+ throw new Error("provide --amount_usdc, or --all with --shares_exact");
624
+ const target = resolveAnyVault(symbol, chain);
625
+ if (target.family === "solana") {
626
+ const session = readSolanaSession();
627
+ if (!session)
628
+ throw notConnectedSolana();
629
+ if (rawReceiver !== session.account)
630
+ throw new Error(`refused: --receiver ${rawReceiver} is not the connected Solana account ${session.account}; on Solana a withdrawal pays only the account whose shares burn`);
631
+ const r = solanaRequest("withdraw", target.vault, { amount_usdc, all, shares_exact });
632
+ return confirmSolana(r.acked, r.amountText, session, mode, r.intent);
633
+ }
385
634
  const session = readSession();
386
635
  if (!session)
387
636
  throw notConnected();
637
+ const receiver = isAddress(rawReceiver) ? getAddress(rawReceiver) : rawReceiver;
388
638
  if (receiver !== session.account)
389
639
  throw new Error(`refused: --receiver ${receiver} is not the connected account ${session.account}; this page pays only the connected account`);
390
640
  const vault = resolveVault(symbol, chain);
391
641
  const owner = session.account;
392
642
  let calls;
393
- if (all && amount_usdc)
394
- throw new Error("give --amount_usdc or --all with --shares_exact, not both");
395
- if (!all && shares_exact)
396
- throw new Error("--shares_exact goes with --all; for an amount in the asset, use --amount_usdc alone");
397
643
  if (all) {
398
644
  if (!shares_exact)
399
645
  throw new Error("--all requires --shares_exact (copy it verbatim from `earn balance`)");
@@ -404,7 +650,519 @@ export function buildConnectCommands(deps = {}) {
404
650
  throw new Error("provide --amount_usdc, or --all with --shares_exact");
405
651
  calls = buildWithdraw(vault, { receiver: receiver, owner, assetsHuman: amount_usdc });
406
652
  }
407
- return confirmInWallet("withdraw", calls, session, receiver, ack);
653
+ return confirmInWallet("withdraw", calls, session, receiver, mode);
654
+ };
655
+ const planCodeArg = z
656
+ .string()
657
+ .regex(/^[0-9a-fA-F]{8}$/, "the 8-character code of an acknowledged plan, from `connect plan`")
658
+ .optional()
659
+ .describe("run this as a leg of an acknowledged plan (`connect plan`): the plan's code, with --leg. Use the leg's command exactly as `connect plan` gave it; instead of --ack");
660
+ const legArg = z.number().int().positive().optional().describe("which leg of the plan this is (1, 2, …), with --plan; legs run in order");
661
+ register("connect_deposit", {
662
+ title: "Deposit through the connected wallet",
663
+ description: "Builds the approve + deposit for the CONNECTED account and checks every call against the registry. Runs twice. Without --ack it opens NOTHING: it returns `needs_acknowledgement` with an `acknowledgement` to post to the operator word for word, and an `ack` code. Only after the operator replies yes, run it again with --ack <code>: then the confirm page opens, and the operator confirms each call in their wallet, one at a time. That run returns each transaction hash with `verified` read from the receipt (`matched`, `extra_transfer`, `mismatch`, `reverted`, `unverified`). Run `earn quote --direction deposit` first. As a leg of a plan the operator acknowledged with `connect plan`, run it with --plan <code> --leg <n> instead of --ack, exactly as the plan gave it.",
664
+ inputSchema: { vault: vaultArg, chain: chainArg, amount_usdc: amountArg, receiver: receiverArg, ack: ackArg, plan: planCodeArg, leg: legArg },
665
+ }, async (args) => asText(await depositLeg(args, modeOf(args))));
666
+ register("connect_withdraw", {
667
+ title: "Withdraw through the connected wallet",
668
+ description: "Builds the withdrawal for the CONNECTED account (the asset to that same account) and checks it against the registry. Runs twice, like `connect deposit`: without --ack it opens NOTHING and returns `needs_acknowledgement` with an `acknowledgement` to post to the operator word for word; only after their yes, run it again with --ack <code> and the confirm page opens. Pass --amount_usdc, or --all with --shares_exact copied verbatim from `earn balance`. Run `earn quote --direction withdraw` first. The second run returns the hash with `verified` read from the receipt. As a leg of a plan the operator acknowledged with `connect plan`, run it with --plan <code> --leg <n> instead of --ack, exactly as the plan gave it.",
669
+ inputSchema: {
670
+ vault: vaultArg,
671
+ chain: chainArg,
672
+ receiver: receiverArg,
673
+ amount_usdc: amountArg.optional(),
674
+ all: z.boolean().optional(),
675
+ shares_exact: z.string().regex(/^\d+(\.\d+)?$/).optional(),
676
+ ack: ackArg,
677
+ plan: planCodeArg,
678
+ leg: legArg,
679
+ },
680
+ }, async (args) => asText(await withdrawLeg(args, modeOf(args))));
681
+ /** One leg of `--legs`, as typed: `deposit <vault> <amount>` or `withdraw <vault> <amount|all>`. */
682
+ const parseLegs = (text) => {
683
+ const parts = text
684
+ .split(/[;\n]/)
685
+ .map((p) => p.trim())
686
+ .filter(Boolean);
687
+ if (parts.length === 0)
688
+ throw new Error('--legs names no leg; give e.g. "deposit tlCashPlusUSDC2B 1; withdraw tlCashPlusUSDC2C all"');
689
+ if (parts.length > PLAN_MAX_LEGS)
690
+ throw new Error(`a plan holds at most ${PLAN_MAX_LEGS} legs, and --legs names ${parts.length}; split it into more than one plan`);
691
+ return parts.map((p, i) => {
692
+ const [action, vault, amount, ...rest] = p.split(/\s+/);
693
+ const where = `leg ${i + 1} ("${p}")`;
694
+ if (rest.length || !vault || !amount)
695
+ throw new Error(`${where}: write each leg as "deposit <vault> <amount>" or "withdraw <vault> <amount|all>"`);
696
+ if (action !== "deposit" && action !== "withdraw")
697
+ throw new Error(`${where}: the action is "deposit" or "withdraw", not "${action}"`);
698
+ if (amount === "all") {
699
+ if (action === "deposit")
700
+ throw new Error(`${where}: "all" withdraws a whole position; a deposit takes an amount the operator chose`);
701
+ }
702
+ else if (!/^\d+(\.\d+)?$/.test(amount))
703
+ throw new Error(`${where}: "${amount}" is not a plain decimal amount (e.g. "25" or "12.5"), or "all" for a whole withdrawal`);
704
+ return { action, vault, amount };
705
+ });
706
+ };
707
+ /** The account's whole share balance in `symbol`, read now: what a `withdraw <vault> all` leg redeems. */
708
+ const wholePosition = async (symbol, account) => {
709
+ const target = resolveAnyVault(symbol);
710
+ if (target.family === "solana") {
711
+ const v = target.vault;
712
+ const held = await tokenBalance(deps.solanaRpc ?? makeSolanaRpc(solanaRpcUrlFromEnv()), await associatedTokenAccount(account, v.sharesMint));
713
+ return formatAmount(held.amount, v.shareDecimals);
714
+ }
715
+ const v = target.vault;
716
+ const client = deps.client ? deps.client(v.chainId) : makePublicClient(v.chainId, rpcUrlFromEnv(v.chainId));
717
+ const shares = (await client.readContract({ address: v.address, abi: erc4626Abi, functionName: "balanceOf", args: [account] }));
718
+ return formatAmount(shares, v.shareDecimals);
719
+ };
720
+ /** Every leg of `--legs`, built and gate-checked exactly as its own run will be, with the args that run takes. */
721
+ const describeLegs = async (text) => {
722
+ const parsed = parseLegs(text);
723
+ const out = [];
724
+ for (const [i, l] of parsed.entries()) {
725
+ const target = resolveAnyVault(l.vault);
726
+ const session = target.family === "solana" ? readSolanaSession() : readSession();
727
+ if (!session)
728
+ throw target.family === "solana" ? notConnectedSolana() : notConnected();
729
+ const chain = target.family === "solana" ? "solana" : CHAIN_INFO[target.vault.chainId].key;
730
+ const whole = l.amount === "all";
731
+ // A whole withdrawal is bound to the share balance read now, so it must be the only leg on its vault that
732
+ // could change that balance before it runs, and nothing on that vault can follow it.
733
+ const earlier = out.find((o) => o.symbol === target.vault.symbol);
734
+ if (whole && earlier) {
735
+ throw new Error(`leg ${i + 1} withdraws all of ${target.vault.symbol}, which an earlier leg ${earlier.action === "deposit" ? "deposits into" : "withdraws from"}; the share balance is read now, before that leg runs, so withdraw it in a later plan`);
736
+ }
737
+ if (earlier?.whole) {
738
+ throw new Error(`leg ${i + 1} is on ${target.vault.symbol}, which an earlier leg withdraws in full; put it in a later plan`);
739
+ }
740
+ // The receiver is the connected account for the leg's chain family: the only account a page pays.
741
+ let args = { vault: target.vault.symbol, receiver: session.account, amount_usdc: l.amount };
742
+ if (whole) {
743
+ const shares = await wholePosition(target.vault.symbol, session.account);
744
+ if (/^0(\.0+)?$/.test(shares))
745
+ throw new Error(`leg ${i + 1} withdraws all of ${target.vault.symbol}, but ${session.account} holds no shares of it`);
746
+ args = { vault: target.vault.symbol, receiver: session.account, all: true, shares_exact: shares };
747
+ }
748
+ const d = l.action === "deposit"
749
+ ? await depositLeg({ vault: target.vault.symbol, receiver: session.account, amount_usdc: l.amount }, { kind: "describe" })
750
+ : await withdrawLeg(args, { kind: "describe" });
751
+ if (typeof d === "string")
752
+ throw new Error("internal: a leg did not describe itself");
753
+ // The single acknowledgement's wording says a given share amount is not checked against the balance; here it was read.
754
+ if (whole)
755
+ d.summary.amount = `${args.shares_exact} ${target.vault.symbol} shares (your whole position, read when this plan was made; shares that arrive after that are not included)`;
756
+ out.push({ action: l.action, symbol: target.vault.symbol, chain, family: target.family, args, d, whole, asset: target.vault.asset });
757
+ }
758
+ return out;
759
+ };
760
+ /** "deposit 3 USDC and 1 USDG; withdraw 1 USDC and the whole position in 2 vaults". */
761
+ const planTotals = (legs) => {
762
+ const sum = (action) => {
763
+ const by = new Map();
764
+ for (const l of legs.filter((x) => x.action === action && !x.whole)) {
765
+ const t = by.get(l.asset.symbol) ?? { raw: 0n, decimals: l.asset.decimals };
766
+ t.raw += parseAmount(l.args.amount_usdc, l.asset.decimals, "amount");
767
+ by.set(l.asset.symbol, t);
768
+ }
769
+ const parts = [...by].map(([s, t]) => `${formatAmount(t.raw, t.decimals)} ${s}`);
770
+ const whole = legs.filter((x) => x.action === action && x.whole).length;
771
+ if (whole)
772
+ parts.push(`the whole position in ${whole} ${whole === 1 ? "vault" : "vaults"}`);
773
+ return parts.length ? `${action} ${parts.join(" and ")}` : undefined;
774
+ };
775
+ return [sum("deposit"), sum("withdraw")].filter((x) => x !== undefined);
776
+ };
777
+ /** The command that runs one leg, exactly: what `connect plan` hands the agent. */
778
+ const legCommand = (l, code, leg) => [
779
+ `connect ${l.action}`,
780
+ `--vault ${l.symbol}`,
781
+ ...(l.args.all ? ["--all", `--shares_exact ${l.args.shares_exact}`] : [`--amount_usdc ${l.args.amount_usdc}`]),
782
+ `--receiver ${l.args.receiver}`,
783
+ `--plan ${code}`,
784
+ `--leg ${leg}`,
785
+ ].join(" ");
786
+ /** A leg's arguments as the plan file keeps them, so `--run` rebuilds the same calls. */
787
+ const legArgs = (a) => ({
788
+ vault: a.vault,
789
+ receiver: a.receiver,
790
+ ...(a.all ? { all: true, shares_exact: a.shares_exact } : { amount_usdc: a.amount_usdc }),
791
+ });
792
+ /** The calls leg `leg` was acknowledged as, rebuilt for the connected account; `checkPlanLeg` holds them to its digest. */
793
+ const legCalls = (leg, session) => {
794
+ const a = leg.args;
795
+ if (!a)
796
+ throw new Error(`refused: leg ${leg.leg} was recorded without its arguments (a plan made by an older version); make the plan again with \`connect plan --legs\``);
797
+ const vault = resolveVault(a.vault);
798
+ const account = session.account;
799
+ const receiver = (isAddress(a.receiver) ? getAddress(a.receiver) : a.receiver);
800
+ if (leg.action === "deposit")
801
+ return buildDeposit(vault, { assetsHuman: a.amount_usdc, receiver, account });
802
+ return a.all ? buildWithdraw(vault, { receiver, owner: account, all: true, sharesExact: a.shares_exact }) : buildWithdraw(vault, { receiver, owner: account, assetsHuman: a.amount_usdc });
803
+ };
804
+ /**
805
+ * A Solana leg as it was acknowledged, rebuilt for the connected Solana account: its digest, which `checkPlanLeg`
806
+ * holds it to, and the intent its transaction is built from. The same derivation as its own `--plan --leg` run.
807
+ */
808
+ const legSolana = (leg, session) => {
809
+ const a = leg.args;
810
+ if (!a)
811
+ throw new Error(`refused: leg ${leg.leg} was recorded without its arguments (a plan made by an older version); make the plan again with \`connect plan --legs\``);
812
+ const target = resolveAnyVault(a.vault);
813
+ if (target.family !== "solana")
814
+ throw new Error(`refused: leg ${leg.leg} names ${a.vault}, which is not a vault on Solana`);
815
+ if (a.receiver !== session.account)
816
+ throw new Error(`refused: leg ${leg.leg} pays ${a.receiver}, which is not the connected Solana account ${session.account}`);
817
+ const r = solanaRequest(leg.action, target.vault, a);
818
+ return { digest: solanaIntentDigest(session.account, target.vault.address, r.acked.kind, r.acked.raw), intent: r.intent };
819
+ };
820
+ /** What the page shows for a leg: the sentence, where the money goes, and how far it got. */
821
+ const legView = (leg) => {
822
+ const base = { leg: leg.leg, action: leg.action, amount: leg.amount, receiver: leg.args?.receiver, status: leg.status, ...(leg.reason ? { reason: leg.reason } : {}), txs: leg.txs ?? [] };
823
+ const target = resolveAnyVault(leg.vault);
824
+ if (target.family === "solana") {
825
+ const v = target.vault;
826
+ const explorer = `https://solscan.io/account/${v.address}`;
827
+ return { ...base, family: "solana", chainId: null, chainName: "Solana", vault: { symbol: v.symbol, name: v.name, address: v.address, asset: v.asset.symbol, assetAddress: v.asset.address, links: { explorer } }, txBase: "https://solscan.io/tx/" };
828
+ }
829
+ const v = target.vault;
830
+ return {
831
+ ...base,
832
+ family: "evm",
833
+ chainId: v.chainId,
834
+ chainName: CHAIN_INFO[v.chainId].name,
835
+ vault: { symbol: v.symbol, name: v.name, address: v.address, asset: v.asset.symbol, assetAddress: v.asset.address, links: linksFor(v) },
836
+ txBase: explorerTxUrl(v.chainId, ""),
837
+ };
838
+ };
839
+ /** The plan as `--run` and `--status` report it. */
840
+ const planSummary = (p) => p.legs.map((l) => ({ leg: l.leg, action: l.action, vault: l.vault, chain: l.chain, amount: l.amount, status: l.status, ...(l.reason ? { reason: l.reason } : {}), txs: l.txs ?? [] }));
841
+ /**
842
+ * `connect plan --run`: ONE page for every leg of the acknowledged plan (#174), EVM and Solana legs alike (#184).
843
+ * Each leg is still rebuilt, held to the digest it was acknowledged as (`checkPlanLeg`), and admitted by the same
844
+ * gate: an EVM leg's calls through `prepareConfirm`, checked from their receipts; a Solana leg's transaction through
845
+ * `buildForIntent` when the leg starts and again when the operator presses for it (`prepareConfirmSolana`: a
846
+ * blockhash lasts about a minute), checked from what landed. One leg at a time; a leg that does not complete ends
847
+ * the plan, exactly as `--plan --leg`. The page lives across runs: it keeps the plan's secret and port, so a run that
848
+ * stops for the shell's time limit (`in_progress`, never mid-leg) and the run after it serve the same page, and the
849
+ * operator's tab carries on.
850
+ */
851
+ const runPlanPage = async () => {
852
+ const plan = readPlan();
853
+ if (!plan)
854
+ throw new Error("no plan is pending; `connect plan --legs …` makes one");
855
+ if (!plan.acknowledged)
856
+ throw new Error(`refused: plan ${plan.code} has not been acknowledged; post its acknowledgement and, on the operator's yes, run \`connect plan\` with --ack ${plan.code}`);
857
+ // Each chain family's connection, needed only when the plan has a leg on it: a Solana-only plan needs no EVM one.
858
+ const isSolana = (l) => l.chain === "solana";
859
+ const session = plan.legs.some((l) => !isSolana(l)) ? readSession() : undefined;
860
+ if (plan.legs.some((l) => !isSolana(l)) && !session)
861
+ throw notConnected();
862
+ const solSession = plan.legs.some(isSolana) ? readSolanaSession() : undefined;
863
+ if (plan.legs.some(isSolana) && !solSession)
864
+ throw notConnectedSolana();
865
+ let solRpc;
866
+ const solanaRpc = () => (solRpc ??= deps.solanaRpc ?? makeSolanaRpc(solanaRpcUrlFromEnv()));
867
+ const report = (status, p, extra, next_step) => JSON.stringify({ status, code: p.code, expiresAtIso: p.expiresAtIso, ...extra, legs: planSummary(p), next_step }, null, 2);
868
+ const endedReport = (p, opened = false) => {
869
+ const ended = p.legs.find((l) => l.status !== "pending" && l.status !== "completed");
870
+ if (ended) {
871
+ return report(ended.status === "running" ? "not_reported" : ended.status, p, { opened }, ended.status === "running"
872
+ ? `Leg ${ended.leg}'s run never finished, so whether it sent anything is not known here. Check the account with \`earn balance\` and tell the operator; the legs after it do not run.`
873
+ : `The plan ended at leg ${ended.leg} (${ended.status}${ended.reason ? `: ${ended.reason}` : ""}); the legs after it did not run. Report each leg's transactions to the operator, check the account with \`earn balance\`, and do not retry blindly.`);
874
+ }
875
+ if (p.legs.every((l) => l.status === "completed")) {
876
+ return report("completed", p, { opened }, "Every leg landed as confirmed. Report each leg's transactions to the operator; any `extra_transfer` is money the wallet moved besides the call — say so.");
877
+ }
878
+ return undefined;
879
+ };
880
+ const done0 = endedReport(plan);
881
+ if (done0)
882
+ return done0;
883
+ if (planExpired(plan, now())) {
884
+ return report("expired", plan, { opened: false }, `The plan expired at ${plan.expiresAtIso}; legs that had not started did not run. Tell the operator; for what is left, make a new plan and ask again.`);
885
+ }
886
+ // Every leg still to run must rebuild to what was acknowledged, for the connected account of its family, before a
887
+ // page opens.
888
+ const account = session?.account;
889
+ const legDigest = (l) => (isSolana(l) ? legSolana(l, solSession).digest : callsDigest(legCalls(l, session), account));
890
+ for (const l of plan.legs.filter((x) => x.status === "pending")) {
891
+ if (legDigest(l) !== l.digest) {
892
+ const who = isSolana(l) ? solSession.account : account;
893
+ throw new Error(`refused: leg ${l.leg} is not what the operator acknowledged for the connected account ${who} (the account, an amount or a share balance changed); make a new plan with \`connect plan --legs\``);
894
+ }
895
+ }
896
+ const ttlMs = deps.ttlMs ?? CONFIRM_TTL_MS;
897
+ const legMinMs = deps.legMinMs ?? LEG_MIN_MS;
898
+ const endsAt = Date.now() + ttlMs;
899
+ let current;
900
+ let paused = false;
901
+ // This run's own copy of its plan: every leg outcome is recorded here first, and the report is built from it. The
902
+ // plan file is written too, but only while it still holds this plan (`markLeg`): another command can replace it
903
+ // (`connect plan --legs`) or clear it (`connect disconnect`, `connect wallet`) while the page is open, and an
904
+ // outcome must never land on another plan, nor be lost with the file.
905
+ const mine = structuredClone(plan);
906
+ const record = (index, update) => {
907
+ mine.legs[index] = { ...mine.legs[index], ...update };
908
+ markLeg(plan.code, index, update);
909
+ };
910
+ const replaced = () => readPlan()?.code !== plan.code;
911
+ // Pending here AND in the file: a `--plan --leg` run of the same plan, in another shell, marks its leg running in
912
+ // the file, and the page must not start that leg too.
913
+ const startable = (index) => mine.legs[index]?.status === "pending" && readPlan()?.legs[index]?.status === "pending";
914
+ // Once the plan is replaced or cleared, no leg starts: the first one still pending is recorded as stopped.
915
+ const endReplaced = () => {
916
+ const next = mine.legs.findIndex((l) => l.status === "pending");
917
+ if (next >= 0)
918
+ mine.legs[next] = { ...mine.legs[next], status: "stopped", reason: "the plan was replaced or cleared by another command while its page was open" };
919
+ };
920
+ const reused = plan.page;
921
+ const legStatusOf = (status, txs, reason) => ({ status, txs: txs.map(planTx), ...(reason ? { reason } : {}) });
922
+ const pendingLeft = () => mine.legs.some((l) => l.status === "pending");
923
+ const serve = (secret, ports) => serveOnce({
924
+ mode: "plan",
925
+ secret,
926
+ ttlMs,
927
+ ...(deps.cancelGraceMs !== undefined ? { cancelGraceMs: deps.cancelGraceMs } : {}),
928
+ timeoutReason: () => (current ? "the page's time limit ran out during a leg" : "paused for the shell's time limit"),
929
+ html: (s) => shell(s, "Your plan — Open Agent Treasury"),
930
+ csp: PAGE_CSP,
931
+ assets: assets(),
932
+ ports,
933
+ info: () => {
934
+ const p = mine;
935
+ return {
936
+ mode: "plan",
937
+ appId: privyAppId(),
938
+ ...(session ? { account, walletType: session.walletType } : {}),
939
+ ...(solSession ? { solanaAccount: solSession.account, solanaWalletType: solSession.walletType } : {}),
940
+ code: p.code,
941
+ expiresAtIso: p.expiresAtIso,
942
+ legMinMs,
943
+ legs: p.legs.map(legView),
944
+ current: current ? { leg: p.legs[current.index].leg, ...current.core.info() } : null,
945
+ };
946
+ },
947
+ // The operator stopped the plan on the page: after the wallet declined (nothing sent), or between legs. A hash
948
+ // the page could not hand over is put on record first, and its leg is then "may have been sent".
949
+ onReject: (body) => {
950
+ const reason = typeof body["reason"] === "string" ? body["reason"].slice(0, 300) : "stopped on the page";
951
+ if (current) {
952
+ current.core.onReject(body);
953
+ const out = current.core.done;
954
+ const unverified = out.some((t) => t.verified === "unverified");
955
+ record(current.index, unverified ? legStatusOf("not_reported", out, `may have been sent: ${reason}`) : legStatusOf(body["declined"] === true ? "stopped" : "not_reported", out, reason));
956
+ current = undefined;
957
+ return;
958
+ }
959
+ const next = mine.legs.findIndex((l) => l.status === "pending");
960
+ if (next < 0)
961
+ return;
962
+ // `unprepared`: the page could not get ready before this leg (a plan's Solana account is connected and checked
963
+ // when it starts), so nothing was asked of the wallet.
964
+ const stop = { status: "stopped", reason: body["unprepared"] === true ? `not prepared: ${reason}` : `the operator stopped the plan before this leg: ${reason}` };
965
+ // A leg another shell is running keeps its record in the file: that run writes its own outcome.
966
+ if (startable(next))
967
+ record(next, stop);
968
+ else
969
+ mine.legs[next] = { ...mine.legs[next], ...stop };
970
+ },
971
+ accept: async (body) => {
972
+ if (body["start"] !== undefined) {
973
+ if (current)
974
+ return { ok: false, reason: "a leg is already in progress on this page" };
975
+ // Never start a leg the page cannot finish: hand over to the next run instead.
976
+ if (endsAt - Date.now() < legMinMs) {
977
+ paused = true;
978
+ return { ok: true, final: true, value: "settled", reply: { paused: true } };
979
+ }
980
+ if (replaced()) {
981
+ endReplaced();
982
+ return { ok: false, fatal: true, reason: "this plan was replaced or cleared by another command, so nothing more runs on this page" };
983
+ }
984
+ if (planExpired(mine, now()))
985
+ return { ok: false, fatal: true, reason: `the plan expired at ${mine.expiresAtIso}; legs that had not started do not run` };
986
+ const leg = mine.legs.find((l) => l.leg === body["start"]);
987
+ if (!leg)
988
+ return { ok: false, reason: `no leg ${String(body["start"])} in this plan` };
989
+ if (!startable(mine.legs.indexOf(leg)))
990
+ return { ok: false, reason: `leg ${leg.leg} is running, or has run, elsewhere; this page does not start it` };
991
+ let next;
992
+ try {
993
+ if (isSolana(leg)) {
994
+ // Held to its digest now; its shares (a withdrawal by amount) worked out now; and the gate and simulation
995
+ // run on a transaction built now, before the leg is marked running. The wallet's transaction is built later,
996
+ // at the operator's press.
997
+ const sl = legSolana(leg, solSession);
998
+ const index = checkPlanLeg(plan.code, leg.leg, mine, sl.digest, now());
999
+ const intent = await sl.intent();
1000
+ await buildForIntent(solanaRpc(), intent, solSession.account);
1001
+ next = { index, family: "solana", core: await prepareConfirmSolana(intent, solSession, solanaRpc(), deps.verifyOpts, endsAt, (sig) => {
1002
+ const file = readPlan();
1003
+ return [mine.legs, file?.code === plan.code ? file.legs : []].some((legs) => legs.some((l) => l.txs?.some((t) => t.hash === sig)));
1004
+ }) };
1005
+ }
1006
+ else {
1007
+ const calls = legCalls(leg, session);
1008
+ const index = checkPlanLeg(plan.code, leg.leg, mine, callsDigest(calls, account), now());
1009
+ next = { index, family: "evm", core: await prepareConfirm(calls, session, deps, endsAt) };
1010
+ }
1011
+ }
1012
+ catch (e) {
1013
+ return { ok: false, reason: redactEndpoints(e instanceof Error ? e.message : String(e)) };
1014
+ }
1015
+ // Preparing awaits the chain reads, and meanwhile the page may have stopped the plan, another tab may have
1016
+ // started this leg, or another command may have replaced the plan.
1017
+ if (current || !startable(next.index) || replaced())
1018
+ return { ok: false, reason: "the plan was stopped, replaced, or this leg started, while it was being prepared" };
1019
+ record(next.index, { status: "running" });
1020
+ current = next;
1021
+ return { ok: true, final: false, reply: { started: leg.leg } };
1022
+ }
1023
+ if (!current)
1024
+ return { ok: false, reason: "no leg is in progress; start one first" };
1025
+ const cur = current;
1026
+ if (cur.family === "solana" && body["build"] === true) {
1027
+ // The operator pressed for this Solana leg's transaction: built now, for this leg only, and handed to no other.
1028
+ const b = await cur.core.build();
1029
+ if (current !== cur)
1030
+ return { ok: false, reason: "the plan was stopped on the page while this step was being prepared" };
1031
+ return b.ok ? { ok: true, final: false, reply: b.reply } : b;
1032
+ }
1033
+ const r = cur.family === "solana" ? await cur.core.accept(body["hash"]) : await cur.core.accept(body["index"], body["hash"]);
1034
+ if (current !== cur) {
1035
+ // The plan was stopped on the page, or the page's time limit ended the leg, while this step's receipt was being
1036
+ // read, and its leg is already on record. Its status stands; only its transactions take what the read found.
1037
+ record(cur.index, { txs: cur.core.done.map(planTx) });
1038
+ return { ok: false, reason: "the plan was stopped on the page while this step was being checked" };
1039
+ }
1040
+ if (!r.ok)
1041
+ return r;
1042
+ if (r.kind === "next")
1043
+ return { ok: true, final: false, reply: r.reply };
1044
+ const { index, core } = cur;
1045
+ current = undefined;
1046
+ if (r.kind === "stop") {
1047
+ record(index, legStatusOf("stopped", core.done, core.stopReason()));
1048
+ return { ok: true, final: true, value: "settled", reply: r.reply };
1049
+ }
1050
+ record(index, legStatusOf("completed", core.done));
1051
+ if (replaced()) {
1052
+ endReplaced();
1053
+ return { ok: true, final: true, value: "settled", reply: { ...r.reply, legDone: true } };
1054
+ }
1055
+ if (!pendingLeft())
1056
+ return { ok: true, final: true, value: "settled", reply: { ...r.reply, planDone: true } };
1057
+ if (endsAt - Date.now() < legMinMs) {
1058
+ paused = true;
1059
+ return { ok: true, final: true, value: "settled", reply: { ...r.reply, legDone: true, paused: true } };
1060
+ }
1061
+ return { ok: true, final: false, reply: { ...r.reply, legDone: true } };
1062
+ },
1063
+ });
1064
+ // The page carries on from the last run's: same secret, same port, so the operator's open tab keeps working. A
1065
+ // secret is only ever served on the port it was minted for: if that port is taken now, the old tab cannot reach
1066
+ // this page anyway, and whatever holds the old port must not be handed a secret this page still accepts.
1067
+ let handle;
1068
+ let secret = reused?.secret ?? newSecret();
1069
+ let sameTab = false;
1070
+ if (reused?.port) {
1071
+ try {
1072
+ handle = await serve(secret, [reused.port]);
1073
+ sameTab = true;
1074
+ }
1075
+ catch (e) {
1076
+ const others = connectPorts().filter((p) => p !== reused.port);
1077
+ if (!e.inUse || others.length === 0)
1078
+ throw e;
1079
+ secret = newSecret();
1080
+ handle = await serve(secret, others);
1081
+ }
1082
+ }
1083
+ else {
1084
+ handle = await serve(secret, connectPorts());
1085
+ }
1086
+ // The page outlives this run: its secret and port stay on the plan for the next one.
1087
+ const pNow = readPlan();
1088
+ if (pNow && pNow.code === plan.code)
1089
+ writePlan({ ...pNow, page: { secret, port: handle.port } });
1090
+ const opened = sameTab ? (process.stderr.write(`The plan's page is already open: ${handle.url}\n`), false) : (deps.open ?? announce)(handle.url);
1091
+ const result = await handle.done;
1092
+ if (current) {
1093
+ // The time limit or a cancel ended the page while a leg was in the wallet: whether it went out is not known here.
1094
+ // Cleared, so a receipt still being read cannot overwrite this afterwards.
1095
+ record(current.index, legStatusOf("not_reported", current.core.done, result.ok ? "the page closed during this leg" : result.reason));
1096
+ current = undefined;
1097
+ }
1098
+ if (replaced())
1099
+ endReplaced();
1100
+ const after = mine;
1101
+ const ended = endedReport(after, opened);
1102
+ if (ended)
1103
+ return ended;
1104
+ if (planExpired(after, now())) {
1105
+ return report("expired", after, { opened }, `The plan expired at ${after.expiresAtIso}; legs that had not started did not run. Tell the operator; for what is left, make a new plan and ask again.`);
1106
+ }
1107
+ const nextLeg = after.legs.find((l) => l.status === "pending");
1108
+ return report("in_progress", after, { opened, ...(paused ? {} : { reason: result.ok ? "paused" : result.reason }) }, `The plan's page is waiting for you: run \`connect plan --run\` again now, with no other flag, and the operator's open page carries on${nextLeg ? ` from leg ${nextLeg.leg}` : ""}. Do not ask the operator again: their yes covers every leg. Give the shell call a timeout of at least 9 minutes.`);
1109
+ };
1110
+ register("connect_plan", {
1111
+ title: "One acknowledgement for several deposits and withdrawals",
1112
+ description: 'For a request that moves money in more than one vault (any mix of deposits and withdrawals, across chains): ONE acknowledgement for the whole plan instead of one per vault. Runs twice, like `connect deposit`. Pass --legs, e.g. "deposit tlCashPlusUSDC2B 25; withdraw tlCashPlusUSDC2G all" (each leg "deposit <vault> <amount>" or "withdraw <vault> <amount|all>"; "all" redeems the share balance read now). Without --ack it opens NOTHING: every leg is built and checked as its own run would be, and it returns `needs_acknowledgement` with ONE `acknowledgement` covering every leg, to post to the operator word for word, and an `ack` code. Only after the operator replies yes, run it again with the same --legs and --ack <code>: that records the yes, opens nothing, and returns each leg\'s exact command. Then run `connect plan --run`: one signing page walks every leg in order, EVM and Solana legs alike (a leg can also run on a page of its own, as `connect deposit|withdraw … --plan <code> --leg <n>`, which is each leg\'s `command`). A leg that does not complete ends the plan. --status (alone) reports the plan\'s legs and their transactions. Each EVM and Solana leg needs that family\'s wallet connected; money lands only in the connected account.',
1113
+ inputSchema: {
1114
+ legs: z.string().optional().describe('the plan, in order: legs separated by ";", each "deposit <vault> <amount>" or "withdraw <vault> <amount|all>" — vaults by their ticker, as `earn vaults` lists them; amounts the operator chose'),
1115
+ ack: ackArg,
1116
+ status: z.boolean().optional().describe("report the current plan and each leg's status and transactions; reads a local file only"),
1117
+ run: z
1118
+ .boolean()
1119
+ .optional()
1120
+ .describe("open ONE signing page for every leg of the acknowledged plan (EVM and Solana legs), instead of one page per leg; returns `in_progress` when the shell's time limit is near: run it again and the operator's open page carries on"),
1121
+ },
1122
+ }, async ({ legs, ack, status, run }) => {
1123
+ if (run) {
1124
+ if (legs !== undefined || ack !== undefined || status)
1125
+ throw new Error("--run takes no other flag: it runs the acknowledged plan");
1126
+ return runPlanPage();
1127
+ }
1128
+ if (status) {
1129
+ if (legs !== undefined || ack !== undefined)
1130
+ throw new Error("--status takes no other flag");
1131
+ const p = readPlan();
1132
+ if (!p)
1133
+ return JSON.stringify({ status: "none", next_step: "no plan is pending; `connect plan --legs …` makes one" }, null, 2);
1134
+ return JSON.stringify({ status: p.acknowledged ? "acknowledged" : "needs_acknowledgement", code: p.code, expiresAtIso: p.expiresAtIso, legs: p.legs }, null, 2);
1135
+ }
1136
+ if (legs === undefined)
1137
+ throw new Error('give --legs (e.g. "deposit tlCashPlusUSDC2B 25; withdraw tlCashPlusUSDC2C all"), or --status');
1138
+ const described = await describeLegs(legs);
1139
+ const digests = described.map((l) => l.d.digest);
1140
+ if (ack === undefined) {
1141
+ const plan = issuePlan(described.map((l, i) => ({ leg: i + 1, action: l.action, vault: l.symbol, chain: l.chain, amount: l.d.summary.amount, digest: l.d.digest, command: "", args: legArgs(l.args) })), now());
1142
+ plan.legs = plan.legs.map((x, i) => ({ ...x, command: legCommand(described[i], plan.code, i + 1) }));
1143
+ writePlan(plan);
1144
+ return JSON.stringify({
1145
+ status: "needs_acknowledgement",
1146
+ opened: false,
1147
+ legs: plan.legs.map((x, i) => ({ leg: x.leg, action: x.action, vault: x.vault, chain: x.chain, amount: x.amount, receiver: described[i].args.receiver })),
1148
+ acknowledgement: planAcknowledgementText(described.map((l) => l.d.summary), planTotals(described)),
1149
+ ack: plan.code,
1150
+ expiresAtIso: plan.expiresAtIso,
1151
+ next_step: `Post \`acknowledgement\` to the operator word for word, as it is, and wait for their reply. Only an explicit yes they give after reading it is an acknowledgement: not a "go" from before they saw it, and not your own reading of the conversation. On yes, run this same command again, with the same --legs, and --ack ${plan.code} added: that records the yes, opens nothing, and returns each leg's command. On anything else, stop. The plan is good until ${plan.expiresAtIso}; a leg not started by then does not run.`,
1152
+ }, null, 2);
1153
+ }
1154
+ const plan = readPlan();
1155
+ checkPlanAck(ack, plan, digests, now());
1156
+ plan.acknowledged = true;
1157
+ writePlan(plan);
1158
+ return JSON.stringify({
1159
+ status: "acknowledged",
1160
+ opened: false,
1161
+ code: plan.code,
1162
+ expiresAtIso: plan.expiresAtIso,
1163
+ legs: plan.legs.map((x) => ({ leg: x.leg, action: x.action, vault: x.vault, chain: x.chain, amount: x.amount, command: x.command })),
1164
+ next_step: "The operator's yes covers every leg: do not ask again between legs. Run `connect plan --run`: it opens ONE signing page that walks every leg in order, and waits up to 9 minutes, so give the shell call a timeout of at least 9 minutes. If it returns `in_progress`, run it again at once: the operator's open page carries on. When it returns any other status, report each leg's result and transactions to the operator.",
1165
+ }, null, 2);
408
1166
  });
409
1167
  return commands;
410
1168
  }