@toon-protocol/rig 2.0.0 → 2.1.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 +10 -3
- package/dist/chunk-3EKP7PMM.js +727 -0
- package/dist/chunk-3EKP7PMM.js.map +1 -0
- package/dist/{chunk-QD437XAW.js → chunk-CW4HJNMU.js} +2 -8
- package/dist/chunk-CW4HJNMU.js.map +1 -0
- package/dist/chunk-O6TXHKWG.js +217 -0
- package/dist/chunk-O6TXHKWG.js.map +1 -0
- package/dist/{chunk-G4W4MH6G.js → chunk-PLKZAUTG.js} +2 -1
- package/dist/chunk-PLKZAUTG.js.map +1 -0
- package/dist/cli/rig.js +1286 -398
- package/dist/cli/rig.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{publisher-CClD9OIZ.d.ts → publisher-Cdr1H1hg.d.ts} +1 -1
- package/dist/standalone/index.d.ts +404 -3
- package/dist/standalone/index.js +16 -2
- package/dist/standalone-mode-FCKTQ33Y.js +595 -0
- package/dist/standalone-mode-FCKTQ33Y.js.map +1 -0
- package/package.json +3 -11
- package/dist/chunk-G4W4MH6G.js.map +0 -1
- package/dist/chunk-QD437XAW.js.map +0 -1
- package/dist/chunk-XRRU6YBQ.js +0 -402
- package/dist/chunk-XRRU6YBQ.js.map +0 -1
- package/dist/standalone-mode-CAYWOURK.js +0 -111
- package/dist/standalone-mode-CAYWOURK.js.map +0 -1
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordKey
|
|
3
|
+
} from "./chunk-O6TXHKWG.js";
|
|
4
|
+
import {
|
|
5
|
+
MAX_OBJECT_SIZE
|
|
6
|
+
} from "./chunk-X2CZPPDM.js";
|
|
7
|
+
|
|
8
|
+
// src/standalone/nonce-guard.ts
|
|
9
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
var DEFAULT_DAEMON_PORT = 8787;
|
|
13
|
+
function defaultDaemonPort() {
|
|
14
|
+
const env = process.env["TOON_CLIENT_HTTP_PORT"];
|
|
15
|
+
const parsed = env ? Number(env) : NaN;
|
|
16
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_DAEMON_PORT;
|
|
17
|
+
}
|
|
18
|
+
function defaultLockDir() {
|
|
19
|
+
return process.env["TOON_CLIENT_HOME"] ?? join(homedir(), ".toon-client");
|
|
20
|
+
}
|
|
21
|
+
var DaemonIdentityConflictError = class extends Error {
|
|
22
|
+
constructor(pubkey, daemonUrl) {
|
|
23
|
+
super(
|
|
24
|
+
`toon-clientd is running with this identity (${pubkey.slice(0, 8)}\u2026) at ${daemonUrl} \u2014 use daemon mode or stop the daemon. Two writers on one identity would race the payment channel's cumulative-claim watermark (double-charge hazard).`
|
|
25
|
+
);
|
|
26
|
+
this.pubkey = pubkey;
|
|
27
|
+
this.daemonUrl = daemonUrl;
|
|
28
|
+
this.name = "DaemonIdentityConflictError";
|
|
29
|
+
}
|
|
30
|
+
pubkey;
|
|
31
|
+
daemonUrl;
|
|
32
|
+
};
|
|
33
|
+
var StandaloneLockError = class extends Error {
|
|
34
|
+
constructor(pubkey, lockPath, holderPid) {
|
|
35
|
+
super(
|
|
36
|
+
`another standalone process (pid ${holderPid}) already holds the payment-channel lock for identity ${pubkey.slice(0, 8)}\u2026 (${lockPath}) \u2014 wait for it to finish or stop it. Two writers on one identity would race the cumulative-claim watermark.`
|
|
37
|
+
);
|
|
38
|
+
this.pubkey = pubkey;
|
|
39
|
+
this.lockPath = lockPath;
|
|
40
|
+
this.holderPid = holderPid;
|
|
41
|
+
this.name = "StandaloneLockError";
|
|
42
|
+
}
|
|
43
|
+
pubkey;
|
|
44
|
+
lockPath;
|
|
45
|
+
holderPid;
|
|
46
|
+
};
|
|
47
|
+
async function checkDaemonIdentity(pubkey, options = {}) {
|
|
48
|
+
const port = options.port ?? defaultDaemonPort();
|
|
49
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
50
|
+
const url = `http://127.0.0.1:${port}/status`;
|
|
51
|
+
let daemonPubkey;
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetchImpl(url, {
|
|
54
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 1500)
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) return;
|
|
57
|
+
const body = await res.json();
|
|
58
|
+
const candidate = body?.identity?.nostrPubkey;
|
|
59
|
+
if (typeof candidate === "string") daemonPubkey = candidate;
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (daemonPubkey !== void 0 && daemonPubkey === pubkey) {
|
|
64
|
+
throw new DaemonIdentityConflictError(pubkey, url);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function pidAlive(pid) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return err.code === "EPERM";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
var NonceLock = class _NonceLock {
|
|
76
|
+
constructor(pubkey, lockPath) {
|
|
77
|
+
this.pubkey = pubkey;
|
|
78
|
+
this.lockPath = lockPath;
|
|
79
|
+
this.exitHandler = () => {
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(this.lockPath);
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
process.once("exit", this.exitHandler);
|
|
86
|
+
}
|
|
87
|
+
pubkey;
|
|
88
|
+
lockPath;
|
|
89
|
+
released = false;
|
|
90
|
+
exitHandler;
|
|
91
|
+
static async acquire(pubkey, options = {}) {
|
|
92
|
+
const dir = options.dir ?? defaultLockDir();
|
|
93
|
+
const pid = options.pid ?? process.pid;
|
|
94
|
+
const lockPath = join(dir, `standalone-${pubkey}.lock`);
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const payload = JSON.stringify(
|
|
97
|
+
{
|
|
98
|
+
pid,
|
|
99
|
+
pubkey,
|
|
100
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
101
|
+
},
|
|
102
|
+
null,
|
|
103
|
+
2
|
|
104
|
+
);
|
|
105
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(lockPath, payload, { flag: "wx" });
|
|
108
|
+
return new _NonceLock(pubkey, lockPath);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err.code !== "EEXIST") throw err;
|
|
111
|
+
let holderPid;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(
|
|
114
|
+
readFileSync(lockPath, "utf8")
|
|
115
|
+
);
|
|
116
|
+
if (typeof parsed.pid === "number") holderPid = parsed.pid;
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
if (holderPid !== void 0 && holderPid !== pid && pidAlive(holderPid)) {
|
|
120
|
+
throw new StandaloneLockError(pubkey, lockPath, holderPid);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(lockPath);
|
|
124
|
+
} catch {
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new StandaloneLockError(pubkey, lockPath, -1);
|
|
129
|
+
}
|
|
130
|
+
/** Remove the lockfile and detach the exit hook. Idempotent. */
|
|
131
|
+
release() {
|
|
132
|
+
if (this.released) return;
|
|
133
|
+
this.released = true;
|
|
134
|
+
process.removeListener("exit", this.exitHandler);
|
|
135
|
+
try {
|
|
136
|
+
unlinkSync(this.lockPath);
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/standalone/standalone-publisher.ts
|
|
143
|
+
import { ToonClient, parseFulfillHttp } from "@toon-protocol/client";
|
|
144
|
+
var StandalonePublishError = class extends Error {
|
|
145
|
+
constructor(message) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = "StandalonePublishError";
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
function deriveRouteDestinations(anchor) {
|
|
151
|
+
const segs = anchor.split(".");
|
|
152
|
+
if (segs.at(-1) === "store" && segs.at(-2) === "relay") {
|
|
153
|
+
const base = segs.slice(0, -2).join(".");
|
|
154
|
+
return { publish: `${base}.relay`, store: `${base}.store` };
|
|
155
|
+
}
|
|
156
|
+
return { publish: anchor, store: anchor };
|
|
157
|
+
}
|
|
158
|
+
var ARWEAVE_TX_ID_REGEX = /^[A-Za-z0-9_-]{43}$/;
|
|
159
|
+
function extractArweaveTxId(base64Data) {
|
|
160
|
+
const http = parseFulfillHttp(base64Data);
|
|
161
|
+
if (!http.isHttp) {
|
|
162
|
+
const legacy = Buffer.from(base64Data, "base64").toString("utf8");
|
|
163
|
+
if (!ARWEAVE_TX_ID_REGEX.test(legacy)) {
|
|
164
|
+
throw new StandalonePublishError(
|
|
165
|
+
`FULFILL data is not a valid Arweave tx ID: "${legacy}"`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return legacy;
|
|
169
|
+
}
|
|
170
|
+
if (http.status < 200 || http.status >= 300) {
|
|
171
|
+
throw new StandalonePublishError(
|
|
172
|
+
`git-object upload failed: store returned HTTP ${http.status}` + (http.body ? ` - ${http.body}` : "")
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(http.body);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new StandalonePublishError(
|
|
180
|
+
`git-object upload response body was not valid JSON: "${http.body}"`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (parsed.accept === false) {
|
|
184
|
+
const reason = typeof parsed.error === "string" ? `: ${parsed.error}` : "";
|
|
185
|
+
throw new StandalonePublishError(
|
|
186
|
+
`git-object upload rejected by store (accept:false)${reason}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (typeof parsed.txId === "string" && ARWEAVE_TX_ID_REGEX.test(parsed.txId)) {
|
|
190
|
+
return parsed.txId;
|
|
191
|
+
}
|
|
192
|
+
if (typeof parsed.data === "string" && parsed.data.length > 0) {
|
|
193
|
+
const decoded = Buffer.from(parsed.data, "base64").toString("utf8");
|
|
194
|
+
if (ARWEAVE_TX_ID_REGEX.test(decoded)) return decoded;
|
|
195
|
+
}
|
|
196
|
+
throw new StandalonePublishError(
|
|
197
|
+
`git-object upload response did not contain a valid Arweave tx ID: "${http.body}"`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
function channelInternals(client) {
|
|
201
|
+
const c = client;
|
|
202
|
+
const negotiations = c.peerNegotiations instanceof Map ? c.peerNegotiations : void 0;
|
|
203
|
+
const cm = c.channelManager && typeof c.channelManager.trackChannel === "function" && c.channelManager.peerChannels instanceof Map ? c.channelManager : void 0;
|
|
204
|
+
const onChain = c.onChainChannelClient && c.onChainChannelClient.channelContext instanceof Map ? c.onChainChannelClient : void 0;
|
|
205
|
+
return {
|
|
206
|
+
...negotiations ? { peerNegotiations: negotiations } : {},
|
|
207
|
+
...cm ? { channelManager: cm } : {},
|
|
208
|
+
...onChain ? { onChainChannelClient: onChain } : {}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
var StandalonePublisher = class {
|
|
212
|
+
client;
|
|
213
|
+
ownsClient;
|
|
214
|
+
publishDestination;
|
|
215
|
+
storeDestination;
|
|
216
|
+
channelDestination;
|
|
217
|
+
eventFee;
|
|
218
|
+
uploadFeePerByte;
|
|
219
|
+
daemonPort;
|
|
220
|
+
lockDir;
|
|
221
|
+
fetchImpl;
|
|
222
|
+
channelMap;
|
|
223
|
+
/** ILP anchor the channel is keyed by in the map (peer/apex destination). */
|
|
224
|
+
channelAnchor;
|
|
225
|
+
negotiationFallbacks;
|
|
226
|
+
warn;
|
|
227
|
+
lock;
|
|
228
|
+
channelId;
|
|
229
|
+
readyPromise;
|
|
230
|
+
/** Guard + client start only (no channel) — see {@link startClientOnly}. */
|
|
231
|
+
clientReadyPromise;
|
|
232
|
+
/** True when the last start() RESUMED the recorded channel (#263). */
|
|
233
|
+
lastOpenResumed = false;
|
|
234
|
+
constructor(options) {
|
|
235
|
+
if (options.client && options.clientConfig) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
"StandalonePublisher: provide either `clientConfig` or `client`, not both"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (options.client) {
|
|
241
|
+
this.client = options.client;
|
|
242
|
+
this.ownsClient = false;
|
|
243
|
+
} else if (options.clientConfig) {
|
|
244
|
+
this.client = new ToonClient(options.clientConfig);
|
|
245
|
+
this.ownsClient = true;
|
|
246
|
+
} else {
|
|
247
|
+
throw new Error(
|
|
248
|
+
"StandalonePublisher: one of `clientConfig` (mnemonic-based ToonClient config) or `client` is required"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const anchor = options.channelDestination ?? options.clientConfig?.destinationAddress;
|
|
252
|
+
const routes = anchor ? deriveRouteDestinations(anchor) : void 0;
|
|
253
|
+
this.publishDestination = options.publishDestination ?? routes?.publish;
|
|
254
|
+
this.storeDestination = options.storeDestination ?? routes?.store;
|
|
255
|
+
this.channelDestination = options.channelDestination;
|
|
256
|
+
this.eventFee = options.eventFee ?? 1n;
|
|
257
|
+
this.uploadFeePerByte = options.uploadFeePerByte ?? 10n;
|
|
258
|
+
this.daemonPort = options.daemonPort;
|
|
259
|
+
this.lockDir = options.lockDir;
|
|
260
|
+
this.fetchImpl = options.fetchImpl;
|
|
261
|
+
this.channelMap = options.channelMap;
|
|
262
|
+
this.channelAnchor = anchor;
|
|
263
|
+
this.negotiationFallbacks = options.negotiationFallbacks;
|
|
264
|
+
this.warn = options.warn ?? ((line) => process.stderr.write(`${line}
|
|
265
|
+
`));
|
|
266
|
+
}
|
|
267
|
+
/** Hex Nostr pubkey of the embedded identity (available before start). */
|
|
268
|
+
getPublicKey() {
|
|
269
|
+
return this.client.getPublicKey();
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Run the nonce guard, start the embedded client, and open (or resume) the
|
|
273
|
+
* payment channel. Called lazily by the first paid operation; safe to call
|
|
274
|
+
* eagerly to fail fast. Idempotent.
|
|
275
|
+
*/
|
|
276
|
+
start() {
|
|
277
|
+
this.readyPromise ??= (async () => {
|
|
278
|
+
await this.startClientOnly();
|
|
279
|
+
try {
|
|
280
|
+
this.channelId = await this.openOrResumeChannel();
|
|
281
|
+
} catch (err) {
|
|
282
|
+
this.lock?.release();
|
|
283
|
+
this.lock = void 0;
|
|
284
|
+
this.clientReadyPromise = void 0;
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
})().catch((err) => {
|
|
288
|
+
this.readyPromise = void 0;
|
|
289
|
+
throw err;
|
|
290
|
+
});
|
|
291
|
+
return this.readyPromise;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Run the nonce guard and start the embedded client WITHOUT touching the
|
|
295
|
+
* payment channel (#263): `channel close`/`settle` operate on a RECORDED
|
|
296
|
+
* channel and must never open a fresh one as a side effect of starting.
|
|
297
|
+
* Idempotent; `start()` layers the channel open/resume on top of this.
|
|
298
|
+
*/
|
|
299
|
+
startClientOnly() {
|
|
300
|
+
this.clientReadyPromise ??= this.doStartClient().catch((err) => {
|
|
301
|
+
this.clientReadyPromise = void 0;
|
|
302
|
+
throw err;
|
|
303
|
+
});
|
|
304
|
+
return this.clientReadyPromise;
|
|
305
|
+
}
|
|
306
|
+
async doStartClient() {
|
|
307
|
+
const pubkey = this.client.getPublicKey();
|
|
308
|
+
await checkDaemonIdentity(pubkey, {
|
|
309
|
+
...this.daemonPort !== void 0 ? { port: this.daemonPort } : {},
|
|
310
|
+
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
311
|
+
});
|
|
312
|
+
this.lock = await NonceLock.acquire(pubkey, {
|
|
313
|
+
...this.lockDir !== void 0 ? { dir: this.lockDir } : {}
|
|
314
|
+
});
|
|
315
|
+
try {
|
|
316
|
+
if (this.client.isStarted?.() !== true) {
|
|
317
|
+
await this.client.start();
|
|
318
|
+
}
|
|
319
|
+
this.applyNegotiationFallbacks();
|
|
320
|
+
} catch (err) {
|
|
321
|
+
this.lock.release();
|
|
322
|
+
this.lock = void 0;
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Back-fill negotiated peer metadata the announce did not carry
|
|
328
|
+
* (#264/#260 root cause 3): after the client's bootstrap negotiation, any
|
|
329
|
+
* peer negotiation missing `tokenNetwork`/`tokenAddress` gets the derived
|
|
330
|
+
* per-chain fallback for its negotiated chain, BEFORE the lazy channel
|
|
331
|
+
* open reads them. Announced values are never overridden.
|
|
332
|
+
*/
|
|
333
|
+
applyNegotiationFallbacks() {
|
|
334
|
+
const fallbacks = this.negotiationFallbacks;
|
|
335
|
+
if (!fallbacks) return;
|
|
336
|
+
const negotiations = channelInternals(this.client).peerNegotiations;
|
|
337
|
+
if (!negotiations) {
|
|
338
|
+
this.warn(
|
|
339
|
+
"rig: settlement fallbacks configured but the client does not expose negotiation internals \u2014 on-chain channel opening may fail if the peer announced no TokenNetwork"
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
for (const negotiation of negotiations.values()) {
|
|
344
|
+
if (!negotiation.tokenNetwork) {
|
|
345
|
+
const tokenNetwork = fallbacks.tokenNetworks?.[negotiation.chain];
|
|
346
|
+
if (tokenNetwork) negotiation.tokenNetwork = tokenNetwork;
|
|
347
|
+
}
|
|
348
|
+
if (!negotiation.tokenAddress) {
|
|
349
|
+
const tokenAddress = fallbacks.preferredTokens?.[negotiation.chain];
|
|
350
|
+
if (tokenAddress) negotiation.tokenAddress = tokenAddress;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Open the payment channel — or, when the channel map has a record for
|
|
356
|
+
* (identity, anchor), RESUME the recorded on-chain channel (#262).
|
|
357
|
+
*
|
|
358
|
+
* Resume seeds the client's ChannelManager (`trackChannel` rehydrates the
|
|
359
|
+
* cumulative-claim watermark from channels.json; `peerChannels` makes the
|
|
360
|
+
* subsequent `openChannel` return the same id instead of opening on-chain).
|
|
361
|
+
* A fresh open is RECORDED so the next invocation resumes it. A corrupt map
|
|
362
|
+
* file throws BEFORE anything is opened — never a silent duplicate open.
|
|
363
|
+
*/
|
|
364
|
+
async openOrResumeChannel() {
|
|
365
|
+
const map = this.channelMap;
|
|
366
|
+
if (!map) {
|
|
367
|
+
return this.client.openChannel(this.channelDestination);
|
|
368
|
+
}
|
|
369
|
+
if (!this.channelAnchor) {
|
|
370
|
+
this.warn(
|
|
371
|
+
"rig: no channel anchor destination configured \u2014 the peer\u2192channel mapping cannot be persisted, so this run may open a fresh channel"
|
|
372
|
+
);
|
|
373
|
+
return this.client.openChannel(this.channelDestination);
|
|
374
|
+
}
|
|
375
|
+
const anchor = this.channelAnchor;
|
|
376
|
+
const identity = this.client.getPublicKey();
|
|
377
|
+
const candidates = map.listFor(identity, anchor);
|
|
378
|
+
const internals = channelInternals(this.client);
|
|
379
|
+
const resumed = await this.resumeRecordedChannel(map, candidates, internals);
|
|
380
|
+
const channelId = await this.client.openChannel(this.channelDestination);
|
|
381
|
+
if (resumed && channelId === resumed.channelId) {
|
|
382
|
+
this.lastOpenResumed = true;
|
|
383
|
+
map.touch(recordKey(resumed));
|
|
384
|
+
} else {
|
|
385
|
+
this.lastOpenResumed = false;
|
|
386
|
+
this.recordOpenedChannel(map, internals, identity, anchor, channelId);
|
|
387
|
+
}
|
|
388
|
+
return channelId;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Try to resume one recorded channel: the first candidate whose peer is
|
|
392
|
+
* still negotiated on the SAME chain + tokenNetwork and whose watermark
|
|
393
|
+
* does not show it closed/settled. Returns the resumed record, if any.
|
|
394
|
+
*/
|
|
395
|
+
async resumeRecordedChannel(map, candidates, internals) {
|
|
396
|
+
if (candidates.length === 0) return void 0;
|
|
397
|
+
const cm = internals.channelManager;
|
|
398
|
+
if (!cm?.trackChannel || !cm.peerChannels) {
|
|
399
|
+
this.warn(
|
|
400
|
+
"rig: a recorded channel exists but the client does not expose channel internals to resume it \u2014 a fresh channel may be opened"
|
|
401
|
+
);
|
|
402
|
+
return void 0;
|
|
403
|
+
}
|
|
404
|
+
for (const record of candidates) {
|
|
405
|
+
const negotiation = internals.peerNegotiations?.get(record.peerId);
|
|
406
|
+
if (!negotiation || negotiation.chain !== record.chain || (negotiation.tokenNetwork ?? "") !== record.tokenNetwork) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const watermark = map.readWatermark(record.channelId);
|
|
410
|
+
if (watermark?.closedAt !== void 0 || watermark?.settledAt !== void 0) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (!watermark) {
|
|
414
|
+
this.warn(
|
|
415
|
+
`rig: resuming channel ${record.channelId} with no local claim watermark (${map.watermarkPath}) \u2014 if this channel was claimed against before, the peer will reject the stale claims; remove its entry from ${map.mapPath} to open a fresh channel instead`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
cm.trackChannel(record.channelId, record.context);
|
|
419
|
+
cm.peerChannels.set(record.peerId, record.channelId);
|
|
420
|
+
if (record.context.chainType === "evm" && this.client.rehydrateChannelDeposit) {
|
|
421
|
+
try {
|
|
422
|
+
const deposit = await this.client.rehydrateChannelDeposit(
|
|
423
|
+
record.channelId,
|
|
424
|
+
{
|
|
425
|
+
chain: record.chain,
|
|
426
|
+
tokenNetworkAddress: record.context.tokenNetworkAddress
|
|
427
|
+
}
|
|
428
|
+
);
|
|
429
|
+
if (deposit !== void 0) {
|
|
430
|
+
map.touch(recordKey(record), {
|
|
431
|
+
depositTotal: deposit.toString()
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
} catch (err) {
|
|
435
|
+
this.warn(
|
|
436
|
+
`rig: deposit re-read for resumed channel ${record.channelId} failed: ${err instanceof Error ? err.message : String(err)}`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return record;
|
|
441
|
+
}
|
|
442
|
+
return void 0;
|
|
443
|
+
}
|
|
444
|
+
/** Record a freshly opened channel (+ seed its claim watermark at 0/0). */
|
|
445
|
+
recordOpenedChannel(map, internals, identity, destination, channelId) {
|
|
446
|
+
let peerId;
|
|
447
|
+
for (const [peer, channel] of internals.channelManager?.peerChannels ?? []) {
|
|
448
|
+
if (channel === channelId) {
|
|
449
|
+
peerId = peer;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const negotiation = peerId !== void 0 ? internals.peerNegotiations?.get(peerId) : void 0;
|
|
454
|
+
if (peerId === void 0 || !negotiation) {
|
|
455
|
+
this.warn(
|
|
456
|
+
`rig: opened channel ${channelId} but could not record the peer\u2192channel mapping (client does not expose negotiation internals) \u2014 the NEXT invocation may open another channel`
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
let depositTotal;
|
|
461
|
+
try {
|
|
462
|
+
depositTotal = this.client.getChannelDepositTotal?.(channelId);
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
map.record({
|
|
466
|
+
channelId,
|
|
467
|
+
peerId,
|
|
468
|
+
identity,
|
|
469
|
+
destination,
|
|
470
|
+
chain: negotiation.chain,
|
|
471
|
+
tokenNetwork: negotiation.tokenNetwork ?? "",
|
|
472
|
+
// Context mirrors the daemon's persistApexChannel shape — exactly what
|
|
473
|
+
// trackChannel needs on resume.
|
|
474
|
+
context: {
|
|
475
|
+
chainType: negotiation.chainType,
|
|
476
|
+
chainId: typeof negotiation.chainId === "number" ? negotiation.chainId : 0,
|
|
477
|
+
tokenNetworkAddress: negotiation.tokenNetwork ?? "",
|
|
478
|
+
...negotiation.tokenAddress ? { tokenAddress: negotiation.tokenAddress } : {},
|
|
479
|
+
recipient: negotiation.settlementAddress
|
|
480
|
+
},
|
|
481
|
+
...depositTotal !== void 0 && depositTotal > 0n ? { depositTotal: depositTotal.toString() } : {}
|
|
482
|
+
});
|
|
483
|
+
map.seedWatermark(channelId);
|
|
484
|
+
}
|
|
485
|
+
/** Release the identity lock and stop the embedded client (if we own it). */
|
|
486
|
+
async stop() {
|
|
487
|
+
this.lock?.release();
|
|
488
|
+
this.lock = void 0;
|
|
489
|
+
this.readyPromise = void 0;
|
|
490
|
+
this.clientReadyPromise = void 0;
|
|
491
|
+
this.channelId = void 0;
|
|
492
|
+
if (this.ownsClient && this.client.isStarted?.() !== false) {
|
|
493
|
+
await this.client.stop();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// ── Money lifecycle (#263) ──────────────────────────────────────────────────
|
|
497
|
+
/**
|
|
498
|
+
* Explicit `rig channel open`: the SAME resume-or-open path the lazy paid
|
|
499
|
+
* writes use (guard → start → resume the recorded channel or open + record
|
|
500
|
+
* a fresh one), surfaced with a receipt — plus an optional extra collateral
|
|
501
|
+
* deposit on top of the open/resume.
|
|
502
|
+
*/
|
|
503
|
+
async openChannelExplicit(opts) {
|
|
504
|
+
await this.start();
|
|
505
|
+
const channelId = this.requireChannel();
|
|
506
|
+
const identity = this.client.getPublicKey();
|
|
507
|
+
const destination = this.channelAnchor ?? "";
|
|
508
|
+
const record = this.channelMap && this.channelAnchor ? this.channelMap.listFor(identity, this.channelAnchor).find((r) => r.channelId === channelId) : void 0;
|
|
509
|
+
const outcome = {
|
|
510
|
+
channelId,
|
|
511
|
+
resumed: this.lastOpenResumed,
|
|
512
|
+
destination,
|
|
513
|
+
...record ? { chain: record.chain, peerId: record.peerId } : {},
|
|
514
|
+
...record?.depositTotal !== void 0 ? { depositTotal: record.depositTotal } : {}
|
|
515
|
+
};
|
|
516
|
+
if (opts?.deposit !== void 0 && opts.deposit > 0n) {
|
|
517
|
+
if (!this.client.depositToChannel) {
|
|
518
|
+
throw new StandalonePublishError(
|
|
519
|
+
"this client build does not support channel deposits (depositToChannel is unavailable)"
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const deposited = await this.client.depositToChannel(
|
|
523
|
+
channelId,
|
|
524
|
+
opts.deposit
|
|
525
|
+
);
|
|
526
|
+
outcome.depositAdded = opts.deposit.toString();
|
|
527
|
+
outcome.depositTotal = deposited.depositTotal;
|
|
528
|
+
if (deposited.txHash) outcome.depositTxHash = deposited.txHash;
|
|
529
|
+
if (record) {
|
|
530
|
+
this.channelMap?.touch(recordKey(record), {
|
|
531
|
+
depositTotal: deposited.depositTotal
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return outcome;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Adopt a RECORDED channel into the running client so on-chain close/
|
|
539
|
+
* settle/deposit can act on it: `trackChannel` rehydrates the claim
|
|
540
|
+
* watermark + withdraw timers from channels.json, `peerChannels` binds the
|
|
541
|
+
* peer, and the on-chain client's context cache is re-seeded (it only
|
|
542
|
+
* learns context at open time — a resumed channel would otherwise throw
|
|
543
|
+
* "No on-chain context").
|
|
544
|
+
*/
|
|
545
|
+
adoptRecordedChannel(record) {
|
|
546
|
+
const internals = channelInternals(this.client);
|
|
547
|
+
const cm = internals.channelManager;
|
|
548
|
+
if (cm?.trackChannel && cm.peerChannels) {
|
|
549
|
+
cm.trackChannel(record.channelId, record.context);
|
|
550
|
+
cm.peerChannels.set(record.peerId, record.channelId);
|
|
551
|
+
}
|
|
552
|
+
const contextCache = internals.onChainChannelClient?.channelContext;
|
|
553
|
+
if (contextCache && !contextCache.has(record.channelId)) {
|
|
554
|
+
contextCache.set(record.channelId, {
|
|
555
|
+
chain: record.chain,
|
|
556
|
+
tokenNetworkAddress: record.context.tokenNetworkAddress,
|
|
557
|
+
...record.context.tokenAddress ? { tokenAddress: record.context.tokenAddress } : {}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Close a recorded channel: starts the on-chain settlement challenge
|
|
563
|
+
* window. The client persists `closedAt`/`settleableAt` into the claim
|
|
564
|
+
* watermark store, which is exactly where `rig channel list`/`balance`
|
|
565
|
+
* derive the closing/settleable/settled status from. Guard + client start,
|
|
566
|
+
* but NEVER a channel open ({@link startClientOnly}).
|
|
567
|
+
*/
|
|
568
|
+
async closeRecordedChannel(record) {
|
|
569
|
+
await this.startClientOnly();
|
|
570
|
+
if (!this.client.closeChannel) {
|
|
571
|
+
throw new StandalonePublishError(
|
|
572
|
+
"this client build does not support closing channels (closeChannel is unavailable)"
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
this.adoptRecordedChannel(record);
|
|
576
|
+
const result = await this.client.closeChannel(record.channelId);
|
|
577
|
+
this.channelMap?.touch(recordKey(record));
|
|
578
|
+
return result;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Settle a recorded channel after its challenge window — releases the
|
|
582
|
+
* remaining collateral. The client enforces the `now >= settleableAt` time
|
|
583
|
+
* guard BEFORE spending gas (a too-early call throws its retryable
|
|
584
|
+
* SettleTooEarlyError) and persists `settledAt` into the watermark store.
|
|
585
|
+
*/
|
|
586
|
+
async settleRecordedChannel(record) {
|
|
587
|
+
await this.startClientOnly();
|
|
588
|
+
if (!this.client.settleChannel) {
|
|
589
|
+
throw new StandalonePublishError(
|
|
590
|
+
"this client build does not support settling channels (settleChannel is unavailable)"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
this.adoptRecordedChannel(record);
|
|
594
|
+
const result = await this.client.settleChannel(record.channelId);
|
|
595
|
+
this.channelMap?.touch(recordKey(record));
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* On-chain wallet balances for the embedded identity — a FREE read on the
|
|
600
|
+
* UNSTARTED client (no nonce guard, no uplink, no channel): the client
|
|
601
|
+
* reads the settlement chain its channels actually use (its EVM key is
|
|
602
|
+
* derived at construction; Solana/Mina keys only exist after a start, so
|
|
603
|
+
* those chains appear once a start-requiring command ran — same
|
|
604
|
+
* best-effort contract as the client's own getBalances).
|
|
605
|
+
*/
|
|
606
|
+
async readWalletBalances() {
|
|
607
|
+
if (!this.client.getBalances) return [];
|
|
608
|
+
return await this.client.getBalances();
|
|
609
|
+
}
|
|
610
|
+
// ── Publisher ─────────────────────────────────────────────────────────────
|
|
611
|
+
/**
|
|
612
|
+
* Fee rates for `planPush` estimation: the flat per-event fee and the
|
|
613
|
+
* per-byte upload rate this publisher pays (daemon `feePerEvent` and seed
|
|
614
|
+
* bid-rate conventions; override via options).
|
|
615
|
+
*/
|
|
616
|
+
getFeeRates() {
|
|
617
|
+
return Promise.resolve({
|
|
618
|
+
uploadFeePerByte: this.uploadFeePerByte,
|
|
619
|
+
eventFee: this.eventFee
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Upload one git object as a kind:5094 store write (Git-SHA/Git-Type/Repo
|
|
624
|
+
* tagged — the proven seed-pipeline shape), signing one balance-proof claim
|
|
625
|
+
* for `body.length × uploadFeePerByte`.
|
|
626
|
+
*/
|
|
627
|
+
async uploadGitObject(upload) {
|
|
628
|
+
if (upload.body.length > MAX_OBJECT_SIZE) {
|
|
629
|
+
throw new StandalonePublishError(
|
|
630
|
+
`git object ${upload.sha} exceeds the ${MAX_OBJECT_SIZE}-byte limit: ${upload.body.length} bytes`
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
await this.start();
|
|
634
|
+
const channelId = this.requireChannel();
|
|
635
|
+
const fee = BigInt(upload.body.length) * this.uploadFeePerByte;
|
|
636
|
+
const event = this.client.signEvent({
|
|
637
|
+
kind: 5094,
|
|
638
|
+
content: "",
|
|
639
|
+
created_at: nowSeconds(),
|
|
640
|
+
tags: [
|
|
641
|
+
["i", upload.body.toString("base64"), "blob"],
|
|
642
|
+
["bid", fee.toString(), "usdc"],
|
|
643
|
+
["output", "application/octet-stream"],
|
|
644
|
+
["Git-SHA", upload.sha],
|
|
645
|
+
["Git-Type", upload.type],
|
|
646
|
+
["Repo", upload.repoId]
|
|
647
|
+
]
|
|
648
|
+
});
|
|
649
|
+
const claim = await this.client.signBalanceProof(channelId, fee);
|
|
650
|
+
const result = await this.client.publishEvent(event, {
|
|
651
|
+
...this.storeDestination ? { destination: this.storeDestination } : {},
|
|
652
|
+
claim,
|
|
653
|
+
ilpAmount: fee,
|
|
654
|
+
// The store backend serves POST /store (not the relay's /write).
|
|
655
|
+
proxyPath: "/store"
|
|
656
|
+
});
|
|
657
|
+
if (!result.success) {
|
|
658
|
+
throw new StandalonePublishError(
|
|
659
|
+
`git-object upload rejected (${upload.sha}): ${result.error ?? "store rejected the write"}`
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
if (!result.data) {
|
|
663
|
+
throw new StandalonePublishError(
|
|
664
|
+
`git-object upload FULFILL carried no data (${upload.sha}); expected the Arweave tx ID`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
return { txId: extractArweaveTxId(result.data), feePaid: fee };
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Sign the event with the embedded identity and pay-to-publish it through
|
|
671
|
+
* the relay write route, one claim for the flat per-event fee.
|
|
672
|
+
*
|
|
673
|
+
* `relayUrls` is the interface's plural forward-compat surface (parked
|
|
674
|
+
* #84): the standalone impl routes over ILP to its single configured
|
|
675
|
+
* publish destination, so more than one relay is refused rather than
|
|
676
|
+
* silently half-published.
|
|
677
|
+
*/
|
|
678
|
+
async publishEvent(event, relayUrls) {
|
|
679
|
+
if (relayUrls.length > 1) {
|
|
680
|
+
throw new StandalonePublishError(
|
|
681
|
+
`multi-relay publish is not supported yet (got ${relayUrls.length} relays) \u2014 the standalone publisher routes to a single relay destination (#84 parked)`
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
await this.start();
|
|
685
|
+
const channelId = this.requireChannel();
|
|
686
|
+
const signed = this.client.signEvent(event);
|
|
687
|
+
const fee = this.eventFee;
|
|
688
|
+
const claim = await this.client.signBalanceProof(channelId, fee);
|
|
689
|
+
const result = await this.client.publishEvent(signed, {
|
|
690
|
+
...this.publishDestination ? { destination: this.publishDestination } : {},
|
|
691
|
+
claim,
|
|
692
|
+
ilpAmount: fee
|
|
693
|
+
});
|
|
694
|
+
if (!result.success) {
|
|
695
|
+
throw new StandalonePublishError(
|
|
696
|
+
`publish rejected (kind ${event.kind}): ${result.error ?? "relay rejected the event"}`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
return { eventId: result.eventId ?? signed.id, feePaid: fee };
|
|
700
|
+
}
|
|
701
|
+
requireChannel() {
|
|
702
|
+
if (!this.channelId) {
|
|
703
|
+
throw new StandalonePublishError(
|
|
704
|
+
"no payment channel open \u2014 start() did not complete"
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
return this.channelId;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
function nowSeconds() {
|
|
711
|
+
return Math.floor(Date.now() / 1e3);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export {
|
|
715
|
+
DEFAULT_DAEMON_PORT,
|
|
716
|
+
defaultDaemonPort,
|
|
717
|
+
defaultLockDir,
|
|
718
|
+
DaemonIdentityConflictError,
|
|
719
|
+
StandaloneLockError,
|
|
720
|
+
checkDaemonIdentity,
|
|
721
|
+
NonceLock,
|
|
722
|
+
StandalonePublishError,
|
|
723
|
+
deriveRouteDestinations,
|
|
724
|
+
extractArweaveTxId,
|
|
725
|
+
StandalonePublisher
|
|
726
|
+
};
|
|
727
|
+
//# sourceMappingURL=chunk-3EKP7PMM.js.map
|