@toon-protocol/rig 2.1.0 → 2.3.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.
@@ -1,22 +1,21 @@
1
- import {
2
- fetchRemoteState,
3
- queryRelay
4
- } from "./chunk-PLKZAUTG.js";
5
1
  import {
6
2
  resolveIdentity
7
3
  } from "./chunk-CW4HJNMU.js";
8
- import "./chunk-HPSOQP7Q.js";
4
+ import {
5
+ fetchRemoteState,
6
+ queryRelay
7
+ } from "./chunk-3HRFDH7H.js";
9
8
  import {
10
9
  StandalonePublisher
11
- } from "./chunk-3EKP7PMM.js";
10
+ } from "./chunk-OIJNRACA.js";
12
11
  import {
13
12
  ChannelMapStore,
14
13
  RIG_CHANNEL_MAP_FILENAME
15
- } from "./chunk-O6TXHKWG.js";
14
+ } from "./chunk-SW7ZHMGS.js";
16
15
  import "./chunk-X2CZPPDM.js";
17
16
 
18
17
  // src/cli/standalone-mode.ts
19
- import { readFileSync } from "fs";
18
+ import { readFileSync as readFileSync2 } from "fs";
20
19
  import { homedir } from "os";
21
20
  import { join } from "path";
22
21
  import { EvmSigner, deriveNostrKeyFromMnemonic } from "@toon-protocol/client";
@@ -25,6 +24,161 @@ import {
25
24
  encodeEventToToon
26
25
  } from "@toon-protocol/core";
27
26
 
27
+ // src/standalone/topology-cache.ts
28
+ import { createHash } from "crypto";
29
+ import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
30
+ import { dirname } from "path";
31
+ var TOPOLOGY_CACHE_FILENAME = "rig-topology-cache.json";
32
+ var DEFAULT_TOPOLOGY_TTL_MS = 15 * 60 * 1e3;
33
+ var TOPOLOGY_TTL_ENV = "RIG_TOPOLOGY_TTL_MS";
34
+ function topologyCacheTtlMs(env) {
35
+ const raw = env[TOPOLOGY_TTL_ENV];
36
+ if (raw === void 0 || raw === "") return DEFAULT_TOPOLOGY_TTL_MS;
37
+ const parsed = Number(raw);
38
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_TOPOLOGY_TTL_MS;
39
+ }
40
+ function topologyCacheKey(args) {
41
+ return createHash("sha256").update(`${args.relayUrl}
42
+ ${args.identity}
43
+ ${args.fingerprint}`).digest("hex");
44
+ }
45
+ function explicitConfigFingerprint(env, file) {
46
+ const envKeys = [
47
+ "TOON_CLIENT_PROXY_URL",
48
+ "TOON_CLIENT_BTP_URL",
49
+ "TOON_CLIENT_DESTINATION",
50
+ "TOON_CLIENT_PUBLISH_DESTINATION",
51
+ "TOON_CLIENT_STORE_DESTINATION",
52
+ "TOON_CLIENT_CHAIN",
53
+ "TOON_CLIENT_NETWORK"
54
+ ];
55
+ const fileKeys = [
56
+ "network",
57
+ "btpUrl",
58
+ "proxyUrl",
59
+ "destination",
60
+ "publishDestination",
61
+ "storeDestination",
62
+ "chain",
63
+ "supportedChains",
64
+ "settlementAddresses",
65
+ "preferredTokens",
66
+ "tokenNetworks",
67
+ "chainRpcUrls"
68
+ ];
69
+ const picked = {};
70
+ for (const key of envKeys) {
71
+ if (env[key] !== void 0) picked[`env:${key}`] = env[key];
72
+ }
73
+ for (const key of fileKeys) {
74
+ if (file[key] !== void 0) picked[`file:${key}`] = file[key];
75
+ }
76
+ return JSON.stringify(picked);
77
+ }
78
+ var TopologyCache = class {
79
+ path;
80
+ ttlMs;
81
+ validate;
82
+ now;
83
+ constructor(options) {
84
+ this.path = options.path;
85
+ this.ttlMs = options.ttlMs;
86
+ this.validate = options.validate;
87
+ this.now = options.now ?? Date.now;
88
+ }
89
+ /** True when the cache is disabled (`RIG_TOPOLOGY_TTL_MS=0`). */
90
+ get disabled() {
91
+ return this.ttlMs <= 0;
92
+ }
93
+ /**
94
+ * A fresh, structurally-valid entry for `key` — or undefined (expired,
95
+ * missing, invalid, corrupt file, or disabled). Also reports the entry age
96
+ * for the "topology from cache" stderr line.
97
+ */
98
+ read(key) {
99
+ if (this.disabled) return void 0;
100
+ const entry = this.readFile().entries[key];
101
+ if (!entry) return void 0;
102
+ const cachedAt = Date.parse(entry.cachedAt);
103
+ if (!Number.isFinite(cachedAt)) return void 0;
104
+ const ageMs = this.now() - cachedAt;
105
+ if (ageMs < 0 || ageMs > this.ttlMs) return void 0;
106
+ if (!this.validate(entry.topology)) return void 0;
107
+ return { topology: entry.topology, ageMs };
108
+ }
109
+ /** Persist `topology` under `key` (prunes expired entries; best-effort). */
110
+ write(key, topology) {
111
+ if (this.disabled) return;
112
+ try {
113
+ const file = this.readFile();
114
+ const nowMs = this.now();
115
+ const fresh = Object.fromEntries(
116
+ Object.entries(file.entries).filter(([, entry]) => {
117
+ const at = Date.parse(entry.cachedAt);
118
+ return Number.isFinite(at) && nowMs - at <= this.ttlMs;
119
+ })
120
+ );
121
+ fresh[key] = {
122
+ cachedAt: new Date(nowMs).toISOString(),
123
+ topology
124
+ };
125
+ mkdirSync(dirname(this.path), { recursive: true });
126
+ writeFileSync(
127
+ this.path,
128
+ JSON.stringify(
129
+ { version: 1, entries: fresh },
130
+ null,
131
+ 2
132
+ ),
133
+ { mode: 384 }
134
+ );
135
+ } catch {
136
+ }
137
+ }
138
+ /** Drop `key` (a cached topology failed to bootstrap). Best-effort. */
139
+ invalidate(key) {
140
+ try {
141
+ const file = this.readFile();
142
+ if (!(key in file.entries)) return;
143
+ const remaining = Object.fromEntries(
144
+ Object.entries(file.entries).filter(([k]) => k !== key)
145
+ );
146
+ if (Object.keys(remaining).length === 0) {
147
+ unlinkSync(this.path);
148
+ return;
149
+ }
150
+ writeFileSync(
151
+ this.path,
152
+ JSON.stringify(
153
+ { version: 1, entries: remaining },
154
+ null,
155
+ 2
156
+ ),
157
+ { mode: 384 }
158
+ );
159
+ } catch {
160
+ }
161
+ }
162
+ readFile() {
163
+ const empty = { version: 1, entries: {} };
164
+ let raw;
165
+ try {
166
+ raw = readFileSync(this.path, "utf8");
167
+ } catch {
168
+ return empty;
169
+ }
170
+ try {
171
+ const parsed = JSON.parse(raw);
172
+ if (typeof parsed !== "object" || parsed === null || parsed.version !== 1 || typeof parsed.entries !== "object" || parsed.entries === null) {
173
+ return empty;
174
+ }
175
+ return parsed;
176
+ } catch {
177
+ return empty;
178
+ }
179
+ }
180
+ };
181
+
28
182
  // src/standalone/network-bootstrap.ts
29
183
  import {
30
184
  CHAIN_PRESETS,
@@ -300,7 +454,7 @@ function configDir(env) {
300
454
  }
301
455
  function readClientConfig(path) {
302
456
  try {
303
- return JSON.parse(readFileSync(path, "utf8"));
457
+ return JSON.parse(readFileSync2(path, "utf8"));
304
458
  } catch (err) {
305
459
  if (err.code === "ENOENT") return {};
306
460
  throw new Error(
@@ -438,7 +592,7 @@ async function resolveNetworkTopology(inputs) {
438
592
  const network = env["TOON_CLIENT_NETWORK"] ?? file.network;
439
593
  if (network && network !== "custom" && !file.supportedChains?.length) {
440
594
  warn(
441
- `rig: ignoring the "${network}" network preset for settlement \u2014 the chain comes from the announce/config (#260); set supportedChains explicitly to use preset chains`
595
+ `rig: ignoring the "${network}" network preset for settlement \u2014 the settlement chain comes from the payment peer's announce and your config, because preset chains can point at networks your wallet has no funds on; set supportedChains explicitly to use preset chains`
442
596
  );
443
597
  }
444
598
  return {
@@ -455,6 +609,87 @@ async function resolveNetworkTopology(inputs) {
455
609
  ...chainRpcUrls && Object.keys(chainRpcUrls).length > 0 ? { chainRpcUrls } : {}
456
610
  };
457
611
  }
612
+ function isNetworkTopology(value) {
613
+ if (typeof value !== "object" || value === null) return false;
614
+ const t = value;
615
+ return typeof t["destination"] === "string" && Array.isArray(t["knownPeers"]);
616
+ }
617
+ var NON_RECOVERABLE_START_ERRORS = /* @__PURE__ */ new Set([
618
+ "DaemonIdentityConflictError",
619
+ "StandaloneLockError",
620
+ "ChannelMapCorruptError"
621
+ ]);
622
+ var TopologyRecoveringPublisher = class {
623
+ inner;
624
+ rebuild;
625
+ warn;
626
+ constructor(inner, rebuild, warn) {
627
+ this.inner = inner;
628
+ this.rebuild = rebuild;
629
+ this.warn = warn;
630
+ }
631
+ getPublicKey() {
632
+ return this.inner.getPublicKey();
633
+ }
634
+ getFeeRates() {
635
+ return this.inner.getFeeRates();
636
+ }
637
+ /**
638
+ * Run the bootstrap step, recovering ONCE from a stale cached topology.
639
+ * A rebuild failure surfaces the LIVE resolution's error — that is the
640
+ * network's real state, strictly more actionable than the cached failure.
641
+ */
642
+ async ensure(start) {
643
+ try {
644
+ await start(this.inner);
645
+ return this.inner;
646
+ } catch (err) {
647
+ const rebuild = this.rebuild;
648
+ this.rebuild = void 0;
649
+ const name = err instanceof Error ? err.name : "";
650
+ if (!rebuild || NON_RECOVERABLE_START_ERRORS.has(name)) throw err;
651
+ this.warn(
652
+ `rig: bootstrap with the cached network topology failed (${err instanceof Error ? err.message : String(err)}) \u2014 invalidating the cache and re-resolving live`
653
+ );
654
+ const fresh = await rebuild();
655
+ try {
656
+ await this.inner.stop();
657
+ } catch {
658
+ }
659
+ this.inner = fresh;
660
+ await start(this.inner);
661
+ return this.inner;
662
+ }
663
+ }
664
+ async publishEvent(event, relayUrls) {
665
+ const p = await this.ensure((x) => x.start());
666
+ return p.publishEvent(event, relayUrls);
667
+ }
668
+ async uploadGitObject(upload) {
669
+ const p = await this.ensure((x) => x.start());
670
+ return p.uploadGitObject(upload);
671
+ }
672
+ // ── money lifecycle passthroughs (#263) — same recovery contract ─────────
673
+ async openChannelExplicit(opts) {
674
+ const p = await this.ensure((x) => x.start());
675
+ return p.openChannelExplicit(opts);
676
+ }
677
+ async closeRecordedChannel(record) {
678
+ const p = await this.ensure((x) => x.startClientOnly());
679
+ return p.closeRecordedChannel(record);
680
+ }
681
+ async settleRecordedChannel(record) {
682
+ const p = await this.ensure((x) => x.startClientOnly());
683
+ return p.settleRecordedChannel(record);
684
+ }
685
+ /** Free read on the unstarted client — no bootstrap, no recovery needed. */
686
+ readWalletBalances() {
687
+ return this.inner.readWalletBalances();
688
+ }
689
+ async stop() {
690
+ await this.inner.stop();
691
+ }
692
+ };
458
693
  function chainRecordsFor(map, identity) {
459
694
  return map.list().filter((r) => r.identity === identity).map((r) => {
460
695
  const watermark = map.readWatermark(r.channelId);
@@ -474,101 +709,136 @@ async function createStandaloneContext(options) {
474
709
  const identity = await resolveIdentity(options);
475
710
  const genesisSeed = loadGenesisSeed();
476
711
  const relayUrl = options.relayUrl ?? env["TOON_CLIENT_RELAY_URL"] ?? file.relayUrl ?? genesisSeed?.relayUrl ?? "ws://localhost:7100";
477
- const fullyExplicit = Boolean(
478
- (env["TOON_CLIENT_PROXY_URL"] ?? file.proxyUrl) || (env["TOON_CLIENT_BTP_URL"] ?? file.btpUrl)
479
- ) && Boolean(env["TOON_CLIENT_DESTINATION"] ?? file.destination) && Boolean(file.supportedChains?.length);
480
- let announce;
481
- if (!fullyExplicit) {
482
- try {
483
- const peers = await discoverAnnouncedPeers(relayUrl, {
484
- timeoutMs: DISCOVERY_TIMEOUT_MS
485
- });
486
- announce = pickPaymentPeer(peers, genesisSeedPubkeys());
487
- if (!announce) {
488
- warn(
489
- `rig: no payment-peer announce (kind:10032) found on ${relayUrl} \u2014 falling back to the genesis peer seed`
490
- );
491
- }
492
- } catch (err) {
493
- warn(
494
- `rig: announce discovery on ${relayUrl} failed (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to the genesis peer seed`
495
- );
496
- }
497
- }
498
712
  const channelStorePath = file.channelStorePath ?? join(dir, "channels.json");
499
713
  const channelMap = new ChannelMapStore({
500
714
  mapPath: join(dir, RIG_CHANNEL_MAP_FILENAME),
501
715
  watermarkPath: channelStorePath
502
716
  });
503
- const topology = await resolveNetworkTopology({
504
- env,
505
- file,
506
- configPath,
717
+ const cache = new TopologyCache({
718
+ path: join(dir, TOPOLOGY_CACHE_FILENAME),
719
+ ttlMs: topologyCacheTtlMs(env),
720
+ validate: isNetworkTopology
721
+ });
722
+ const cacheKey = topologyCacheKey({
507
723
  relayUrl,
508
- announce,
509
- genesisSeed,
510
- identity: {
511
- mnemonic: identity.mnemonic,
512
- accountIndex: identity.accountIndex,
513
- pubkey: identity.pubkey
514
- },
515
- channelRecords: () => chainRecordsFor(channelMap, identity.pubkey),
516
- ...options.requireUplink !== void 0 ? { requireUplink: options.requireUplink } : {},
517
- warn
724
+ identity: identity.pubkey,
725
+ fingerprint: explicitConfigFingerprint(
726
+ env,
727
+ file
728
+ )
518
729
  });
730
+ const resolveLiveTopology = async () => {
731
+ const fullyExplicit = Boolean(
732
+ (env["TOON_CLIENT_PROXY_URL"] ?? file.proxyUrl) || (env["TOON_CLIENT_BTP_URL"] ?? file.btpUrl)
733
+ ) && Boolean(env["TOON_CLIENT_DESTINATION"] ?? file.destination) && Boolean(file.supportedChains?.length);
734
+ let announce;
735
+ if (!fullyExplicit) {
736
+ try {
737
+ const peers = await discoverAnnouncedPeers(relayUrl, {
738
+ timeoutMs: DISCOVERY_TIMEOUT_MS
739
+ });
740
+ announce = pickPaymentPeer(peers, genesisSeedPubkeys());
741
+ if (!announce) {
742
+ warn(
743
+ `rig: no payment-peer announce (kind:10032) found on ${relayUrl} \u2014 falling back to the genesis peer seed`
744
+ );
745
+ }
746
+ } catch (err) {
747
+ warn(
748
+ `rig: announce discovery on ${relayUrl} failed (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to the genesis peer seed`
749
+ );
750
+ }
751
+ }
752
+ const resolved = await resolveNetworkTopology({
753
+ env,
754
+ file,
755
+ configPath,
756
+ relayUrl,
757
+ announce,
758
+ genesisSeed,
759
+ identity: {
760
+ mnemonic: identity.mnemonic,
761
+ accountIndex: identity.accountIndex,
762
+ pubkey: identity.pubkey
763
+ },
764
+ channelRecords: () => chainRecordsFor(channelMap, identity.pubkey),
765
+ ...options.requireUplink !== void 0 ? { requireUplink: options.requireUplink } : {},
766
+ warn
767
+ });
768
+ if (options.requireUplink !== false) cache.write(cacheKey, resolved);
769
+ return resolved;
770
+ };
771
+ const cached = cache.read(cacheKey);
772
+ if (cached) {
773
+ warn(
774
+ `rig: network topology from cache (${Math.round(cached.ageMs / 1e3)}s old; ${TOPOLOGY_TTL_ENV}=0 disables) \u2014 skipping announce discovery`
775
+ );
776
+ }
777
+ const topology = cached?.topology ?? await resolveLiveTopology();
519
778
  const eventFee = BigInt(file.feePerEvent ?? "1");
520
- const clientConfig = {
521
- // validateConfig requires connectorUrl OR proxyUrl; with BTP-only config a
522
- // dummy connectorUrl satisfies it (unused at runtime — same convention as
523
- // the daemon).
524
- ...topology.proxyUrl ? { proxyUrl: topology.proxyUrl } : { connectorUrl: "http://127.0.0.1:1" },
525
- mnemonic: identity.mnemonic,
526
- mnemonicAccountIndex: identity.accountIndex,
527
- ilpInfo: {
528
- pubkey: "00".repeat(32),
529
- ilpAddress: "g.toon.client",
530
- btpEndpoint: topology.btpUrl ?? "",
531
- assetCode: "USD",
532
- assetScale: 6
533
- },
534
- toonEncoder: encodeEventToToon,
535
- toonDecoder: decodeEventFromToon,
536
- ...topology.btpUrl ? { btpUrl: topology.btpUrl, btpAuthToken: "" } : {},
537
- destinationAddress: topology.destination,
538
- // The embedded client bootstraps against the known peer above; its
539
- // `relayUrl` config only seeds ArDrive-merged peers, so it stays unset.
540
- relayUrl: "",
541
- knownPeers: topology.knownPeers,
542
- channelStorePath,
543
- ...topology.supportedChains ? { supportedChains: topology.supportedChains } : {},
544
- ...file.settlementAddresses ? { settlementAddresses: file.settlementAddresses } : {},
545
- ...topology.preferredTokens ? { preferredTokens: topology.preferredTokens } : {},
546
- ...topology.tokenNetworks ? { tokenNetworks: topology.tokenNetworks } : {},
547
- ...topology.chainRpcUrls ? { chainRpcUrls: topology.chainRpcUrls } : {},
548
- ...file.solanaChannel ? { solanaChannel: file.solanaChannel } : {},
549
- ...file.minaChannel ? { minaChannel: file.minaChannel } : {}
779
+ const buildPublisher = (topo) => {
780
+ const clientConfig = {
781
+ // validateConfig requires connectorUrl OR proxyUrl; with BTP-only
782
+ // config a dummy connectorUrl satisfies it (unused at runtime — same
783
+ // convention as the daemon).
784
+ ...topo.proxyUrl ? { proxyUrl: topo.proxyUrl } : { connectorUrl: "http://127.0.0.1:1" },
785
+ mnemonic: identity.mnemonic,
786
+ mnemonicAccountIndex: identity.accountIndex,
787
+ ilpInfo: {
788
+ pubkey: "00".repeat(32),
789
+ ilpAddress: "g.toon.client",
790
+ btpEndpoint: topo.btpUrl ?? "",
791
+ assetCode: "USD",
792
+ assetScale: 6
793
+ },
794
+ toonEncoder: encodeEventToToon,
795
+ toonDecoder: decodeEventFromToon,
796
+ ...topo.btpUrl ? { btpUrl: topo.btpUrl, btpAuthToken: "" } : {},
797
+ destinationAddress: topo.destination,
798
+ // The embedded client bootstraps against the known peer above; its
799
+ // `relayUrl` config only seeds ArDrive-merged peers, so it stays unset.
800
+ relayUrl: "",
801
+ knownPeers: topo.knownPeers,
802
+ channelStorePath,
803
+ ...topo.supportedChains ? { supportedChains: topo.supportedChains } : {},
804
+ ...file.settlementAddresses ? { settlementAddresses: file.settlementAddresses } : {},
805
+ ...topo.preferredTokens ? { preferredTokens: topo.preferredTokens } : {},
806
+ ...topo.tokenNetworks ? { tokenNetworks: topo.tokenNetworks } : {},
807
+ ...topo.chainRpcUrls ? { chainRpcUrls: topo.chainRpcUrls } : {},
808
+ ...file.solanaChannel ? { solanaChannel: file.solanaChannel } : {},
809
+ ...file.minaChannel ? { minaChannel: file.minaChannel } : {}
810
+ };
811
+ return new StandalonePublisher({
812
+ clientConfig,
813
+ eventFee,
814
+ channelMap,
815
+ warn,
816
+ ...topo.publishDestination ? { publishDestination: topo.publishDestination } : {},
817
+ ...topo.storeDestination ? { storeDestination: topo.storeDestination } : {},
818
+ // `rig channel open --peer` (#263): anchor the channel (and its map
819
+ // key) to an explicit peer destination instead of the configured
820
+ // default.
821
+ ...options.channelDestination ? { channelDestination: options.channelDestination } : {},
822
+ // The peer's announce does not carry TokenNetwork/token parameters, so
823
+ // the client's negotiation leaves them empty (#260 root cause 3) — the
824
+ // publisher back-fills them from the derived per-chain maps before the
825
+ // channel opens.
826
+ ...topo.tokenNetworks || topo.preferredTokens ? {
827
+ negotiationFallbacks: {
828
+ ...topo.tokenNetworks ? { tokenNetworks: topo.tokenNetworks } : {},
829
+ ...topo.preferredTokens ? { preferredTokens: topo.preferredTokens } : {}
830
+ }
831
+ } : {}
832
+ });
550
833
  };
551
- const publisher = new StandalonePublisher({
552
- clientConfig,
553
- eventFee,
554
- channelMap,
555
- warn,
556
- ...topology.publishDestination ? { publishDestination: topology.publishDestination } : {},
557
- ...topology.storeDestination ? { storeDestination: topology.storeDestination } : {},
558
- // `rig channel open --peer` (#263): anchor the channel (and its map key)
559
- // to an explicit peer destination instead of the configured default.
560
- ...options.channelDestination ? { channelDestination: options.channelDestination } : {},
561
- // The peer's announce does not carry TokenNetwork/token parameters, so
562
- // the client's negotiation leaves them empty (#260 root cause 3) — the
563
- // publisher back-fills them from the derived per-chain maps before the
564
- // channel opens.
565
- ...topology.tokenNetworks || topology.preferredTokens ? {
566
- negotiationFallbacks: {
567
- ...topology.tokenNetworks ? { tokenNetworks: topology.tokenNetworks } : {},
568
- ...topology.preferredTokens ? { preferredTokens: topology.preferredTokens } : {}
569
- }
570
- } : {}
571
- });
834
+ const publisher = new TopologyRecoveringPublisher(
835
+ buildPublisher(topology),
836
+ cached ? async () => {
837
+ cache.invalidate(cacheKey);
838
+ return buildPublisher(await resolveLiveTopology());
839
+ } : void 0,
840
+ warn
841
+ );
572
842
  return {
573
843
  ownerPubkey: publisher.getPublicKey(),
574
844
  identitySource: identity.source,
@@ -592,4 +862,4 @@ export {
592
862
  createStandaloneContext,
593
863
  resolveNetworkTopology
594
864
  };
595
- //# sourceMappingURL=standalone-mode-FCKTQ33Y.js.map
865
+ //# sourceMappingURL=standalone-mode-JLGAUMRF.js.map