@optimystic/db-p2p 0.24.1 → 0.25.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.
Files changed (257) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +21 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +28 -17
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +78 -6
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +136 -57
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +150 -62
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/logger.d.ts +2 -2
  85. package/dist/src/logger.js +2 -2
  86. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  87. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  88. package/dist/src/network/open-protocol-stream.js +60 -0
  89. package/dist/src/network/open-protocol-stream.js.map +1 -0
  90. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  91. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  92. package/dist/src/network/register-protocol-handler.js +51 -0
  93. package/dist/src/network/register-protocol-handler.js.map +1 -0
  94. package/dist/src/peer-address-book.d.ts +128 -1
  95. package/dist/src/peer-address-book.d.ts.map +1 -1
  96. package/dist/src/peer-address-book.js +198 -15
  97. package/dist/src/peer-address-book.js.map +1 -1
  98. package/dist/src/pend-validation.d.ts +65 -0
  99. package/dist/src/pend-validation.d.ts.map +1 -0
  100. package/dist/src/pend-validation.js +69 -0
  101. package/dist/src/pend-validation.js.map +1 -0
  102. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  103. package/dist/src/reactivity/notify-transport.js +2 -1
  104. package/dist/src/reactivity/notify-transport.js.map +1 -1
  105. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  106. package/dist/src/reactivity/push-state-gossip.js +2 -1
  107. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  109. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  110. package/dist/src/repo/cluster-coordinator.js +44 -10
  111. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  112. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  113. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  114. package/dist/src/repo/coordinator-repo.js +650 -62
  115. package/dist/src/repo/coordinator-repo.js.map +1 -1
  116. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  117. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  118. package/dist/src/repo/served-repo-proxy.js +50 -0
  119. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  120. package/dist/src/repo/service.d.ts +22 -1
  121. package/dist/src/repo/service.d.ts.map +1 -1
  122. package/dist/src/repo/service.js +25 -13
  123. package/dist/src/repo/service.js.map +1 -1
  124. package/dist/src/reputation/types.d.ts +3 -1
  125. package/dist/src/reputation/types.d.ts.map +1 -1
  126. package/dist/src/reputation/types.js +3 -1
  127. package/dist/src/reputation/types.js.map +1 -1
  128. package/dist/src/rn.d.ts +24 -2
  129. package/dist/src/rn.d.ts.map +1 -1
  130. package/dist/src/rn.js +33 -4
  131. package/dist/src/rn.js.map +1 -1
  132. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  133. package/dist/src/routing/libp2p-known-peers.js +5 -0
  134. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  135. package/dist/src/storage/block-archive.d.ts +153 -0
  136. package/dist/src/storage/block-archive.d.ts.map +1 -0
  137. package/dist/src/storage/block-archive.js +194 -0
  138. package/dist/src/storage/block-archive.js.map +1 -0
  139. package/dist/src/storage/block-latch.d.ts +92 -0
  140. package/dist/src/storage/block-latch.d.ts.map +1 -0
  141. package/dist/src/storage/block-latch.js +132 -0
  142. package/dist/src/storage/block-latch.js.map +1 -0
  143. package/dist/src/storage/block-storage.d.ts +169 -12
  144. package/dist/src/storage/block-storage.d.ts.map +1 -1
  145. package/dist/src/storage/block-storage.js +415 -152
  146. package/dist/src/storage/block-storage.js.map +1 -1
  147. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  148. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  149. package/dist/src/storage/cached-raw-storage.js +16 -1
  150. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  151. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  152. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  153. package/dist/src/storage/cached-store-driver.js +39 -5
  154. package/dist/src/storage/cached-store-driver.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +70 -19
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js +21 -1
  158. package/dist/src/storage/i-block-storage.js.map +1 -1
  159. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  160. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  161. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  162. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  163. package/dist/src/storage/kv-raw-storage.js +29 -9
  164. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  165. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  166. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/memory-store-driver.js +22 -2
  168. package/dist/src/storage/memory-store-driver.js.map +1 -1
  169. package/dist/src/storage/raw-store-codec.js +2 -2
  170. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  171. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  172. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  173. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  174. package/dist/src/storage/shared-cache-pool.js +68 -4
  175. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  176. package/dist/src/storage/storage-repo.d.ts +204 -26
  177. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  178. package/dist/src/storage/storage-repo.js +422 -119
  179. package/dist/src/storage/storage-repo.js.map +1 -1
  180. package/dist/src/storage/store-identity.d.ts +21 -0
  181. package/dist/src/storage/store-identity.d.ts.map +1 -0
  182. package/dist/src/storage/store-identity.js +33 -0
  183. package/dist/src/storage/store-identity.js.map +1 -0
  184. package/dist/src/storage/struct.d.ts +15 -0
  185. package/dist/src/storage/struct.d.ts.map +1 -1
  186. package/dist/src/storage/with-read-cache.d.ts +92 -0
  187. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  188. package/dist/src/storage/with-read-cache.js +132 -0
  189. package/dist/src/storage/with-read-cache.js.map +1 -0
  190. package/dist/src/sync/service.d.ts +13 -3
  191. package/dist/src/sync/service.d.ts.map +1 -1
  192. package/dist/src/sync/service.js +7 -30
  193. package/dist/src/sync/service.js.map +1 -1
  194. package/dist/src/testing/mesh-harness.d.ts +75 -2
  195. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  196. package/dist/src/testing/mesh-harness.js +193 -81
  197. package/dist/src/testing/mesh-harness.js.map +1 -1
  198. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  199. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  200. package/dist/src/testing/raw-storage-conformance.js +97 -2
  201. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  202. package/package.json +5 -6
  203. package/src/cluster/block-transfer-service.ts +528 -312
  204. package/src/cluster/block-transfer.ts +547 -424
  205. package/src/cluster/certified-claims.ts +319 -0
  206. package/src/cluster/client-signature-verifier.ts +34 -0
  207. package/src/cluster/cluster-policy.ts +294 -203
  208. package/src/cluster/cluster-repo.ts +2501 -2034
  209. package/src/cluster/commit-proof.ts +344 -0
  210. package/src/cluster/quorum-restore.ts +348 -223
  211. package/src/cluster/rebalance-monitor.ts +539 -254
  212. package/src/cluster/reconcile-block.ts +404 -203
  213. package/src/cluster/service.ts +308 -293
  214. package/src/cluster/spread-on-churn.ts +352 -346
  215. package/src/cohort-topic/host.ts +8 -7
  216. package/src/cohort-topic/stream-util.ts +14 -43
  217. package/src/dispute/cascade.ts +31 -15
  218. package/src/dispute/dispute-service.ts +706 -696
  219. package/src/dispute/invalidation.ts +162 -52
  220. package/src/dispute/service.ts +126 -125
  221. package/src/index.ts +7 -0
  222. package/src/libp2p-key-network.ts +142 -58
  223. package/src/libp2p-node-base.ts +1793 -1675
  224. package/src/logger.ts +27 -27
  225. package/src/network/open-protocol-stream.ts +87 -0
  226. package/src/network/register-protocol-handler.ts +85 -0
  227. package/src/peer-address-book.ts +360 -149
  228. package/src/pend-validation.ts +89 -0
  229. package/src/reactivity/notify-transport.ts +145 -144
  230. package/src/reactivity/push-state-gossip.ts +292 -291
  231. package/src/repo/cluster-coordinator.ts +65 -11
  232. package/src/repo/coordinator-repo.ts +729 -71
  233. package/src/repo/served-repo-proxy.ts +55 -0
  234. package/src/repo/service.ts +332 -313
  235. package/src/reputation/types.ts +3 -1
  236. package/src/rn.ts +34 -4
  237. package/src/routing/libp2p-known-peers.ts +31 -26
  238. package/src/storage/block-archive.ts +242 -0
  239. package/src/storage/block-latch.ts +144 -0
  240. package/src/storage/block-storage.ts +787 -498
  241. package/src/storage/cached-raw-storage.ts +21 -1
  242. package/src/storage/cached-store-driver.ts +898 -859
  243. package/src/storage/i-block-storage.ts +172 -116
  244. package/src/storage/i-raw-storage.ts +40 -0
  245. package/src/storage/kv-raw-storage.ts +34 -9
  246. package/src/storage/memory-store-driver.ts +26 -2
  247. package/src/storage/raw-store-codec.ts +42 -42
  248. package/src/storage/raw-store-driver.ts +45 -3
  249. package/src/storage/shared-cache-pool.ts +516 -452
  250. package/src/storage/storage-repo.ts +1421 -1014
  251. package/src/storage/store-identity.ts +41 -0
  252. package/src/storage/struct.ts +52 -34
  253. package/src/storage/with-read-cache.ts +182 -0
  254. package/src/sync/service.ts +178 -198
  255. package/src/testing/cohort-topic-mesh-harness.ts +673 -673
  256. package/src/testing/mesh-harness.ts +524 -344
  257. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,149 +1,360 @@
1
- import type { PeerId } from '@libp2p/interface'
2
- import { peerIdFromString } from '@libp2p/peer-id'
3
- import { multiaddr, type Multiaddr } from '@multiformats/multiaddr'
4
-
5
- /**
6
- * Cap on addresses merged per peer from one application-level message.
7
- *
8
- * Without a cap, a crafted cluster record or redirect payload could stuff the address
9
- * book and turn every cohort member into a dial amplifier aimed at an address of the
10
- * sender's choosing. This bounds the per-peer cost;
11
- * {@link MAX_LEARNED_PEERS_PER_RECORD} bounds how many peers one record may introduce.
12
- */
13
- export const MAX_MERGED_ADDRS_PER_PEER = 8
14
-
15
- /**
16
- * Cap on how many distinct peers one cluster record may teach us addresses for.
17
- *
18
- * A record's peer map is NOT self-limiting: `ClusterService.processOperation` learns from it
19
- * before `checkRedirect` and before `cluster.update` validates a single signature, and inbound
20
- * stream authorization is opt-in (`authorizeInboundStream` is undefined by default), so the map
21
- * is attacker-authored at that point. One 1 MiB control message (`MAX_CONTROL_MESSAGE_BYTES`)
22
- * holds on the order of a thousand fabricated `{ id, multiaddrs, publicKey }` entries, each of
23
- * which would otherwise become a persisted peerStore record. Real cohorts are `clusterSize`
24
- * peers — single digits — so this is generous margin, not a functional limit.
25
- */
26
- export const MAX_LEARNED_PEERS_PER_RECORD = 64
27
-
28
- /** The narrow slice of libp2p this module needs — a peer id and (optionally) a peerStore writer. */
29
- export interface PeerAddressBookHost {
30
- peerId: PeerId
31
- peerStore?: {
32
- merge?: (id: PeerId, data: { multiaddrs: Multiaddr[] }) => Promise<unknown>
33
- }
34
- }
35
-
36
- /** Log sink shaped like both `debug` loggers and libp2p's `Logger`. */
37
- export type AddressLog = (fmt: string, ...args: unknown[]) => void
38
-
39
- /**
40
- * Keep only the entries that parse as multiaddrs, logging (but not throwing on) the rest.
41
- *
42
- * The single validator for address strings arriving from the wire or from a connection —
43
- * `Libp2pKeyPeerNetwork.parseMultiaddrs` delegates here so there is one definition of
44
- * "an address string we are willing to carry".
45
- */
46
- export function validMultiaddrStrings(addrs: string[], log: AddressLog): string[] {
47
- const out: string[] = []
48
- for (const a of addrs) {
49
- try {
50
- // An empty string parses as the root multiaddr `/` — syntactically fine, addresses
51
- // nothing, and encodes to zero bytes. Reject it so a blank entry can't occupy a slot
52
- // in the address book (or in the per-message cap).
53
- if (multiaddr(a).bytes.length === 0) {
54
- log('WARN: multiaddr addresses nothing %s', a)
55
- continue
56
- }
57
- out.push(a)
58
- } catch (err) {
59
- log('WARN: invalid multiaddr %s %o', a, err)
60
- }
61
- }
62
- return out
63
- }
64
-
65
- /**
66
- * Write dialable addresses for `peerId` into the libp2p address book, from addresses
67
- * carried by an application-level message.
68
- *
69
- * Trust boundary: a merged multiaddr only makes a dial *attempt* possible. The dialed
70
- * peer still authenticates by peer id at the noise handshake, so an address taken from
71
- * a record we have not otherwise verified can waste a dial but can never impersonate.
72
- * That is precisely why it is safe to consume addresses from an unverified message
73
- * and why the cost, not the authenticity, is what needs bounding (see
74
- * {@link MAX_MERGED_ADDRS_PER_PEER}).
75
- */
76
- export function mergePeerAddresses(
77
- host: PeerAddressBookHost,
78
- peerId: PeerId,
79
- addrs: string[],
80
- log: AddressLog
81
- ): void {
82
- // A self entry is meaningless to our own dialer and, for a relay-only self, self-referential.
83
- if (peerId.toString() === host.peerId.toString()) return
84
- if (addrs.length === 0) return
85
-
86
- const merge = host.peerStore?.merge
87
- if (typeof merge !== 'function') return
88
-
89
- const valid = validMultiaddrStrings(addrs, log)
90
- if (valid.length === 0) return
91
- if (valid.length > MAX_MERGED_ADDRS_PER_PEER) {
92
- log('peer-address-book:capped peer=%s offered=%d kept=%d',
93
- peerId.toString().substring(0, 12), valid.length, MAX_MERGED_ADDRS_PER_PEER)
94
- }
95
- const multiaddrs = valid.slice(0, MAX_MERGED_ADDRS_PER_PEER).map(a => multiaddr(a))
96
-
97
- log('peer-address-book:merge peer=%s addrs=%d', peerId.toString().substring(0, 12), multiaddrs.length)
98
- // `merge` is async and nothing downstream awaits the address book the very next dial
99
- // either sees the entry or falls back to the same failure it had before. Log a rejection
100
- // rather than swallowing it: a persistently failing peerStore is exactly the condition
101
- // that would make this whole mechanism silently inert.
102
- void Promise.resolve(merge.call(host.peerStore, peerId, { multiaddrs }))
103
- .catch((err: unknown) => log('WARN: peerStore.merge failed peer=%s %o', peerId.toString().substring(0, 12), err))
104
- }
105
-
106
- /** The peer map a `ClusterRecord` carries, as it arrives off the wire (nothing about it is trusted). */
107
- export type RecordPeerMap = Record<string, { multiaddrs?: string[] } | undefined>
108
-
109
- /**
110
- * Offer the addresses a cluster record carries for its cohort to an address-book `sink`, one
111
- * peer at a time.
112
- *
113
- * The one traversal shared by both record ingress points — `ClusterService` (inbound, from the
114
- * coordinator) and `ClusterClient` (outbound, from a member's reply) — so the entries a record is
115
- * allowed to introduce are bounded in one place rather than two. Entries with no addresses, with
116
- * an id equal to `skipId`, or with an unparseable id are dropped; everything past
117
- * {@link MAX_LEARNED_PEERS_PER_RECORD} candidates is dropped with a log line. The per-address
118
- * validation, the per-peer cap, and the trust boundary live behind `sink`
119
- * (see {@link mergePeerAddresses}).
120
- */
121
- export function mergeRecordPeerAddresses(
122
- peers: RecordPeerMap | undefined,
123
- sink: (peerId: PeerId, addrs: string[]) => void,
124
- log: AddressLog,
125
- skipId?: string
126
- ): void {
127
- let offered = 0
128
- for (const [idStr, peer] of Object.entries(peers ?? {})) {
129
- const addrs = peer?.multiaddrs ?? []
130
- if (addrs.length === 0 || idStr === skipId) continue
131
- if (offered >= MAX_LEARNED_PEERS_PER_RECORD) {
132
- // Count candidates, not successes, so a record full of unparseable ids cannot spend
133
- // unbounded parse attempts and log lines either.
134
- log('peer-address-book:record-capped kept=%d', MAX_LEARNED_PEERS_PER_RECORD)
135
- return
136
- }
137
- offered += 1
138
- let pid: PeerId
139
- try {
140
- pid = peerIdFromString(idStr)
141
- } catch (err) {
142
- // An id we cannot parse is not dialable by any route; the consensus path surfaces the
143
- // resulting membership failure on its own.
144
- log('WARN: record carried an unparseable peer id %s %o', idStr, err)
145
- continue
146
- }
147
- sink(pid, addrs)
148
- }
149
- }
1
+ import type { PeerId } from '@libp2p/interface'
2
+ import { peerIdFromString } from '@libp2p/peer-id'
3
+ import { multiaddr, type Component, type Multiaddr } from '@multiformats/multiaddr'
4
+
5
+ /**
6
+ * Cap on addresses merged per peer from one application-level message.
7
+ *
8
+ * Without a cap, a crafted cluster record or redirect payload could stuff the address
9
+ * book and turn every cohort member into a dial amplifier aimed at an address of the
10
+ * sender's choosing. This bounds the per-peer cost;
11
+ * {@link MAX_LEARNED_PEERS_PER_RECORD} bounds how many peers one record may introduce.
12
+ */
13
+ export const MAX_MERGED_ADDRS_PER_PEER = 8
14
+
15
+ /**
16
+ * Cap on how many distinct peers one cluster record may teach us addresses for.
17
+ *
18
+ * A record's peer map is NOT self-limiting: `ClusterService.processOperation` learns from it
19
+ * before `checkRedirect` and before `cluster.update` validates a single signature, and inbound
20
+ * stream authorization is opt-in (`authorizeInboundStream` is undefined by default), so the map
21
+ * is attacker-authored at that point. One 1 MiB control message (`MAX_CONTROL_MESSAGE_BYTES`)
22
+ * holds on the order of a thousand fabricated `{ id, multiaddrs, publicKey }` entries, each of
23
+ * which would otherwise become a persisted peerStore record. Real cohorts are `clusterSize`
24
+ * peers — single digits — so this is generous margin, not a functional limit.
25
+ */
26
+ export const MAX_LEARNED_PEERS_PER_RECORD = 64
27
+
28
+ /** The narrow slice of libp2p this module needs — a peer id and (optionally) a peerStore. */
29
+ export interface PeerAddressBookHost {
30
+ peerId: PeerId
31
+ peerStore?: {
32
+ merge?: (id: PeerId, data: { multiaddrs: Multiaddr[] }) => Promise<unknown>
33
+ get?: (id: PeerId) => Promise<{ addresses?: Array<{ multiaddr: { toString(): string } }> }>
34
+ }
35
+ }
36
+
37
+ /** Log sink shaped like both `debug` loggers and libp2p's `Logger`. */
38
+ export type AddressLog = (fmt: string, ...args: unknown[]) => void
39
+
40
+ /**
41
+ * Keep only the entries that parse as multiaddrs, logging (but not throwing on) the rest.
42
+ *
43
+ * The single validator for address strings arriving from the wire or from a connection —
44
+ * `Libp2pKeyPeerNetwork.parseMultiaddrs` delegates here so there is one definition of
45
+ * "an address string we are willing to carry".
46
+ */
47
+ export function validMultiaddrStrings(addrs: string[], log: AddressLog): string[] {
48
+ return addrs.filter(a => isCarriableMultiaddrString(a, log))
49
+ }
50
+
51
+ /** One address string's verdict, shared by {@link validMultiaddrStrings} and {@link publishableConnectionAddr}. */
52
+ function isCarriableMultiaddrString(addr: string, log: AddressLog): boolean {
53
+ try {
54
+ // An empty string parses as the root multiaddr `/` syntactically fine, addresses
55
+ // nothing, and encodes to zero bytes. Reject it so a blank entry can't occupy a slot
56
+ // in the address book (or in the per-message cap).
57
+ if (multiaddr(addr).bytes.length === 0) {
58
+ log('WARN: multiaddr addresses nothing %s', addr)
59
+ return false
60
+ }
61
+ return true
62
+ } catch (err) {
63
+ log('WARN: invalid multiaddr %s %o', addr, err)
64
+ return false
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The slice of a libp2p `Connection` that decides whether its remote address may be published.
70
+ *
71
+ * `direction` is populated on every connection libp2p creates; it is optional here only because
72
+ * unit stubs build connection literals by hand and a missing `direction` is deliberately treated
73
+ * as NOT publishable, so a stub cannot silently opt back into the pre-fix behavior.
74
+ */
75
+ export interface DirectionalConnection {
76
+ direction?: 'inbound' | 'outbound'
77
+ remoteAddr?: { toString?: () => string }
78
+ }
79
+
80
+ /**
81
+ * A live connection's remote address, when it is one we may publish to a **third** party —
82
+ * otherwise `undefined`.
83
+ *
84
+ * The companion to {@link validMultiaddrStrings}: that answers "an address string we are willing
85
+ * to carry", this answers "an address we are willing to hand to someone else". They are not the
86
+ * same question, because an inbound connection's `remoteAddr` is not an address at all in the
87
+ * sense a third party needs. For an **outbound** connection it is the address we dialed — a real
88
+ * listen (or circuit) address that anyone can reach the peer on. For an **inbound** one it is the
89
+ * far side's *ephemeral source socket*: the port their operating system picked for this single
90
+ * connection. It is reachable by nobody else, it is indistinguishable from a listen address once
91
+ * it is on the wire, and it takes a slot against {@link MAX_MERGED_ADDRS_PER_PEER} in every peer
92
+ * that merges it. So the cure has to be here, at the producer.
93
+ *
94
+ * An inbound-only peer loses nothing by this: its own advertised addresses reach us through
95
+ * `identify`/`identifyPush` and are published from the peerStore instead — see
96
+ * {@link publishableAddrsForPeer}, which is where the two halves are joined.
97
+ *
98
+ * NOTE: accepted tradeoff making an INBOUND connection's `remoteAddr` publishable when it is a
99
+ * circuit address was proposed (the "at least a relayed dialer has a real address" reading) and
100
+ * declined. Read from `@libp2p/circuit-relay-v2@4.1.3` as vendored under
101
+ * `packages/db-p2p/node_modules`: the destination side composes that address as
102
+ * `ourConnectionToTheRelay.remoteAddr` encapsulated with `/p2p-circuit/p2p/<dialer>`
103
+ * (`dist/src/transport/index.js:272`), so the relay it names is the one WE hold a reservation
104
+ * with, not one the dialer is reachable on. A relay's `handleConnect` requires a reservation for
105
+ * the DESTINATION only (`dist/src/server/index.js:219-222`, status `NO_RESERVATION`) — a dialer
106
+ * needs none so a third party dialing that composed address reaches the dialer only if the
107
+ * dialer coincidentally also holds a reservation on our relay, which nothing establishes. When our
108
+ * own hop to the relay was itself inbound the prefix is an ephemeral source socket, making it
109
+ * undialable twice over. And in the one case where it would work — dialer and we share a relay —
110
+ * the dialer's genuine self-advertised circuit address has already reached us through `identify`,
111
+ * so publishing the composed form adds nothing and costs a slot against
112
+ * {@link MAX_MERGED_ADDRS_PER_PEER}. Revisit only on a MEASURED case where a peer's genuine
113
+ * circuit address reaches a third party by no other route.
114
+ */
115
+ export function publishableConnectionAddr(conn: DirectionalConnection, log: AddressLog): string | undefined {
116
+ if (conn.direction !== 'outbound') return undefined
117
+ const addr = conn.remoteAddr?.toString?.()
118
+ if (addr === undefined) return undefined
119
+ return isCarriableMultiaddrString(addr, log) ? addr : undefined
120
+ }
121
+
122
+ /**
123
+ * Join the two sources of a third-party-publishable address set: the publishable half of our live
124
+ * connections, then the peer's own advertised addresses. De-duplicated, **connection-first**.
125
+ *
126
+ * The ordering is not cosmetic. An address that reaches `connectionAddrs` is one we OUTBOUND-dialed,
127
+ * so libp2p has just succeeded with it; an advertised address is one we have never tried. So the
128
+ * proven one goes first, and the recipient which caps what it merges at
129
+ * {@link MAX_MERGED_ADDRS_PER_PEER} keeps the proven ones when it truncates.
130
+ *
131
+ * `connectionAddrs` are already validated by {@link publishableConnectionAddr}; the advertised half
132
+ * arrives from a peerStore or a record and is put through {@link validMultiaddrStrings} here, so the
133
+ * union is uniformly carriable regardless of which side an address came from.
134
+ *
135
+ * Split out from {@link publishableAddrsForPeer} for the one caller that has already read the
136
+ * peerStore for other reasons (`findCluster`'s membership-scoped path reads protocols and addresses
137
+ * in a single `store.get` per member) and must not pay a second read to reuse the rule.
138
+ */
139
+ export function unionPublishableAddrs(connectionAddrs: string[], advertisedAddrs: string[], log: AddressLog): string[] {
140
+ return Array.from(new Set([...connectionAddrs, ...validMultiaddrStrings(advertisedAddrs, log)]))
141
+ }
142
+
143
+ /**
144
+ * Every address we may hand a **third** party for `peerId`.
145
+ *
146
+ * The single answer to that question: `findCluster` (via {@link unionPublishableAddrs}) and all
147
+ * three redirect-address resolvers — `RepoService.getPeerAddrs`, `ClusterService.getPeerAddrs`, and
148
+ * the `getConnectionAddrs` the node wires into the cluster service — go through this one rule.
149
+ * They used to answer it two different ways, and the connections-only half was the weaker one: a
150
+ * cohort member that only ever dialed US and is reachable only through a relay has its real circuit
151
+ * address in exactly one place — the peerStore, where `identify`/`identifyPush` put it — so a
152
+ * redirect built from connections alone described it as having no address at all.
153
+ *
154
+ * A peerStore read that fails or finds nothing yields the connection-derived half rather than
155
+ * throwing: a redirect carrying half the answer is strictly better than a redirect that errors.
156
+ */
157
+ export async function publishableAddrsForPeer(
158
+ host: PeerAddressBookHost,
159
+ connections: DirectionalConnection[],
160
+ peerId: PeerId,
161
+ log: AddressLog
162
+ ): Promise<string[]> {
163
+ const connectionAddrs: string[] = []
164
+ for (const conn of connections) {
165
+ const addr = publishableConnectionAddr(conn, log)
166
+ if (addr !== undefined) connectionAddrs.push(addr)
167
+ }
168
+ return unionPublishableAddrs(connectionAddrs, await advertisedAddrsForPeer(host, peerId, log), log)
169
+ }
170
+
171
+ /**
172
+ * The addresses `peerId` has advertised to us, as libp2p's peerStore holds them.
173
+ *
174
+ * A miss is the common case, not an anomaly — libp2p's `peerStore.get` THROWS for a peer it has no
175
+ * record of, and a redirect routinely names cohort members we have never met — so this logs under
176
+ * the ordinary `peer-address-book:*` tag family rather than `WARN:`, which is reserved for input we
177
+ * were handed and rejected.
178
+ *
179
+ * NOTE: this adds one `peerStore.get` per redirect target where the redirect resolvers previously
180
+ * did none. A redirect names at most `clusterSize` peers — single digits — and only fires when this
181
+ * node is NOT responsible for the key, so the reads are bounded and off the hot path. Unmeasured;
182
+ * if redirect volume ever shows up in a profile, batch the reads per payload (they are already
183
+ * issued concurrently by `Promise.all` at both call sites) rather than dropping the peerStore arm.
184
+ */
185
+ async function advertisedAddrsForPeer(host: PeerAddressBookHost, peerId: PeerId, log: AddressLog): Promise<string[]> {
186
+ const get = host.peerStore?.get
187
+ if (typeof get !== 'function') return []
188
+ try {
189
+ const peer = await get.call(host.peerStore, peerId)
190
+ return (peer?.addresses ?? []).map(a => a.multiaddr.toString())
191
+ } catch (err) {
192
+ log('peer-address-book:peerstore-miss peer=%s %o', peerId.toString().substring(0, 12), err)
193
+ return []
194
+ }
195
+ }
196
+
197
+ /**
198
+ * How useful the addresses we hold for a peer are **to this node's own dialer**.
199
+ *
200
+ * - `none` — we hold no address at all. Nobody has told us how to reach the peer.
201
+ * - `self-relay-only` — we hold addresses, but every one of them reaches the peer by relaying
202
+ * through *us*. Useful to everyone except us: to use one we would have to relay to the peer
203
+ * through ourselves.
204
+ * - `dialable` — at least one address does not route through us, so a dial can be attempted.
205
+ */
206
+ export type SelfDialability = 'none' | 'self-relay-only' | 'dialable'
207
+
208
+ /**
209
+ * Does `addr` reach its target by relaying through `relayPeerId`?
210
+ *
211
+ * A circuit multiaddr names its relay in the `p2p` component immediately BEFORE the
212
+ * `p2p-circuit` marker — `/<transport>/p2p/<relay>/p2p-circuit[/p2p/<target>]`. So the question is
213
+ * answered by walking the address's components, not by testing the string for `/p2p/<id>/p2p-circuit`:
214
+ * the peer id after the marker (appended by libp2p's dial queue), a bare `/p2p-circuit` with no
215
+ * relay named, and multi-hop addresses with two circuit markers all read differently as text but
216
+ * classify correctly as components. True if ANY hop relays through `relayPeerId` — a chain that
217
+ * passes through us at any point is one we cannot open ourselves.
218
+ *
219
+ * Called with our own peer id, this is the "can WE dial this?" question. It is deliberately NOT
220
+ * the same question as {@link publishableConnectionAddr}'s: a self-relay address is perfectly
221
+ * publishable — a cohort sibling reaching a peer through our relay is the working path — and is
222
+ * simply unusable by the one node the circuit terminates on.
223
+ */
224
+ export function routesThroughRelay(addr: string, relayPeerId: string, log: AddressLog): boolean {
225
+ let components: Component[]
226
+ try {
227
+ components = multiaddr(addr).getComponents()
228
+ } catch (err) {
229
+ // Fail open. An address we cannot parse is not evidence of a self-relay loop, and the
230
+ // caller's fallback — dial it and let libp2p reject it — is the pre-existing behavior.
231
+ log('WARN: invalid multiaddr %s %o', addr, err)
232
+ return false
233
+ }
234
+ return components.some((component, i) =>
235
+ component.name === 'p2p-circuit' && isRelayComponent(components[i - 1], relayPeerId))
236
+ }
237
+
238
+ /**
239
+ * True when `component` is the `p2p` hop naming `relayPeerId` (absent/other component → false).
240
+ *
241
+ * `relayPeerId` is always a `PeerId.toString()`, i.e. base58btc. A multiaddr's `p2p` value usually
242
+ * is too — but it may equally be written as a CIDv1 libp2p-key string, and `@multiformats/multiaddr`
243
+ * keeps whichever form it was given rather than normalizing. String equality alone would therefore
244
+ * miss a CIDv1-form self-relay address arriving from the wire (`mergePeerAddresses` accepts any
245
+ * parseable multiaddr). The canonical compare runs only when the cheap one fails, and only for the
246
+ * single component sitting in front of a circuit marker, so the parse is bounded to circuit
247
+ * addresses rather than paid per address.
248
+ */
249
+ function isRelayComponent(component: Component | undefined, relayPeerId: string): boolean {
250
+ if (component?.name !== 'p2p' || component.value === undefined) return false
251
+ if (component.value === relayPeerId) return true
252
+ try {
253
+ return peerIdFromString(component.value).toString() === relayPeerId
254
+ } catch {
255
+ // multiaddr accepted the component but we cannot read it back as a peer id; we simply
256
+ // cannot claim it is ours, and the address stays dialable-as-far-as-we-know.
257
+ return false
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Classify what the addresses we hold for one peer are worth to our own dialer.
263
+ *
264
+ * `self-relay-only` is the state a relay lands in for its own reservation holders: the address
265
+ * such a client advertises — and therefore the one we learn through `identifyPush` and store — is
266
+ * `/<our transport addr>/p2p/<our peer id>/p2p-circuit`. Dialing it is guaranteed to fail, and the
267
+ * failure is indistinguishable from `none` in libp2p's error text, so the two are separated here
268
+ * instead. Nothing can repair it from our side: once the client's connection drops only the client
269
+ * can re-initiate, so the useful response is to fail fast and let the caller move on.
270
+ */
271
+ export function classifySelfDialability(addrs: string[], selfPeerId: string, log: AddressLog): SelfDialability {
272
+ if (addrs.length === 0) return 'none'
273
+ return addrs.some(addr => !routesThroughRelay(addr, selfPeerId, log)) ? 'dialable' : 'self-relay-only'
274
+ }
275
+
276
+ /**
277
+ * Write dialable addresses for `peerId` into the libp2p address book, from addresses
278
+ * carried by an application-level message.
279
+ *
280
+ * Trust boundary: a merged multiaddr only makes a dial *attempt* possible. The dialed
281
+ * peer still authenticates by peer id at the noise handshake, so an address taken from
282
+ * a record we have not otherwise verified can waste a dial but can never impersonate.
283
+ * That is precisely why it is safe to consume addresses from an unverified message —
284
+ * and why the cost, not the authenticity, is what needs bounding (see
285
+ * {@link MAX_MERGED_ADDRS_PER_PEER}).
286
+ */
287
+ export function mergePeerAddresses(
288
+ host: PeerAddressBookHost,
289
+ peerId: PeerId,
290
+ addrs: string[],
291
+ log: AddressLog
292
+ ): void {
293
+ // A self entry is meaningless to our own dialer and, for a relay-only self, self-referential.
294
+ if (peerId.toString() === host.peerId.toString()) return
295
+ if (addrs.length === 0) return
296
+
297
+ const merge = host.peerStore?.merge
298
+ if (typeof merge !== 'function') return
299
+
300
+ const valid = validMultiaddrStrings(addrs, log)
301
+ if (valid.length === 0) return
302
+ if (valid.length > MAX_MERGED_ADDRS_PER_PEER) {
303
+ log('peer-address-book:capped peer=%s offered=%d kept=%d',
304
+ peerId.toString().substring(0, 12), valid.length, MAX_MERGED_ADDRS_PER_PEER)
305
+ }
306
+ const multiaddrs = valid.slice(0, MAX_MERGED_ADDRS_PER_PEER).map(a => multiaddr(a))
307
+
308
+ log('peer-address-book:merge peer=%s addrs=%d', peerId.toString().substring(0, 12), multiaddrs.length)
309
+ // `merge` is async and nothing downstream awaits the address book — the very next dial
310
+ // either sees the entry or falls back to the same failure it had before. Log a rejection
311
+ // rather than swallowing it: a persistently failing peerStore is exactly the condition
312
+ // that would make this whole mechanism silently inert.
313
+ void Promise.resolve(merge.call(host.peerStore, peerId, { multiaddrs }))
314
+ .catch((err: unknown) => log('WARN: peerStore.merge failed peer=%s %o', peerId.toString().substring(0, 12), err))
315
+ }
316
+
317
+ /** The peer map a `ClusterRecord` carries, as it arrives off the wire (nothing about it is trusted). */
318
+ export type RecordPeerMap = Record<string, { multiaddrs?: string[] } | undefined>
319
+
320
+ /**
321
+ * Offer the addresses a cluster record carries for its cohort to an address-book `sink`, one
322
+ * peer at a time.
323
+ *
324
+ * The one traversal shared by both record ingress points — `ClusterService` (inbound, from the
325
+ * coordinator) and `ClusterClient` (outbound, from a member's reply) — so the entries a record is
326
+ * allowed to introduce are bounded in one place rather than two. Entries with no addresses, with
327
+ * an id equal to `skipId`, or with an unparseable id are dropped; everything past
328
+ * {@link MAX_LEARNED_PEERS_PER_RECORD} candidates is dropped with a log line. The per-address
329
+ * validation, the per-peer cap, and the trust boundary live behind `sink`
330
+ * (see {@link mergePeerAddresses}).
331
+ */
332
+ export function mergeRecordPeerAddresses(
333
+ peers: RecordPeerMap | undefined,
334
+ sink: (peerId: PeerId, addrs: string[]) => void,
335
+ log: AddressLog,
336
+ skipId?: string
337
+ ): void {
338
+ let offered = 0
339
+ for (const [idStr, peer] of Object.entries(peers ?? {})) {
340
+ const addrs = peer?.multiaddrs ?? []
341
+ if (addrs.length === 0 || idStr === skipId) continue
342
+ if (offered >= MAX_LEARNED_PEERS_PER_RECORD) {
343
+ // Count candidates, not successes, so a record full of unparseable ids cannot spend
344
+ // unbounded parse attempts and log lines either.
345
+ log('peer-address-book:record-capped kept=%d', MAX_LEARNED_PEERS_PER_RECORD)
346
+ return
347
+ }
348
+ offered += 1
349
+ let pid: PeerId
350
+ try {
351
+ pid = peerIdFromString(idStr)
352
+ } catch (err) {
353
+ // An id we cannot parse is not dialable by any route; the consensus path surfaces the
354
+ // resulting membership failure on its own.
355
+ log('WARN: record carried an unparseable peer id %s %o', idStr, err)
356
+ continue
357
+ }
358
+ sink(pid, addrs)
359
+ }
360
+ }
@@ -0,0 +1,89 @@
1
+ import type { PendRequest, PendValidationResult, UnvalidatablePendPolicy } from "@optimystic/db-core";
2
+
3
+ /**
4
+ * Stable, greppable prefix on the failure reason a validating receiver emits when it refuses a pend
5
+ * that carries no `validation` payload (nothing to re-execute — the single-collection
6
+ * `Collection.sync` shape) under the fail-closed `unvalidatablePendPolicy: 'reject'`.
7
+ */
8
+ export const PEND_NOT_VALIDATABLE = 'pend-not-validatable';
9
+
10
+ /**
11
+ * Stable, greppable prefix on the failure reason a validating receiver emits when the checker
12
+ * itself THREW (engine fault, missing table, parse error) rather than returning a verdict. Distinct
13
+ * from a content verdict on purpose: an operator reading a reject reason can tell "this transaction
14
+ * is wrong" from "this node could not tell".
15
+ */
16
+ export const VALIDATOR_FAULT = 'validator-fault';
17
+
18
+ /**
19
+ * One re-check attempt: hand the pend's `validation` pair to whatever checker this tier holds
20
+ * (a `ClusterMember`'s `ITransactionValidator`, a `StorageRepo`'s `validatePend` hook) and
21
+ * return its verdict. Throwing is allowed — {@link checkPendValidation} converts a throw into a
22
+ * {@link VALIDATOR_FAULT} rejection.
23
+ */
24
+ export type PendChecker = (validation: NonNullable<PendRequest['validation']>) => Promise<PendValidationResult>;
25
+
26
+ /** Structured trace of the decision this helper took, rendered by whichever tier called it. */
27
+ export type PendValidationEvent =
28
+ | { kind: 'unvalidatable'; policy: UnvalidatablePendPolicy }
29
+ | { kind: 'validator-fault'; error: string };
30
+
31
+ /**
32
+ * The fail-closed pend re-check, shared by BOTH tiers that hold a checker — `ClusterMember`
33
+ * (voting on a cluster record) and `StorageRepo` (applying a pend locally). One implementation
34
+ * rather than two mirrored copies, because the two tiers refusing with *different* prefixes or
35
+ * *different* policy semantics is a silent inconsistency nothing would catch: they are read by the
36
+ * same operator, and a member that votes approve while its own storage refuses at apply burns a
37
+ * consensus round for nothing.
38
+ *
39
+ * Two decisions live here:
40
+ *
41
+ * - **No `validation` pair** (the single-collection `Collection.sync` shape — bare transforms, so
42
+ * there is nothing to re-execute): an explicit, LOGGED policy branch on both arms, never a silent
43
+ * fall-through. `'accept'` admits it unchecked, preserving the historical behaviour; `'reject'`
44
+ * refuses with {@link PEND_NOT_VALIDATABLE}. The presence test is on the whole pair, so a sender
45
+ * cannot talk a receiver out of validating by omitting half of it.
46
+ * - **A checker that throws**: caught and turned into a {@link VALIDATOR_FAULT} rejection, never an
47
+ * escaping error. At the cluster tier an escaping throw costs the member its vote entirely —
48
+ * indistinguishable from an unreachable peer, and with no signed reason for the dispute path.
49
+ *
50
+ * NOTE: a TRANSIENT checker fault (database busy, momentary connection loss) therefore produces a
51
+ * terminal reject where a redelivery might have produced an approve. `CoordinatorRepo`'s two
52
+ * rejection classifiers confirm retryability against LOCAL storage state and never read this prose,
53
+ * so a validator-fault reject is returned as retryable only when local state independently shows a
54
+ * stale revision or a rival pending — otherwise it reaches the writer as a throw. If transient
55
+ * validator faults ever show up in practice, give the classifier an arm keyed on this prefix rather
56
+ * than reverting to a silent pass or a lost vote.
57
+ *
58
+ * @param request the pend under consideration
59
+ * @param check the tier's checker, or undefined when this node re-validates nothing (then every
60
+ * pend passes and the policy is irrelevant — a storage-only node)
61
+ * @param policy what to do with a pend that carries no `validation` pair
62
+ * @param onEvent trace sink; called on every decision this helper takes, so the two tiers log the
63
+ * same facts in their own formats
64
+ */
65
+ export async function checkPendValidation(
66
+ request: PendRequest,
67
+ check: PendChecker | undefined,
68
+ policy: UnvalidatablePendPolicy,
69
+ onEvent: (event: PendValidationEvent) => void
70
+ ): Promise<PendValidationResult> {
71
+ if (!check) {
72
+ return { valid: true };
73
+ }
74
+ if (!request.validation) {
75
+ onEvent({ kind: 'unvalidatable', policy });
76
+ return policy === 'reject'
77
+ // Plain prose after the stable prefix: at the cluster tier this reason is fed to
78
+ // computeSigningPayload and carried as Signature.rejectReason, so it must stay a string.
79
+ ? { valid: false, reason: `${PEND_NOT_VALIDATABLE}: pend carries no transaction to re-execute` }
80
+ : { valid: true };
81
+ }
82
+ try {
83
+ return await check(request.validation);
84
+ } catch (err) {
85
+ const error = (err as Error).message;
86
+ onEvent({ kind: 'validator-fault', error });
87
+ return { valid: false, reason: `${VALIDATOR_FAULT}: ${error}` };
88
+ }
89
+ }