@temporalabs/treasury 0.1.3 → 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.
@@ -26,16 +26,17 @@ import { resolveAnyVault, resolveVault } from "../registry.js";
26
26
  import { isAddress as isSolanaAddress } from "@solana/kit";
27
27
  import { parseAmount, formatAmount } from "../units.js";
28
28
  import { makeSolanaRpc } from "../solana/rpc.js";
29
- import { readVault } from "../solana/vault.js";
29
+ import { readVault, tokenBalance } from "../solana/vault.js";
30
+ import { associatedTokenAccount } from "../solana/kvault.js";
30
31
  import { solanaRpcUrlFromEnv } from "../client.js";
31
- import { runConfirmSolana, runConnectSolana } from "./solana.js";
32
- import { acknowledgementText, checkAck, checkAckDigest, issueAck, issueAckForDigest, solanaIntentDigest, summarize, summarizeSolana } from "./ack.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";
33
34
  import { connectPorts, privyAppId } from "./config.js";
34
35
  import { admit } from "./gate.js";
35
- import { serveOnce } from "./http.js";
36
+ import { newSecret, serveOnce } from "./http.js";
36
37
  import { openBrowser } from "./open.js";
37
38
  import { PAGE_CSP, readPageBundle, shell } from "./page.js";
38
- import { clearPendingAck, clearSession, clearSolanaSession, readPendingAck, readSession, readSolanaSession, writePendingAck, writeSession, writeSolanaSession } from "./session.js";
39
+ import { clearPendingAck, clearSession, clearSolanaSession, readPendingAck, readPlan, readSession, readSolanaSession, writePendingAck, writePlan, writeSession, writeSolanaSession, } from "./session.js";
39
40
  import { verifyLanded } from "./verify.js";
40
41
  /** An address in either family's form; each command checks the family its vault needs. */
41
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");
@@ -51,6 +52,12 @@ const ackArg = z
51
52
  /** One sign-in or one confirmation flow at a time, each whole flow bounded under a shell tool's 10-minute limit. */
52
53
  const CONNECT_TTL_MS = 9 * 60_000;
53
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;
54
61
  export function signInMessage(a) {
55
62
  return [
56
63
  `${new URL(a.origin).host} wants you to sign in with your Ethereum account:`,
@@ -115,9 +122,12 @@ export async function runConnect(deps = {}) {
115
122
  writeSession(result.value);
116
123
  return { result, url: handle.url, opened };
117
124
  }
118
- export async function runConfirm(calls, session, deps = {}) {
119
- // The gate runs here, on exactly the calls this flow will hand the wallet, so no caller can pass
120
- // 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) {
121
131
  const admitted = admit(calls, session.account);
122
132
  const chainId = calls[0].chainId;
123
133
  if (!isSupportedChainId(chainId))
@@ -126,9 +136,6 @@ export async function runConfirm(calls, session, deps = {}) {
126
136
  const client = deps.client ? deps.client(chainId) : makePublicClient(chainId, rpcUrlFromEnv(chainId));
127
137
  const done = [];
128
138
  let stopReason;
129
- const ttlMs = deps.ttlMs ?? CONFIRM_TTL_MS;
130
- // Every chain read stops waiting when the flow's time limit is up, so the command returns inside it.
131
- const deadline = Date.now() + ttlMs;
132
139
  // The chain head when the flow starts: a receipt mined in it or before it cannot be from this flow.
133
140
  // Read beside the balances below; a read that fails or is slow leaves no floor, never blocks the flow.
134
141
  const notBeforeRead = client.getBlockNumber
@@ -174,37 +181,12 @@ export async function runConfirm(calls, session, deps = {}) {
174
181
  ]);
175
182
  const verifyOpts = { ...deps.verifyOpts, deadline, ...(notBefore !== undefined ? { notBefore } : {}) };
176
183
  const deposit = admitted.find((a) => a.kind === "deposit");
177
- const handle = await serveOnce({
178
- mode: "confirm",
179
- ttlMs,
180
- ...(deps.cancelGraceMs !== undefined ? { cancelGraceMs: deps.cancelGraceMs } : {}),
181
- timeoutReason: () => done.length
182
- ? `the ${Math.round(ttlMs / 60_000)}-minute limit ran out after ${done.length} of ${calls.length} transactions were reported`
183
- : `nothing happened in the browser within ${Math.round(ttlMs / 60_000)} minutes`,
184
- // The page reports a hash it could not hand over (its POST failed after the wallet sent):
185
- // put it on record, against the step the page names, so the command reports it unchecked
186
- // instead of losing it. A hash that names no step is still recorded, as such.
187
- onReject: (body) => {
188
- const hash = body["hash"];
189
- if (!wellFormedHash(hash) || known(hash))
190
- return;
191
- const i = body["index"];
192
- const call = typeof i === "number" && Number.isInteger(i) && i >= 0 && i < calls.length ? calls[i] : undefined;
193
- done.push({
194
- step: call?.step ?? 0,
195
- description: call?.description ?? "a transaction the page reported for no known step",
196
- hash,
197
- verified: "unverified",
198
- detail: "the page reported this transaction but could not hand it over for checking; look it up before retrying anything",
199
- });
200
- },
201
- html: (s) => shell(s, "Confirm — Open Agent Treasury"),
202
- csp: PAGE_CSP,
203
- assets: assets(),
204
- ports: connectPorts(),
184
+ return {
185
+ chainId,
186
+ calls,
187
+ done,
188
+ stopReason: () => stopReason,
205
189
  info: () => ({
206
- mode: "confirm",
207
- appId: privyAppId(),
208
190
  account,
209
191
  walletType: session.walletType,
210
192
  chainId,
@@ -223,12 +205,27 @@ export async function runConfirm(calls, session, deps = {}) {
223
205
  to: c.to,
224
206
  data: c.data,
225
207
  value: c.value,
226
- 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) },
227
209
  })),
228
210
  }),
229
- accept: async (body) => {
230
- 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) => {
231
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) => {
232
229
  if (index !== done.length)
233
230
  return { ok: false, reason: `expected step ${done.length + 1}` };
234
231
  if (!wellFormedHash(hash))
@@ -255,19 +252,45 @@ export async function runConfirm(calls, session, deps = {}) {
255
252
  if (verdict.verified !== "matched" && verdict.verified !== "extra_transfer") {
256
253
  // Stop at the first step that did not land as confirmed: the next one depends on it.
257
254
  stopReason = `step ${call.step} (${call.description}) is ${verdict.verified}: ${verdict.detail ?? "it did not land as confirmed"}`;
258
- return { ok: true, final: true, value: done, reply: { stop: true, verdict } };
255
+ return { ok: true, kind: "stop", reply: { stop: true, verdict } };
259
256
  }
260
257
  if (index + 1 === calls.length)
261
- return { ok: true, final: true, value: done, reply: { finished: true, verdict } };
258
+ return { ok: true, kind: "finished", reply: { finished: true, verdict } };
262
259
  if (!(await allowanceVisible(index + 1))) {
263
260
  stopReason = "the approval landed, but its allowance is not visible on this RPC yet; the deposit was not sent";
264
- return { ok: true, final: true, value: done, reply: { stop: true, reason: stopReason } };
261
+ return { ok: true, kind: "stop", reply: { stop: true, reason: stopReason } };
265
262
  }
266
- 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 };
267
289
  },
268
290
  });
269
291
  const opened = (deps.open ?? announce)(handle.url);
270
292
  const result = await handle.done;
293
+ const stopReason = core.stopReason();
271
294
  return { result, url: handle.url, opened, done, total: calls.length, ...(stopReason ? { stopReason } : {}) };
272
295
  }
273
296
  const notConnected = () => new Error("no wallet is connected — run `treasury connect wallet` first, and let the operator sign in");
@@ -361,20 +384,89 @@ export function buildConnectCommands(deps = {}) {
361
384
  appId: privyAppId(),
362
385
  });
363
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
+ };
364
454
  /**
365
455
  * The Solana counterpart of `confirmInWallet`: the same two runs. The acknowledgement covers what the
366
456
  * operator asked for (`acked`: account, vault, action, raw amount), because the transaction itself is built
367
457
  * when the operator confirms. `intent` gives the shares and amount to build from; it runs again after the
368
458
  * acknowledgement is accepted, so a withdrawal by amount burns the shares worth that amount at that moment.
369
459
  */
370
- const confirmSolana = async (acked, amountText, session, ack, intent) => {
460
+ const confirmSolana = async (acked, amountText, session, mode, intent) => {
371
461
  const digest = solanaIntentDigest(session.account, acked.vault.address, acked.kind, acked.raw);
372
- if (ack === undefined) {
462
+ if (mode.kind === "describe" || (mode.kind === "ack" && mode.ack === undefined)) {
373
463
  // Resolves the request (for a withdrawal by amount, the shares it burns now), so one that cannot be priced
374
464
  // fails before the operator is asked. It is NOT the gate: no transaction is built or simulated here. The
375
465
  // --ack run builds and simulates it (buildForIntent) before any page opens.
376
466
  await intent();
377
467
  const summary = summarizeSolana(acked.kind === "deposit" ? "deposit" : "withdraw", acked.vault, amountText, session.account);
468
+ if (mode.kind === "describe")
469
+ return { digest, summary };
378
470
  const pending = issueAckForDigest(digest, now());
379
471
  writePendingAck(pending);
380
472
  return JSON.stringify({
@@ -393,28 +485,18 @@ export function buildConnectCommands(deps = {}) {
393
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}.`,
394
486
  }, null, 2);
395
487
  }
396
- checkAckDigest(ack, readPendingAck(), digest, now());
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());
397
498
  clearPendingAck();
398
- const r = await runConfirmSolana(await intent(), session, solanaDeps());
399
- const txs = r.result.ok ? r.result.value : r.done;
400
- const all = txs.length === r.total && txs.every((t) => t.verified === "matched" || t.verified === "extra_transfer");
401
- const status = r.result.ok ? (all ? "completed" : "stopped") : txs.length ? "stopped" : "not_reported";
402
- const reason = (r.result.ok ? r.stopReason : r.result.reason) ?? (status === "stopped" ? "the flow ended before the transaction was confirmed" : undefined);
403
- return JSON.stringify({
404
- status,
405
- chain: "solana",
406
- chainId: null,
407
- account: session.account,
408
- opened: r.opened,
409
- ...(reason ? { reason } : {}),
410
- calls_total: r.total,
411
- txs,
412
- next_step: status === "completed"
413
- ? "The transaction landed as confirmed. Report its signature to the operator; any `extra_transfer` is money the wallet moved besides the call — say so."
414
- : status === "stopped"
415
- ? "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."
416
- : "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).",
417
- }, null, 2);
499
+ return solanaOutcome(session, await runConfirmSolana(await intent(), session, solanaDeps()));
418
500
  };
419
501
  /** A Solana withdrawal's shares: `shares_exact` verbatim, or the shares worth `amount_usdc`, rounded up. */
420
502
  const solanaWithdrawShares = async (vault, amount_usdc, shares_exact) => {
@@ -426,13 +508,47 @@ export function buildConnectCommands(deps = {}) {
426
508
  throw new Error(`${vault.symbol} holds nothing to withdraw`);
427
509
  return (amount * v.state.sharesIssued + v.holdings.netAum - 1n) / v.holdings.netAum;
428
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
+ };
429
536
  /**
430
537
  * Without `ack`: gate-check the calls, record them as pending, and return the acknowledgement for
431
538
  * the operator; nothing opens. With `ack`: open the confirm page only for the calls the operator
432
- * 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.
433
541
  */
434
- const confirmInWallet = async (action, calls, session, receiver, ack) => {
542
+ const confirmInWallet = async (action, calls, session, receiver, mode) => {
435
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;
436
552
  if (ack === undefined) {
437
553
  const summary = summarize(action, calls, admit(calls, account), receiver);
438
554
  const pending = issueAck(calls, account, now());
@@ -460,11 +576,23 @@ export function buildConnectCommands(deps = {}) {
460
576
  const r = await runConfirm(calls, session, deps);
461
577
  return outcome(calls[0].chainId, session.account, r);
462
578
  };
463
- register("connect_deposit", {
464
- title: "Deposit through the connected wallet",
465
- 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.",
466
- inputSchema: { vault: vaultArg, chain: chainArg, amount_usdc: amountArg, receiver: receiverArg, ack: ackArg },
467
- }, async ({ vault: symbol, chain, amount_usdc, receiver: rawReceiver, 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) => {
468
596
  const target = resolveAnyVault(symbol, chain);
469
597
  if (target.family === "solana") {
470
598
  const session = readSolanaSession();
@@ -472,11 +600,8 @@ export function buildConnectCommands(deps = {}) {
472
600
  throw notConnectedSolana();
473
601
  if (rawReceiver !== session.account)
474
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`);
475
- const raw = parseAmount(amount_usdc, target.vault.asset.decimals, `deposit amount (${target.vault.asset.symbol})`);
476
- if (raw === 0n)
477
- throw new Error("a deposit of 0");
478
- const intent = { kind: "deposit", vault: target.vault, raw };
479
- return confirmSolana(intent, `${formatAmount(raw, target.vault.asset.decimals)} ${target.vault.asset.symbol}`, session, ack, async () => intent);
603
+ const r = solanaRequest("deposit", target.vault, { amount_usdc });
604
+ return confirmSolana(r.acked, r.amountText, session, mode, r.intent);
480
605
  }
481
606
  const session = readSession();
482
607
  if (!session)
@@ -485,21 +610,9 @@ export function buildConnectCommands(deps = {}) {
485
610
  if (receiver !== session.account)
486
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)`);
487
612
  const vault = resolveVault(symbol, chain);
488
- return confirmInWallet("deposit", buildDeposit(vault, { assetsHuman: amount_usdc, receiver: receiver, account: session.account }), session, receiver, ack);
489
- });
490
- register("connect_withdraw", {
491
- title: "Withdraw through the connected wallet",
492
- 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.",
493
- inputSchema: {
494
- vault: vaultArg,
495
- chain: chainArg,
496
- receiver: receiverArg,
497
- amount_usdc: amountArg.optional(),
498
- all: z.boolean().optional(),
499
- shares_exact: z.string().regex(/^\d+(\.\d+)?$/).optional(),
500
- ack: ackArg,
501
- },
502
- }, async ({ vault: symbol, chain, receiver: rawReceiver, 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) => {
503
616
  if (all && amount_usdc)
504
617
  throw new Error("give --amount_usdc or --all with --shares_exact, not both");
505
618
  if (!all && shares_exact)
@@ -515,19 +628,8 @@ export function buildConnectCommands(deps = {}) {
515
628
  throw notConnectedSolana();
516
629
  if (rawReceiver !== session.account)
517
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`);
518
- const v = target.vault;
519
- const intent = async () => {
520
- const raw = await solanaWithdrawShares(v, all ? undefined : amount_usdc, all ? shares_exact : undefined);
521
- if (raw === 0n)
522
- throw new Error("a withdrawal of 0");
523
- return { kind: "withdraw", vault: v, raw };
524
- };
525
- if (all) {
526
- const shares = parseAmount(shares_exact, v.shareDecimals, "shares_exact");
527
- return confirmSolana({ kind: "withdraw", vault: v, raw: shares }, `${formatAmount(shares, v.shareDecimals)} ${v.symbol} shares`, session, ack, intent);
528
- }
529
- const assets = parseAmount(amount_usdc, v.asset.decimals, `withdrawal amount (${v.asset.symbol})`);
530
- return confirmSolana({ kind: "withdraw-assets", vault: v, raw: assets }, `${formatAmount(assets, v.asset.decimals)} ${v.asset.symbol}`, session, ack, intent);
631
+ const r = solanaRequest("withdraw", target.vault, { amount_usdc, all, shares_exact });
632
+ return confirmSolana(r.acked, r.amountText, session, mode, r.intent);
531
633
  }
532
634
  const session = readSession();
533
635
  if (!session)
@@ -548,7 +650,519 @@ export function buildConnectCommands(deps = {}) {
548
650
  throw new Error("provide --amount_usdc, or --all with --shares_exact");
549
651
  calls = buildWithdraw(vault, { receiver: receiver, owner, assetsHuman: amount_usdc });
550
652
  }
551
- 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);
552
1166
  });
553
1167
  return commands;
554
1168
  }