@upoch/guard 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +71 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +256 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +70 -0
- package/dist/guard.d.ts +123 -0
- package/dist/guard.js +332 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/secrets-scan.d.ts +2 -0
- package/dist/secrets-scan.js +45 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +81 -0
- package/dist/setup.d.ts +46 -0
- package/dist/setup.js +113 -0
- package/package.json +54 -4
package/dist/guard.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Upoch Guard: the checkpoint, journal and recovery operations an agent framework calls (MCP tools now,
|
|
2
|
+
// framework plugins later). One open execution at a time, kept in session.json so it survives restarts of the
|
|
3
|
+
// MCP client. Every checkpoint is verified after commit: retrieved, decrypted and hashed against the chain.
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { ActionGate, CheckpointManager, EpochReader, FileJournal, IpfsStorageAdapter, LocalAesGcmProvider, LocalKeySigner, LocalStorageAdapter, RecoveryCoordinator, ReplicatedStorage, RpcCheckpointChain, StateSerializer, canonicalBytes, httpProvider, isEpochError, keccak256, } from "@upoch/sdk";
|
|
6
|
+
import { journalPath, loadConfig, loadSecrets, loadSession, networkInfo, saveSession, upochHome } from "./config.js";
|
|
7
|
+
import { findSecrets } from "./secrets-scan.js";
|
|
8
|
+
/** Gas one checkpoint uses on Robinhood Chain, with headroom (measured: about 180k). */
|
|
9
|
+
const CHECKPOINT_GAS = 230000n;
|
|
10
|
+
const HASH = /^0x[0-9a-fA-F]{64}$/;
|
|
11
|
+
const TYPES = { progress: "SAFE_BOUNDARY", before_action: "PRE_ACTION", after_action: "POST_ACTION", manual: "MANUAL" };
|
|
12
|
+
const STATUSES = { planned: "PENDING", submitted: "SUBMITTED", confirmed: "CONFIRMED", failed: "FAILED" };
|
|
13
|
+
/** A refusal the agent can act on (wrong input, a limit, missing funds). Distinct from unexpected failures. */
|
|
14
|
+
export class GuardError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const bigintSafe = (v) => JSON.parse(JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x)));
|
|
22
|
+
export class Guard {
|
|
23
|
+
home;
|
|
24
|
+
config;
|
|
25
|
+
network;
|
|
26
|
+
executor;
|
|
27
|
+
ownerAddress;
|
|
28
|
+
provider;
|
|
29
|
+
reader;
|
|
30
|
+
journal;
|
|
31
|
+
now;
|
|
32
|
+
principal;
|
|
33
|
+
parts;
|
|
34
|
+
constructor(o = {}) {
|
|
35
|
+
this.home = o.home ?? upochHome();
|
|
36
|
+
this.config = loadConfig(this.home);
|
|
37
|
+
const secrets = loadSecrets(this.home);
|
|
38
|
+
this.network = networkInfo(this.config);
|
|
39
|
+
this.provider = o.provider ?? httpProvider(this.network.rpcUrl, { timeoutMs: 60_000 });
|
|
40
|
+
this.executor = new LocalKeySigner(secrets.executorKey, this.provider);
|
|
41
|
+
this.ownerAddress = new LocalKeySigner(secrets.ownerKey, this.provider).address;
|
|
42
|
+
this.reader = new EpochReader(this.provider, this.network.ledger);
|
|
43
|
+
this.journal = new FileJournal(journalPath(this.home));
|
|
44
|
+
this.now = o.now ?? (() => new Date());
|
|
45
|
+
this.principal = { kind: "agent", id: this.config.agentId };
|
|
46
|
+
// Built lazily (needs a registered workflow); the secrets stay inside these closures, never on `this`.
|
|
47
|
+
this.build = () => {
|
|
48
|
+
if (!this.config.workflowId)
|
|
49
|
+
throw new GuardError("NOT_REGISTERED", 'No workflow is registered yet. Run "upoch register" in your own terminal.');
|
|
50
|
+
const workflowId = BigInt(this.config.workflowId);
|
|
51
|
+
const chain = new RpcCheckpointChain({ provider: this.provider, ledger: this.network.ledger, workflowId, sender: this.executor, fromBlock: this.network.startBlock, pollMs: 1500, confirmationTimeoutMs: 240_000 });
|
|
52
|
+
const s = this.config.storage;
|
|
53
|
+
const storage = new ReplicatedStorage([
|
|
54
|
+
s.kind === "ipfs"
|
|
55
|
+
? new IpfsStorageAdapter({ apiUrl: s.apiUrl, gatewayUrl: s.gatewayUrl, authorization: secrets.ipfsAuthorization })
|
|
56
|
+
: new LocalStorageAdapter(s.dir),
|
|
57
|
+
]);
|
|
58
|
+
const encryption = new LocalAesGcmProvider(Buffer.from(secrets.encryptionKey, "hex"));
|
|
59
|
+
const serializer = new StateSerializer({ applicationVersion: `upoch-guard/${this.config.agentId}`, schemaVersion: 1 });
|
|
60
|
+
const gate = new ActionGate({ journal: this.journal });
|
|
61
|
+
const coordinator = new RecoveryCoordinator({ agentId: this.config.agentId, chain, storage, encryption, serializer, journal: this.journal, gate });
|
|
62
|
+
const manager = new CheckpointManager({
|
|
63
|
+
agentId: this.config.agentId, chain, storage, encryption, serializer,
|
|
64
|
+
policy: { checkpointPolicy: { afterConfirmedAction: true, beforeExternalAction: true, everySteps: 0 }, failurePolicy: "halt", partialEffects: "keep" },
|
|
65
|
+
model: { provider: "mcp", id: "agent-reported" },
|
|
66
|
+
verify: async (seq) => void (await coordinator.verifyCheckpoint(seq, this.principal)),
|
|
67
|
+
});
|
|
68
|
+
return { chain, coordinator, manager };
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
build;
|
|
72
|
+
get p() {
|
|
73
|
+
return (this.parts ??= this.build());
|
|
74
|
+
}
|
|
75
|
+
get workflowId() {
|
|
76
|
+
if (!this.config.workflowId)
|
|
77
|
+
throw new GuardError("NOT_REGISTERED", 'No workflow is registered yet. Run "upoch register" in your own terminal.');
|
|
78
|
+
return BigInt(this.config.workflowId);
|
|
79
|
+
}
|
|
80
|
+
session() {
|
|
81
|
+
return loadSession(this.home);
|
|
82
|
+
}
|
|
83
|
+
today() {
|
|
84
|
+
return this.now().toISOString().slice(0, 10);
|
|
85
|
+
}
|
|
86
|
+
openExecution() {
|
|
87
|
+
const s = this.session();
|
|
88
|
+
if (s.executionId)
|
|
89
|
+
return { executionId: s.executionId, nextSequence: s.nextSequence ?? 0 };
|
|
90
|
+
const fresh = { executionId: `exec_${randomBytes(12).toString("hex")}`, nextSequence: 0 };
|
|
91
|
+
saveSession({ ...s, ...fresh }, this.home);
|
|
92
|
+
return fresh;
|
|
93
|
+
}
|
|
94
|
+
/** Workflow, executor funds, the open execution and today's usage. Read-only. */
|
|
95
|
+
async status() {
|
|
96
|
+
const balance = BigInt((await this.provider.request({ method: "eth_getBalance", params: [this.executor.address, "latest"] })));
|
|
97
|
+
const s = this.session();
|
|
98
|
+
const usedToday = s.day === this.today() ? (s.checkpointsToday ?? 0) : 0;
|
|
99
|
+
const base = {
|
|
100
|
+
network: this.network.name,
|
|
101
|
+
chainId: this.network.chainId,
|
|
102
|
+
contract: this.network.ledger,
|
|
103
|
+
agentId: this.config.agentId,
|
|
104
|
+
executor: this.executor.address,
|
|
105
|
+
executorBalanceEth: (Number(balance) / 1e18).toFixed(8),
|
|
106
|
+
checkpointsToday: `${usedToday} of ${this.config.maxCheckpointsPerDay}`,
|
|
107
|
+
openExecution: s.executionId ?? null,
|
|
108
|
+
storage: this.config.storage.kind === "ipfs" ? "IPFS" : "local folder (development only)",
|
|
109
|
+
};
|
|
110
|
+
if (!this.config.workflowId)
|
|
111
|
+
return { ...base, workflow: null, next: 'Run "upoch register" in your own terminal.' };
|
|
112
|
+
const w = await this.reader.getWorkflow(this.workflowId);
|
|
113
|
+
return {
|
|
114
|
+
...base,
|
|
115
|
+
workflow: {
|
|
116
|
+
id: this.config.workflowId,
|
|
117
|
+
status: w.status,
|
|
118
|
+
checkpoints: w.checkpointCount.toString(),
|
|
119
|
+
headSequence: w.checkpointCount > 0n ? w.headSequence.toString() : null,
|
|
120
|
+
headHash: w.checkpointCount > 0n ? w.headHash : null,
|
|
121
|
+
},
|
|
122
|
+
...(this.network.debuggerUrl ? { debugger: this.network.debuggerUrl(this.config.workflowId) } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Commits a verified checkpoint of `state`. Refuses state that looks like it holds secrets. */
|
|
126
|
+
async checkpoint(input, executionStatus = "RUNNING") {
|
|
127
|
+
const found = findSecrets(input.state);
|
|
128
|
+
if (found.length > 0)
|
|
129
|
+
throw new GuardError("SECRET_IN_STATE", `Refused: the state looks like it contains secrets at ${found.slice(0, 5).join(", ")}. Remove keys, seed phrases, passwords and tokens from the state; checkpoints are permanent.`);
|
|
130
|
+
if (input.note && findSecrets({ note: input.note }).length > 0)
|
|
131
|
+
throw new GuardError("SECRET_IN_STATE", "Refused: the note looks like it contains a secret.");
|
|
132
|
+
const s = this.session();
|
|
133
|
+
const day = this.today();
|
|
134
|
+
const used = s.day === day ? (s.checkpointsToday ?? 0) : 0;
|
|
135
|
+
const cap = loadConfig(this.home).maxCheckpointsPerDay; // re-read: the owner can change it without a restart
|
|
136
|
+
if (used >= cap)
|
|
137
|
+
throw new GuardError("DAILY_LIMIT", `Daily limit reached: ${used} of ${cap} checkpoints today (UTC). The owner can raise maxCheckpointsPerDay in ${this.home}/config.json.`);
|
|
138
|
+
const gasPrice = BigInt((await this.provider.request({ method: "eth_gasPrice", params: [] })));
|
|
139
|
+
const balance = BigInt((await this.provider.request({ method: "eth_getBalance", params: [this.executor.address, "latest"] })));
|
|
140
|
+
if (balance < CHECKPOINT_GAS * gasPrice)
|
|
141
|
+
throw new GuardError("LOW_FUNDS", `The executor ${this.executor.address} has too little ETH for a checkpoint on ${this.network.name}. Ask the owner to send it a little ETH.`);
|
|
142
|
+
const exec = this.openExecution();
|
|
143
|
+
const actions = (await this.journal.list(exec.executionId)).map((r) => ({
|
|
144
|
+
operationId: r.operationId,
|
|
145
|
+
status: r.status,
|
|
146
|
+
...(r.transactionHash ? { transactionHash: r.transactionHash } : {}),
|
|
147
|
+
...(r.resultCommitment ? { resultHash: r.resultCommitment } : {}),
|
|
148
|
+
}));
|
|
149
|
+
const rec = await this.p.manager.create({
|
|
150
|
+
executionId: exec.executionId,
|
|
151
|
+
checkpointSequence: exec.nextSequence,
|
|
152
|
+
checkpointType: TYPES[input.kind ?? "progress"],
|
|
153
|
+
state: input.state,
|
|
154
|
+
context: input.note ? { note: input.note } : {},
|
|
155
|
+
actions,
|
|
156
|
+
executionStatus,
|
|
157
|
+
});
|
|
158
|
+
const committed = rec.status === "COMMITTED" || rec.status === "VERIFIED_RECOVERY_POINT";
|
|
159
|
+
if (committed) {
|
|
160
|
+
const after = this.session();
|
|
161
|
+
saveSession(executionStatus === "COMPLETED"
|
|
162
|
+
? { day, checkpointsToday: used + 1 }
|
|
163
|
+
: { ...after, executionId: exec.executionId, nextSequence: exec.nextSequence + 1, day, checkpointsToday: used + 1 }, this.home);
|
|
164
|
+
}
|
|
165
|
+
return bigintSafe({
|
|
166
|
+
status: rec.status,
|
|
167
|
+
verified: rec.status === "VERIFIED_RECOVERY_POINT",
|
|
168
|
+
sequence: rec.sequence,
|
|
169
|
+
checkpointId: rec.checkpointId,
|
|
170
|
+
transactionHash: rec.transactionHash,
|
|
171
|
+
executionId: exec.executionId,
|
|
172
|
+
executionStatus,
|
|
173
|
+
...(rec.error ? { error: rec.error } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** Records an external action (a swap, a transfer, an API call) so the next checkpoint and any recovery know about it. */
|
|
177
|
+
async recordAction(a) {
|
|
178
|
+
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(a.operationId))
|
|
179
|
+
throw new GuardError("INVALID_ARGUMENT", "operationId must be 1–128 characters: letters, digits, . _ : -");
|
|
180
|
+
if (a.transactionHash && !HASH.test(a.transactionHash))
|
|
181
|
+
throw new GuardError("INVALID_ARGUMENT", "transactionHash must be 0x followed by 64 hex digits");
|
|
182
|
+
const found = [...findSecrets(a.input ?? null), ...findSecrets(a.result ?? null)];
|
|
183
|
+
if (found.length > 0)
|
|
184
|
+
throw new GuardError("SECRET_IN_STATE", `Refused: the action looks like it contains secrets at ${found.slice(0, 5).join(", ")}.`);
|
|
185
|
+
const exec = this.openExecution();
|
|
186
|
+
const prev = await this.journal.get(a.operationId);
|
|
187
|
+
if (prev && prev.executionId !== exec.executionId)
|
|
188
|
+
throw new GuardError("INVALID_ARGUMENT", `operationId ${a.operationId} was already used in another execution; use a new one.`);
|
|
189
|
+
if (prev?.status === "CONFIRMED" && a.status !== "confirmed")
|
|
190
|
+
throw new GuardError("INVALID_ARGUMENT", `${a.operationId} is already confirmed; a confirmed action cannot change status.`);
|
|
191
|
+
const rec = {
|
|
192
|
+
operationId: a.operationId,
|
|
193
|
+
executionId: exec.executionId,
|
|
194
|
+
operationType: a.type,
|
|
195
|
+
inputCommitment: prev?.inputCommitment ?? keccak256(canonicalBytes((a.input ?? {}))),
|
|
196
|
+
attempt: prev?.attempt ?? 1,
|
|
197
|
+
status: STATUSES[a.status],
|
|
198
|
+
...(a.transactionHash ? { transactionHash: a.transactionHash.toLowerCase() } : prev?.transactionHash ? { transactionHash: prev.transactionHash } : {}),
|
|
199
|
+
...(a.result !== undefined ? { result: a.result, resultCommitment: keccak256(canonicalBytes(a.result)) } : {}),
|
|
200
|
+
compensation: "NONE",
|
|
201
|
+
updatedAt: this.now().getTime(),
|
|
202
|
+
};
|
|
203
|
+
await this.journal.put(rec);
|
|
204
|
+
return { recorded: true, operationId: a.operationId, status: a.status, executionId: exec.executionId, note: "Saved locally. The next checkpoint puts it on-chain." };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Restores the latest verified checkpoint (or `sequence`): checks it against the chain, decrypts it and
|
|
208
|
+
* reconciles recorded actions. Continues that execution when it was still running.
|
|
209
|
+
*/
|
|
210
|
+
async restore(o = {}) {
|
|
211
|
+
const w = await this.reader.getWorkflow(this.workflowId);
|
|
212
|
+
if (w.checkpointCount === 0n)
|
|
213
|
+
return { status: "NOTHING_TO_RESTORE", message: "This workflow has no checkpoints yet. Start fresh." };
|
|
214
|
+
const r = await this.p.coordinator.recover({ principal: this.principal, ...(o.sequence !== undefined ? { sequence: BigInt(o.sequence) } : {}), partialEffects: "keep" });
|
|
215
|
+
if (r.status === "FAILED" || !r.checkpoint)
|
|
216
|
+
return bigintSafe({ status: "FAILED", error: r.error });
|
|
217
|
+
const cp = r.checkpoint;
|
|
218
|
+
const isHead = cp.sequence === w.headSequence;
|
|
219
|
+
if (isHead) {
|
|
220
|
+
const s = this.session();
|
|
221
|
+
saveSession(cp.executionStatus === "RUNNING" ? { ...s, executionId: cp.manifest.executionId, nextSequence: cp.manifest.checkpointSequence + 1 } : { day: s.day, checkpointsToday: s.checkpointsToday }, this.home);
|
|
222
|
+
}
|
|
223
|
+
const note = cp.context?.note;
|
|
224
|
+
return bigintSafe({
|
|
225
|
+
status: r.status,
|
|
226
|
+
sequence: cp.sequence,
|
|
227
|
+
checkpointId: cp.checkpointId,
|
|
228
|
+
executionId: cp.manifest.executionId,
|
|
229
|
+
executionStatus: cp.executionStatus,
|
|
230
|
+
checkpointType: cp.manifest.checkpointType,
|
|
231
|
+
state: cp.state,
|
|
232
|
+
...(typeof note === "string" ? { note } : {}),
|
|
233
|
+
actions: cp.manifest.actions,
|
|
234
|
+
completedActions: r.completed.map((c) => c.operationId),
|
|
235
|
+
unresolvedActions: r.unresolved,
|
|
236
|
+
...(isHead ? {} : { warning: "This is an older checkpoint, read for inspection only. To go back to it and continue from it, call upoch_rollback with this sequence." }),
|
|
237
|
+
next: r.status === "HALTED_FOR_REVIEW"
|
|
238
|
+
? "Some actions have an unknown outcome. Check them (for example by transaction hash) before doing anything else, then record their real status."
|
|
239
|
+
: cp.executionStatus === "RUNNING"
|
|
240
|
+
? "Continue from this state. Do not repeat completed actions."
|
|
241
|
+
: "That run had finished. A new checkpoint starts a new run.",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Rolls the agent back to an earlier checkpoint and continues from it: verifies that checkpoint, then commits its
|
|
246
|
+
* state again as a new MANUAL checkpoint ("rolled back to #n"), so every later restore starts from it. The run
|
|
247
|
+
* continues as a new execution. On-chain effects since then (trades, transfers) are not undone; they are listed so
|
|
248
|
+
* the agent can account for them.
|
|
249
|
+
*/
|
|
250
|
+
async rollback(o) {
|
|
251
|
+
if (!o.reason?.trim())
|
|
252
|
+
throw new GuardError("INVALID_ARGUMENT", "Give a short reason for the rollback; it is saved in the checkpoint.");
|
|
253
|
+
const w = await this.reader.getWorkflow(this.workflowId);
|
|
254
|
+
if (w.checkpointCount === 0n)
|
|
255
|
+
throw new GuardError("NOTHING_TO_RESTORE", "This workflow has no checkpoints yet.");
|
|
256
|
+
const target = BigInt(o.sequence);
|
|
257
|
+
if (target >= w.checkpointCount)
|
|
258
|
+
throw new GuardError("CHECKPOINT_NOT_FOUND", `There is no checkpoint #${o.sequence}; the latest is #${w.headSequence}.`);
|
|
259
|
+
if (target === w.headSequence)
|
|
260
|
+
throw new GuardError("INVALID_ARGUMENT", `#${o.sequence} is already the latest checkpoint; use upoch_restore.`);
|
|
261
|
+
if (!(await this.p.coordinator.isCanonical(target)))
|
|
262
|
+
throw new GuardError("INVALID_CHECKPOINT_LINEAGE", `#${o.sequence} is not on this agent's current history.`);
|
|
263
|
+
let cp, head;
|
|
264
|
+
try {
|
|
265
|
+
[cp, head] = [await this.p.coordinator.verifyCheckpoint(target, this.principal), await this.p.coordinator.verifyCheckpoint(w.headSequence, this.principal)];
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
if (isEpochError(e))
|
|
269
|
+
throw new GuardError(e.code, `Cannot roll back: ${e.message}`);
|
|
270
|
+
throw e;
|
|
271
|
+
}
|
|
272
|
+
// Actions that settled after the target: rolling back the agent's memory does not reverse them.
|
|
273
|
+
const settledThen = new Set(cp.manifest.actions.filter((a) => a.status === "CONFIRMED").map((a) => a.operationId));
|
|
274
|
+
const since = new Map();
|
|
275
|
+
for (const a of [...head.manifest.actions, ...(await this.journal.list(head.manifest.executionId)).map((r) => ({ operationId: r.operationId, status: r.status, ...(r.transactionHash ? { transactionHash: r.transactionHash } : {}) }))]) {
|
|
276
|
+
if (!settledThen.has(a.operationId))
|
|
277
|
+
since.set(a.operationId, a);
|
|
278
|
+
}
|
|
279
|
+
// Start a new execution from the restored state, then make it the head.
|
|
280
|
+
const s = this.session();
|
|
281
|
+
saveSession({ day: s.day, checkpointsToday: s.checkpointsToday }, this.home);
|
|
282
|
+
const note = `Rolled back to checkpoint #${o.sequence}: ${o.reason.trim()}`.slice(0, 500);
|
|
283
|
+
const actionsSince = [...since.values()];
|
|
284
|
+
const state = o.annotate ? o.annotate(structuredClone(cp.state), actionsSince) : cp.state;
|
|
285
|
+
const committed = await this.checkpoint({ state, note, kind: "manual" });
|
|
286
|
+
if (committed.status !== "VERIFIED_RECOVERY_POINT" && committed.status !== "COMMITTED")
|
|
287
|
+
return { ...committed, rolledBackTo: o.sequence, state: undefined };
|
|
288
|
+
return bigintSafe({
|
|
289
|
+
status: "ROLLED_BACK",
|
|
290
|
+
rolledBackTo: cp.sequence,
|
|
291
|
+
newCheckpoint: committed.sequence,
|
|
292
|
+
transactionHash: committed.transactionHash,
|
|
293
|
+
executionId: committed.executionId,
|
|
294
|
+
state,
|
|
295
|
+
actionsSince,
|
|
296
|
+
next: actionsSince.length > 0
|
|
297
|
+
? "Continue from this state. These actions happened after that checkpoint and were NOT undone on-chain: check their effects (balances, positions) before acting."
|
|
298
|
+
: "Continue from this state.",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** The most recent checkpoints on-chain, newest first. Read-only. */
|
|
302
|
+
async history(o = {}) {
|
|
303
|
+
const w = await this.reader.getWorkflow(this.workflowId);
|
|
304
|
+
const total = w.checkpointCount;
|
|
305
|
+
const limit = BigInt(Math.min(Math.max(o.limit ?? 10, 1), 50));
|
|
306
|
+
const start = total > limit ? total - limit : 0n;
|
|
307
|
+
const rows = total === 0n ? [] : await this.reader.getCheckpoints(this.workflowId, start, total - start);
|
|
308
|
+
return bigintSafe({
|
|
309
|
+
workflowId: this.config.workflowId,
|
|
310
|
+
total,
|
|
311
|
+
checkpoints: rows
|
|
312
|
+
.map((c, i) => ({ sequence: start + BigInt(i), checkpointId: c.checkpointHash, parentSequence: c.parentHash === "0x" + "0".repeat(64) ? null : c.parentSequence, submitter: c.submitter, time: new Date(Number(c.timestamp) * 1000).toISOString(), head: start + BigInt(i) === w.headSequence }))
|
|
313
|
+
.reverse(),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/** Independently verifies one checkpoint without restoring it. Read-only. */
|
|
317
|
+
async verify(o) {
|
|
318
|
+
try {
|
|
319
|
+
const cp = await this.p.coordinator.verifyCheckpoint(BigInt(o.sequence), this.principal);
|
|
320
|
+
return bigintSafe({ ok: true, sequence: cp.sequence, checkpointId: cp.checkpointId, executionId: cp.manifest.executionId, checkpointType: cp.manifest.checkpointType, executionStatus: cp.executionStatus, actions: cp.manifest.actions.length, checks: "lineage, locator digest, manifest digest, payload digest, decryption, state hash and context hash all match" });
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
if (isEpochError(e))
|
|
324
|
+
return { ok: false, code: e.code, message: e.message };
|
|
325
|
+
throw e;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Final checkpoint marking the run finished; the next checkpoint starts a new execution. */
|
|
329
|
+
async complete(input) {
|
|
330
|
+
return this.checkpoint({ ...input, kind: "progress" }, "COMPLETED");
|
|
331
|
+
}
|
|
332
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Upoch Guard: checkpoint and recovery tools for any AI agent, as an MCP server or as a library for plugins. */
|
|
2
|
+
export { Guard, GuardError, type ActionStatus, type CheckpointKind, type GuardOptions } from "./guard.ts";
|
|
3
|
+
export { createServer, INSTRUCTIONS, VERSION } from "./server.ts";
|
|
4
|
+
export { initialize, register, registrationPlan, type InitOptions } from "./setup.ts";
|
|
5
|
+
export { upochHome, loadConfig, type UpochConfig, type StorageConfig, type NetworkName } from "./config.ts";
|
|
6
|
+
export { findSecrets } from "./secrets-scan.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Upoch Guard: checkpoint and recovery tools for any AI agent, as an MCP server or as a library for plugins. */
|
|
2
|
+
export { Guard, GuardError } from "./guard.js";
|
|
3
|
+
export { createServer, INSTRUCTIONS, VERSION } from "./server.js";
|
|
4
|
+
export { initialize, register, registrationPlan } from "./setup.js";
|
|
5
|
+
export { upochHome, loadConfig } from "./config.js";
|
|
6
|
+
export { findSecrets } from "./secrets-scan.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Refuses state that looks like it holds credentials before it becomes a permanent checkpoint. The same secret
|
|
2
|
+
// rules as the debugger's security scan (web/src/lib/debugger/scan.ts); returns JSON paths only, never values.
|
|
3
|
+
const SECRET_KEY = /(private[_-]?key|secret|seed|mnemonic|passw(or)?d|api[_-]?key|access[_-]?token|auth[_-]?token|bearer|credential)/i;
|
|
4
|
+
const HEX32 = /^(0x)?[0-9a-fA-F]{64}$/;
|
|
5
|
+
const API_KEYS = [
|
|
6
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}/,
|
|
7
|
+
/\bsk-(proj-)?[A-Za-z0-9_-]{32,}/,
|
|
8
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
9
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
|
|
10
|
+
/\bxox[abpr]-[A-Za-z0-9-]{10,}/,
|
|
11
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/,
|
|
12
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
|
|
13
|
+
/-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----/,
|
|
14
|
+
];
|
|
15
|
+
function looksLikeMnemonic(s) {
|
|
16
|
+
const words = s.trim().split(/\s+/);
|
|
17
|
+
return [12, 15, 18, 21, 24].includes(words.length) && words.every((w) => /^[a-z]{3,8}$/.test(w));
|
|
18
|
+
}
|
|
19
|
+
function walk(v, path, key, out) {
|
|
20
|
+
if (out.length >= 20)
|
|
21
|
+
return;
|
|
22
|
+
if (Array.isArray(v))
|
|
23
|
+
return v.forEach((x, i) => walk(x, `${path}[${i}]`, key, out));
|
|
24
|
+
if (v !== null && typeof v === "object") {
|
|
25
|
+
for (const [k, x] of Object.entries(v))
|
|
26
|
+
walk(x, path ? `${path}.${k}` : k, k, out);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (typeof v !== "string")
|
|
30
|
+
return;
|
|
31
|
+
const secret = (SECRET_KEY.test(key) && v.length >= 8) ||
|
|
32
|
+
(HEX32.test(v) && /key|priv|signer|wallet/i.test(key)) ||
|
|
33
|
+
looksLikeMnemonic(v) ||
|
|
34
|
+
API_KEYS.some((re) => re.test(v)) ||
|
|
35
|
+
/^https?:\/\/[^/\s:@]+:[^/\s@]+@/i.test(v) ||
|
|
36
|
+
/[?&](api[_-]?key|token|secret|access_token|key)=[^&\s]{8,}/i.test(v);
|
|
37
|
+
if (secret)
|
|
38
|
+
out.push(path || "(root)");
|
|
39
|
+
}
|
|
40
|
+
/** JSON paths whose values look like secrets (empty when none). */
|
|
41
|
+
export function findSecrets(value) {
|
|
42
|
+
const out = [];
|
|
43
|
+
walk(value, "state", "", out);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { Guard } from "./guard.ts";
|
|
3
|
+
export declare const VERSION = "0.1.1";
|
|
4
|
+
export declare const INSTRUCTIONS = "Upoch Guard saves verifiable checkpoints of your work on Robinhood Chain, so a crash, a bad prompt or a bad trade can be recovered from the last good state.\n\nWhen to use it:\n- At the start of a task, call upoch_restore. If a run was interrupted, continue from the returned state and do not repeat its completed actions.\n- After meaningful progress, call upoch_checkpoint with the state you would need to continue (plan, step, positions, results so far). Not after every small step: each checkpoint is an on-chain transaction.\n- Before a consequential external action (a swap, a transfer, a deployment), call upoch_record_action with status \"planned\" and then upoch_checkpoint with kind \"before_action\". After it settles, record it again as \"confirmed\" (with its transaction hash) or \"failed\".\n- If something went wrong (a bad trade, a bad instruction, corrupted state), use upoch_history to find the last good checkpoint and upoch_rollback to go back to it and continue. Rolling back restores your state; it does not undo on-chain transactions, so check the actions it lists.\n- When the task is finished, call upoch_complete.\n\nNever put private keys, seed phrases, passwords or API tokens in the state, notes or actions: checkpoints are permanent. Upoch refuses state that looks like it contains them. Setup (keys, registration, funding) is done by the user in their own terminal with the upoch command; never run \"upoch init\", \"upoch register\", \"upoch key\" or \"upoch export-owner\" yourself.";
|
|
5
|
+
export declare function createServer(guard: Guard | (() => Guard)): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The Upoch Guard MCP server: eight tools over a Guard. Read-only tools are marked so clients can run them
|
|
2
|
+
// without asking; checkpoint and complete send one on-chain transaction each.
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { Guard, GuardError } from "./guard.js";
|
|
6
|
+
export const VERSION = "0.1.1";
|
|
7
|
+
export const INSTRUCTIONS = `Upoch Guard saves verifiable checkpoints of your work on Robinhood Chain, so a crash, a bad prompt or a bad trade can be recovered from the last good state.
|
|
8
|
+
|
|
9
|
+
When to use it:
|
|
10
|
+
- At the start of a task, call upoch_restore. If a run was interrupted, continue from the returned state and do not repeat its completed actions.
|
|
11
|
+
- After meaningful progress, call upoch_checkpoint with the state you would need to continue (plan, step, positions, results so far). Not after every small step: each checkpoint is an on-chain transaction.
|
|
12
|
+
- Before a consequential external action (a swap, a transfer, a deployment), call upoch_record_action with status "planned" and then upoch_checkpoint with kind "before_action". After it settles, record it again as "confirmed" (with its transaction hash) or "failed".
|
|
13
|
+
- If something went wrong (a bad trade, a bad instruction, corrupted state), use upoch_history to find the last good checkpoint and upoch_rollback to go back to it and continue. Rolling back restores your state; it does not undo on-chain transactions, so check the actions it lists.
|
|
14
|
+
- When the task is finished, call upoch_complete.
|
|
15
|
+
|
|
16
|
+
Never put private keys, seed phrases, passwords or API tokens in the state, notes or actions: checkpoints are permanent. Upoch refuses state that looks like it contains them. Setup (keys, registration, funding) is done by the user in their own terminal with the upoch command; never run "upoch init", "upoch register", "upoch key" or "upoch export-owner" yourself.`;
|
|
17
|
+
async function run(f) {
|
|
18
|
+
try {
|
|
19
|
+
return { content: [{ type: "text", text: JSON.stringify(await f(), null, 2) }] };
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
const message = e instanceof GuardError ? `${e.code}: ${e.message}` : e instanceof Error ? e.message : String(e);
|
|
23
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
27
|
+
const writes = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
28
|
+
export function createServer(guard) {
|
|
29
|
+
let g = typeof guard === "function" ? undefined : guard;
|
|
30
|
+
// Loaded on first use, so the server starts (and explains what to do) even before "upoch init".
|
|
31
|
+
const get = () => (g ??= guard());
|
|
32
|
+
const server = new McpServer({ name: "upoch", title: "Upoch Guard", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
33
|
+
server.registerTool("upoch_status", { title: "Upoch status", description: "Shows the agent's Upoch workflow: network, checkpoint count and head, executor funds, the open run and today's checkpoint usage.", inputSchema: {}, annotations: readOnly }, () => run(() => get().status()));
|
|
34
|
+
server.registerTool("upoch_checkpoint", {
|
|
35
|
+
title: "Save a checkpoint",
|
|
36
|
+
description: "Saves the current state as an encrypted checkpoint (stored off-chain, committed on Robinhood Chain, then verified). Sends one transaction. Use after meaningful progress and before consequential actions.",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
state: z.unknown().describe("Everything needed to continue the task from here, as JSON. No secrets."),
|
|
39
|
+
note: z.string().max(500).optional().describe("A short note on what just happened, shown in the debugger."),
|
|
40
|
+
kind: z.enum(["progress", "before_action", "after_action", "manual"]).optional().describe('Default "progress". Use "before_action" right before a consequential action.'),
|
|
41
|
+
},
|
|
42
|
+
annotations: writes,
|
|
43
|
+
}, ({ state, note, kind }) => run(() => get().checkpoint({ state: state ?? null, note, kind })));
|
|
44
|
+
server.registerTool("upoch_record_action", {
|
|
45
|
+
title: "Record an action",
|
|
46
|
+
description: "Records an external action (swap, transfer, API call) in the local journal, so the next checkpoint and any recovery know whether it happened. Record it as planned before it runs, then as confirmed or failed.",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
operationId: z.string().describe("A unique id for this action, reused for each status update (e.g. swap-2026-11-05-1)."),
|
|
49
|
+
type: z.string().max(64).describe('What kind of action, e.g. "swap", "transfer", "deploy".'),
|
|
50
|
+
status: z.enum(["planned", "submitted", "confirmed", "failed"]),
|
|
51
|
+
transactionHash: z.string().optional().describe("The on-chain transaction hash, when there is one."),
|
|
52
|
+
input: z.unknown().optional().describe("The action's parameters, as JSON. No secrets."),
|
|
53
|
+
result: z.unknown().optional().describe("The outcome, as JSON, once known."),
|
|
54
|
+
},
|
|
55
|
+
annotations: { ...writes, openWorldHint: false },
|
|
56
|
+
}, (a) => run(() => get().recordAction(a)));
|
|
57
|
+
server.registerTool("upoch_restore", {
|
|
58
|
+
title: "Restore the last checkpoint",
|
|
59
|
+
description: "Restores the latest checkpoint after verifying it against the chain: returns its state, note and which recorded actions completed or are still unresolved. Call at the start of a task.",
|
|
60
|
+
inputSchema: { sequence: z.number().int().min(0).optional().describe("A specific checkpoint to read (default: the latest).") },
|
|
61
|
+
annotations: readOnly,
|
|
62
|
+
}, ({ sequence }) => run(() => get().restore({ sequence })));
|
|
63
|
+
server.registerTool("upoch_rollback", {
|
|
64
|
+
title: "Roll back to a checkpoint",
|
|
65
|
+
description: "Goes back to an earlier checkpoint and continues from it (for example after a bad trade or a bad prompt). Verifies it, saves it again as the latest checkpoint with your reason, and lists actions since then that were not undone on-chain. Sends one transaction.",
|
|
66
|
+
inputSchema: {
|
|
67
|
+
sequence: z.number().int().min(0).describe("The checkpoint to go back to (see upoch_history)."),
|
|
68
|
+
reason: z.string().min(1).max(300).describe("Why you are rolling back, saved in the checkpoint."),
|
|
69
|
+
},
|
|
70
|
+
annotations: writes,
|
|
71
|
+
}, ({ sequence, reason }) => run(() => get().rollback({ sequence, reason })));
|
|
72
|
+
server.registerTool("upoch_history", { title: "Checkpoint history", description: "Lists the most recent checkpoints on-chain, newest first.", inputSchema: { limit: z.number().int().min(1).max(50).optional() }, annotations: readOnly }, ({ limit }) => run(() => get().history({ limit })));
|
|
73
|
+
server.registerTool("upoch_verify", { title: "Verify a checkpoint", description: "Independently verifies one checkpoint (lineage, storage digests, decryption and state hash) without restoring it.", inputSchema: { sequence: z.number().int().min(0) }, annotations: readOnly }, ({ sequence }) => run(() => get().verify({ sequence })));
|
|
74
|
+
server.registerTool("upoch_complete", {
|
|
75
|
+
title: "Finish the run",
|
|
76
|
+
description: "Saves a final checkpoint marking the task finished, so the next start begins a new run instead of resuming. Sends one transaction.",
|
|
77
|
+
inputSchema: { state: z.unknown().describe("The final state, as JSON. No secrets."), note: z.string().max(500).optional() },
|
|
78
|
+
annotations: writes,
|
|
79
|
+
}, ({ state, note }) => run(() => get().complete({ state: state ?? null, note })));
|
|
80
|
+
return server;
|
|
81
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Eip1193Provider } from "@upoch/sdk";
|
|
2
|
+
import { type NetworkName, type StorageConfig, type UpochConfig } from "./config.ts";
|
|
3
|
+
export interface InitOptions {
|
|
4
|
+
home?: string;
|
|
5
|
+
network: NetworkName;
|
|
6
|
+
custom?: UpochConfig["custom"];
|
|
7
|
+
agentName: string;
|
|
8
|
+
storage: StorageConfig;
|
|
9
|
+
ipfsAuthorization?: string;
|
|
10
|
+
maxCheckpointsPerDay?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function slug(name: string): string;
|
|
13
|
+
/** Creates config.json and secrets.json. Never overwrites existing keys: losing them loses access to the checkpoints. */
|
|
14
|
+
export declare function initialize(o: InitOptions): {
|
|
15
|
+
home: string;
|
|
16
|
+
owner: string;
|
|
17
|
+
executor: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
};
|
|
20
|
+
/** What registration will cost, for the confirmation prompt. */
|
|
21
|
+
export declare function registrationPlan(o: {
|
|
22
|
+
home?: string;
|
|
23
|
+
fundEth: string;
|
|
24
|
+
provider?: Eip1193Provider;
|
|
25
|
+
}): Promise<{
|
|
26
|
+
network: string;
|
|
27
|
+
owner: string;
|
|
28
|
+
executor: string;
|
|
29
|
+
balance: bigint;
|
|
30
|
+
gasPrice: bigint;
|
|
31
|
+
fund: bigint;
|
|
32
|
+
cost: bigint;
|
|
33
|
+
enough: boolean;
|
|
34
|
+
}>;
|
|
35
|
+
/** Registers the workflow from the owner key and sends `fundEth` to the executor for checkpoint gas. */
|
|
36
|
+
export declare function register(o: {
|
|
37
|
+
home?: string;
|
|
38
|
+
fundEth: string;
|
|
39
|
+
provider?: Eip1193Provider;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
workflowId: string;
|
|
42
|
+
registerTx: `0x${string}`;
|
|
43
|
+
fundTx: `0x${string}` | undefined;
|
|
44
|
+
owner: `0x${string}`;
|
|
45
|
+
debugger: string | undefined;
|
|
46
|
+
}>;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// One-time setup the user runs in their own terminal: create keys, register a workflow, fund the executor.
|
|
2
|
+
// The owner key registers and owns the workflow (it approves rollbacks); the executor key is the agent's and only
|
|
3
|
+
// submits checkpoints. The contract requires them to be different accounts.
|
|
4
|
+
import { EpochReader, httpProvider } from "@upoch/sdk";
|
|
5
|
+
import { createPublicClient, createWalletClient, custom, decodeEventLog, defineChain, encodeFunctionData, parseAbi, parseEther } from "viem";
|
|
6
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
7
|
+
import { hasSetup, loadConfig, loadSecrets, networkInfo, newSecrets, saveConfig, saveSecrets, upochHome } from "./config.js";
|
|
8
|
+
const ABI = parseAbi([
|
|
9
|
+
"function registerWorkflow(address[] executors, address[] guardians, (uint8 guardianThreshold, bool ownerApprovalRequired, uint32 ownerFallbackDelay, uint32 timelock, uint32 approvalWindow, uint32 executionWindow) policy, string metadataURI) returns (uint256)",
|
|
10
|
+
"event WorkflowRegistered(uint256 indexed workflowId, address indexed owner, string metadataURI)",
|
|
11
|
+
]);
|
|
12
|
+
/** Owner approval alone, no timelock: the simplest policy the contract accepts (no guardians needed). */
|
|
13
|
+
const POLICY = { guardianThreshold: 0, ownerApprovalRequired: true, ownerFallbackDelay: 0, timelock: 0, approvalWindow: 86400, executionWindow: 86400 };
|
|
14
|
+
const REGISTER_GAS = 400000n;
|
|
15
|
+
const TRANSFER_GAS = 30000n;
|
|
16
|
+
export function slug(name) {
|
|
17
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "agent";
|
|
18
|
+
}
|
|
19
|
+
/** Creates config.json and secrets.json. Never overwrites existing keys: losing them loses access to the checkpoints. */
|
|
20
|
+
export function initialize(o) {
|
|
21
|
+
const home = o.home ?? upochHome();
|
|
22
|
+
if (hasSetup(home))
|
|
23
|
+
throw new Error(`Upoch is already set up in ${home}. Delete that folder only if you are sure you no longer need its keys.`);
|
|
24
|
+
if (o.storage.kind === "ipfs" && !o.storage.apiUrl)
|
|
25
|
+
throw new Error("IPFS storage needs an API URL");
|
|
26
|
+
if (o.storage.kind === "local" && !o.storage.dir)
|
|
27
|
+
throw new Error("local storage needs a folder");
|
|
28
|
+
const secrets = newSecrets(o.ipfsAuthorization);
|
|
29
|
+
const config = {
|
|
30
|
+
network: o.network,
|
|
31
|
+
...(o.custom ? { custom: o.custom } : {}),
|
|
32
|
+
agentId: `${slug(o.agentName)}-${secrets.executorKey.slice(-6)}`,
|
|
33
|
+
agentName: o.agentName,
|
|
34
|
+
storage: o.storage,
|
|
35
|
+
maxCheckpointsPerDay: o.maxCheckpointsPerDay ?? 100,
|
|
36
|
+
};
|
|
37
|
+
saveSecrets(secrets, home);
|
|
38
|
+
saveConfig(config, home);
|
|
39
|
+
return { home, owner: privateKeyToAccount(secrets.ownerKey).address.toLowerCase(), executor: privateKeyToAccount(secrets.executorKey).address.toLowerCase(), agentId: config.agentId };
|
|
40
|
+
}
|
|
41
|
+
function clients(config, key, provider) {
|
|
42
|
+
const n = networkInfo(config);
|
|
43
|
+
const chain = defineChain({ id: n.chainId, name: n.name, nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: { default: { http: [n.rpcUrl] } } });
|
|
44
|
+
const transport = custom(provider ?? httpProvider(n.rpcUrl, { timeoutMs: 60_000 }));
|
|
45
|
+
const account = privateKeyToAccount(key);
|
|
46
|
+
return { n, account, pub: createPublicClient({ chain, transport }), wallet: createWalletClient({ chain, transport, account }) };
|
|
47
|
+
}
|
|
48
|
+
function metadataURI(config) {
|
|
49
|
+
const json = JSON.stringify({ name: config.agentName, agentType: "AI agent (Upoch Guard)", description: `Checkpointed with Upoch Guard. Agent id ${config.agentId}.` });
|
|
50
|
+
const uri = `data:application/json;base64,${Buffer.from(json).toString("base64")}`;
|
|
51
|
+
if (uri.length > 512)
|
|
52
|
+
throw new Error("the agent name is too long for the on-chain metadata (512 bytes)");
|
|
53
|
+
return uri;
|
|
54
|
+
}
|
|
55
|
+
/** What registration will cost, for the confirmation prompt. */
|
|
56
|
+
export async function registrationPlan(o) {
|
|
57
|
+
const home = o.home ?? upochHome();
|
|
58
|
+
const config = loadConfig(home);
|
|
59
|
+
if (config.workflowId)
|
|
60
|
+
throw new Error(`Already registered as workflow #${config.workflowId}.`);
|
|
61
|
+
const secrets = loadSecrets(home);
|
|
62
|
+
const { n, account, pub } = clients(config, secrets.ownerKey, o.provider);
|
|
63
|
+
const chainId = await pub.getChainId();
|
|
64
|
+
if (chainId !== n.chainId)
|
|
65
|
+
throw new Error(`The RPC is chain ${chainId}, expected ${n.chainId}`);
|
|
66
|
+
const [balance, gasPrice] = await Promise.all([pub.getBalance({ address: account.address }), pub.getGasPrice()]);
|
|
67
|
+
const fund = parseEther(o.fundEth);
|
|
68
|
+
const cost = (REGISTER_GAS + (fund > 0n ? TRANSFER_GAS : 0n)) * gasPrice + fund;
|
|
69
|
+
return { network: n.name, owner: account.address.toLowerCase(), executor: privateKeyToAccount(secrets.executorKey).address.toLowerCase(), balance, gasPrice, fund, cost, enough: balance >= cost };
|
|
70
|
+
}
|
|
71
|
+
/** Registers the workflow from the owner key and sends `fundEth` to the executor for checkpoint gas. */
|
|
72
|
+
export async function register(o) {
|
|
73
|
+
const home = o.home ?? upochHome();
|
|
74
|
+
const config = loadConfig(home);
|
|
75
|
+
if (config.workflowId)
|
|
76
|
+
throw new Error(`Already registered as workflow #${config.workflowId}.`);
|
|
77
|
+
const secrets = loadSecrets(home);
|
|
78
|
+
const { n, pub, wallet } = clients(config, secrets.ownerKey, o.provider);
|
|
79
|
+
const executor = privateKeyToAccount(secrets.executorKey).address;
|
|
80
|
+
const data = encodeFunctionData({ abi: ABI, functionName: "registerWorkflow", args: [[executor], [], POLICY, metadataURI(config)] });
|
|
81
|
+
await pub.call({ account: wallet.account, to: n.ledger, data }); // simulate first: a rejection sends nothing
|
|
82
|
+
const hash = await wallet.sendTransaction({ to: n.ledger, data });
|
|
83
|
+
const receipt = await pub.waitForTransactionReceipt({ hash, timeout: 240_000 });
|
|
84
|
+
if (receipt.status !== "success")
|
|
85
|
+
throw new Error(`registration reverted: ${hash}`);
|
|
86
|
+
let workflowId;
|
|
87
|
+
for (const log of receipt.logs) {
|
|
88
|
+
if (log.address.toLowerCase() !== n.ledger)
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const ev = decodeEventLog({ abi: ABI, data: log.data, topics: log.topics });
|
|
92
|
+
if (ev.eventName === "WorkflowRegistered")
|
|
93
|
+
workflowId = ev.args.workflowId;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* another event */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (workflowId === undefined)
|
|
100
|
+
throw new Error(`no WorkflowRegistered event in ${hash}`);
|
|
101
|
+
config.workflowId = workflowId.toString();
|
|
102
|
+
saveConfig(config, home);
|
|
103
|
+
let fundTx;
|
|
104
|
+
const fund = parseEther(o.fundEth);
|
|
105
|
+
if (fund > 0n) {
|
|
106
|
+
fundTx = await wallet.sendTransaction({ to: executor, value: fund });
|
|
107
|
+
const r = await pub.waitForTransactionReceipt({ hash: fundTx, timeout: 240_000 });
|
|
108
|
+
if (r.status !== "success")
|
|
109
|
+
throw new Error(`funding the executor failed: ${fundTx}`);
|
|
110
|
+
}
|
|
111
|
+
const w = await new EpochReader(o.provider ?? httpProvider(n.rpcUrl), n.ledger).getWorkflow(workflowId);
|
|
112
|
+
return { workflowId: workflowId.toString(), registerTx: hash, fundTx, owner: w.owner, debugger: n.debuggerUrl?.(workflowId.toString()) };
|
|
113
|
+
}
|