@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.
- package/README.md +4 -4
- package/THIRD_PARTY_NOTICES.connect-page.md +78 -24
- package/THIRD_PARTY_NOTICES.md +50 -50
- package/dist/client.d.ts +6 -307
- package/dist/client.js.map +1 -1
- package/dist/connect/ack.d.ts +70 -0
- package/dist/connect/ack.js +135 -0
- package/dist/connect/ack.js.map +1 -1
- package/dist/connect/commands.d.ts +27 -0
- package/dist/connect/commands.js +726 -112
- package/dist/connect/commands.js.map +1 -1
- package/dist/connect/http.d.ts +10 -1
- package/dist/connect/http.js +5 -3
- package/dist/connect/http.js.map +1 -1
- package/dist/connect/session.d.ts +20 -4
- package/dist/connect/session.js +134 -18
- package/dist/connect/session.js.map +1 -1
- package/dist/connect/solana.d.ts +39 -0
- package/dist/connect/solana.js +140 -37
- package/dist/connect/solana.js.map +1 -1
- package/dist/connect-page.js +835 -673
- package/dist/disclosures.d.ts +2 -0
- package/dist/disclosures.js +2 -0
- package/dist/disclosures.js.map +1 -1
- package/dist/earn/solana.js +19 -5
- package/dist/earn/solana.js.map +1 -1
- package/dist/redact.js +7 -1
- package/dist/redact.js.map +1 -1
- package/dist/solana/vault.js +0 -2
- package/dist/solana/vault.js.map +1 -1
- package/dist/treasury.mjs +36772 -34368
- package/llms.txt +65 -49
- package/package.json +8 -5
package/dist/connect/commands.js
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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,
|
|
255
|
+
return { ok: true, kind: "stop", reply: { stop: true, verdict } };
|
|
259
256
|
}
|
|
260
257
|
if (index + 1 === calls.length)
|
|
261
|
-
return { ok: true,
|
|
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,
|
|
261
|
+
return { ok: true, kind: "stop", reply: { stop: true, reason: stopReason } };
|
|
265
262
|
}
|
|
266
|
-
return { ok: true,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
476
|
-
|
|
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,
|
|
489
|
-
}
|
|
490
|
-
|
|
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
|
|
519
|
-
|
|
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,
|
|
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
|
}
|