@optimystic/db-p2p 0.2.3 → 0.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.
Files changed (139) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +66 -0
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -0
  3. package/dist/src/cluster/block-transfer-service.js +163 -0
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -0
  5. package/dist/src/cluster/block-transfer.d.ts +79 -0
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -0
  7. package/dist/src/cluster/block-transfer.js +211 -0
  8. package/dist/src/cluster/block-transfer.js.map +1 -0
  9. package/dist/src/cluster/cluster-repo.d.ts +14 -3
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +80 -35
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/rebalance-monitor.d.ts +64 -0
  14. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -0
  15. package/dist/src/cluster/rebalance-monitor.js +159 -0
  16. package/dist/src/cluster/rebalance-monitor.js.map +1 -0
  17. package/dist/src/cluster/service.js +1 -1
  18. package/dist/src/cluster/service.js.map +1 -1
  19. package/dist/src/dispute/arbitrator-selection.d.ts +10 -0
  20. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -0
  21. package/dist/src/dispute/arbitrator-selection.js +22 -0
  22. package/dist/src/dispute/arbitrator-selection.js.map +1 -0
  23. package/dist/src/dispute/client.d.ts +17 -0
  24. package/dist/src/dispute/client.d.ts.map +1 -0
  25. package/dist/src/dispute/client.js +28 -0
  26. package/dist/src/dispute/client.js.map +1 -0
  27. package/dist/src/dispute/dispute-service.d.ts +81 -0
  28. package/dist/src/dispute/dispute-service.d.ts.map +1 -0
  29. package/dist/src/dispute/dispute-service.js +365 -0
  30. package/dist/src/dispute/dispute-service.js.map +1 -0
  31. package/dist/src/dispute/engine-health-monitor.d.ts +22 -0
  32. package/dist/src/dispute/engine-health-monitor.d.ts.map +1 -0
  33. package/dist/src/dispute/engine-health-monitor.js +75 -0
  34. package/dist/src/dispute/engine-health-monitor.js.map +1 -0
  35. package/dist/src/dispute/index.d.ts +7 -0
  36. package/dist/src/dispute/index.d.ts.map +1 -0
  37. package/dist/src/dispute/index.js +7 -0
  38. package/dist/src/dispute/index.js.map +1 -0
  39. package/dist/src/dispute/service.d.ts +41 -0
  40. package/dist/src/dispute/service.d.ts.map +1 -0
  41. package/dist/src/dispute/service.js +82 -0
  42. package/dist/src/dispute/service.js.map +1 -0
  43. package/dist/src/dispute/types.d.ts +106 -0
  44. package/dist/src/dispute/types.d.ts.map +1 -0
  45. package/dist/src/dispute/types.js +7 -0
  46. package/dist/src/dispute/types.js.map +1 -0
  47. package/dist/src/index.d.ts +3 -0
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/dist/src/index.js +3 -0
  50. package/dist/src/index.js.map +1 -1
  51. package/dist/src/libp2p-key-network.d.ts +23 -2
  52. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  53. package/dist/src/libp2p-key-network.js +100 -15
  54. package/dist/src/libp2p-key-network.js.map +1 -1
  55. package/dist/src/libp2p-node-base.d.ts +6 -0
  56. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  57. package/dist/src/libp2p-node-base.js +66 -12
  58. package/dist/src/libp2p-node-base.js.map +1 -1
  59. package/dist/src/logger.d.ts +1 -0
  60. package/dist/src/logger.d.ts.map +1 -1
  61. package/dist/src/logger.js +2 -0
  62. package/dist/src/logger.js.map +1 -1
  63. package/dist/src/network/network-manager-service.d.ts +15 -4
  64. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  65. package/dist/src/network/network-manager-service.js +33 -20
  66. package/dist/src/network/network-manager-service.js.map +1 -1
  67. package/dist/src/protocol-client.d.ts +1 -0
  68. package/dist/src/protocol-client.d.ts.map +1 -1
  69. package/dist/src/protocol-client.js +23 -2
  70. package/dist/src/protocol-client.js.map +1 -1
  71. package/dist/src/repo/client.d.ts +1 -0
  72. package/dist/src/repo/client.d.ts.map +1 -1
  73. package/dist/src/repo/client.js +18 -1
  74. package/dist/src/repo/client.js.map +1 -1
  75. package/dist/src/repo/cluster-coordinator.d.ts +3 -1
  76. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  77. package/dist/src/repo/cluster-coordinator.js +42 -2
  78. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  79. package/dist/src/repo/coordinator-repo.d.ts +18 -2
  80. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  81. package/dist/src/repo/coordinator-repo.js +62 -6
  82. package/dist/src/repo/coordinator-repo.js.map +1 -1
  83. package/dist/src/repo/service.d.ts +18 -2
  84. package/dist/src/repo/service.d.ts.map +1 -1
  85. package/dist/src/repo/service.js +88 -91
  86. package/dist/src/repo/service.js.map +1 -1
  87. package/dist/src/reputation/index.d.ts +3 -0
  88. package/dist/src/reputation/index.d.ts.map +1 -0
  89. package/dist/src/reputation/index.js +3 -0
  90. package/dist/src/reputation/index.js.map +1 -0
  91. package/dist/src/reputation/peer-reputation.d.ts +23 -0
  92. package/dist/src/reputation/peer-reputation.d.ts.map +1 -0
  93. package/dist/src/reputation/peer-reputation.js +121 -0
  94. package/dist/src/reputation/peer-reputation.js.map +1 -0
  95. package/dist/src/reputation/types.d.ts +89 -0
  96. package/dist/src/reputation/types.d.ts.map +1 -0
  97. package/dist/src/reputation/types.js +42 -0
  98. package/dist/src/reputation/types.js.map +1 -0
  99. package/dist/src/storage/arachnode-fret-adapter.d.ts +5 -0
  100. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  101. package/dist/src/storage/arachnode-fret-adapter.js +10 -0
  102. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  103. package/dist/src/storage/block-storage.d.ts.map +1 -1
  104. package/dist/src/storage/block-storage.js +5 -0
  105. package/dist/src/storage/block-storage.js.map +1 -1
  106. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  107. package/dist/src/storage/storage-repo.js +8 -0
  108. package/dist/src/storage/storage-repo.js.map +1 -1
  109. package/package.json +11 -10
  110. package/src/cluster/block-transfer-service.ts +228 -0
  111. package/src/cluster/block-transfer.ts +284 -0
  112. package/src/cluster/cluster-repo.ts +93 -38
  113. package/src/cluster/rebalance-monitor.ts +225 -0
  114. package/src/dispute/arbitrator-selection.ts +28 -0
  115. package/src/dispute/client.ts +41 -0
  116. package/src/dispute/dispute-service.ts +453 -0
  117. package/src/dispute/engine-health-monitor.ts +86 -0
  118. package/src/dispute/index.ts +17 -0
  119. package/src/dispute/service.ts +119 -0
  120. package/src/dispute/types.ts +114 -0
  121. package/src/index.ts +3 -0
  122. package/src/libp2p-key-network.ts +120 -22
  123. package/src/libp2p-node-base.ts +77 -13
  124. package/src/logger.ts +2 -1
  125. package/src/network/network-manager-service.ts +47 -16
  126. package/src/protocol-client.ts +29 -7
  127. package/src/repo/client.ts +20 -6
  128. package/src/repo/cluster-coordinator.ts +43 -2
  129. package/src/repo/coordinator-repo.ts +70 -7
  130. package/src/repo/redirect.ts +0 -2
  131. package/src/repo/service.ts +95 -87
  132. package/src/reputation/index.ts +12 -0
  133. package/src/reputation/peer-reputation.ts +147 -0
  134. package/src/reputation/types.ts +117 -0
  135. package/src/storage/arachnode-fret-adapter.ts +11 -0
  136. package/src/storage/block-storage.ts +6 -0
  137. package/src/storage/storage-repo.ts +9 -0
  138. package/dist/index.min.js +0 -53
  139. package/dist/index.min.js.map +0 -7
@@ -1,11 +1,14 @@
1
1
  import { pipe } from 'it-pipe'
2
2
  import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed'
3
- import type { Startable, Logger, Stream, Connection, StreamHandler } from '@libp2p/interface'
3
+ import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } from '@libp2p/interface'
4
4
  import type { IRepo, RepoMessage } from '@optimystic/db-core'
5
5
  import { peersEqual } from '../peer-utils.js'
6
6
  import { sha256 } from 'multiformats/hashes/sha2'
7
- import { encodePeers } from './redirect.js'
7
+ import { encodePeers, type RedirectPayload } from './redirect.js'
8
8
  import type { Uint8ArrayList } from 'uint8arraylist'
9
+ import { createLogger } from '../logger.js'
10
+
11
+ const debugLog = createLogger('repo-service')
9
12
 
10
13
  // Define Components interface
11
14
  interface BaseComponents {
@@ -16,8 +19,15 @@ interface BaseComponents {
16
19
  }
17
20
  }
18
21
 
22
+ export interface NetworkManagerLike {
23
+ getCluster(key: Uint8Array): Promise<PeerId[]>
24
+ }
25
+
19
26
  export type RepoServiceComponents = BaseComponents & {
20
27
  repo: IRepo
28
+ networkManager?: NetworkManagerLike
29
+ peerId?: PeerId
30
+ getConnectionAddrs?: (peerId: PeerId) => string[]
21
31
  }
22
32
 
23
33
  export type RepoServiceInit = {
@@ -100,6 +110,61 @@ export class RepoService implements Startable {
100
110
  this.running = false
101
111
  }
102
112
 
113
+ private getNetworkManager(): NetworkManagerLike | undefined {
114
+ if (this.components.networkManager) return this.components.networkManager
115
+ return (this.components as any).libp2p?.services?.networkManager as NetworkManagerLike | undefined
116
+ }
117
+
118
+ private getSelfId(): PeerId | undefined {
119
+ if (this.components.peerId) return this.components.peerId
120
+ return (this.components as any).libp2p?.peerId as PeerId | undefined
121
+ }
122
+
123
+ private getPeerAddrs(peerId: PeerId): string[] {
124
+ if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(peerId)
125
+ const libp2p = (this.components as any).libp2p
126
+ if (!libp2p?.getConnections) return []
127
+ const conns: any[] = libp2p.getConnections(peerId) ?? []
128
+ const addrs: string[] = []
129
+ for (const c of conns) {
130
+ const addr = c.remoteAddr?.toString?.()
131
+ if (addr) addrs.push(addr)
132
+ }
133
+ return addrs
134
+ }
135
+
136
+ /**
137
+ * Check if this node should redirect the request for a given key.
138
+ * Returns a RedirectPayload if not responsible, null if should handle locally.
139
+ * Also attaches cluster info to the message for downstream use.
140
+ */
141
+ async checkRedirect(blockKey: string, opName: string, message: RepoMessage): Promise<RedirectPayload | null> {
142
+ const nm = this.getNetworkManager()
143
+ if (!nm) return null
144
+
145
+ const mh = await sha256.digest(new TextEncoder().encode(blockKey))
146
+ const key = mh.digest
147
+ const cluster = await nm.getCluster(key)
148
+ ;(message as any).cluster = cluster.map((p: PeerId) => p.toString?.() ?? String(p))
149
+
150
+ const selfId = this.getSelfId()
151
+ if (!selfId) return null
152
+
153
+ const isMember = cluster.some((p: PeerId) => peersEqual(p, selfId))
154
+ const smallMesh = cluster.length < this.responsibilityK
155
+
156
+ if (!smallMesh && !isMember) {
157
+ const peers = cluster.filter((p: PeerId) => !peersEqual(p, selfId))
158
+ debugLog('redirect op=%s blockKey=%s cluster=%d', opName, blockKey, cluster.length)
159
+ return encodePeers(peers.map((pid: PeerId) => ({
160
+ id: pid.toString(),
161
+ addrs: this.getPeerAddrs(pid)
162
+ })))
163
+ }
164
+
165
+ return null
166
+ }
167
+
103
168
  /**
104
169
  * Handle incoming streams on the repo protocol
105
170
  */
@@ -117,97 +182,40 @@ export class RepoService implements Startable {
117
182
  let response: any
118
183
 
119
184
  if ('get' in operation) {
120
- {
121
- // Use sha256 digest of block id string for consistent key space
122
- const mh = await sha256.digest(new TextEncoder().encode(operation.get.blockIds[0]!))
123
- const key = mh.digest
124
- const nm: any = (this.components as any).libp2p?.services?.networkManager
125
- if (nm?.getCluster) {
126
- const cluster: any[] = await nm.getCluster(key);
127
- (message as any).cluster = (cluster as any[]).map(p => p.toString?.() ?? String(p))
128
- const selfId = (this.components as any).libp2p.peerId
129
- const isMember = cluster.some((p: any) => peersEqual(p, selfId))
130
- // Use responsibilityK to determine if we're in the responsible set
131
- const smallMesh = cluster.length < this.responsibilityK
132
- if (!smallMesh && !isMember) {
133
- const peers = cluster.filter((p: any) => !peersEqual(p, selfId))
134
- console.debug('repo-service:redirect', {
135
- peerId: selfId.toString(),
136
- reason: 'not-cluster-member',
137
- operation: 'get',
138
- blockId: operation.get.blockIds[0],
139
- cluster: cluster.map((p: any) => p.toString?.() ?? String(p))
140
- })
141
- response = encodePeers(peers.map((pid: any) => ({ id: pid.toString(), addrs: [] })))
142
- } else {
143
- response = await this.repo.get(operation.get, { expiration: message.expiration, skipClusterFetch: true } as any)
144
- }
145
- } else {
146
- response = await this.repo.get(operation.get, { expiration: message.expiration, skipClusterFetch: true } as any)
147
- }
185
+ const blockKey = operation.get.blockIds[0]!
186
+ const redirect = await this.checkRedirect(blockKey, 'get', message)
187
+ if (redirect) {
188
+ response = redirect
189
+ } else {
190
+ response = await this.repo.get(operation.get, { expiration: message.expiration, skipClusterFetch: true } as any)
148
191
  }
149
192
  } else if ('pend' in operation) {
150
- {
151
- const id = Object.keys(operation.pend.transforms)[0]!
152
- const mh = await sha256.digest(new TextEncoder().encode(id))
153
- const key = mh.digest
154
- const nm: any = (this.components as any).libp2p?.services?.networkManager
155
- if (nm?.getCluster) {
156
- const cluster: any[] = await nm.getCluster(key)
157
- ; (message as any).cluster = (cluster as any[]).map(p => p.toString?.() ?? String(p))
158
- const selfId = (this.components as any).libp2p.peerId
159
- const isMember = cluster.some((p: any) => peersEqual(p, selfId))
160
- // Use responsibilityK to determine if we're in the responsible set
161
- const smallMesh = cluster.length < this.responsibilityK
162
- if (!smallMesh && !isMember) {
163
- const peers = cluster.filter((p: any) => !peersEqual(p, selfId))
164
- console.debug('repo-service:redirect', {
165
- peerId: selfId.toString(),
166
- reason: 'not-cluster-member',
167
- operation: 'pend',
168
- blockId: id,
169
- cluster: cluster.map((p: any) => p.toString?.() ?? String(p))
170
- })
171
- response = encodePeers(peers.map((pid: any) => ({ id: pid.toString(), addrs: [] })))
172
- } else {
173
- response = await this.repo.pend(operation.pend, { expiration: message.expiration })
174
- }
175
- } else {
176
- response = await this.repo.pend(operation.pend, { expiration: message.expiration })
177
- }
193
+ const blockKey = Object.keys(operation.pend.transforms)[0]!
194
+ const redirect = await this.checkRedirect(blockKey, 'pend', message)
195
+ if (redirect) {
196
+ response = redirect
197
+ } else {
198
+ response = await this.repo.pend(operation.pend, { expiration: message.expiration })
178
199
  }
179
200
  } else if ('cancel' in operation) {
180
- response = await this.repo.cancel(operation.cancel.actionRef, {
181
- expiration: message.expiration
182
- })
183
- } else if ('commit' in operation) {
184
- {
185
- const mh = await sha256.digest(new TextEncoder().encode(operation.commit.tailId))
186
- const key = mh.digest
187
- const nm: any = (this.components as any).libp2p?.services?.networkManager
188
- if (nm?.getCluster) {
189
- const cluster: any[] = await nm.getCluster(key)
190
- ; (message as any).cluster = (cluster as any[]).map(p => p.toString?.() ?? String(p))
191
- const selfId = (this.components as any).libp2p.peerId
192
- const isMember = cluster.some((p: any) => peersEqual(p, selfId))
193
- // Use responsibilityK to determine if we're in the responsible set
194
- const smallMesh = cluster.length < this.responsibilityK
195
- if (!smallMesh && !isMember) {
196
- const peers = cluster.filter((p: any) => !peersEqual(p, selfId))
197
- console.debug('repo-service:redirect', {
198
- peerId: selfId.toString(),
199
- reason: 'not-cluster-member',
200
- operation: 'commit',
201
- tailId: operation.commit.tailId,
202
- cluster: cluster.map((p: any) => p.toString?.() ?? String(p))
203
- })
204
- response = encodePeers(peers.map((pid: any) => ({ id: pid.toString(), addrs: [] })))
205
- } else {
206
- response = await this.repo.commit(operation.commit, { expiration: message.expiration })
207
- }
201
+ const blockKey = operation.cancel.actionRef.blockIds[0]
202
+ if (blockKey) {
203
+ const redirect = await this.checkRedirect(blockKey, 'cancel', message)
204
+ if (redirect) {
205
+ response = redirect
208
206
  } else {
209
- response = await this.repo.commit(operation.commit, { expiration: message.expiration })
207
+ response = await this.repo.cancel(operation.cancel.actionRef, { expiration: message.expiration })
210
208
  }
209
+ } else {
210
+ response = await this.repo.cancel(operation.cancel.actionRef, { expiration: message.expiration })
211
+ }
212
+ } else if ('commit' in operation) {
213
+ const blockKey = operation.commit.tailId
214
+ const redirect = await this.checkRedirect(blockKey, 'commit', message)
215
+ if (redirect) {
216
+ response = redirect
217
+ } else {
218
+ response = await this.repo.commit(operation.commit, { expiration: message.expiration })
211
219
  }
212
220
  }
213
221
 
@@ -0,0 +1,12 @@
1
+ export { PeerReputationService } from './peer-reputation.js';
2
+ export {
3
+ PenaltyReason,
4
+ DEFAULT_PENALTY_WEIGHTS,
5
+ DEFAULT_THRESHOLDS,
6
+ type IPeerReputation,
7
+ type PeerReputationSummary,
8
+ type ReputationConfig,
9
+ type ReputationThresholds,
10
+ type PenaltyRecord,
11
+ type PeerRecord,
12
+ } from './types.js';
@@ -0,0 +1,147 @@
1
+ import {
2
+ type IPeerReputation,
3
+ type PeerRecord,
4
+ type PenaltyRecord,
5
+ type PeerReputationSummary,
6
+ type ReputationConfig,
7
+ type ReputationThresholds,
8
+ PenaltyReason,
9
+ DEFAULT_PENALTY_WEIGHTS,
10
+ DEFAULT_THRESHOLDS,
11
+ } from './types.js';
12
+ import { createLogger } from '../logger.js';
13
+
14
+ const log = createLogger('peer-reputation');
15
+
16
+ export class PeerReputationService implements IPeerReputation {
17
+ private readonly peers = new Map<string, PeerRecord>();
18
+ private readonly halfLifeMs: number;
19
+ private readonly thresholds: ReputationThresholds;
20
+ private readonly weights: Record<PenaltyReason, number>;
21
+ private readonly maxPenaltiesPerPeer: number;
22
+
23
+ constructor(config?: ReputationConfig) {
24
+ this.halfLifeMs = config?.halfLifeMs ?? 30 * 60_000;
25
+ this.thresholds = {
26
+ ...DEFAULT_THRESHOLDS,
27
+ ...config?.thresholds,
28
+ };
29
+ this.weights = {
30
+ ...DEFAULT_PENALTY_WEIGHTS,
31
+ ...config?.weights,
32
+ };
33
+ this.maxPenaltiesPerPeer = config?.maxPenaltiesPerPeer ?? 100;
34
+ }
35
+
36
+ reportPeer(peerId: string, reason: PenaltyReason, context?: string): void {
37
+ const record = this.getOrCreateRecord(peerId);
38
+ const weight = this.weights[reason];
39
+ const penalty: PenaltyRecord = {
40
+ reason,
41
+ weight,
42
+ timestamp: Date.now(),
43
+ context,
44
+ };
45
+ record.penalties.push(penalty);
46
+ record.lastPenalty = penalty.timestamp;
47
+ this.pruneRecord(record);
48
+
49
+ const score = this.computeScore(record);
50
+ log('report peerId=%s reason=%s weight=%d score=%d context=%s',
51
+ peerId.substring(0, 12), reason, weight, Math.round(score), context ?? '');
52
+ }
53
+
54
+ recordSuccess(peerId: string): void {
55
+ const record = this.getOrCreateRecord(peerId);
56
+ record.successCount++;
57
+ record.lastSuccess = Date.now();
58
+ }
59
+
60
+ getScore(peerId: string): number {
61
+ const record = this.peers.get(peerId);
62
+ if (!record) return 0;
63
+ return this.computeScore(record);
64
+ }
65
+
66
+ isBanned(peerId: string): boolean {
67
+ return this.getScore(peerId) >= this.thresholds.ban;
68
+ }
69
+
70
+ isDeprioritized(peerId: string): boolean {
71
+ return this.getScore(peerId) >= this.thresholds.deprioritize;
72
+ }
73
+
74
+ getReputation(peerId: string): PeerReputationSummary {
75
+ const score = this.getScore(peerId);
76
+ const record = this.peers.get(peerId);
77
+ return {
78
+ peerId,
79
+ effectiveScore: score,
80
+ isBanned: score >= this.thresholds.ban,
81
+ isDeprioritized: score >= this.thresholds.deprioritize,
82
+ penaltyCount: record?.penalties.length ?? 0,
83
+ successCount: record?.successCount ?? 0,
84
+ lastPenalty: record?.lastPenalty ?? 0,
85
+ lastSuccess: record?.lastSuccess ?? 0,
86
+ };
87
+ }
88
+
89
+ getAllReputations(): Map<string, PeerReputationSummary> {
90
+ const result = new Map<string, PeerReputationSummary>();
91
+ for (const peerId of this.peers.keys()) {
92
+ result.set(peerId, this.getReputation(peerId));
93
+ }
94
+ return result;
95
+ }
96
+
97
+ resetPeer(peerId: string): void {
98
+ this.peers.delete(peerId);
99
+ log('reset peerId=%s', peerId.substring(0, 12));
100
+ }
101
+
102
+ private getOrCreateRecord(peerId: string): PeerRecord {
103
+ let record = this.peers.get(peerId);
104
+ if (!record) {
105
+ record = {
106
+ penalties: [],
107
+ successCount: 0,
108
+ lastSuccess: 0,
109
+ lastPenalty: 0,
110
+ };
111
+ this.peers.set(peerId, record);
112
+ }
113
+ return record;
114
+ }
115
+
116
+ private computeScore(record: PeerRecord): number {
117
+ const now = Date.now();
118
+ let score = 0;
119
+ for (const penalty of record.penalties) {
120
+ score += penalty.weight * this.decayFactor(now, penalty.timestamp);
121
+ }
122
+ return score;
123
+ }
124
+
125
+ private decayFactor(now: number, timestamp: number): number {
126
+ const elapsed = now - timestamp;
127
+ if (elapsed <= 0) return 1;
128
+ return Math.pow(0.5, elapsed / this.halfLifeMs);
129
+ }
130
+
131
+ /** Remove penalties that have decayed below significance (< 1% of original weight) */
132
+ private pruneRecord(record: PeerRecord): void {
133
+ const now = Date.now();
134
+ const cutoff = this.halfLifeMs * 7; // 2^-7 ≈ 0.8% — below significance
135
+ record.penalties = record.penalties.filter(p => (now - p.timestamp) < cutoff);
136
+
137
+ // Hard cap to prevent unbounded growth
138
+ if (record.penalties.length > this.maxPenaltiesPerPeer) {
139
+ record.penalties = record.penalties.slice(-this.maxPenaltiesPerPeer);
140
+ }
141
+
142
+ // Remove peer records with no significant penalties
143
+ if (record.penalties.length === 0 && record.successCount === 0) {
144
+ // Don't remove — the caller may still be using this record
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,117 @@
1
+ /** Categories of peer misbehavior with associated severity */
2
+ export enum PenaltyReason {
3
+ /** Peer sent a signature that failed cryptographic verification */
4
+ InvalidSignature = 'invalid-signature',
5
+ /** Peer promised conflicting transactions (equivocation) */
6
+ Equivocation = 'equivocation',
7
+ /** Peer's validation logic rejected a valid transaction (repeated false rejections) */
8
+ FalseRejection = 'false-rejection',
9
+ /** Peer failed to respond within timeout during consensus */
10
+ ConsensusTimeout = 'consensus-timeout',
11
+ /** Peer sent a message with mismatched hash */
12
+ InvalidMessageHash = 'invalid-message-hash',
13
+ /** Peer sent an expired transaction */
14
+ ExpiredTransaction = 'expired-transaction',
15
+ /** Generic protocol violation */
16
+ ProtocolViolation = 'protocol-violation',
17
+ /** Connection-level failures (lighter weight) */
18
+ ConnectionFailure = 'connection-failure',
19
+ /** Majority peer approved a transaction that was later found invalid via dispute */
20
+ FalseApproval = 'false-approval',
21
+ /** Challenger lost a dispute (their rejection was wrong) */
22
+ DisputeLost = 'dispute-lost',
23
+ }
24
+
25
+ /** Default penalty weights by reason */
26
+ export const DEFAULT_PENALTY_WEIGHTS: Record<PenaltyReason, number> = {
27
+ [PenaltyReason.InvalidSignature]: 50,
28
+ [PenaltyReason.Equivocation]: 100,
29
+ [PenaltyReason.FalseRejection]: 10,
30
+ [PenaltyReason.ConsensusTimeout]: 5,
31
+ [PenaltyReason.InvalidMessageHash]: 50,
32
+ [PenaltyReason.ExpiredTransaction]: 3,
33
+ [PenaltyReason.ProtocolViolation]: 30,
34
+ [PenaltyReason.ConnectionFailure]: 2,
35
+ [PenaltyReason.FalseApproval]: 40,
36
+ [PenaltyReason.DisputeLost]: 30,
37
+ };
38
+
39
+ /** Thresholds controlling graduated reputation responses */
40
+ export interface ReputationThresholds {
41
+ /** Score above which peer is deprioritized in coordinator selection. Default: 20 */
42
+ deprioritize: number;
43
+ /** Score above which peer is excluded from cluster operations. Default: 80 */
44
+ ban: number;
45
+ }
46
+
47
+ export const DEFAULT_THRESHOLDS: ReputationThresholds = {
48
+ deprioritize: 20,
49
+ ban: 80,
50
+ };
51
+
52
+ /** Configuration for the reputation service */
53
+ export interface ReputationConfig {
54
+ /** Half-life for exponential decay of penalties (ms). Default: 30 minutes */
55
+ halfLifeMs?: number;
56
+ /** Thresholds for deprioritize/ban. Uses DEFAULT_THRESHOLDS if not provided */
57
+ thresholds?: Partial<ReputationThresholds>;
58
+ /** Custom penalty weights. Merged with DEFAULT_PENALTY_WEIGHTS */
59
+ weights?: Partial<Record<PenaltyReason, number>>;
60
+ /** Maximum penalty records per peer before pruning. Default: 100 */
61
+ maxPenaltiesPerPeer?: number;
62
+ }
63
+
64
+ /** A single recorded penalty event */
65
+ export interface PenaltyRecord {
66
+ reason: PenaltyReason;
67
+ weight: number;
68
+ timestamp: number;
69
+ context?: string;
70
+ }
71
+
72
+ /** Internal record for a tracked peer */
73
+ export interface PeerRecord {
74
+ penalties: PenaltyRecord[];
75
+ successCount: number;
76
+ lastSuccess: number;
77
+ lastPenalty: number;
78
+ }
79
+
80
+ /** Summary of a peer's reputation for diagnostics */
81
+ export interface PeerReputationSummary {
82
+ peerId: string;
83
+ effectiveScore: number;
84
+ isBanned: boolean;
85
+ isDeprioritized: boolean;
86
+ penaltyCount: number;
87
+ successCount: number;
88
+ lastPenalty: number;
89
+ lastSuccess: number;
90
+ }
91
+
92
+ /** Interface for reputation scoring consumed by other components */
93
+ export interface IPeerReputation {
94
+ /** Record a misbehavior incident */
95
+ reportPeer(peerId: string, reason: PenaltyReason, context?: string): void;
96
+
97
+ /** Record successful interaction */
98
+ recordSuccess(peerId: string): void;
99
+
100
+ /** Get effective score for a peer (0 = clean) */
101
+ getScore(peerId: string): number;
102
+
103
+ /** Check if peer should be excluded from operations */
104
+ isBanned(peerId: string): boolean;
105
+
106
+ /** Check if peer should be deprioritized */
107
+ isDeprioritized(peerId: string): boolean;
108
+
109
+ /** Get summary for diagnostics */
110
+ getReputation(peerId: string): PeerReputationSummary;
111
+
112
+ /** Get all tracked peers and their statuses */
113
+ getAllReputations(): Map<string, PeerReputationSummary>;
114
+
115
+ /** Reset a peer's reputation (admin/testing) */
116
+ resetPeer(peerId: string): void;
117
+ }
@@ -118,6 +118,17 @@ export class ArachnodeFretAdapter {
118
118
  .sort((a, b) => a.ringDepth - b.ringDepth);
119
119
  }
120
120
 
121
+ /**
122
+ * Update the status field of this node's ArachnodeInfo.
123
+ * No-op if no ArachnodeInfo has been set yet.
124
+ */
125
+ setStatus(status: ArachnodeInfo['status']): void {
126
+ const current = this.getMyArachnodeInfo();
127
+ if (current) {
128
+ this.setArachnodeInfo({ ...current, status });
129
+ }
130
+ }
131
+
121
132
  /**
122
133
  * Access the underlying FRET service.
123
134
  */
@@ -4,6 +4,9 @@ import type { BlockArchive, BlockMetadata, RestoreCallback, RevisionRange } from
4
4
  import type { IRawStorage } from "./i-raw-storage.js";
5
5
  import { mergeRanges } from "./helpers.js";
6
6
  import type { IBlockStorage } from "./i-block-storage.js";
7
+ import { createLogger } from "../logger.js";
8
+
9
+ const log = createLogger('block-storage');
7
10
 
8
11
  export class BlockStorage implements IBlockStorage {
9
12
  constructor(
@@ -45,6 +48,7 @@ export class BlockStorage implements IBlockStorage {
45
48
  }
46
49
 
47
50
  async savePendingTransaction(actionId: ActionId, transform: Transform): Promise<void> {
51
+ log('pend blockId=%s actionId=%s', this.blockId, actionId);
48
52
  let meta = await this.storage.getMetadata(this.blockId);
49
53
  if (!meta) {
50
54
  meta = { latest: undefined, ranges: [[0]] };
@@ -54,6 +58,7 @@ export class BlockStorage implements IBlockStorage {
54
58
  }
55
59
 
56
60
  async deletePendingTransaction(actionId: ActionId): Promise<void> {
61
+ log('cancel blockId=%s actionId=%s', this.blockId, actionId);
57
62
  await this.storage.deletePendingTransaction(this.blockId, actionId);
58
63
  }
59
64
 
@@ -70,6 +75,7 @@ export class BlockStorage implements IBlockStorage {
70
75
  }
71
76
 
72
77
  async promotePendingTransaction(actionId: ActionId): Promise<void> {
78
+ log('commit blockId=%s actionId=%s', this.blockId, actionId);
73
79
  await this.storage.promotePendingTransaction(this.blockId, actionId);
74
80
  }
75
81
 
@@ -11,6 +11,9 @@ import {
11
11
  } from "@optimystic/db-core";
12
12
  import { asyncIteratorToArray } from "../it-utility.js";
13
13
  import type { IBlockStorage } from "./i-block-storage.js";
14
+ import { createLogger } from "../logger.js";
15
+
16
+ const log = createLogger('storage-repo');
14
17
 
15
18
  export type StorageRepoOptions = {
16
19
  /** Optional hook to validate transactions in PendRequests */
@@ -29,6 +32,7 @@ export class StorageRepo implements IRepo {
29
32
 
30
33
  async get({ blockIds, context }: BlockGets, options?: MessageOptions): Promise<GetBlockResults> {
31
34
  const distinctBlockIds = Array.from(new Set(blockIds));
35
+ log('get blockIds=%d', distinctBlockIds.length);
32
36
  const results = await Promise.all(distinctBlockIds.map(async (blockId) => {
33
37
  const blockStorage = this.createBlockStorage(blockId);
34
38
 
@@ -92,6 +96,7 @@ export class StorageRepo implements IRepo {
92
96
  }
93
97
 
94
98
  const blockIds = blockIdsForTransforms(request.transforms);
99
+ log('pend actionId=%s blockIds=%d rev=%s', request.actionId, blockIds.length, request.rev);
95
100
  const pendings: ActionPending[] = [];
96
101
  const missing: ActionTransforms[] = [];
97
102
 
@@ -131,6 +136,7 @@ export class StorageRepo implements IRepo {
131
136
  }
132
137
 
133
138
  if (missing.length) {
139
+ log('pend:stale actionId=%s missing=%d', request.actionId, missing.length);
134
140
  return {
135
141
  success: false,
136
142
  missing
@@ -175,6 +181,7 @@ export class StorageRepo implements IRepo {
175
181
  }
176
182
 
177
183
  async cancel(actionRef: ActionBlocks, _options?: MessageOptions): Promise<void> {
184
+ log('cancel actionId=%s blockIds=%d', actionRef.actionId, actionRef.blockIds.length);
178
185
  await Promise.all(actionRef.blockIds.map(blockId => {
179
186
  const blockStorage = this.createBlockStorage(blockId);
180
187
  return blockStorage.deletePendingTransaction(actionRef.actionId);
@@ -182,6 +189,7 @@ export class StorageRepo implements IRepo {
182
189
  }
183
190
 
184
191
  async commit(request: CommitRequest, options?: MessageOptions): Promise<CommitResult> {
192
+ log('commit actionId=%s rev=%d blockIds=%d', request.actionId, request.rev, request.blockIds.length);
185
193
  const uniqueBlockIds = Array.from(new Set(request.blockIds)).sort();
186
194
  const releases: (() => void)[] = [];
187
195
 
@@ -222,6 +230,7 @@ export class StorageRepo implements IRepo {
222
230
  }
223
231
 
224
232
  if (missedCommits.length) {
233
+ log('commit:stale actionId=%s missed=%d', request.actionId, missedCommits.length);
225
234
  return { // Return directly, locks will be released in finally
226
235
  success: false,
227
236
  missing: perBlockActionTransformsToPerAction(missedCommits)