@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,360 @@
|
|
|
1
|
+
import { SettlementWindowOpenError } from "@absol-labs/sdk";
|
|
2
|
+
|
|
3
|
+
import { booleanFlag, integerFlag } from "../args.js";
|
|
4
|
+
import { formatUsdc } from "../amounts.js";
|
|
5
|
+
import { assertTestnetRpc, parseBytes32 } from "../config.js";
|
|
6
|
+
import { CliError, describeError, usageError } from "../errors.js";
|
|
7
|
+
import {
|
|
8
|
+
describeEscapeHatch,
|
|
9
|
+
escapeHatchWindow,
|
|
10
|
+
readChainNow,
|
|
11
|
+
} from "../escape-hatch.js";
|
|
12
|
+
import { signRecoveryMandate } from "../mandate.js";
|
|
13
|
+
import { assertCanPayGas } from "../preflight.js";
|
|
14
|
+
import { resolveCliWallet } from "../wallet.js";
|
|
15
|
+
import type { CommandContext, CommandSpec } from "../context.js";
|
|
16
|
+
import type { CliReadClient, CliWriteClient } from "../deps.js";
|
|
17
|
+
import type {
|
|
18
|
+
ReclaimVerifiedStreamResult,
|
|
19
|
+
StreamProofTransactionResult,
|
|
20
|
+
} from "../../sdk/client.js";
|
|
21
|
+
import type { StreamV2 } from "@absol-labs/sdk";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long to keep retrying a reclaim that is blocked only by an open
|
|
25
|
+
* settlement window. Defaults chosen to comfortably cover the oracle's ~15-22s
|
|
26
|
+
* checkpoint cadence: 12 x 10s = two minutes.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_ATTEMPTS = 12;
|
|
29
|
+
const DEFAULT_INTERVAL_SECONDS = 10;
|
|
30
|
+
|
|
31
|
+
export const reclaimCommand: CommandSpec = {
|
|
32
|
+
name: "reclaim",
|
|
33
|
+
summary: "Take back the budget that was never earned",
|
|
34
|
+
usage:
|
|
35
|
+
"metrik reclaim <streamId> [--close-first] [--attempts 12] [--interval 10]",
|
|
36
|
+
flags: ["close-first", "attempts", "interval"],
|
|
37
|
+
signs: true,
|
|
38
|
+
async run(context: CommandContext): Promise<void> {
|
|
39
|
+
const { args, config, env, out, deps } = context;
|
|
40
|
+
const [streamIdArg] = args.positionals;
|
|
41
|
+
if (streamIdArg === undefined) {
|
|
42
|
+
throw usageError("metrik reclaim requires a <streamId>");
|
|
43
|
+
}
|
|
44
|
+
const streamId = parseBytes32(streamIdArg, "<streamId>");
|
|
45
|
+
const closeFirst = booleanFlag(args, "close-first");
|
|
46
|
+
const attempts = integerFlag(args, "attempts") ?? DEFAULT_ATTEMPTS;
|
|
47
|
+
const interval = integerFlag(args, "interval") ?? DEFAULT_INTERVAL_SECONDS;
|
|
48
|
+
if (attempts < 1) throw usageError("--attempts must be at least 1");
|
|
49
|
+
if (interval < 1) throw usageError("--interval must be at least 1 second");
|
|
50
|
+
|
|
51
|
+
const { account } = await resolveCliWallet(env, { note: out.note });
|
|
52
|
+
const read = deps.createReadClient(config);
|
|
53
|
+
await assertTestnetRpc(config, () => read.getChainId());
|
|
54
|
+
await assertCanPayGas(read, account.address);
|
|
55
|
+
|
|
56
|
+
const write = deps.createWriteClient(config, account);
|
|
57
|
+
|
|
58
|
+
// `--close-first` is TWO transactions, and the second one only simulates
|
|
59
|
+
// successfully against post-close state. Sequencing them inside a single SDK
|
|
60
|
+
// call sent the reclaim simulation at a node that had not yet applied the
|
|
61
|
+
// close, and the command died with `StreamNotReclaimable()` having already
|
|
62
|
+
// spent the buyer's close — leaving the stream closed-but-unreclaimed, and
|
|
63
|
+
// the obvious retry dead-ending on `StreamClosedAlready()`. So the CLI owns
|
|
64
|
+
// the ordering: skip the close when the stream is already closed, and wait
|
|
65
|
+
// for the close to be VISIBLE to the node we are about to simulate against.
|
|
66
|
+
let closeResult: StreamProofTransactionResult | null = null;
|
|
67
|
+
let alreadyClosed = false;
|
|
68
|
+
if (closeFirst) {
|
|
69
|
+
const before = await read.getStreamV2(streamId).catch(() => null);
|
|
70
|
+
if (before !== null && before.status === "closed") {
|
|
71
|
+
alreadyClosed = true;
|
|
72
|
+
out.note(
|
|
73
|
+
"stream is already closed — skipping the close and going straight to reclaim.",
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
closeResult = await closeBeforeReclaim(write, {
|
|
77
|
+
streamId,
|
|
78
|
+
signedMandate: await signRecoveryMandate(account, config.chainId),
|
|
79
|
+
spentSoFarUsdc: 0n,
|
|
80
|
+
nowSeconds: deps.nowSeconds(),
|
|
81
|
+
});
|
|
82
|
+
if (closeResult === null) {
|
|
83
|
+
alreadyClosed = true;
|
|
84
|
+
out.note(
|
|
85
|
+
"the stream was already closed by the time the close was sent — going straight to reclaim.",
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
out.note(
|
|
89
|
+
`closed in ${closeResult.txHash} — waiting for it to land …`,
|
|
90
|
+
);
|
|
91
|
+
await awaitClosed(read, streamId, deps.sleep, closeResult.txHash);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result = await reclaimWithBoundedRetry(
|
|
97
|
+
write,
|
|
98
|
+
async () => ({
|
|
99
|
+
streamId,
|
|
100
|
+
signedMandate: await signRecoveryMandate(account, config.chainId),
|
|
101
|
+
spentSoFarUsdc: 0n,
|
|
102
|
+
nowSeconds: deps.nowSeconds(),
|
|
103
|
+
}),
|
|
104
|
+
{
|
|
105
|
+
attempts,
|
|
106
|
+
intervalMs: interval * 1_000,
|
|
107
|
+
sleep: deps.sleep,
|
|
108
|
+
onRetry: (attempt, reason) =>
|
|
109
|
+
out.note(
|
|
110
|
+
`reclaim blocked: no checkpoint covers the stream's stop time yet (attempt ${attempt}/${attempts}) — ${reason}`,
|
|
111
|
+
),
|
|
112
|
+
// "Retry later" is the wrong advice when the oracle is gone: every
|
|
113
|
+
// attempt this command makes needs a proof FROM that oracle. So the
|
|
114
|
+
// give-up message names the path that needs no proof, and says whether
|
|
115
|
+
// it is open yet.
|
|
116
|
+
exhaustedHint: async () =>
|
|
117
|
+
await describeLastResort(read, streamId, deps.nowSeconds()),
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Read the settled figure back only once the node we are reading from has
|
|
122
|
+
// actually applied the reclaim. `sepolia.base.org` is load-balanced, so an
|
|
123
|
+
// immediate read can land on a lagged node and report `settledCumulative: 0`
|
|
124
|
+
// for a stream that just settled a non-zero amount — printing "settled to
|
|
125
|
+
// operator 0 USDC" when it was not zero. Poll for `reclaimed` (which the
|
|
126
|
+
// transaction we just landed sets) so the number beside it is from the same
|
|
127
|
+
// post-reclaim state. Bounded, and falls back to the last value read.
|
|
128
|
+
const stream = await readSettledStream(read, streamId, deps.sleep);
|
|
129
|
+
out.result({
|
|
130
|
+
streamId,
|
|
131
|
+
txHash: result.reclaimResult.txHash,
|
|
132
|
+
closeTxHash: closeResult?.txHash ?? null,
|
|
133
|
+
// `--close-first` on a stream that was already closed is a no-op, not a
|
|
134
|
+
// failure. Machine consumers need to tell that apart from "no close was
|
|
135
|
+
// asked for".
|
|
136
|
+
closeSkippedAlreadyClosed: closeFirst ? alreadyClosed : null,
|
|
137
|
+
settledCumulative: stream?.settledCumulative ?? null,
|
|
138
|
+
});
|
|
139
|
+
out.print("Reclaimed.");
|
|
140
|
+
out.table([
|
|
141
|
+
["streamId", streamId],
|
|
142
|
+
["reclaim tx", result.reclaimResult.txHash],
|
|
143
|
+
...(closeResult === null
|
|
144
|
+
? alreadyClosed
|
|
145
|
+
? ([["close", "skipped — already closed"]] as const)
|
|
146
|
+
: []
|
|
147
|
+
: ([["close tx", closeResult.txHash]] as const)),
|
|
148
|
+
...(stream === null
|
|
149
|
+
? []
|
|
150
|
+
: ([
|
|
151
|
+
["settled to operator", formatUsdc(stream.settledCumulative)],
|
|
152
|
+
] as const)),
|
|
153
|
+
]);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Send the close that `--close-first` asks for, tolerating the one race that
|
|
159
|
+
* matters: a stale read said "active" but the stream was in fact already closed.
|
|
160
|
+
* That is the state the caller wanted, so it is reported as `null` (no close
|
|
161
|
+
* sent) rather than as a failure — re-running the command must never dead-end on
|
|
162
|
+
* `StreamClosedAlready()` while the buyer's money is still reclaimable.
|
|
163
|
+
*
|
|
164
|
+
* Every OTHER close failure is rethrown untouched.
|
|
165
|
+
*/
|
|
166
|
+
export async function closeBeforeReclaim(
|
|
167
|
+
write: CliWriteClient,
|
|
168
|
+
input: Parameters<CliWriteClient["closeStream"]>[0],
|
|
169
|
+
): Promise<StreamProofTransactionResult | null> {
|
|
170
|
+
try {
|
|
171
|
+
return await write.closeStream(input);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (isAlreadyClosed(error)) return null;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** True when `error`, or anything in its cause chain, is StreamClosedAlready. */
|
|
179
|
+
function isAlreadyClosed(error: unknown): boolean {
|
|
180
|
+
for (let current: unknown = error, depth = 0; depth < 8; depth++) {
|
|
181
|
+
if (!(current instanceof Error)) return false;
|
|
182
|
+
if (current.message.includes("StreamClosedAlready")) return true;
|
|
183
|
+
if (current.cause === undefined) return false;
|
|
184
|
+
current = current.cause;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Block until the node we read from reports the stream as closed.
|
|
191
|
+
*
|
|
192
|
+
* The close transaction's own receipt is awaited inside the SDK, but
|
|
193
|
+
* `sepolia.base.org` is load-balanced: the receipt can come back from one node
|
|
194
|
+
* while the very next `simulateContract` lands on another that has not imported
|
|
195
|
+
* the block yet. That node simulates `reclaim` against PRE-close state and
|
|
196
|
+
* reverts `StreamNotReclaimable()`. Confirming the close through the same read
|
|
197
|
+
* path the reclaim will use is what closes that window.
|
|
198
|
+
*/
|
|
199
|
+
const CLOSE_VISIBLE_ATTEMPTS = 20;
|
|
200
|
+
const CLOSE_VISIBLE_INTERVAL_MS = 500;
|
|
201
|
+
|
|
202
|
+
export async function awaitClosed(
|
|
203
|
+
read: Pick<CliReadClient, "getStreamV2">,
|
|
204
|
+
streamId: `0x${string}`,
|
|
205
|
+
sleep: (ms: number) => Promise<void>,
|
|
206
|
+
closeTxHash: string,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
for (let attempt = 1; attempt <= CLOSE_VISIBLE_ATTEMPTS; attempt += 1) {
|
|
209
|
+
const stream = await read.getStreamV2(streamId).catch(() => null);
|
|
210
|
+
if (stream !== null && stream.status === "closed") return;
|
|
211
|
+
if (attempt < CLOSE_VISIBLE_ATTEMPTS)
|
|
212
|
+
await sleep(CLOSE_VISIBLE_INTERVAL_MS);
|
|
213
|
+
}
|
|
214
|
+
throw new CliError(
|
|
215
|
+
`the close landed in ${closeTxHash} but the RPC endpoint still reports the stream as active`,
|
|
216
|
+
{
|
|
217
|
+
exitCode: 4,
|
|
218
|
+
hint: `the close is not lost and neither is the money: re-run \`metrik reclaim ${streamId} --close-first\` once the endpoint has caught up — it will skip the close and reclaim.`,
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* `reclaimWithProof` reverts with `SettlementWindowOpen` until the oracle has
|
|
225
|
+
* published a checkpoint whose `checkedAt` is at or after the stream's stop
|
|
226
|
+
* time. That is not an error condition — it is the normal ordering of a
|
|
227
|
+
* checkpoint rail, and it happens on the FIRST attempt essentially every time.
|
|
228
|
+
* A CLI that surfaced the raw revert would teach every user that reclaim is
|
|
229
|
+
* broken, so the wait is built in and narrated. Any other failure is rethrown
|
|
230
|
+
* immediately: only an open settlement window is retried.
|
|
231
|
+
*/
|
|
232
|
+
export async function reclaimWithBoundedRetry(
|
|
233
|
+
write: CliWriteClient,
|
|
234
|
+
buildInput: () => Promise<Parameters<CliWriteClient["reclaimStream"]>[0]>,
|
|
235
|
+
options: {
|
|
236
|
+
readonly attempts: number;
|
|
237
|
+
readonly intervalMs: number;
|
|
238
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
239
|
+
readonly onRetry: (attempt: number, reason: string) => void;
|
|
240
|
+
/** What to tell the buyer once the budget is spent. See `describeLastResort`. */
|
|
241
|
+
readonly exhaustedHint?: () => Promise<string>;
|
|
242
|
+
},
|
|
243
|
+
): Promise<ReclaimVerifiedStreamResult> {
|
|
244
|
+
let lastError: unknown;
|
|
245
|
+
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
|
246
|
+
try {
|
|
247
|
+
return await write.reclaimStream(await buildInput());
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (!isSettlementWindowOpen(error)) throw explainReclaimFailure(error);
|
|
250
|
+
lastError = error;
|
|
251
|
+
if (attempt === options.attempts) break;
|
|
252
|
+
options.onRetry(attempt, describeError(error));
|
|
253
|
+
await options.sleep(options.intervalMs);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
throw new CliError(
|
|
257
|
+
`reclaim is still blocked after ${options.attempts} attempt${options.attempts === 1 ? "" : "s"}: the oracle has not published a checkpoint covering this stream's stop time`,
|
|
258
|
+
{
|
|
259
|
+
exitCode: 4,
|
|
260
|
+
hint:
|
|
261
|
+
(await options.exhaustedHint?.().catch(() => null)) ??
|
|
262
|
+
DEFAULT_EXHAUSTED_HINT,
|
|
263
|
+
cause: lastError,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const DEFAULT_EXHAUSTED_HINT =
|
|
269
|
+
"the funds are not lost — retry later, or raise --attempts / --interval. If the oracle stays down, `metrik reclaim-unverified` takes them back with no proof once the escape hatch opens.";
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* What to do when the proof-based path has run out of attempts.
|
|
273
|
+
*
|
|
274
|
+
* Every reclaim this command can perform goes through `reclaimWithProof`, which
|
|
275
|
+
* needs a Merkle proof fetched from the oracle — so a buyer whose oracle is gone
|
|
276
|
+
* can retry this command forever and never get their money. The escrow's
|
|
277
|
+
* `reclaimUnverified` is the path that does not need the oracle, and the only
|
|
278
|
+
* question that matters is whether its window has opened yet. That is fully
|
|
279
|
+
* computable: `expiresAt + escapeGraceSeconds` against the chain's clock.
|
|
280
|
+
*/
|
|
281
|
+
async function describeLastResort(
|
|
282
|
+
read: CliReadClient,
|
|
283
|
+
streamId: `0x${string}`,
|
|
284
|
+
fallbackNowSeconds: number,
|
|
285
|
+
): Promise<string> {
|
|
286
|
+
const [stream, grace] = await Promise.all([
|
|
287
|
+
read.getStreamV2(streamId),
|
|
288
|
+
read.escapeGraceSeconds(),
|
|
289
|
+
]);
|
|
290
|
+
const now = await readChainNow(read, fallbackNowSeconds);
|
|
291
|
+
const window = escapeHatchWindow(stream, grace, now.seconds);
|
|
292
|
+
const escapeCommand = `metrik reclaim-unverified ${streamId} --forfeit-unclaimed`;
|
|
293
|
+
return window.ready
|
|
294
|
+
? `the funds are not lost. The no-proof escape hatch is OPEN (${describeEscapeHatch(window)}): \`${escapeCommand}\` takes them back without the oracle — at the cost of forfeiting whatever the operator earned but has not yet claimed. Retrying this command only helps if the oracle comes back.`
|
|
295
|
+
: `the funds are not lost, but retrying only helps if the oracle comes back — this path needs a proof from it. The no-proof escape hatch ${describeEscapeHatch(window)}; from then on \`${escapeCommand}\` takes them back without the oracle, forfeiting whatever the operator earned but has not yet claimed.`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `StreamNotReclaimable()` means exactly one thing: the stream is neither closed
|
|
300
|
+
* nor past its expiry. Say that, and say which command fixes it, instead of
|
|
301
|
+
* handing the reader a selector.
|
|
302
|
+
*/
|
|
303
|
+
function explainReclaimFailure(error: unknown): unknown {
|
|
304
|
+
if (!(error instanceof Error)) return error;
|
|
305
|
+
for (let current: unknown = error, depth = 0; depth < 8; depth++) {
|
|
306
|
+
if (!(current instanceof Error)) break;
|
|
307
|
+
if (current.message.includes("StreamNotReclaimable")) {
|
|
308
|
+
return new CliError(
|
|
309
|
+
"the stream is still running, so there is nothing to reclaim yet",
|
|
310
|
+
{
|
|
311
|
+
exitCode: 4,
|
|
312
|
+
hint: "stop the clock first — `metrik close <streamId>`, or re-run this command with --close-first — or wait for the stream to expire.",
|
|
313
|
+
cause: error,
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (current.cause === undefined) break;
|
|
318
|
+
current = current.cause;
|
|
319
|
+
}
|
|
320
|
+
return error;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** True when `error`, or anything in its cause chain, is SettlementWindowOpen. */
|
|
324
|
+
function isSettlementWindowOpen(error: unknown): boolean {
|
|
325
|
+
for (let current = error, depth = 0; depth < 8; depth++) {
|
|
326
|
+
if (current instanceof SettlementWindowOpenError) return true;
|
|
327
|
+
if (!(current instanceof Error) || current.cause === undefined)
|
|
328
|
+
return false;
|
|
329
|
+
current = current.cause;
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Bounded poll for the post-reclaim stream state. Returns as soon as the node
|
|
336
|
+
* reports `reclaimed`, or the last successful read once attempts are exhausted
|
|
337
|
+
* (never throws — the reclaim already succeeded and a display read must not
|
|
338
|
+
* turn that into a command failure).
|
|
339
|
+
*/
|
|
340
|
+
const SETTLED_READ_ATTEMPTS = 5;
|
|
341
|
+
// Deliberately sub-second and distinct from the retry interval (whole seconds,
|
|
342
|
+
// `--interval`), so the two waits are tellable apart in tests and in logs.
|
|
343
|
+
const SETTLED_READ_INTERVAL_MS = 500;
|
|
344
|
+
|
|
345
|
+
async function readSettledStream(
|
|
346
|
+
read: { getStreamV2(streamId: `0x${string}`): Promise<StreamV2> },
|
|
347
|
+
streamId: `0x${string}`,
|
|
348
|
+
sleep: (ms: number) => Promise<void>,
|
|
349
|
+
): Promise<StreamV2 | null> {
|
|
350
|
+
let last: StreamV2 | null = null;
|
|
351
|
+
for (let attempt = 1; attempt <= SETTLED_READ_ATTEMPTS; attempt += 1) {
|
|
352
|
+
const stream = await read.getStreamV2(streamId).catch(() => null);
|
|
353
|
+
if (stream !== null) {
|
|
354
|
+
last = stream;
|
|
355
|
+
if (stream.reclaimed) return stream;
|
|
356
|
+
}
|
|
357
|
+
if (attempt < SETTLED_READ_ATTEMPTS) await sleep(SETTLED_READ_INTERVAL_MS);
|
|
358
|
+
}
|
|
359
|
+
return last;
|
|
360
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { attesterDisclosure } from "../attesters.js";
|
|
2
|
+
import { assertTestnetRpc, parseBytes32 } from "../config.js";
|
|
3
|
+
import { 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
|
+
export const statusCommand: CommandSpec = {
|
|
12
|
+
name: "status",
|
|
13
|
+
summary: "Show a stream's on-chain state and its oracle-verified accrual",
|
|
14
|
+
usage: "metrik status <streamId>",
|
|
15
|
+
flags: [],
|
|
16
|
+
signs: false,
|
|
17
|
+
async run(context: CommandContext): Promise<void> {
|
|
18
|
+
const { args, config, out, deps } = context;
|
|
19
|
+
const [streamIdArg] = args.positionals;
|
|
20
|
+
if (streamIdArg === undefined) {
|
|
21
|
+
throw usageError("metrik status requires a <streamId>");
|
|
22
|
+
}
|
|
23
|
+
const streamId = parseBytes32(streamIdArg, "<streamId>");
|
|
24
|
+
|
|
25
|
+
const read = deps.createReadClient(config);
|
|
26
|
+
// Reads get the same chain allowlist as the signing paths. A read pointed at
|
|
27
|
+
// the wrong chain does not lose money, but the V1 and V2 stream tuples
|
|
28
|
+
// diverge from index 5 onward, so a wrong-chain read does not revert — it
|
|
29
|
+
// silently decodes something else into fields with the right names.
|
|
30
|
+
await assertTestnetRpc(config, () => read.getChainId());
|
|
31
|
+
const report = await readStreamReport(read, streamId, deps.nowSeconds());
|
|
32
|
+
|
|
33
|
+
out.result(streamReportJson(report));
|
|
34
|
+
// Who attests, before the numbers. Printed on stderr so it survives `--json`
|
|
35
|
+
// and cannot be lost by a consumer that only parses stdout.
|
|
36
|
+
const disclosure = attesterDisclosure(
|
|
37
|
+
report.attesters,
|
|
38
|
+
report.stream.operator,
|
|
39
|
+
);
|
|
40
|
+
if (disclosure !== null) out.note(disclosure);
|
|
41
|
+
out.print(`Stream ${streamId}`);
|
|
42
|
+
out.table(streamReportRows(report));
|
|
43
|
+
if (report.settlement.pending) {
|
|
44
|
+
out.print("");
|
|
45
|
+
out.print(
|
|
46
|
+
"This stream has stopped but its final checkpoint has not landed, so there is no settled reclaimable figure yet — reclaim reverts until the oracle publishes a checkpoint reaching the stop time. `metrik reclaim <streamId>` waits for exactly that.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (report.proof === null) {
|
|
50
|
+
out.print("");
|
|
51
|
+
out.print(
|
|
52
|
+
"No checkpoint covers this stream yet. Accrual is published by the oracle roughly every 15-22s; funds are earned only for intervals that verified.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|