@rhinestone/shared-configs 2.20.1 → 2.21.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.
@@ -10,7 +10,8 @@
10
10
  "reorg_window_k": 32,
11
11
  "reverify_delay_ms": 500,
12
12
  "scan_batch_max_blocks": 200,
13
- "streaming_poll_interval_ms": 36000
13
+ "streaming_poll_interval_ms": 36000,
14
+ "vm": "evm"
14
15
  },
15
16
  "10": {
16
17
  "finality": "sequencer",
@@ -23,7 +24,8 @@
23
24
  "reorg_window_k": 16,
24
25
  "reverify_delay_ms": 500,
25
26
  "scan_batch_max_blocks": 200,
26
- "streaming_poll_interval_ms": 6000
27
+ "streaming_poll_interval_ms": 6000,
28
+ "vm": "evm"
27
29
  },
28
30
  "56": {
29
31
  "finality": "probabilistic",
@@ -36,7 +38,8 @@
36
38
  "reorg_window_k": 32,
37
39
  "reverify_delay_ms": 500,
38
40
  "scan_batch_max_blocks": 200,
39
- "streaming_poll_interval_ms": 3000
41
+ "streaming_poll_interval_ms": 3000,
42
+ "vm": "evm"
40
43
  },
41
44
  "100": {
42
45
  "finality": "probabilistic",
@@ -49,7 +52,8 @@
49
52
  "reorg_window_k": 32,
50
53
  "reverify_delay_ms": 500,
51
54
  "scan_batch_max_blocks": 200,
52
- "streaming_poll_interval_ms": 15000
55
+ "streaming_poll_interval_ms": 15000,
56
+ "vm": "evm"
53
57
  },
54
58
  "130": {
55
59
  "finality": "sequencer",
@@ -62,7 +66,8 @@
62
66
  "reorg_window_k": 16,
63
67
  "reverify_delay_ms": 500,
64
68
  "scan_batch_max_blocks": 200,
65
- "streaming_poll_interval_ms": 3000
69
+ "streaming_poll_interval_ms": 3000,
70
+ "vm": "evm"
66
71
  },
67
72
  "137": {
68
73
  "finality": "probabilistic",
@@ -75,7 +80,8 @@
75
80
  "reorg_window_k": 64,
76
81
  "reverify_delay_ms": 500,
77
82
  "scan_batch_max_blocks": 200,
78
- "streaming_poll_interval_ms": 4500
83
+ "streaming_poll_interval_ms": 4500,
84
+ "vm": "evm"
79
85
  },
80
86
  "143": {
81
87
  "finality": "instant",
@@ -88,7 +94,8 @@
88
94
  "reorg_window_k": 4,
89
95
  "reverify_delay_ms": 0,
90
96
  "scan_batch_max_blocks": 200,
91
- "streaming_poll_interval_ms": 3000
97
+ "streaming_poll_interval_ms": 3000,
98
+ "vm": "evm"
92
99
  },
93
100
  "146": {
94
101
  "finality": "instant",
@@ -101,7 +108,8 @@
101
108
  "reorg_window_k": 4,
102
109
  "reverify_delay_ms": 0,
103
110
  "scan_batch_max_blocks": 200,
104
- "streaming_poll_interval_ms": 4500
111
+ "streaming_poll_interval_ms": 4500,
112
+ "vm": "evm"
105
113
  },
106
114
  "196": {
107
115
  "finality": "sequencer",
@@ -114,7 +122,8 @@
114
122
  "reorg_window_k": 16,
115
123
  "reverify_delay_ms": 500,
116
124
  "scan_batch_max_blocks": 200,
117
- "streaming_poll_interval_ms": 3000
125
+ "streaming_poll_interval_ms": 3000,
126
+ "vm": "evm"
118
127
  },
119
128
  "999": {
120
129
  "finality": "instant",
@@ -127,7 +136,8 @@
127
136
  "reorg_window_k": 4,
128
137
  "reverify_delay_ms": 0,
129
138
  "scan_batch_max_blocks": 200,
130
- "streaming_poll_interval_ms": 3000
139
+ "streaming_poll_interval_ms": 3000,
140
+ "vm": "evm"
131
141
  },
132
142
  "1868": {
133
143
  "finality": "sequencer",
@@ -140,7 +150,8 @@
140
150
  "reorg_window_k": 16,
141
151
  "reverify_delay_ms": 500,
142
152
  "scan_batch_max_blocks": 200,
143
- "streaming_poll_interval_ms": 6000
153
+ "streaming_poll_interval_ms": 6000,
154
+ "vm": "evm"
144
155
  },
145
156
  "4663": {
146
157
  "finality": "sequencer",
@@ -153,7 +164,8 @@
153
164
  "reorg_window_k": 16,
154
165
  "reverify_delay_ms": 500,
155
166
  "scan_batch_max_blocks": 200,
156
- "streaming_poll_interval_ms": 3000
167
+ "streaming_poll_interval_ms": 3000,
168
+ "vm": "evm"
157
169
  },
158
170
  "8453": {
159
171
  "finality": "sequencer",
@@ -166,7 +178,8 @@
166
178
  "reorg_window_k": 16,
167
179
  "reverify_delay_ms": 500,
168
180
  "scan_batch_max_blocks": 200,
169
- "streaming_poll_interval_ms": 6000
181
+ "streaming_poll_interval_ms": 6000,
182
+ "vm": "evm"
170
183
  },
171
184
  "9745": {
172
185
  "finality": "instant",
@@ -179,7 +192,8 @@
179
192
  "reorg_window_k": 4,
180
193
  "reverify_delay_ms": 0,
181
194
  "scan_batch_max_blocks": 200,
182
- "streaming_poll_interval_ms": 3000
195
+ "streaming_poll_interval_ms": 3000,
196
+ "vm": "evm"
183
197
  },
184
198
  "9746": {
185
199
  "finality": "instant",
@@ -192,7 +206,8 @@
192
206
  "reorg_window_k": 4,
193
207
  "reverify_delay_ms": 0,
194
208
  "scan_batch_max_blocks": 200,
195
- "streaming_poll_interval_ms": 3000
209
+ "streaming_poll_interval_ms": 3000,
210
+ "vm": "evm"
196
211
  },
197
212
  "42161": {
198
213
  "finality": "sequencer",
@@ -205,7 +220,8 @@
205
220
  "reorg_window_k": 16,
206
221
  "reverify_delay_ms": 500,
207
222
  "scan_batch_max_blocks": 200,
208
- "streaming_poll_interval_ms": 3000
223
+ "streaming_poll_interval_ms": 3000,
224
+ "vm": "evm"
209
225
  },
210
226
  "43114": {
211
227
  "finality": "instant",
@@ -218,7 +234,8 @@
218
234
  "reorg_window_k": 4,
219
235
  "reverify_delay_ms": 0,
220
236
  "scan_batch_max_blocks": 200,
221
- "streaming_poll_interval_ms": 3000
237
+ "streaming_poll_interval_ms": 3000,
238
+ "vm": "evm"
222
239
  },
223
240
  "57073": {
224
241
  "finality": "sequencer",
@@ -231,7 +248,8 @@
231
248
  "reorg_window_k": 16,
232
249
  "reverify_delay_ms": 500,
233
250
  "scan_batch_max_blocks": 200,
234
- "streaming_poll_interval_ms": 3000
251
+ "streaming_poll_interval_ms": 3000,
252
+ "vm": "evm"
235
253
  },
236
254
  "84532": {
237
255
  "finality": "sequencer",
@@ -244,7 +262,8 @@
244
262
  "reorg_window_k": 8,
245
263
  "reverify_delay_ms": 500,
246
264
  "scan_batch_max_blocks": 200,
247
- "streaming_poll_interval_ms": 6000
265
+ "streaming_poll_interval_ms": 6000,
266
+ "vm": "evm"
248
267
  },
249
268
  "421614": {
250
269
  "finality": "sequencer",
@@ -257,7 +276,8 @@
257
276
  "reorg_window_k": 8,
258
277
  "reverify_delay_ms": 500,
259
278
  "scan_batch_max_blocks": 200,
260
- "streaming_poll_interval_ms": 3000
279
+ "streaming_poll_interval_ms": 3000,
280
+ "vm": "evm"
261
281
  },
262
282
  "747474": {
263
283
  "finality": "sequencer",
@@ -270,7 +290,8 @@
270
290
  "reorg_window_k": 16,
271
291
  "reverify_delay_ms": 500,
272
292
  "scan_batch_max_blocks": 200,
273
- "streaming_poll_interval_ms": 3000
293
+ "streaming_poll_interval_ms": 3000,
294
+ "vm": "evm"
274
295
  },
275
296
  "11155111": {
276
297
  "finality": "probabilistic",
@@ -283,7 +304,8 @@
283
304
  "reorg_window_k": 32,
284
305
  "reverify_delay_ms": 500,
285
306
  "scan_batch_max_blocks": 200,
286
- "streaming_poll_interval_ms": 36000
307
+ "streaming_poll_interval_ms": 36000,
308
+ "vm": "evm"
287
309
  },
288
310
  "11155420": {
289
311
  "finality": "sequencer",
@@ -296,6 +318,29 @@
296
318
  "reorg_window_k": 8,
297
319
  "reverify_delay_ms": 500,
298
320
  "scan_batch_max_blocks": 200,
299
- "streaming_poll_interval_ms": 6000
321
+ "streaming_poll_interval_ms": 6000,
322
+ "vm": "evm"
323
+ },
324
+ "792703809": {
325
+ "commitment": "finalized",
326
+ "max_catchup_signatures": 5000,
327
+ "overlap_slots": 300,
328
+ "page_limit": 1000,
329
+ "poll_interval_ms": 2000,
330
+ "topics": [
331
+ "5q44zFF8gziimbCipqNQ7GYed8pkTRDmUKFUFTwd4DjB"
332
+ ],
333
+ "vm": "svm"
334
+ },
335
+ "792703810": {
336
+ "commitment": "finalized",
337
+ "max_catchup_signatures": 5000,
338
+ "overlap_slots": 300,
339
+ "page_limit": 1000,
340
+ "poll_interval_ms": 2000,
341
+ "topics": [
342
+ "5q44zFF8gziimbCipqNQ7GYed8pkTRDmUKFUFTwd4DjB"
343
+ ],
344
+ "vm": "svm"
300
345
  }
301
346
  }
@@ -3142,6 +3142,7 @@
3142
3142
  "alchemy": null,
3143
3143
  "zerion": null
3144
3144
  },
3145
+ "rpcId": "mainnet-beta",
3145
3146
  "sessionCapable": false,
3146
3147
  "settlementConfig": {
3147
3148
  "cctp": {
@@ -3246,6 +3247,7 @@
3246
3247
  "alchemy": null,
3247
3248
  "zerion": null
3248
3249
  },
3250
+ "rpcId": "devnet",
3249
3251
  "sessionCapable": false,
3250
3252
  "settlementConfig": {
3251
3253
  "cctp": {
@@ -327,6 +327,56 @@ function errorsFor(chain) {
327
327
  (0, bun_test_1.expect)(error.message).toContain("TELEPORT");
328
328
  (0, bun_test_1.expect)(error.message).toContain("src/types.ts");
329
329
  });
330
+ // Absent is fine: chainRegistry() defaults it to the chain id, and a
331
+ // consumer reading a document written before the field existed does the
332
+ // same. Only the two ways AUTHORING can get it wrong are checked.
333
+ (0, bun_test_1.it)("accepts a chain with no rpcId", () => {
334
+ (0, bun_test_1.expect)(errorsFor(validChain())).toEqual([]);
335
+ });
336
+ (0, bun_test_1.it)("accepts an rpcId that matches the chain id", () => {
337
+ (0, bun_test_1.expect)(errorsFor(validChain({ rpcId: "1" }))).toEqual([]);
338
+ });
339
+ (0, bun_test_1.it)("rejects an rpcId override on an EVM chain", () => {
340
+ const [error] = errorsFor(validChain({ rpcId: "mainnet" }));
341
+ (0, bun_test_1.expect)(error.message).toContain("does not match the chain id");
342
+ });
343
+ // The failure this prevents is an empty feed, which reads exactly like a
344
+ // chain with no traffic — so it has to fail the build, not runtime.
345
+ (0, bun_test_1.it)("rejects a Solana chain whose rpcId is still the numeric default", () => {
346
+ const errors = (0, validation_1.validateChainConfig)({
347
+ "792703810": validChain({
348
+ name: "Solana Devnet",
349
+ vmType: "svm",
350
+ network: "testnet",
351
+ caip2: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
352
+ rpcId: "792703810",
353
+ }),
354
+ }).issues.filter((i) => i.severity === "error");
355
+ (0, bun_test_1.expect)(errors.map((e) => e.message).join("\n")).toContain("addressed by its cluster name");
356
+ });
357
+ (0, bun_test_1.it)("rejects a Solana chain with no rpcId at all", () => {
358
+ const errors = (0, validation_1.validateChainConfig)({
359
+ "792703810": validChain({
360
+ name: "Solana Devnet",
361
+ vmType: "svm",
362
+ network: "testnet",
363
+ caip2: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
364
+ }),
365
+ }).issues.filter((i) => i.severity === "error");
366
+ (0, bun_test_1.expect)(errors.map((e) => e.message).join("\n")).toContain("addressed by its cluster name");
367
+ });
368
+ (0, bun_test_1.it)("accepts a Solana chain addressed by its cluster", () => {
369
+ const errors = (0, validation_1.validateChainConfig)({
370
+ "792703810": validChain({
371
+ name: "Solana Devnet",
372
+ vmType: "svm",
373
+ network: "testnet",
374
+ caip2: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
375
+ rpcId: "devnet",
376
+ }),
377
+ }).issues.filter((i) => i.severity === "error");
378
+ (0, bun_test_1.expect)(errors).toEqual([]);
379
+ });
330
380
  });
331
381
  (0, bun_test_1.describe)("validateChainConfig — reachability", () => {
332
382
  (0, bun_test_1.it)("returns no issues for chains with multiple settlement layers", () => {
@@ -516,6 +566,7 @@ function errorsFor(chain) {
516
566
  /** A complete class-B entry, so semantic tests don't trip the shape checks. */
517
567
  function validIndexerEntry(overrides = {}) {
518
568
  return {
569
+ vm: "evm",
519
570
  poll_interval_ms: 1000,
520
571
  streaming_poll_interval_ms: 15000,
521
572
  reorg_window_k: 16,
@@ -541,12 +592,12 @@ function validIndexerEntry(overrides = {}) {
541
592
  (0, bun_test_1.expect)(issues).toHaveLength(1);
542
593
  (0, bun_test_1.expect)(issues[0].message).toContain("no chain-indexer entry");
543
594
  });
544
- (0, bun_test_1.it)("reports an entry addressing no real EVM chain", () => {
595
+ (0, bun_test_1.it)("reports an entry addressing a chain the indexer does not dial", () => {
545
596
  const issues = (0, validation_1.validateChainIndexerCoverage)({}, {
546
597
  "1": validIndexerEntry(),
547
598
  });
548
599
  (0, bun_test_1.expect)(issues).toHaveLength(1);
549
- (0, bun_test_1.expect)(issues[0].message).toContain("no real EVM chain");
600
+ (0, bun_test_1.expect)(issues[0].message).toContain("no chain the indexer dials");
550
601
  });
551
602
  // HyperCore is vmType "evm" with a hypercore: caip2 — the eip155 namespace,
552
603
  // not vmType, decides whether the indexer dials a chain.
@@ -627,6 +678,105 @@ function validIndexerEntry(overrides = {}) {
627
678
  });
628
679
  (0, bun_test_1.expect)(issues.map((i) => i.message).join("\n")).toContain("the only legal value is false");
629
680
  });
681
+ /** The Solana entry as the artifact publishes it. */
682
+ function validSvmEntry(overrides = {}) {
683
+ return {
684
+ vm: "svm",
685
+ poll_interval_ms: 2000,
686
+ commitment: "finalized",
687
+ topics: ["5q44zFF8gziimbCipqNQ7GYed8pkTRDmUKFUFTwd4DjB"],
688
+ page_limit: 1000,
689
+ overlap_slots: 300,
690
+ max_catchup_signatures: 5000,
691
+ ...overrides,
692
+ };
693
+ }
694
+ function solanaChain(overrides = {}) {
695
+ return validChain({
696
+ name: "Solana Devnet",
697
+ vmType: "svm",
698
+ network: "testnet",
699
+ caip2: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
700
+ rpcId: "devnet",
701
+ ...overrides,
702
+ });
703
+ }
704
+ (0, bun_test_1.it)("accepts a complete SVM entry", () => {
705
+ (0, bun_test_1.expect)((0, validation_1.validateChainIndexerCoverage)({ "792703810": solanaChain() }, {
706
+ "792703810": validSvmEntry(),
707
+ })).toEqual([]);
708
+ });
709
+ (0, bun_test_1.it)("requires an entry for a Solana chain, like any other dialable chain", () => {
710
+ const issues = (0, validation_1.validateChainIndexerCoverage)({ "792703810": solanaChain() }, {});
711
+ (0, bun_test_1.expect)(issues).toHaveLength(1);
712
+ (0, bun_test_1.expect)(issues[0].message).toContain("SVM chain has no chain-indexer entry");
713
+ });
714
+ // The knob sets are disjoint, so judging an entry against the wrong one
715
+ // reports every field of both as wrong — which is the point: an SVM entry is
716
+ // not an EVM entry missing eleven fields.
717
+ (0, bun_test_1.it)("rejects EVM knobs on a Solana chain", () => {
718
+ const issues = (0, validation_1.validateChainIndexerCoverage)({ "792703810": solanaChain() }, { "792703810": validIndexerEntry() });
719
+ (0, bun_test_1.expect)(issues.map((i) => i.message).join("\n")).toContain('`vm` = "evm" but solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 is a svm chain');
720
+ });
721
+ // The dangerous direction: the indexer reads `vm` first and passes over
722
+ // families it does not index, so an svm tag on an EVM chain would validate
723
+ // as a coherent SVM entry here and then leave that chain silently unscanned.
724
+ (0, bun_test_1.it)("rejects an svm tag on an EVM chain", () => {
725
+ const issues = (0, validation_1.validateChainIndexerCoverage)({ "1": validChain() }, {
726
+ "1": validSvmEntry(),
727
+ });
728
+ (0, bun_test_1.expect)(issues.map((i) => i.message).join("\n")).toContain("would never be scanned");
729
+ });
730
+ bun_test_1.it.each([
731
+ ["a missing SVM field", { topics: undefined }, "missing `topics`"],
732
+ ["an empty topic list", { topics: [] }, "`topics` = [] is not a legal value"],
733
+ [
734
+ "a malformed topic",
735
+ { topics: ["not-base58-и"] },
736
+ "is not a legal value",
737
+ ],
738
+ // A character-count regex accepts both of these — they are legal base58
739
+ // characters at a legal length — but neither decodes to a 32-byte account.
740
+ // A marker like this would pass the build and then name a feed no
741
+ // transaction can appear in, which reads as a chain with no traffic.
742
+ [
743
+ "a topic that decodes to 33 bytes",
744
+ { topics: ["z".repeat(44)] },
745
+ "is not a legal value",
746
+ ],
747
+ [
748
+ "a topic that decodes to 43 zero bytes",
749
+ { topics: ["1".repeat(43)] },
750
+ "is not a legal value",
751
+ ],
752
+ [
753
+ "a commitment nothing settles at",
754
+ { commitment: "processed" },
755
+ '`commitment` = "processed" is not a legal value',
756
+ ],
757
+ // Not a narrow allowlist: the SVM entry omits every reorg and retraction
758
+ // knob BECAUSE a finalized signature is settled. Reading at `confirmed`
759
+ // has to arrive with the retraction kind, not as a value a facts release
760
+ // can set on its own.
761
+ [
762
+ "a reversible commitment the schema cannot retract",
763
+ { commitment: "confirmed" },
764
+ '`commitment` = "confirmed" is not a legal value',
765
+ ],
766
+ [
767
+ "a page limit above the RPC ceiling",
768
+ { page_limit: 1001 },
769
+ "`page_limit` = 1001 is not a legal value",
770
+ ],
771
+ [
772
+ "an EVM knob smuggled in",
773
+ { reorg_window_k: 16 },
774
+ "unrecognized field `reorg_window_k`",
775
+ ],
776
+ ])("rejects %s on an SVM entry", (_label, overrides, expected) => {
777
+ const issues = (0, validation_1.validateChainIndexerCoverage)({ "792703810": solanaChain() }, { "792703810": validSvmEntry(overrides) });
778
+ (0, bun_test_1.expect)(issues.map((i) => i.message).join("\n")).toContain(expected);
779
+ });
630
780
  (0, bun_test_1.it)("accepts a coherent instant-class entry", () => {
631
781
  const entry = validIndexerEntry({
632
782
  finality: "instant",
@@ -162,6 +162,12 @@ function renderChains(chainRegistry, mainnets, testnets) {
162
162
  " /** Public, unauthenticated https JSON-RPC endpoint — what a browser needs to",
163
163
  " * build this chain with `defineChain`. Absent means display-only. */",
164
164
  " publicRpcUrl?: string;",
165
+ " /** How this chain is addressed when dialling it, within its architecture:",
166
+ " * the last segment of `{proxy}/{architecture}/{rpcId}`. Present only where",
167
+ " * the chain id is NOT what you dial — the cluster name (`mainnet-beta`,",
168
+ " * `devnet`) on Solana. Absent means the chain id is the id, so read it as",
169
+ " * `chain.rpcId ?? String(chainId)` rather than branching on vmType. */",
170
+ " rpcId?: string;",
165
171
  " /** True for virtual chains (e.g. HyperCore) that settle on another chain. */",
166
172
  " virtual?: boolean;",
167
173
  " /** Which directions of transfer the chain supports; absent means both.",
@@ -226,19 +226,24 @@ export declare function validateChainConfig(chains: ChainRegistry): ValidationRe
226
226
  */
227
227
  export declare function validateOftCoverage(chains: ChainRegistry, oft: Record<string, unknown>): ValidationIssue[];
228
228
  /**
229
- * Insists that every real EVM chain carries a complete, coherent chain-indexer
230
- * entry, and that no entry addresses a chain that does not exist.
229
+ * Insists that every chain the indexer dials carries a complete, coherent
230
+ * chain-indexer entry, and that no entry addresses a chain that does not exist.
231
231
  *
232
- * The indexer scans every dialable EVM chain, and its contract is that an
233
- * unclassified chain is a hard failure — this check moves that failure from
234
- * the indexer's boot to this build, where the person adding the chain sees it
235
- * (RHI-6107). Selected by the `eip155:` caip2 namespace, NOT by vmType:
232
+ * The indexer scans every dialable chain it has a machine for, and its contract
233
+ * is that an unclassified chain is a hard failure — this check moves that
234
+ * failure from the indexer's boot to this build, where the person adding the
235
+ * chain sees it (RHI-6107). Selected by the caip2 NAMESPACE, NOT by vmType:
236
236
  * HyperCore is vmType "evm" with a `hypercore:` caip2 and must not require an
237
237
  * entry — the same discriminator the publish workflow's contracts guard uses,
238
238
  * for the same reason. Runs after `classifyChainCaip2`, which stamps the field.
239
239
  *
240
- * The cross-field rules mirror chain-indexer's `ChainScanSettings::validate`,
241
- * so a value set this check accepts is one the indexer will accept.
240
+ * Two families today (`eip155:` → evm, `solana:` → svm), with disjoint knob
241
+ * sets — an entry is judged against the one its chain implies, and its own `vm`
242
+ * is cross-checked against that rather than trusted (RHI-7338).
243
+ *
244
+ * The EVM cross-field rules mirror chain-indexer's
245
+ * `ChainScanSettings::validate`, so a value set this check accepts is one the
246
+ * indexer will accept.
242
247
  *
243
248
  * A chain deliberately not indexed carries `{ indexed: false }` as its whole
244
249
  * entry — a decision the check accepts, unlike a missing entry.
@@ -104,6 +104,47 @@ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
104
104
  const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
105
105
  const isNonNegativeInt = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0;
106
106
  const isAbsentOr = (v, check) => v === undefined || check(v);
107
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
108
+ /**
109
+ * Decode base58 (Bitcoin/Solana alphabet), or null if the string is not
110
+ * base58. Each leading `1` stands for one leading zero byte.
111
+ *
112
+ * Decoded rather than pattern-matched because the length is what actually
113
+ * matters and a character-count regex does not imply it: 44 `z`s are all legal
114
+ * base58 characters and decode to THIRTY-THREE bytes, while 43 `1`s decode to
115
+ * 43 zero bytes. Both would pass a `{43,44}` regex and neither is an account.
116
+ * The failure that buys is a marker that validates here and then names a feed
117
+ * no transaction can ever appear in — silence indistinguishable from a chain
118
+ * with no traffic.
119
+ */
120
+ function decodeBase58(value) {
121
+ if (value.length === 0)
122
+ return null;
123
+ const bytes = [];
124
+ for (const char of value) {
125
+ let carry = BASE58_ALPHABET.indexOf(char);
126
+ if (carry < 0)
127
+ return null;
128
+ for (let i = 0; i < bytes.length; i++) {
129
+ carry += bytes[i] * 58;
130
+ bytes[i] = carry & 0xff;
131
+ carry >>= 8;
132
+ }
133
+ while (carry > 0) {
134
+ bytes.push(carry & 0xff);
135
+ carry >>= 8;
136
+ }
137
+ }
138
+ let zeros = 0;
139
+ while (zeros < value.length && value[zeros] === '1')
140
+ zeros += 1;
141
+ return Uint8Array.from([
142
+ ...new Array(zeros).fill(0),
143
+ ...bytes.reverse(),
144
+ ]);
145
+ }
146
+ /** A base58 Solana account address: exactly 32 bytes once decoded. */
147
+ const isBase58Account = (v) => typeof v === 'string' && decodeBase58(v)?.length === 32;
107
148
  // Every vendor layer's REQUIRED payload, keyed by the SettlementLayer value
108
149
  // that switches it on. This is what replaces the chain-id allowlists the
109
150
  // jsonnet used to carry with "must match <map> in the orchestrator" comments —
@@ -282,6 +323,30 @@ function structuralProblems(registryChainId, chain) {
282
323
  if (!isAbsentOr(chain.publicRpcUrl, isPublicRpcUrl)) {
283
324
  problems.push(`publicRpcUrl ${JSON.stringify(chain.publicRpcUrl)} is not an https:// URL`);
284
325
  }
326
+ // `rpcId` is authored, and only where the chain id is not what you dial — so
327
+ // absence is the normal answer and carries meaning ("the chain id is the
328
+ // id"). There is deliberately no blanket presence check; what is checked is
329
+ // the two ways authoring it can go wrong.
330
+ //
331
+ // An `rpcId` on an EVM chain that is not its own id routes every call for
332
+ // that chain to the wrong network. A Solana chain with none leaves a consumer
333
+ // falling back to `{proxy}/svm/792703810`, which reaches nothing — and the
334
+ // symptom is an empty feed, indistinguishable from a chain with no traffic,
335
+ // which is why it has to fail here rather than at runtime.
336
+ if (!isAbsentOr(chain.rpcId, isNonEmptyString)) {
337
+ problems.push(`rpcId ${JSON.stringify(chain.rpcId)} is not a non-empty string`);
338
+ }
339
+ else if (typeof chain.caip2 === 'string' &&
340
+ chain.caip2.startsWith('eip155:') &&
341
+ chain.rpcId !== undefined &&
342
+ chain.rpcId !== registryChainId) {
343
+ problems.push(`rpcId ${JSON.stringify(chain.rpcId)} does not match the chain id for an eip155 chain — EVM chains are addressed by their id, so drop the override`);
344
+ }
345
+ else if (typeof chain.caip2 === 'string' &&
346
+ chain.caip2.startsWith('solana:') &&
347
+ (chain.rpcId === undefined || chain.rpcId === registryChainId)) {
348
+ problems.push('rpcId is missing or is the chain id, but a Solana chain is addressed by its cluster name (e.g. "devnet") — author it in config/non_evm_chains.jsonnet');
349
+ }
285
350
  if (!isAbsentOr(chain.virtual, (v) => typeof v === 'boolean')) {
286
351
  problems.push('virtual is not a boolean');
287
352
  }
@@ -1236,12 +1301,15 @@ const CHAIN_INDEXER_FINALITY_SOURCE = new Set([
1236
1301
  'safe',
1237
1302
  'instant',
1238
1303
  ]);
1239
- // Every field of one chain-indexer entry, all required. The wire is exact on
1240
- // purpose: the indexer's own contract is "every field mandatory — a knob nobody
1241
- // set is a decision nobody made", so a missing field here is the same authoring
1242
- // mistake as a missing entry, and an unrecognized field is a typo that would
1243
- // otherwise ship silently.
1304
+ // Every field of one EVM chain-indexer entry, all required. The wire is exact
1305
+ // on purpose: the indexer's own contract is "every field mandatory — a knob
1306
+ // nobody set is a decision nobody made", so a missing field here is the same
1307
+ // authoring mistake as a missing entry, and an unrecognized field is a typo
1308
+ // that would otherwise ship silently.
1309
+ //
1310
+ // The SVM set below is disjoint from this one — see CHAIN_INDEXER_FIELDS_BY_VM.
1244
1311
  const CHAIN_INDEXER_FIELDS = {
1312
+ vm: (v) => v === 'evm',
1245
1313
  poll_interval_ms: isNonNegativeInt,
1246
1314
  streaming_poll_interval_ms: isNonNegativeInt,
1247
1315
  reorg_window_k: isNonNegativeInt,
@@ -1254,38 +1322,104 @@ const CHAIN_INDEXER_FIELDS = {
1254
1322
  finality_poll_interval_ms: isNonNegativeInt,
1255
1323
  grace_ms: isNonNegativeInt,
1256
1324
  };
1325
+ // `finalized` is the ONLY legal commitment, and that is a coherence rule, not
1326
+ // a narrow allowlist waiting to be widened.
1327
+ //
1328
+ // The SVM entry carries no reorg window, no grace period and no retraction
1329
+ // vocabulary — the family has one message kind where EVM has four — and it can
1330
+ // omit all of that precisely BECAUSE a finalized signature is settled. Admit
1331
+ // `confirmed` here and that stops being true: the indexer would publish
1332
+ // reversible signatures as settled with nothing in the schema able to take
1333
+ // them back, and the build would pass.
1334
+ //
1335
+ // So the two travel together. Reading at `confirmed` is a real thing we may
1336
+ // want one day; it arrives with the retraction kind, as one change, not as a
1337
+ // value someone can set in a facts release. Same shape as the EVM rule that
1338
+ // `finality: instant` and `finality_source: instant` imply each other.
1339
+ //
1340
+ // `processed` is absent for a further reason: eRPC refuses it outright for
1341
+ // getSignaturesForAddress and getTransaction.
1342
+ const CHAIN_INDEXER_COMMITMENT = new Set(['finalized']);
1343
+ // The SVM knob set — a different machine, so none of the EVM fields above
1344
+ // apply. There is no block scan, no parent-hash chain and no log filter: the
1345
+ // unit is a transaction signature reached through the address → signature
1346
+ // index. And no reorg or finality knobs, because ingest is at `finalized`
1347
+ // commitment where a signature is settled (RHI-7338).
1348
+ //
1349
+ // `commitment` is its own field rather than an assumed constant because
1350
+ // omitting it on the wire silently yields eRPC's `confirmed` network default —
1351
+ // unrooted reads that look exactly like correct operation.
1352
+ const CHAIN_INDEXER_SVM_FIELDS = {
1353
+ vm: (v) => v === 'svm',
1354
+ poll_interval_ms: isNonNegativeInt,
1355
+ commitment: (v) => typeof v === 'string' && CHAIN_INDEXER_COMMITMENT.has(v),
1356
+ // A list, not a scalar: rotating the marker or adding our own no-op program
1357
+ // id as a second topic must be a facts release, not an indexer deploy. Each
1358
+ // entry is a base58 account address.
1359
+ topics: (v) => Array.isArray(v) && v.length > 0 && v.every((t) => isBase58Account(t)),
1360
+ page_limit: (v) => isNonNegativeInt(v) && v > 0 && v <= 1000,
1361
+ overlap_slots: isNonNegativeInt,
1362
+ max_catchup_signatures: isNonNegativeInt,
1363
+ };
1364
+ // Which machine an entry's knobs belong to. Mirrors the `vm` tag chain-indexer
1365
+ // reads before anything else in the entry: an unrecognised value there is
1366
+ // skipped rather than indexed, so a typo here would silently stop a chain
1367
+ // being scanned, which is why it fails the build instead.
1368
+ const CHAIN_INDEXER_FIELDS_BY_VM = {
1369
+ evm: CHAIN_INDEXER_FIELDS,
1370
+ svm: CHAIN_INDEXER_SVM_FIELDS,
1371
+ };
1372
+ // Which caip2 namespace implies which machine, for the cross-check that an
1373
+ // entry's `vm` matches the chain it addresses. Namespace rather than vmType,
1374
+ // for the same reason coverage is selected that way: HyperCore is vmType "evm"
1375
+ // under a `hypercore:` caip2 and is not a chain the indexer dials.
1376
+ const CHAIN_INDEXER_VM_BY_NAMESPACE = {
1377
+ eip155: 'evm',
1378
+ solana: 'svm',
1379
+ };
1257
1380
  /**
1258
- * Insists that every real EVM chain carries a complete, coherent chain-indexer
1259
- * entry, and that no entry addresses a chain that does not exist.
1381
+ * Insists that every chain the indexer dials carries a complete, coherent
1382
+ * chain-indexer entry, and that no entry addresses a chain that does not exist.
1260
1383
  *
1261
- * The indexer scans every dialable EVM chain, and its contract is that an
1262
- * unclassified chain is a hard failure — this check moves that failure from
1263
- * the indexer's boot to this build, where the person adding the chain sees it
1264
- * (RHI-6107). Selected by the `eip155:` caip2 namespace, NOT by vmType:
1384
+ * The indexer scans every dialable chain it has a machine for, and its contract
1385
+ * is that an unclassified chain is a hard failure — this check moves that
1386
+ * failure from the indexer's boot to this build, where the person adding the
1387
+ * chain sees it (RHI-6107). Selected by the caip2 NAMESPACE, NOT by vmType:
1265
1388
  * HyperCore is vmType "evm" with a `hypercore:` caip2 and must not require an
1266
1389
  * entry — the same discriminator the publish workflow's contracts guard uses,
1267
1390
  * for the same reason. Runs after `classifyChainCaip2`, which stamps the field.
1268
1391
  *
1269
- * The cross-field rules mirror chain-indexer's `ChainScanSettings::validate`,
1270
- * so a value set this check accepts is one the indexer will accept.
1392
+ * Two families today (`eip155:` → evm, `solana:` → svm), with disjoint knob
1393
+ * sets — an entry is judged against the one its chain implies, and its own `vm`
1394
+ * is cross-checked against that rather than trusted (RHI-7338).
1395
+ *
1396
+ * The EVM cross-field rules mirror chain-indexer's
1397
+ * `ChainScanSettings::validate`, so a value set this check accepts is one the
1398
+ * indexer will accept.
1271
1399
  *
1272
1400
  * A chain deliberately not indexed carries `{ indexed: false }` as its whole
1273
1401
  * entry — a decision the check accepts, unlike a missing entry.
1274
1402
  */
1275
1403
  function validateChainIndexerCoverage(chains, chainIndexer) {
1276
1404
  const issues = [];
1277
- const requiresEntry = (chain) => typeof chain.caip2 === 'string' &&
1278
- chain.caip2.startsWith('eip155:') &&
1279
- chain.virtual !== true;
1405
+ // Which machine's knobs a chain takes, or undefined for one the indexer does
1406
+ // not dial at all. Keyed on the caip2 NAMESPACE for the reason in the doc
1407
+ // comment: HyperCore is vmType "evm" under `hypercore:` and must stay
1408
+ // exempt.
1409
+ const machineOf = (chain) => typeof chain.caip2 === 'string'
1410
+ ? CHAIN_INDEXER_VM_BY_NAMESPACE[chain.caip2.split(':')[0]]
1411
+ : undefined;
1412
+ const requiresEntry = (chain) => machineOf(chain) !== undefined && chain.virtual !== true;
1280
1413
  for (const [chainId, chain] of Object.entries(chains)) {
1281
- if (!requiresEntry(chain))
1414
+ const machine = machineOf(chain);
1415
+ if (machine === undefined || chain.virtual === true)
1282
1416
  continue;
1283
1417
  if (chainIndexer[chainId] === undefined) {
1284
1418
  issues.push({
1285
1419
  severity: 'error',
1286
1420
  chainId,
1287
1421
  chainName: isNonEmptyString(chain.name) ? chain.name : '<unnamed>',
1288
- message: 'EVM chain has no chain-indexer entry — classify it in config/chain-indexer.jsonnet (see docs/add-chain.md, "Classify the chain for the indexer")',
1422
+ message: `${machine.toUpperCase()} chain has no chain-indexer entry — classify it in config/chain-indexer.jsonnet (see docs/add-chain.md, "Classify the chain for the indexer")`,
1289
1423
  });
1290
1424
  }
1291
1425
  }
@@ -1294,7 +1428,7 @@ function validateChainIndexerCoverage(chains, chainIndexer) {
1294
1428
  const chainName = chain && isNonEmptyString(chain.name) ? chain.name : '<not in registry>';
1295
1429
  const issue = (message) => issues.push({ severity: 'error', chainId, chainName, message });
1296
1430
  if (!chain || !requiresEntry(chain)) {
1297
- issue('chain-indexer entry addresses no real EVM chain — remove it from config/chain-indexer.jsonnet, or add the chain first');
1431
+ issue('chain-indexer entry addresses no chain the indexer dials — remove it from config/chain-indexer.jsonnet, or add the chain first');
1298
1432
  continue;
1299
1433
  }
1300
1434
  if (!isObject(entry)) {
@@ -1316,7 +1450,19 @@ function validateChainIndexerCoverage(chains, chainIndexer) {
1316
1450
  }
1317
1451
  continue;
1318
1452
  }
1319
- for (const [field, check] of Object.entries(CHAIN_INDEXER_FIELDS)) {
1453
+ // Which machine's knobs this entry is judged against. Cross-checked
1454
+ // against the chain rather than taken from the entry: an `svm` tag on an
1455
+ // eip155 chain would otherwise be validated as a coherent SVM entry and
1456
+ // then silently skipped by the indexer, which reads `vm` first and passes
1457
+ // over families it does not index — a chain that stops being scanned with
1458
+ // nothing failing anywhere (RHI-7338).
1459
+ const machine = machineOf(chain);
1460
+ if (entry.vm !== undefined && entry.vm !== machine) {
1461
+ issue(`chain-indexer \`vm\` = ${JSON.stringify(entry.vm)} but ${chain.caip2} is a ${machine} chain — the indexer skips entries for machines it does not index, so this one would never be scanned`);
1462
+ continue;
1463
+ }
1464
+ const fields = CHAIN_INDEXER_FIELDS_BY_VM[machine];
1465
+ for (const [field, check] of Object.entries(fields)) {
1320
1466
  if (entry[field] === undefined) {
1321
1467
  issue(`chain-indexer entry is missing \`${field}\` — every field is mandatory, a knob nobody set is a decision nobody made`);
1322
1468
  }
@@ -1325,10 +1471,17 @@ function validateChainIndexerCoverage(chains, chainIndexer) {
1325
1471
  }
1326
1472
  }
1327
1473
  for (const field of Object.keys(entry)) {
1328
- if (!(field in CHAIN_INDEXER_FIELDS)) {
1474
+ if (!(field in fields)) {
1329
1475
  issue(`chain-indexer entry carries unrecognized field \`${field}\` — the artifact's shape is exact, fix the name or drop it`);
1330
1476
  }
1331
1477
  }
1478
+ // The cross-field rules below are EVM classification coherence — reorg
1479
+ // window against finality source against watermark cadence. The SVM entry
1480
+ // has no reorg or finality knobs to be incoherent about: at `finalized`
1481
+ // commitment a signature is settled, so there is no second classification
1482
+ // to agree with.
1483
+ if (machine !== 'evm')
1484
+ continue;
1332
1485
  const instantClass = entry.finality === 'instant';
1333
1486
  const instantSource = entry.finality_source === 'instant';
1334
1487
  if (instantClass !== instantSource) {
@@ -66,6 +66,12 @@ interface Chain {
66
66
  /** Public, unauthenticated https JSON-RPC endpoint — what a browser needs to
67
67
  * build this chain with `defineChain`. Absent means display-only. */
68
68
  publicRpcUrl?: string;
69
+ /** How this chain is addressed when dialling it, within its architecture:
70
+ * the last segment of `{proxy}/{architecture}/{rpcId}`. Present only where
71
+ * the chain id is NOT what you dial — the cluster name (`mainnet-beta`,
72
+ * `devnet`) on Solana. Absent means the chain id is the id, so read it as
73
+ * `chain.rpcId ?? String(chainId)` rather than branching on vmType. */
74
+ rpcId?: string;
69
75
  /** True for virtual chains (e.g. HyperCore) that settle on another chain. */
70
76
  virtual?: boolean;
71
77
  /** Which directions of transfer the chain supports; absent means both.
@@ -3245,6 +3245,7 @@ const chains = {
3245
3245
  "alchemy": null,
3246
3246
  "zerion": null
3247
3247
  },
3248
+ "rpcId": "mainnet-beta",
3248
3249
  "sessionCapable": false,
3249
3250
  "settlementConfig": {
3250
3251
  "cctp": {
@@ -3350,6 +3351,7 @@ const chains = {
3350
3351
  "alchemy": null,
3351
3352
  "zerion": null
3352
3353
  },
3354
+ "rpcId": "devnet",
3353
3355
  "sessionCapable": false,
3354
3356
  "settlementConfig": {
3355
3357
  "cctp": {
@@ -1,2 +1,2 @@
1
- declare const PACKAGE_VERSION = "2.20.1";
1
+ declare const PACKAGE_VERSION = "2.21.0";
2
2
  export { PACKAGE_VERSION };
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PACKAGE_VERSION = void 0;
4
4
  // Auto-generated by scripts/generate.ts. Do not edit manually.
5
- const PACKAGE_VERSION = "2.20.1";
5
+ const PACKAGE_VERSION = "2.21.0";
6
6
  exports.PACKAGE_VERSION = PACKAGE_VERSION;
@@ -349,6 +349,26 @@ interface ChainEntry {
349
349
  * `validateChainIconSlugs`.
350
350
  */
351
351
  iconSlug?: string;
352
+ /**
353
+ * The identifier this chain is addressed by when you dial it, within its
354
+ * architecture — the last path segment of `{proxy}/{architecture}/{rpcId}`.
355
+ *
356
+ * For an EVM chain it is the chain id as a string. For Solana it is the
357
+ * cluster name (`mainnet-beta`, `devnet`), which is what every Solana RPC
358
+ * endpoint and the CLI call it, and what eRPC keys its network by. Not a
359
+ * proxy-specific spelling: a vendor endpoint uses the same word.
360
+ *
361
+ * Read this instead of branching on `vmType` and rebuilding the segment per
362
+ * family: every consumer that reached Solana previously kept its own caip2 →
363
+ * cluster table, and those tables drifted from each other.
364
+ *
365
+ * Present only where the chain id is NOT what you dial — the two Solana
366
+ * entries today, and nowhere else. **Absent means the chain id is the id**,
367
+ * so a consumer reads `chain.rpcId ?? String(chainId)`. Storing the id again
368
+ * on every EVM entry would be a second copy of the key it sits under, which
369
+ * is the kind of copy that drifts.
370
+ */
371
+ rpcId?: string;
352
372
  /**
353
373
  * A public, unauthenticated JSON-RPC endpoint over HTTPS, when one is known.
354
374
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinestone/shared-configs",
3
- "version": "2.20.1",
3
+ "version": "2.21.0",
4
4
  "description": "Reusable configuration files for Rhinestone services",
5
5
  "author": {
6
6
  "name": "Rhinestone",