@absol-labs/agent 0.9.1 → 0.10.0
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 +77 -3
- package/dist/cli/amounts.d.ts +26 -0
- package/dist/cli/amounts.d.ts.map +1 -0
- package/dist/cli/amounts.js +71 -0
- package/dist/cli/amounts.js.map +1 -0
- package/dist/cli/args.d.ts +32 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +119 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/attesters.d.ts +51 -0
- package/dist/cli/attesters.d.ts.map +1 -0
- package/dist/cli/attesters.js +58 -0
- package/dist/cli/attesters.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/commands/claim.d.ts +13 -0
- package/dist/cli/commands/claim.d.ts.map +1 -0
- package/dist/cli/commands/claim.js +86 -0
- package/dist/cli/commands/claim.js.map +1 -0
- package/dist/cli/commands/close.d.ts +3 -0
- package/dist/cli/commands/close.d.ts.map +1 -0
- package/dist/cli/commands/close.js +43 -0
- package/dist/cli/commands/close.js.map +1 -0
- package/dist/cli/commands/discover.d.ts +3 -0
- package/dist/cli/commands/discover.d.ts.map +1 -0
- package/dist/cli/commands/discover.js +183 -0
- package/dist/cli/commands/discover.js.map +1 -0
- package/dist/cli/commands/invoke.d.ts +3 -0
- package/dist/cli/commands/invoke.d.ts.map +1 -0
- package/dist/cli/commands/invoke.js +141 -0
- package/dist/cli/commands/invoke.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +3 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +113 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/open.d.ts +3 -0
- package/dist/cli/commands/open.d.ts.map +1 -0
- package/dist/cli/commands/open.js +279 -0
- package/dist/cli/commands/open.js.map +1 -0
- package/dist/cli/commands/reclaim-unverified.d.ts +3 -0
- package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -0
- package/dist/cli/commands/reclaim-unverified.js +164 -0
- package/dist/cli/commands/reclaim-unverified.js.map +1 -0
- package/dist/cli/commands/reclaim.d.ts +33 -0
- package/dist/cli/commands/reclaim.d.ts.map +1 -0
- package/dist/cli/commands/reclaim.js +297 -0
- package/dist/cli/commands/reclaim.js.map +1 -0
- package/dist/cli/commands/status.d.ts +3 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +43 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/streams.d.ts +3 -0
- package/dist/cli/commands/streams.d.ts.map +1 -0
- package/dist/cli/commands/streams.js +257 -0
- package/dist/cli/commands/streams.js.map +1 -0
- package/dist/cli/commands/watch.d.ts +3 -0
- package/dist/cli/commands/watch.d.ts.map +1 -0
- package/dist/cli/commands/watch.js +55 -0
- package/dist/cli/commands/watch.js.map +1 -0
- package/dist/cli/config.d.ts +61 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +140 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/context.d.ts +29 -0
- package/dist/cli/context.d.ts.map +1 -0
- package/dist/cli/context.js +2 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/deps.d.ts +145 -0
- package/dist/cli/deps.d.ts.map +1 -0
- package/dist/cli/deps.js +227 -0
- package/dist/cli/deps.js.map +1 -0
- package/dist/cli/descriptor.d.ts +61 -0
- package/dist/cli/descriptor.d.ts.map +1 -0
- package/dist/cli/descriptor.js +78 -0
- package/dist/cli/descriptor.js.map +1 -0
- package/dist/cli/errors.d.ts +48 -0
- package/dist/cli/errors.d.ts.map +1 -0
- package/dist/cli/errors.js +129 -0
- package/dist/cli/errors.js.map +1 -0
- package/dist/cli/escape-hatch.d.ts +50 -0
- package/dist/cli/escape-hatch.d.ts.map +1 -0
- package/dist/cli/escape-hatch.js +45 -0
- package/dist/cli/escape-hatch.js.map +1 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +7 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/listings.d.ts +15 -0
- package/dist/cli/listings.d.ts.map +1 -0
- package/dist/cli/listings.js +56 -0
- package/dist/cli/listings.js.map +1 -0
- package/dist/cli/mandate.d.ts +66 -0
- package/dist/cli/mandate.d.ts.map +1 -0
- package/dist/cli/mandate.js +61 -0
- package/dist/cli/mandate.js.map +1 -0
- package/dist/cli/output.d.ts +33 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +34 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/preflight.d.ts +38 -0
- package/dist/cli/preflight.d.ts.map +1 -0
- package/dist/cli/preflight.js +72 -0
- package/dist/cli/preflight.js.map +1 -0
- package/dist/cli/progress.d.ts +36 -0
- package/dist/cli/progress.d.ts.map +1 -0
- package/dist/cli/progress.js +40 -0
- package/dist/cli/progress.js.map +1 -0
- package/dist/cli/run.d.ts +25 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +173 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/scan-range.d.ts +55 -0
- package/dist/cli/scan-range.d.ts.map +1 -0
- package/dist/cli/scan-range.js +57 -0
- package/dist/cli/scan-range.js.map +1 -0
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +252 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/timeout.d.ts +30 -0
- package/dist/cli/timeout.d.ts.map +1 -0
- package/dist/cli/timeout.js +55 -0
- package/dist/cli/timeout.js.map +1 -0
- package/dist/cli/version.d.ts +12 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/cli/version.js +22 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli/wallet.d.ts +95 -0
- package/dist/cli/wallet.d.ts.map +1 -0
- package/dist/cli/wallet.js +169 -0
- package/dist/cli/wallet.js.map +1 -0
- package/dist/frameworks/crewai.d.ts +10 -0
- package/dist/frameworks/crewai.d.ts.map +1 -1
- package/dist/frameworks/crewai.js +12 -2
- package/dist/frameworks/crewai.js.map +1 -1
- package/dist/gateway/server-entry.d.ts +1 -0
- package/dist/gateway/server-entry.js +1 -0
- package/dist/gateway/server-entry.js.map +1 -1
- package/dist/mcp/http-server.d.ts +1 -0
- package/dist/mcp/http-server.js +1 -0
- package/dist/mcp/http-server.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +20 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/stdio.d.ts +1 -0
- package/dist/mcp/stdio.js +1 -0
- package/dist/mcp/stdio.js.map +1 -1
- package/dist/sdk/client.d.ts +25 -1
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +20 -1
- package/dist/sdk/client.js.map +1 -1
- package/docs/agent-layer.md +2 -1
- package/docs/cli.md +569 -0
- package/docs/crewai.md +6 -2
- package/docs/eliza.md +6 -1
- package/docs/mcp-hosted.md +3 -2
- package/docs/quickstart.md +18 -5
- package/package.json +17 -5
- package/src/cli/amounts.ts +102 -0
- package/src/cli/args.ts +150 -0
- package/src/cli/attesters.ts +86 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/commands/claim.ts +100 -0
- package/src/cli/commands/close.ts +53 -0
- package/src/cli/commands/discover.ts +228 -0
- package/src/cli/commands/invoke.ts +177 -0
- package/src/cli/commands/mcp.ts +135 -0
- package/src/cli/commands/open.ts +331 -0
- package/src/cli/commands/reclaim-unverified.ts +206 -0
- package/src/cli/commands/reclaim.ts +360 -0
- package/src/cli/commands/status.ts +56 -0
- package/src/cli/commands/streams.ts +344 -0
- package/src/cli/commands/watch.ts +70 -0
- package/src/cli/config.ts +208 -0
- package/src/cli/context.ts +30 -0
- package/src/cli/deps.ts +437 -0
- package/src/cli/descriptor.ts +133 -0
- package/src/cli/errors.ts +162 -0
- package/src/cli/escape-hatch.ts +92 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/listings.ts +77 -0
- package/src/cli/mandate.ts +126 -0
- package/src/cli/output.ts +63 -0
- package/src/cli/preflight.ts +88 -0
- package/src/cli/progress.ts +75 -0
- package/src/cli/run.ts +209 -0
- package/src/cli/scan-range.ts +76 -0
- package/src/cli/status.ts +381 -0
- package/src/cli/timeout.ts +64 -0
- package/src/cli/version.ts +21 -0
- package/src/cli/wallet.ts +241 -0
- package/src/frameworks/crewai.ts +13 -2
- package/src/gateway/server-entry.ts +1 -0
- package/src/mcp/http-server.ts +1 -0
- package/src/mcp/server.ts +25 -1
- package/src/mcp/stdio.ts +1 -0
- package/src/sdk/client.ts +33 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { BASE_SEPOLIA_ESCROW_V2 } from "@absol-labs/sdk";
|
|
2
|
+
import { getAddress, isAddress } from "viem";
|
|
3
|
+
|
|
4
|
+
import { integerFlag, stringFlag } from "../args.js";
|
|
5
|
+
import { formatUsdc } from "../amounts.js";
|
|
6
|
+
import {
|
|
7
|
+
BASE_SEPOLIA_ESCROW_V2_DEPLOY_BLOCK,
|
|
8
|
+
assertTestnetRpc,
|
|
9
|
+
} from "../config.js";
|
|
10
|
+
import { CliError, configError, usageError } from "../errors.js";
|
|
11
|
+
import { escrowScanRange } from "../scan-range.js";
|
|
12
|
+
import { optionalCliWallet, PRIVATE_KEY_ENV } from "../wallet.js";
|
|
13
|
+
import type { CommandContext, CommandSpec } from "../context.js";
|
|
14
|
+
import type {
|
|
15
|
+
CliReadClient,
|
|
16
|
+
OpenedStream,
|
|
17
|
+
StreamOpenedQuery,
|
|
18
|
+
} from "../deps.js";
|
|
19
|
+
|
|
20
|
+
/** Indexed-party filters, and the `--role` values that select them. */
|
|
21
|
+
const ROLES = ["buyer", "operator"] as const;
|
|
22
|
+
type Role = (typeof ROLES)[number];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How many `eth_getLogs` calls are in flight at once. The full history of the
|
|
26
|
+
* live escrow is ~106 windows x 2 role filters; firing those unbounded is how a
|
|
27
|
+
* public RPC starts answering 429, and a rate-limited window is a window this
|
|
28
|
+
* command has to report as unscanned.
|
|
29
|
+
*/
|
|
30
|
+
const SCAN_CONCURRENCY = 6;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Attempts per window before it is reported as unscanned. A public RPC answers
|
|
34
|
+
* a burst of `eth_getLogs` with an occasional 429 — measured against
|
|
35
|
+
* `sepolia.base.org`, 2 of 212 windows on a full-history scan — and a transient
|
|
36
|
+
* rate-limit is NOT a window that cannot be scanned. Declaring it one would
|
|
37
|
+
* make the "this scan is incomplete" warning routine, and a warning that fires
|
|
38
|
+
* on every healthy run is a warning nobody reads on the run that matters.
|
|
39
|
+
*/
|
|
40
|
+
const WINDOW_ATTEMPTS = 3;
|
|
41
|
+
const RETRY_DELAY_MS = 400;
|
|
42
|
+
|
|
43
|
+
export const streamsCommand: CommandSpec = {
|
|
44
|
+
name: "streams",
|
|
45
|
+
summary: "List the streams a wallet has opened, or been hired through",
|
|
46
|
+
usage:
|
|
47
|
+
"metrik streams [--address 0x…] [--role buyer|operator|any] [--limit N] [--from-block N]",
|
|
48
|
+
flags: ["address", "role", "limit", "from-block"],
|
|
49
|
+
signs: false,
|
|
50
|
+
async run(context: CommandContext): Promise<void> {
|
|
51
|
+
const { args, config, env, out, deps } = context;
|
|
52
|
+
|
|
53
|
+
const roleFlag = stringFlag(args, "role") ?? "any";
|
|
54
|
+
if (roleFlag !== "any" && !ROLES.includes(roleFlag as Role)) {
|
|
55
|
+
throw usageError(
|
|
56
|
+
`--role must be buyer, operator or any (got "${roleFlag}")`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const roles: readonly Role[] =
|
|
60
|
+
roleFlag === "any" ? ROLES : [roleFlag as Role];
|
|
61
|
+
const limit = integerFlag(args, "limit");
|
|
62
|
+
if (limit !== undefined && limit < 1) {
|
|
63
|
+
throw usageError("--limit must be at least 1");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const address = await resolveScanAddress(context);
|
|
67
|
+
const read = deps.createReadClient(config);
|
|
68
|
+
// The same allowlist the signing paths get. This command spends nothing,
|
|
69
|
+
// but it reads a V2 stream tuple, and the V1 and V2 tuples diverge from
|
|
70
|
+
// index 5 onward — a wrong-chain read does not revert, it decodes something
|
|
71
|
+
// else into fields with the right names.
|
|
72
|
+
await assertTestnetRpc(config, () => read.getChainId());
|
|
73
|
+
|
|
74
|
+
const anchor = resolveAnchorBlock(args, config.escrow);
|
|
75
|
+
const head = await read.blockNumber();
|
|
76
|
+
const range = escrowScanRange(head, anchor.block);
|
|
77
|
+
|
|
78
|
+
// Retention canary, before the scan is believed. A node that has pruned its
|
|
79
|
+
// log index answers a range it no longer retains with a SUCCESSFUL EMPTY
|
|
80
|
+
// result (measured on Base Sepolia: one public endpoint returned 0 logs for
|
|
81
|
+
// a window where two others returned 582). This CLI talks to ONE endpoint,
|
|
82
|
+
// so it cannot recover those logs from a sibling the way a multi-provider
|
|
83
|
+
// client can — but it can refuse to call the silence "no streams".
|
|
84
|
+
const retention =
|
|
85
|
+
anchor.canary === null
|
|
86
|
+
? null
|
|
87
|
+
: await read
|
|
88
|
+
.escrowLogCountAt(anchor.canary)
|
|
89
|
+
.then((count) => count > 0)
|
|
90
|
+
.catch(() => false);
|
|
91
|
+
|
|
92
|
+
const scan = await scanOpenedStreams(
|
|
93
|
+
read,
|
|
94
|
+
address,
|
|
95
|
+
roles,
|
|
96
|
+
range.windows,
|
|
97
|
+
deps.sleep,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const reasons: string[] = [];
|
|
101
|
+
if (range.degraded) {
|
|
102
|
+
reasons.push(
|
|
103
|
+
`the window budget could not reach block ${anchor.block} (scan starts at ${range.fromBlock})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (scan.failedWindows > 0) {
|
|
107
|
+
reasons.push(
|
|
108
|
+
`${scan.failedWindows} of ${range.windows.length * roles.length} log queries failed after ${WINDOW_ATTEMPTS} attempts each`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (retention === false) {
|
|
112
|
+
reasons.push(
|
|
113
|
+
`${config.rpcUrl} does not serve logs for block ${anchor.canary}, so it has pruned the escrow's history and its empty windows cannot be trusted`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const degraded = reasons.length > 0;
|
|
117
|
+
|
|
118
|
+
// Newest first: the stream someone is looking for is almost always the one
|
|
119
|
+
// they just opened.
|
|
120
|
+
const opened = [...scan.opened].sort((a, b) =>
|
|
121
|
+
a.blockNumber === b.blockNumber
|
|
122
|
+
? 0
|
|
123
|
+
: a.blockNumber > b.blockNumber
|
|
124
|
+
? -1
|
|
125
|
+
: 1,
|
|
126
|
+
);
|
|
127
|
+
const shown = limit === undefined ? opened : opened.slice(0, limit);
|
|
128
|
+
|
|
129
|
+
// One `getStream` per row. A list that cannot distinguish an active stream
|
|
130
|
+
// from a closed one is not actionable, and status is the one field the
|
|
131
|
+
// event cannot carry. The oracle's checkpoint proof is deliberately NOT
|
|
132
|
+
// fetched here — that is a per-stream HTTP call, and `metrik status` is the
|
|
133
|
+
// command that makes it.
|
|
134
|
+
const rows = await Promise.all(
|
|
135
|
+
shown.map(async (log) => {
|
|
136
|
+
const stream = await read.getStreamV2(log.streamId).catch(() => null);
|
|
137
|
+
return {
|
|
138
|
+
streamId: log.streamId,
|
|
139
|
+
role: describeRole(log, address),
|
|
140
|
+
buyer: log.buyer,
|
|
141
|
+
operator: log.operator,
|
|
142
|
+
serviceRef: log.serviceRef,
|
|
143
|
+
// V2 MUTATES `Stream.deposit` on reclaim, so the event is the only
|
|
144
|
+
// surviving record of what was originally escrowed.
|
|
145
|
+
originalDepositUsdc: log.deposit,
|
|
146
|
+
ratePerSecondUsdc: log.ratePerSecond,
|
|
147
|
+
expiresAt: log.expiresAt,
|
|
148
|
+
openedAtBlock: log.blockNumber,
|
|
149
|
+
// A read failure is reported as unread, never as a dropped row: a
|
|
150
|
+
// stream that vanishes from this list is the exact failure the whole
|
|
151
|
+
// command is built to avoid.
|
|
152
|
+
status: stream?.status ?? "unread",
|
|
153
|
+
reclaimed: stream?.reclaimed ?? null,
|
|
154
|
+
};
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
out.result({
|
|
159
|
+
address,
|
|
160
|
+
role: roleFlag,
|
|
161
|
+
escrow: config.escrow,
|
|
162
|
+
fromBlock: range.fromBlock,
|
|
163
|
+
toBlock: range.toBlock,
|
|
164
|
+
windows: range.windows.length,
|
|
165
|
+
logRetentionProven: retention,
|
|
166
|
+
degraded,
|
|
167
|
+
degradedReasons: reasons,
|
|
168
|
+
found: opened.length,
|
|
169
|
+
count: rows.length,
|
|
170
|
+
streams: rows,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (degraded) {
|
|
174
|
+
// stderr in both modes: a partial scan must reach someone piping stdout
|
|
175
|
+
// into jq just as loudly as someone reading the table.
|
|
176
|
+
out.note(
|
|
177
|
+
`warning: this scan is INCOMPLETE — ${reasons.join("; ")}. Streams may exist that are not listed below.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (rows.length === 0) {
|
|
182
|
+
out.print(
|
|
183
|
+
degraded
|
|
184
|
+
? `No streams found for ${address} in the part of the escrow's history that could be scanned. This is NOT a statement that none exist — see the warning above.`
|
|
185
|
+
: `No streams for ${address} across the escrow's full history (blocks ${range.fromBlock}-${range.toBlock}).`,
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
out.print(
|
|
191
|
+
`${rows.length} stream${rows.length === 1 ? "" : "s"} for ${address}${
|
|
192
|
+
opened.length > rows.length ? ` (of ${opened.length}, limited)` : ""
|
|
193
|
+
}`,
|
|
194
|
+
);
|
|
195
|
+
for (const row of rows) {
|
|
196
|
+
out.print("");
|
|
197
|
+
out.print(`${row.streamId} [${row.status}]`);
|
|
198
|
+
out.table([
|
|
199
|
+
["role", row.role],
|
|
200
|
+
["counterparty", row.role === "buyer" ? row.operator : row.buyer],
|
|
201
|
+
["serviceRef", row.serviceRef],
|
|
202
|
+
["escrowed", formatUsdc(row.originalDepositUsdc)],
|
|
203
|
+
["rate", `${formatUsdc(row.ratePerSecondUsdc)} / second`],
|
|
204
|
+
["opened at block", row.openedAtBlock.toString()],
|
|
205
|
+
[
|
|
206
|
+
"reclaimed",
|
|
207
|
+
row.reclaimed === null ? "unread" : String(row.reclaimed),
|
|
208
|
+
],
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
out.print("");
|
|
212
|
+
out.print(
|
|
213
|
+
"Earned-so-far is published by the oracle, not held on the stream: metrik status <streamId>",
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Whose streams to list: an explicit `--address`, else the configured wallet. */
|
|
219
|
+
async function resolveScanAddress(
|
|
220
|
+
context: CommandContext,
|
|
221
|
+
): Promise<`0x${string}`> {
|
|
222
|
+
const { args, env, out } = context;
|
|
223
|
+
const explicit = stringFlag(args, "address");
|
|
224
|
+
if (explicit !== undefined) {
|
|
225
|
+
if (!isAddress(explicit)) {
|
|
226
|
+
throw usageError(`--address must be an EVM address (got "${explicit}")`);
|
|
227
|
+
}
|
|
228
|
+
return getAddress(explicit) as `0x${string}`;
|
|
229
|
+
}
|
|
230
|
+
// This command broadcasts nothing and signs nothing, so it must not demand a
|
|
231
|
+
// wallet: `--address` is enough to read anyone's streams. A configured wallet
|
|
232
|
+
// is simply the default subject.
|
|
233
|
+
const wallet = await optionalCliWallet(env, { note: out.note });
|
|
234
|
+
if (wallet === null) {
|
|
235
|
+
throw configError(
|
|
236
|
+
"metrik streams needs a wallet to know whose streams to list",
|
|
237
|
+
`pass --address 0x… to list any address's streams without one, or set ${PRIVATE_KEY_ENV} (or the CDP variables).`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return wallet.account.address;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Where the log scan starts, and whether a retention canary is possible there.
|
|
245
|
+
*
|
|
246
|
+
* The canary block must be one that is KNOWN to carry escrow logs, otherwise a
|
|
247
|
+
* zero from it proves nothing and every scan would report itself degraded
|
|
248
|
+
* forever. That is only true of the deploy block of the escrow we shipped the
|
|
249
|
+
* constant for — so an overridden `--escrow` or `--from-block` scans without a
|
|
250
|
+
* canary rather than with a meaningless one.
|
|
251
|
+
*/
|
|
252
|
+
function resolveAnchorBlock(
|
|
253
|
+
args: CommandContext["args"],
|
|
254
|
+
escrow: `0x${string}`,
|
|
255
|
+
): { readonly block: bigint; readonly canary: bigint | null } {
|
|
256
|
+
const override = integerFlag(args, "from-block");
|
|
257
|
+
const isKnownEscrow =
|
|
258
|
+
escrow.toLowerCase() === BASE_SEPOLIA_ESCROW_V2.toLowerCase();
|
|
259
|
+
|
|
260
|
+
if (override !== undefined) {
|
|
261
|
+
return { block: BigInt(override), canary: null };
|
|
262
|
+
}
|
|
263
|
+
if (!isKnownEscrow) {
|
|
264
|
+
throw configError(
|
|
265
|
+
`no scan anchor is known for escrow ${escrow}`,
|
|
266
|
+
"the scan is anchored at the escrow's deploy block, and this CLI only ships that block for its default Base Sepolia escrow. Pass --from-block <deploy block> for a different escrow.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
block: BASE_SEPOLIA_ESCROW_V2_DEPLOY_BLOCK,
|
|
271
|
+
canary: BASE_SEPOLIA_ESCROW_V2_DEPLOY_BLOCK,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Page every window for every selected role filter, bounded concurrency.
|
|
277
|
+
*
|
|
278
|
+
* A window that ERRORS is counted, not thrown: a partial list with an honest
|
|
279
|
+
* warning is more useful than no list at all, and the count is what makes the
|
|
280
|
+
* warning specific.
|
|
281
|
+
*/
|
|
282
|
+
async function scanOpenedStreams(
|
|
283
|
+
read: CliReadClient,
|
|
284
|
+
address: `0x${string}`,
|
|
285
|
+
roles: readonly Role[],
|
|
286
|
+
windows: readonly (readonly [bigint, bigint])[],
|
|
287
|
+
sleep: (ms: number) => Promise<void>,
|
|
288
|
+
): Promise<{
|
|
289
|
+
readonly opened: readonly OpenedStream[];
|
|
290
|
+
readonly failedWindows: number;
|
|
291
|
+
}> {
|
|
292
|
+
// A wallet that is BOTH buyer and operator on one stream matches two filters.
|
|
293
|
+
const byStreamId = new Map<`0x${string}`, OpenedStream>();
|
|
294
|
+
let failedWindows = 0;
|
|
295
|
+
|
|
296
|
+
const readWindow = async (
|
|
297
|
+
query: StreamOpenedQuery,
|
|
298
|
+
): Promise<readonly OpenedStream[] | null> => {
|
|
299
|
+
for (let attempt = 1; attempt <= WINDOW_ATTEMPTS; attempt++) {
|
|
300
|
+
try {
|
|
301
|
+
return await read.streamOpenedLogs(query);
|
|
302
|
+
} catch {
|
|
303
|
+
if (attempt < WINDOW_ATTEMPTS) await sleep(RETRY_DELAY_MS * attempt);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const queries: StreamOpenedQuery[] = windows.flatMap(([fromBlock, toBlock]) =>
|
|
310
|
+
roles.map((role) => ({ role, address, fromBlock, toBlock })),
|
|
311
|
+
);
|
|
312
|
+
for (let index = 0; index < queries.length; index += SCAN_CONCURRENCY) {
|
|
313
|
+
const batch = queries.slice(index, index + SCAN_CONCURRENCY);
|
|
314
|
+
const settled = await Promise.all(batch.map(readWindow));
|
|
315
|
+
for (const logs of settled) {
|
|
316
|
+
if (logs === null) {
|
|
317
|
+
failedWindows++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
for (const log of logs) {
|
|
321
|
+
if (!byStreamId.has(log.streamId)) byStreamId.set(log.streamId, log);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { opened: [...byStreamId.values()], failedWindows };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function describeRole(log: OpenedStream, address: `0x${string}`): string {
|
|
330
|
+
const wanted = address.toLowerCase();
|
|
331
|
+
const isBuyer = log.buyer.toLowerCase() === wanted;
|
|
332
|
+
const isOperator = log.operator.toLowerCase() === wanted;
|
|
333
|
+
if (isBuyer && isOperator) return "buyer+operator";
|
|
334
|
+
if (isBuyer) return "buyer";
|
|
335
|
+
if (isOperator) return "operator";
|
|
336
|
+
// Only reachable if the node ignored the indexed topic filter it was given.
|
|
337
|
+
throw new CliError(
|
|
338
|
+
`the RPC returned stream ${log.streamId}, which does not involve ${address}`,
|
|
339
|
+
{
|
|
340
|
+
exitCode: 4,
|
|
341
|
+
hint: "the escrow log filter was not applied by the endpoint; the result cannot be trusted.",
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { integerFlag } from "../args.js";
|
|
2
|
+
import { assertTestnetRpc, parseBytes32 } from "../config.js";
|
|
3
|
+
import { describeError, usageError } from "../errors.js";
|
|
4
|
+
import {
|
|
5
|
+
readStreamReport,
|
|
6
|
+
streamReportJson,
|
|
7
|
+
streamReportRows,
|
|
8
|
+
} from "../status.js";
|
|
9
|
+
import type { CommandContext, CommandSpec } from "../context.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_INTERVAL_SECONDS = 15;
|
|
12
|
+
|
|
13
|
+
export const watchCommand: CommandSpec = {
|
|
14
|
+
name: "watch",
|
|
15
|
+
summary:
|
|
16
|
+
"Poll a stream's verified accrual until it is closed (Ctrl-C to stop)",
|
|
17
|
+
usage:
|
|
18
|
+
"metrik watch <streamId> [--interval 15] [--max-polls N (default: unbounded)]",
|
|
19
|
+
flags: ["interval", "max-polls"],
|
|
20
|
+
signs: false,
|
|
21
|
+
async run(context: CommandContext): Promise<void> {
|
|
22
|
+
const { args, config, out, deps } = context;
|
|
23
|
+
const [streamIdArg] = args.positionals;
|
|
24
|
+
if (streamIdArg === undefined) {
|
|
25
|
+
throw usageError("metrik watch requires a <streamId>");
|
|
26
|
+
}
|
|
27
|
+
const streamId = parseBytes32(streamIdArg, "<streamId>");
|
|
28
|
+
const interval = integerFlag(args, "interval") ?? DEFAULT_INTERVAL_SECONDS;
|
|
29
|
+
if (interval <= 0) {
|
|
30
|
+
throw usageError("--interval must be a positive number of seconds");
|
|
31
|
+
}
|
|
32
|
+
const maxPolls = integerFlag(args, "max-polls");
|
|
33
|
+
|
|
34
|
+
const read = deps.createReadClient(config);
|
|
35
|
+
// Same chain allowlist as the signing paths — see `status`.
|
|
36
|
+
await assertTestnetRpc(config, () => read.getChainId());
|
|
37
|
+
out.note(
|
|
38
|
+
`watching ${streamId} every ${interval}s — the oracle checkpoints roughly every 15-22s.`,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
for (let poll = 0; maxPolls === undefined || poll < maxPolls; poll++) {
|
|
42
|
+
if (poll > 0) await deps.sleep(interval * 1_000);
|
|
43
|
+
|
|
44
|
+
let report;
|
|
45
|
+
try {
|
|
46
|
+
report = await readStreamReport(read, streamId, deps.nowSeconds());
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// A transient RPC blip must not end a long watch.
|
|
49
|
+
out.note(`read failed, retrying: ${describeError(error)}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `--json` streams one COMPACT JSON object per poll — JSON Lines, as the
|
|
54
|
+
// docs promise. The pretty-printed form used by the single-shot commands
|
|
55
|
+
// would put one object across ~30 lines, none of which parses on its own,
|
|
56
|
+
// which is useless to the line-at-a-time consumer this mode exists for.
|
|
57
|
+
out.resultLine(streamReportJson(report));
|
|
58
|
+
out.print(
|
|
59
|
+
`[${new Date(report.nowSeconds * 1000).toISOString()}] ${streamId}`,
|
|
60
|
+
);
|
|
61
|
+
out.table(streamReportRows(report));
|
|
62
|
+
out.print("");
|
|
63
|
+
|
|
64
|
+
if (report.stream.status === "closed") {
|
|
65
|
+
out.note("stream is closed — watch complete.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BASE_SEPOLIA_ESCROW_V2,
|
|
3
|
+
BASE_SEPOLIA_USDC,
|
|
4
|
+
DEFAULT_ORACLE_BASE_URL,
|
|
5
|
+
DEFAULT_PUBLIC_REGISTRY_URL,
|
|
6
|
+
} from "@absol-labs/sdk";
|
|
7
|
+
import { isAddress, getAddress } from "viem";
|
|
8
|
+
import { baseSepolia } from "viem/chains";
|
|
9
|
+
|
|
10
|
+
import { CliError, configError, usageError } from "./errors.js";
|
|
11
|
+
import { stringFlag, integerFlag, type ParsedArgs } from "./args.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TESTNET ONLY — the single allowlist this CLI will settle against.
|
|
15
|
+
*
|
|
16
|
+
* Metrik's escrow moves real USDC the moment it is pointed at a mainnet chain,
|
|
17
|
+
* and this CLI signs with a raw private key read from the environment. So the
|
|
18
|
+
* chain is an allowlist, not a default: any other chain id is refused outright,
|
|
19
|
+
* with no override flag. There is deliberately no `--allow-mainnet` — an escape
|
|
20
|
+
* hatch that re-enables real funds would defeat the guarantee, and a mainnet
|
|
21
|
+
* deployment is not something a flag can conjure (there is none).
|
|
22
|
+
*
|
|
23
|
+
* The allowlist is enforced TWICE: once against the configured/declared chain
|
|
24
|
+
* id, and again against the chain id the RPC endpoint actually reports before
|
|
25
|
+
* any fund-moving command signs anything (see `assertTestnetRpc`). The second
|
|
26
|
+
* check is what stops `--chain-id 84532 --rpc-url <a mainnet RPC>`.
|
|
27
|
+
*/
|
|
28
|
+
export const TESTNET_CHAIN_IDS: readonly number[] = [baseSepolia.id];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The block `BASE_SEPOLIA_ESCROW_V2` was deployed at — the anchor of every
|
|
32
|
+
* escrow log scan, because the contract has no history before it.
|
|
33
|
+
*
|
|
34
|
+
* Verified against Base Sepolia on 2026-09-17: `eth_getLogs` for this single
|
|
35
|
+
* block returns 6 escrow logs (the deployment's own events). History is
|
|
36
|
+
* immutable, so that is a stable known answer forever, which is what lets the
|
|
37
|
+
* same block serve as the log-retention canary in `metrik streams`.
|
|
38
|
+
*
|
|
39
|
+
* It is tied to that ONE address. A run pointed at a different `--escrow` has
|
|
40
|
+
* no anchor and must be given `--from-block`, rather than silently scanning
|
|
41
|
+
* from a block that means nothing for it.
|
|
42
|
+
*/
|
|
43
|
+
export const BASE_SEPOLIA_ESCROW_V2_DEPLOY_BLOCK = 45_892_018n;
|
|
44
|
+
|
|
45
|
+
export interface CliConfig {
|
|
46
|
+
readonly chain: typeof baseSepolia;
|
|
47
|
+
readonly chainId: number;
|
|
48
|
+
readonly rpcUrl: string;
|
|
49
|
+
readonly escrow: `0x${string}`;
|
|
50
|
+
readonly usdc: `0x${string}`;
|
|
51
|
+
readonly oracleBaseUrl: string;
|
|
52
|
+
readonly registryUrl: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves settlement configuration from flags, then env, then the baked-in
|
|
57
|
+
* Base Sepolia defaults — so `metrik discover` works with nothing configured at
|
|
58
|
+
* all, while every value stays overridable.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveConfig(
|
|
61
|
+
args: ParsedArgs,
|
|
62
|
+
env: NodeJS.ProcessEnv,
|
|
63
|
+
): CliConfig {
|
|
64
|
+
const chainId =
|
|
65
|
+
integerFlag(args, "chain-id") ??
|
|
66
|
+
parseEnvChainId(env.METRIK_AGENT_CHAIN_ID) ??
|
|
67
|
+
baseSepolia.id;
|
|
68
|
+
assertTestnetChainId(chainId, "the configured chain id");
|
|
69
|
+
|
|
70
|
+
const rpcUrl =
|
|
71
|
+
stringFlag(args, "rpc-url") ??
|
|
72
|
+
env.METRIK_AGENT_RPC_URL ??
|
|
73
|
+
baseSepolia.rpcUrls.default.http[0];
|
|
74
|
+
if (rpcUrl === undefined || !isHttpUrl(rpcUrl)) {
|
|
75
|
+
throw configError(
|
|
76
|
+
`RPC URL must be an http(s) URL (got "${rpcUrl ?? "undefined"}")`,
|
|
77
|
+
"set --rpc-url or METRIK_AGENT_RPC_URL.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
chain: baseSepolia,
|
|
83
|
+
chainId,
|
|
84
|
+
rpcUrl,
|
|
85
|
+
escrow: resolveAddress(
|
|
86
|
+
stringFlag(args, "escrow") ?? env.METRIK_AGENT_ESCROW,
|
|
87
|
+
BASE_SEPOLIA_ESCROW_V2,
|
|
88
|
+
"escrow",
|
|
89
|
+
),
|
|
90
|
+
usdc: resolveAddress(
|
|
91
|
+
stringFlag(args, "usdc") ?? env.METRIK_AGENT_USDC,
|
|
92
|
+
BASE_SEPOLIA_USDC,
|
|
93
|
+
"usdc",
|
|
94
|
+
),
|
|
95
|
+
oracleBaseUrl: resolveHttpUrl(
|
|
96
|
+
stringFlag(args, "oracle-url") ?? env.METRIK_AGENT_ORACLE_URL,
|
|
97
|
+
DEFAULT_ORACLE_BASE_URL,
|
|
98
|
+
"oracle-url",
|
|
99
|
+
),
|
|
100
|
+
registryUrl: resolveHttpUrl(
|
|
101
|
+
stringFlag(args, "registry-url") ?? env.METRIK_AGENT_REGISTRY_URL,
|
|
102
|
+
DEFAULT_PUBLIC_REGISTRY_URL,
|
|
103
|
+
"registry-url",
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Flags every command accepts for settlement configuration. */
|
|
109
|
+
export const CONFIG_FLAGS: readonly string[] = [
|
|
110
|
+
"chain-id",
|
|
111
|
+
"rpc-url",
|
|
112
|
+
"escrow",
|
|
113
|
+
"usdc",
|
|
114
|
+
"oracle-url",
|
|
115
|
+
"registry-url",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/** Flags every command accepts for output shape. */
|
|
119
|
+
export const GLOBAL_FLAGS: readonly string[] = ["json", "help"];
|
|
120
|
+
|
|
121
|
+
export function assertTestnetChainId(chainId: number, source: string): void {
|
|
122
|
+
if (TESTNET_CHAIN_IDS.includes(chainId)) return;
|
|
123
|
+
throw configError(
|
|
124
|
+
`refusing to run against chain ${chainId}: ${source} is not a supported testnet`,
|
|
125
|
+
`this CLI signs with a raw private key and settles real value, so it is testnet-only. Supported: ${TESTNET_CHAIN_IDS.join(
|
|
126
|
+
", ",
|
|
127
|
+
)} (Base Sepolia). There is no override flag.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Second half of the testnet guard: the chain the RPC ACTUALLY serves. Called
|
|
133
|
+
* before any command that can move funds, because a declared chain id is just a
|
|
134
|
+
* number the caller typed — the endpoint is the thing that settles.
|
|
135
|
+
*/
|
|
136
|
+
export async function assertTestnetRpc(
|
|
137
|
+
config: CliConfig,
|
|
138
|
+
readChainId: () => Promise<number>,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
let observed: number;
|
|
141
|
+
try {
|
|
142
|
+
observed = await readChainId();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new CliError(`could not read the chain id from ${config.rpcUrl}`, {
|
|
145
|
+
exitCode: 3,
|
|
146
|
+
hint: "the RPC endpoint must be reachable before a fund-moving command runs.",
|
|
147
|
+
cause: error,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
assertTestnetChainId(observed, `the RPC endpoint ${config.rpcUrl}`);
|
|
151
|
+
if (observed !== config.chainId) {
|
|
152
|
+
throw configError(
|
|
153
|
+
`RPC chain id ${observed} does not match the configured chain id ${config.chainId}`,
|
|
154
|
+
"point --rpc-url at the configured chain, or fix --chain-id.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseEnvChainId(raw: string | undefined): number | undefined {
|
|
160
|
+
if (raw === undefined || raw.length === 0) return undefined;
|
|
161
|
+
if (!/^\d+$/.test(raw)) {
|
|
162
|
+
throw configError(
|
|
163
|
+
`METRIK_AGENT_CHAIN_ID must be a positive integer (got "${raw}")`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return Number(raw);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveAddress(
|
|
170
|
+
candidate: string | undefined,
|
|
171
|
+
fallback: `0x${string}`,
|
|
172
|
+
label: string,
|
|
173
|
+
): `0x${string}` {
|
|
174
|
+
if (candidate === undefined || candidate.length === 0) return fallback;
|
|
175
|
+
if (!isAddress(candidate)) {
|
|
176
|
+
throw configError(`--${label} must be an EVM address (got "${candidate}")`);
|
|
177
|
+
}
|
|
178
|
+
return getAddress(candidate) as `0x${string}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveHttpUrl(
|
|
182
|
+
candidate: string | undefined,
|
|
183
|
+
fallback: string,
|
|
184
|
+
label: string,
|
|
185
|
+
): string {
|
|
186
|
+
if (candidate === undefined || candidate.length === 0) return fallback;
|
|
187
|
+
if (!isHttpUrl(candidate)) {
|
|
188
|
+
throw configError(`--${label} must be an http(s) URL (got "${candidate}")`);
|
|
189
|
+
}
|
|
190
|
+
return candidate.replace(/\/+$/, "");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isHttpUrl(value: string): boolean {
|
|
194
|
+
try {
|
|
195
|
+
const url = new URL(value);
|
|
196
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Parses a `0x`-prefixed 32-byte hex id (stream id / serviceRef). */
|
|
203
|
+
export function parseBytes32(value: string, label: string): `0x${string}` {
|
|
204
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(value)) {
|
|
205
|
+
throw usageError(`${label} must be a 0x-prefixed 32-byte hex value`);
|
|
206
|
+
}
|
|
207
|
+
return value.toLowerCase() as `0x${string}`;
|
|
208
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ParsedArgs } from "./args.js";
|
|
2
|
+
import type { CliConfig } from "./config.js";
|
|
3
|
+
import type { CliDependencies } from "./deps.js";
|
|
4
|
+
import type { Printer } from "./output.js";
|
|
5
|
+
|
|
6
|
+
/** Everything a command needs, assembled once by the runner. */
|
|
7
|
+
export interface CommandContext {
|
|
8
|
+
readonly args: ParsedArgs;
|
|
9
|
+
readonly config: CliConfig;
|
|
10
|
+
readonly env: NodeJS.ProcessEnv;
|
|
11
|
+
readonly out: Printer;
|
|
12
|
+
readonly deps: CliDependencies;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommandSpec {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly summary: string;
|
|
18
|
+
readonly usage: string;
|
|
19
|
+
/** Command-specific flags, on top of the global + config flags. */
|
|
20
|
+
readonly flags: readonly string[];
|
|
21
|
+
/** True when the command broadcasts a transaction that can move funds. */
|
|
22
|
+
readonly signs: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* True when the command needs `METRIK_AGENT_PRIVATE_KEY` even though it
|
|
25
|
+
* broadcasts nothing — `invoke` signs an off-chain capability. Defaults to
|
|
26
|
+
* `signs`, since everything that broadcasts obviously needs a key.
|
|
27
|
+
*/
|
|
28
|
+
readonly requiresKey?: boolean;
|
|
29
|
+
readonly run: (context: CommandContext) => Promise<void>;
|
|
30
|
+
}
|