@steve02081504/fount-p2p 0.0.35 → 0.0.37

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 (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/core/entity_id.mjs +6 -6
  3. package/core/entity_id_parse.mjs +7 -7
  4. package/core/hexIds.mjs +6 -18
  5. package/crypto/checkpoint_sign.mjs +4 -4
  6. package/dag/canonicalize_row.mjs +4 -7
  7. package/discovery/adverts.mjs +17 -10
  8. package/discovery/bt/index.mjs +11 -11
  9. package/discovery/bt/peer_hints.mjs +4 -7
  10. package/discovery/index.mjs +8 -12
  11. package/discovery/internal/signal_crypto.mjs +1 -2
  12. package/discovery/lan.mjs +7 -9
  13. package/discovery/lan_peer_hints.mjs +5 -8
  14. package/discovery/nostr/census.mjs +281 -0
  15. package/discovery/nostr/census_math.mjs +54 -0
  16. package/discovery/nostr/constants.mjs +43 -0
  17. package/discovery/nostr/index.mjs +612 -0
  18. package/discovery/nostr/relays.mjs +1071 -0
  19. package/discovery/nostr/selection.mjs +224 -0
  20. package/discovery/nostr/session.mjs +584 -0
  21. package/discovery/peer_clue.mjs +1 -4
  22. package/docs/nostr_relay_discovery.md +92 -0
  23. package/federation/entity_key_chain.mjs +10 -9
  24. package/federation/message_rate_limit.mjs +1 -3
  25. package/files/manifest/fetch.mjs +4 -6
  26. package/files/manifest/normalize.mjs +2 -2
  27. package/files/manifest/public.mjs +5 -5
  28. package/governance/branch.mjs +1 -2
  29. package/governance/join_pow.mjs +2 -4
  30. package/governance/owner_succession_ballot.mjs +5 -5
  31. package/index.mjs +11 -4
  32. package/infra/priority.mjs +1 -2
  33. package/link/handshake.mjs +153 -41
  34. package/link/pipe.mjs +1 -2
  35. package/link/providers/ble_gatt.mjs +2 -3
  36. package/link/providers/lan_tcp.mjs +2 -3
  37. package/link/providers/link_id_pipe.mjs +3 -3
  38. package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
  39. package/mailbox/deliver_or_store.mjs +11 -12
  40. package/mailbox/prune.mjs +3 -4
  41. package/mailbox/rate.mjs +3 -2
  42. package/mailbox/store.mjs +6 -9
  43. package/node/denylist.mjs +25 -28
  44. package/node/feature_config.mjs +30 -0
  45. package/node/identity.mjs +6 -6
  46. package/node/instance.mjs +21 -0
  47. package/node/network.mjs +12 -12
  48. package/node/personal_block.mjs +4 -4
  49. package/node/reputation_store.mjs +13 -14
  50. package/node/reputation_sync.mjs +9 -10
  51. package/node/storage.mjs +23 -0
  52. package/package.json +10 -4
  53. package/pages/app.mjs +172 -0
  54. package/pages/index.html +36 -0
  55. package/reputation/engine.mjs +13 -16
  56. package/schemas/discovery.mjs +5 -6
  57. package/schemas/federation_pull.mjs +7 -7
  58. package/schemas/mailbox.mjs +3 -3
  59. package/schemas/part_query.mjs +5 -5
  60. package/timeline/verify_remote.mjs +7 -7
  61. package/transport/link_registry.mjs +16 -19
  62. package/transport/mesh_keepalive.mjs +8 -11
  63. package/transport/offer_answer.mjs +3 -3
  64. package/transport/peer_health.mjs +12 -15
  65. package/transport/runtime_bootstrap.mjs +39 -3
  66. package/trust_graph/send.mjs +3 -3
  67. package/discovery/nostr.mjs +0 -1074
@@ -1,4 +1,4 @@
1
- import { assertHex64, isHex64, normalizeHex64 } from '../core/hexIds.mjs'
1
+ import { assertHex64, isHex64 } from '../core/hexIds.mjs'
2
2
  import { recordWantIdsBackoff, wantIdsPeerKey } from '../federation/want_ids.mjs'
3
3
  import {
4
4
  applyDecayCollusionAfterSlashPure,
@@ -120,7 +120,7 @@ export function recordGossipAllUnknownWant(groupId, peerNodeHash) {
120
120
  * @returns {Promise<void>}
121
121
  */
122
122
  export function recordMessageRateViolation(peerNodeHash, excessRatio = 1) {
123
- const id = normalizeHex64(peerNodeHash)
123
+ const id = peerNodeHash
124
124
  if (!id) return Promise.resolve()
125
125
  return mutateReputation(data => {
126
126
  recordMessageRateViolationPure(data, id, undefined, excessRatio)
@@ -134,7 +134,7 @@ export function recordMessageRateViolation(peerNodeHash, excessRatio = 1) {
134
134
  * @returns {Promise<boolean>} 是否触发异常隔离
135
135
  */
136
136
  export async function observePeerBehavior(peerNodeHash, sample) {
137
- const id = normalizeHex64(peerNodeHash)
137
+ const id = peerNodeHash
138
138
  if (!id) return false
139
139
  let anomaly = false
140
140
  await mutateReputation(data => {
@@ -149,7 +149,7 @@ export async function observePeerBehavior(peerNodeHash, sample) {
149
149
  * @returns {void}
150
150
  */
151
151
  export function bumpChunkStorageReputation(storagePeerKey) {
152
- const id = normalizeHex64(storagePeerKey)
152
+ const id = storagePeerKey
153
153
  if (!id) return
154
154
  void mutateReputation(data => {
155
155
  bumpChunkStorageReputationPure(data, id)
@@ -161,7 +161,7 @@ export function bumpChunkStorageReputation(storagePeerKey) {
161
161
  * @returns {void}
162
162
  */
163
163
  export function penalizeChunkStorageFailure(blamePeerKey) {
164
- const id = normalizeHex64(blamePeerKey)
164
+ const id = blamePeerKey
165
165
  if (!id) return
166
166
  void mutateReputation(data => {
167
167
  penalizeChunkStorageFailurePure(data, id)
@@ -173,7 +173,7 @@ export function penalizeChunkStorageFailure(blamePeerKey) {
173
173
  * @returns {void}
174
174
  */
175
175
  export function penalizeArchiveServeMismatch(peerNodeHash) {
176
- const id = normalizeHex64(peerNodeHash)
176
+ const id = peerNodeHash
177
177
  if (!id) return
178
178
  void mutateReputation(data => {
179
179
  penalizeArchiveServeMismatchPure(data, id)
@@ -187,9 +187,9 @@ export function penalizeArchiveServeMismatch(peerNodeHash) {
187
187
  export async function applyVolatileSlashAlert(alert) {
188
188
  const expiresAt = Number(alert?.expiresAt)
189
189
  if (Number.isFinite(expiresAt) && Date.now() > expiresAt) return false
190
- const target = normalizeHex64(alert?.targetPubKeyHash)
191
- const sender = normalizeHex64(alert?.sender)
192
- if (!isHex64(target) || !isHex64(sender)) return false
190
+ const target = isHex64(alert?.targetPubKeyHash)
191
+ const sender = isHex64(alert?.sender)
192
+ if (!target || !sender) return false
193
193
  const claim = Number.isFinite(Number(alert?.claim)) ? Number(alert.claim) : reputationTunables.slashDefaultClaim
194
194
  await mutateReputation(data => {
195
195
  applySubjectiveSlashPure(data, target, sender, claim, false)
@@ -227,13 +227,12 @@ export function buildUnverifiedSlashAlert(senderPubKeyHash, content, groupSettin
227
227
  export async function applySubjectiveSlashFromEvent(username, groupId, event, readEvents) {
228
228
  if (event.type !== 'reputation_slash') return
229
229
  const { content } = event
230
- const target = normalizeHex64(content.targetPubKeyHash)
231
- const sender = normalizeHex64(event.sender)
230
+ const target = content.targetPubKeyHash
232
231
 
233
232
  await mutateReputation(async data => {
234
233
  const verified = content.verified && await verifySlashProof(username, groupId, content, readEvents)
235
234
  const claim = Number(content.claim ?? (verified ? reputationTunables.slashVerifiedDefaultClaim : reputationTunables.slashUnverifiedDefaultClaim))
236
- applySubjectiveSlashPure(data, target, sender, claim, verified)
235
+ applySubjectiveSlashPure(data, target, event.sender, claim, verified)
237
236
  })
238
237
  }
239
238
 
@@ -245,7 +244,7 @@ export async function applySubjectiveSlashFromEvent(username, groupId, event, re
245
244
  * @returns {Promise<boolean>} 是否找到 proof 对应事件
246
245
  */
247
246
  async function verifySlashProof(username, groupId, content, readEvents) {
248
- const eventId = normalizeHex64(content?.proof?.eventId)
247
+ const eventId = content?.proof?.eventId
249
248
  if (!eventId) return false
250
249
  const events = await readEvents(username, groupId)
251
250
  return events.some(event => event.id === eventId)
@@ -261,7 +260,7 @@ export function applyDecayCollusionAfterSlash(targetPubKeyHash, inviteEdges) {
261
260
  const applied = applyDecayCollusionAfterSlashPure(data, targetPubKeyHash, inviteEdges)
262
261
  if (applied.length)
263
262
  getNodeLogger()?.warn?.('reputation: collusion decay after slash', {
264
- target: normalizeHex64(targetPubKeyHash),
263
+ target: targetPubKeyHash,
265
264
  upstreamCount: applied.length,
266
265
  hops: applied.map(row => row.hop),
267
266
  })
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
- import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
3
+ import { isHex64 } from '../core/hexIds.mjs'
4
4
  import { sendToNodeLink } from '../transport/link_registry.mjs'
5
5
  import { attachNodeScopeFeature } from '../transport/node_scope/features.mjs'
6
6
  import { ensureNodeScope, getNodeScopeWire } from '../transport/node_scope/wire.mjs'
@@ -55,9 +55,8 @@ function loadSyncConfig() {
55
55
  ? raw.lockedMaxPrevByNodeHash
56
56
  : {}
57
57
  for (const [nodeHash, score] of Object.entries(prevRaw)) {
58
- const id = normalizeHex64(nodeHash)
59
58
  const n = Number(score)
60
- if (id && isHex64(id) && Number.isFinite(n)) lockedMaxPrevByNodeHash[id] = n
59
+ if (isHex64(nodeHash) && Number.isFinite(n)) lockedMaxPrevByNodeHash[nodeHash] = n
61
60
  }
62
61
  syncConfig = {
63
62
  trustSyncDonors: normalizeHashList(raw.trustSyncDonors),
@@ -73,7 +72,7 @@ function loadSyncConfig() {
73
72
  * @returns {string[]} 规范化去重后的 64-hex 列表
74
73
  */
75
74
  function normalizeHashList(list) {
76
- return [...new Set((Array.isArray(list) ? list : []).map(isHex64).filter(Boolean))]
75
+ return [...new Set(list?.map?.(isHex64)?.filter?.(Boolean) || [])]
77
76
  }
78
77
 
79
78
  /** 将当前 sync 配置写盘 */
@@ -91,8 +90,8 @@ export async function setReputationTable(table) {
91
90
  await mutateReputation(data => {
92
91
  data.byNodeHash = data.byNodeHash || {}
93
92
  for (const [nodeHash, row] of Object.entries(incoming)) {
94
- const id = normalizeHex64(nodeHash)
95
- if (!id || !isHex64(id)) continue
93
+ const id = isHex64(nodeHash)
94
+ if (!id) continue
96
95
  const score = Number(row?.score ?? row)
97
96
  if (!Number.isFinite(score)) continue
98
97
  data.byNodeHash[id] = { ...data.byNodeHash[id] || {}, score }
@@ -223,7 +222,7 @@ export function attachReputationSyncWire() {
223
222
  * @param {string} peerId 对端 nodeHash
224
223
  */
225
224
  rep_sync_req(payload, peerId) {
226
- const requester = normalizeHex64(peerId)
225
+ const requester = peerId
227
226
  if (!requester || !getReputationExportAllowlist().includes(requester)) return
228
227
  try {
229
228
  wire.send('rep_sync_res', {
@@ -241,7 +240,7 @@ export function attachReputationSyncWire() {
241
240
  const requestId = String(payload?.requestId || '')
242
241
  const pending = pendingPulls.get(requestId)
243
242
  if (!pending) return
244
- if (normalizeHex64(peerId) !== pending.donor) return
243
+ if (peerId !== pending.donor) return
245
244
  clearTimeout(pending.timer)
246
245
  pendingPulls.delete(requestId)
247
246
  pending.resolve(payload)
@@ -271,8 +270,8 @@ export function detachReputationSyncWire() {
271
270
  * @returns {Promise<object>} donor 返回的信誉表
272
271
  */
273
272
  export async function pullReputationFromNode(nodeHash) {
274
- const donor = normalizeHex64(nodeHash)
275
- if (!donor || !isHex64(donor)) throw new Error('p2p: pullReputationFromNode requires valid nodeHash')
273
+ const donor = isHex64(nodeHash)
274
+ if (!donor) throw new Error('p2p: pullReputationFromNode requires valid nodeHash')
276
275
  if (!getTrustSyncDonors().includes(donor))
277
276
  throw new Error('p2p: node not in trustSyncDonors')
278
277
  attachReputationSyncWire()
package/node/storage.mjs CHANGED
@@ -28,3 +28,26 @@ export function readNodeJsonSync(name) {
28
28
  export function writeNodeJsonSync(name, data) {
29
29
  writeJsonFileSync(nodeJsonPath(name), data)
30
30
  }
31
+
32
+ /**
33
+ * Nostr relay 池 / peer 路由持久化文件路径(`nodeDir/nostr/relays.json`)。
34
+ * @returns {string} 绝对路径
35
+ */
36
+ export function nostrRelayJsonPath() {
37
+ return path.join(getNodeDir(), 'nostr', 'relays.json')
38
+ }
39
+
40
+ /**
41
+ * @returns {object | null} 解析后的 Nostr relay JSON 或 null
42
+ */
43
+ export function readNostrRelaysJsonSync() {
44
+ return readJsonFileSync(nostrRelayJsonPath())
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} data 数据
49
+ * @returns {void}
50
+ */
51
+ export function writeNostrRelaysJsonSync(data) {
52
+ writeJsonFileSync(nostrRelayJsonPath(), data)
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve02081504/fount-p2p",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
5
5
  "keywords": [
6
6
  "network",
@@ -21,10 +21,11 @@
21
21
  "node": ">=20"
22
22
  },
23
23
  "scripts": {
24
- "test": "node --test --test-concurrency=1 --test-force-exit test/pure/*.test.mjs test/integration/*.test.mjs",
24
+ "test": "node --test --test-concurrency=1 --test-force-exit test/pure/*.test.mjs test/integration/*.test.mjs test/frontend/*.test.mjs",
25
25
  "test:fount": "deno test -A --config deno.json test/fount/",
26
26
  "test:live": "node --test --test-concurrency=1 --test-force-exit test/live/*.test.mjs",
27
- "test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs"
27
+ "test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs",
28
+ "test:frontend": "node --test --test-concurrency=1 --test-force-exit test/frontend/*.test.mjs"
28
29
  },
29
30
  "bin": {
30
31
  "fount-p2p": "infra/cli.mjs"
@@ -33,6 +34,8 @@
33
34
  ".": "./index.mjs",
34
35
  "./infra": "./infra/service.mjs",
35
36
  "./link": "./link/providers/index.mjs",
37
+ "./link/providers/nostr": "./link/providers/nostr/index.mjs",
38
+ "./link/providers/nostr/*": "./link/providers/nostr/*.mjs",
36
39
  "./core/*": "./core/*.mjs",
37
40
  "./crypto": "./crypto/crypto.mjs",
38
41
  "./crypto/*": "./crypto/*.mjs",
@@ -43,6 +46,8 @@
43
46
  "./discovery": "./discovery/index.mjs",
44
47
  "./discovery/bt": "./discovery/bt/index.mjs",
45
48
  "./discovery/bt/*": "./discovery/bt/*.mjs",
49
+ "./discovery/nostr": "./discovery/nostr/index.mjs",
50
+ "./discovery/nostr/*": "./discovery/nostr/*.mjs",
46
51
  "./discovery/*": "./discovery/*.mjs",
47
52
  "./transport/node_scope/*": "./transport/node_scope/*.mjs",
48
53
  "./transport/*": "./transport/*.mjs",
@@ -73,7 +78,8 @@
73
78
  "ws": "latest"
74
79
  },
75
80
  "devDependencies": {
76
- "@steve02081504/exec": "latest"
81
+ "@steve02081504/exec": "latest",
82
+ "playwright-core": "latest"
77
83
  },
78
84
  "optionalDependencies": {
79
85
  "@stoprocent/bleno": "latest",
package/pages/app.mjs ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * fount 人口统计前端(独立实现,不依赖包内代码)。
3
+ * - 浏览器原生 WebSocket 订阅 Nostr 中继 kind=30789 / #t=fount / #x=census。
4
+ * - 本地用 @noble 验签(懒加载,仅 live 模式需要)。
5
+ * - 在线节点数估计 = Σ(1/p)(HT)。
6
+ * - `?demo=1`(或无 relay 参数)进入演示模式:喂入 20 条 p=0.1 的样本 → 估计 200。
7
+ */
8
+ const CENSUS_KIND = 30789
9
+ const CENSUS_TTL_MS = 10 * 60_000
10
+ const CENSUS_SUB_ID = 'census'
11
+ const CENSUS_MIN_P = 0.001
12
+ /** relay 帧 content 最大长度(超出直接丢弃,避免先 Base64 解码再被拒)。 */
13
+ const MAX_FRAME_BYTES = 16 * 1024
14
+ /** 并发验签任务上限;达上限时多余帧直接丢弃。 */
15
+ const MAX_INFLIGHT_VERIFICATIONS = 8
16
+
17
+ const statusEl = document.querySelector('#status')
18
+ const toggleEl = document.querySelector('#toggle')
19
+
20
+ const params = new URLSearchParams(location.search)
21
+ const relayUrl = params.get('relay')
22
+ const demoMode = params.get('demo') === '1' || !relayUrl
23
+
24
+ /** @type {Map<string, { p: number, at: number }>} */
25
+ const events = new Map()
26
+ let webSocket = null
27
+ let enabled = true
28
+ let inflightVerifications = 0
29
+
30
+ /**
31
+ * HT 估计;遍历时逐出过期事件,避免窗口 Map 持续增长。
32
+ * @returns {{ estimate: number, sampleSize: number }} 估计与采样数
33
+ */
34
+ function estimatePopulation() {
35
+ let total = 0
36
+ let sampleSize = 0
37
+ const now = Date.now()
38
+ for (const [hash, { p, at }] of events) {
39
+ if (!Number.isFinite(at) || now - at > CENSUS_TTL_MS) { events.delete(hash); continue }
40
+ total += 1 / p
41
+ sampleSize++
42
+ }
43
+ return { estimate: total, sampleSize }
44
+ }
45
+
46
+ let noblePromise = null
47
+ /** @returns {Promise<[object, object]>} noble 模块 */
48
+ function loadNoble() {
49
+ return noblePromise ||= Promise.all([
50
+ import('https://esm.sh/@noble/curves@1/ed25519.js'),
51
+ import('https://esm.sh/@noble/hashes@1/sha2.js'),
52
+ ])
53
+ }
54
+
55
+ /**
56
+ * 校验 census 包:nodeHash=sha256(nodePubKey) 且 Ed25519 签名匹配 `fount-census\0ts\0nodeHash\0p`。
57
+ * @param {object} packet 原始包
58
+ * @returns {Promise<{ nodeHash: string, p: number, ts: number } | null>} 验签结果
59
+ */
60
+ async function verifyCensusPacket(packet) {
61
+ const nodeHash = /^[\da-f]{64}$/u.test(String(packet?.nodeHash ?? '')) ? packet.nodeHash : null
62
+ const nodePubKey = /^[\da-f]{64}$/u.test(String(packet?.nodePubKey ?? '')) ? packet.nodePubKey : null
63
+ const sig = /^[\da-f]{128}$/u.test(String(packet?.sig ?? '')) ? packet.sig : null
64
+ const ts = Number(packet?.ts)
65
+ const p = Number(packet?.p)
66
+ if (!nodeHash || !nodePubKey || !sig || !Number.isFinite(ts)) return null
67
+ if (Math.abs(Date.now() - ts) > CENSUS_TTL_MS) return null
68
+ if (!Number.isFinite(p) || p < CENSUS_MIN_P || p > 1) return null
69
+ const [{ ed25519 }, { sha256 }] = await loadNoble()
70
+ const pubBytes = new Uint8Array(nodePubKey.match(/.{2}/gu).map(hex => parseInt(hex, 16)))
71
+ if ([...sha256(pubBytes)].map(byte => byte.toString(16).padStart(2, '0')).join('') !== nodeHash) return null
72
+ const ok = ed25519.verify(
73
+ new Uint8Array(sig.match(/.{2}/gu).map(hex => parseInt(hex, 16))),
74
+ new TextEncoder().encode(`fount-census\0${ts}\0${nodeHash}\0${p}`),
75
+ pubBytes,
76
+ )
77
+ return ok ? { nodeHash, p, ts } : null
78
+ }
79
+
80
+ /** @param {{ nodeHash: string, p: number, ts: number }} verified 已验签 census 包 @returns {void} */
81
+ function ingest(verified) {
82
+ const existing = events.get(verified.nodeHash)
83
+ if (existing && verified.ts < existing.at) return
84
+ events.set(verified.nodeHash, { p: verified.p, at: verified.ts })
85
+ render()
86
+ }
87
+
88
+ /** @returns {void} 渲染人口估计 */
89
+ function render() {
90
+ const { estimate, sampleSize } = estimatePopulation()
91
+ document.querySelector('#estimate').textContent = estimate.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
92
+ document.querySelector('#sample-size').textContent = String(sampleSize)
93
+ document.querySelector('#window-events').textContent = String(events.size)
94
+ }
95
+
96
+ /** @returns {void} 注入演示数据 */
97
+ function seedDemo() {
98
+ events.clear()
99
+ for (let index = 0; index < 20; index++)
100
+ events.set((index + 1).toString(16).padStart(2, '0').repeat(32), { p: 0.1, at: Date.now() })
101
+ statusEl.textContent = '演示模式:20 条 p=0.1 样本(HT 估计 = 200)'
102
+ document.querySelector('#footer').textContent = '演示模式样本为虚构数据;添加 ?relay=wss://... 进入 live 模式。'
103
+ }
104
+
105
+ /** @returns {void} 连接中继并订阅 */
106
+ function connectRelay() {
107
+ if (webSocket) {
108
+ try { webSocket.close() } catch { /* ignore */ }
109
+ webSocket = null
110
+ }
111
+ if (!relayUrl || !enabled) return
112
+ statusEl.textContent = `连接 ${relayUrl} …`
113
+ const connection = new WebSocket(relayUrl)
114
+ webSocket = connection
115
+ /** WebSocket 已连接后发送 REQ 订阅 census 事件。 */
116
+ connection.onopen = () => {
117
+ if (connection !== webSocket || !enabled) return
118
+ connection.send(JSON.stringify(['REQ', CENSUS_SUB_ID, { kinds: [CENSUS_KIND], '#t': ['fount'], '#x': ['census'] }]))
119
+ statusEl.textContent = `监听 ${relayUrl}(kind ${CENSUS_KIND})`
120
+ }
121
+ /**
122
+ * @param {MessageEvent} rawMessage 中继消息事件
123
+ * @returns {void}
124
+ */
125
+ connection.onmessage = rawMessage => {
126
+ if (connection !== webSocket || !enabled) return
127
+ if (inflightVerifications >= MAX_INFLIGHT_VERIFICATIONS) return
128
+ if (typeof rawMessage.data !== 'string' || rawMessage.data.length > MAX_FRAME_BYTES) return
129
+ let parsed
130
+ try { parsed = JSON.parse(String(rawMessage.data)) } catch { return }
131
+ if (parsed?.[0] !== 'EVENT' || parsed[1] !== CENSUS_SUB_ID) return
132
+ if (parsed[2]?.kind !== CENSUS_KIND) return
133
+ const content = parsed[2]?.content
134
+ if (typeof content !== 'string' || content.length > MAX_FRAME_BYTES) return
135
+ inflightVerifications++
136
+ void (async () => {
137
+ try {
138
+ const verified = await verifyCensusPacket(JSON.parse(atob(content)))
139
+ if (connection !== webSocket || !enabled) return
140
+ if (verified) ingest(verified)
141
+ }
142
+ catch { /* ignore malformed */ }
143
+ finally { inflightVerifications-- }
144
+ })()
145
+ }
146
+ /** WebSocket 关闭时更新状态为已断开。 */
147
+ connection.onclose = () => {
148
+ if (connection !== webSocket || !enabled) return
149
+ statusEl.textContent = '已断开'
150
+ }
151
+ /** WebSocket 出错时提示中继连接失败。 */
152
+ connection.onerror = () => {
153
+ if (connection !== webSocket || !enabled) return
154
+ statusEl.textContent = '中继连接失败'
155
+ }
156
+ }
157
+
158
+ toggleEl.addEventListener('change', () => {
159
+ enabled = toggleEl.checked
160
+ if (!enabled) {
161
+ events.clear()
162
+ if (webSocket) { try { webSocket.close() } catch { /* ignore */ } webSocket = null }
163
+ statusEl.textContent = '已关闭'
164
+ }
165
+ else if (demoMode) seedDemo()
166
+ else connectRelay()
167
+ render()
168
+ })
169
+
170
+ if (demoMode) seedDemo()
171
+ else connectRelay()
172
+ render()
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>fount 人口统计</title>
7
+ <style>
8
+ :root { color-scheme: light dark; }
9
+ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 3rem auto; padding: 0 1rem; line-height: 1.6; }
10
+ h1 { font-size: 1.4rem; }
11
+ .card { border: 1px solid #8884; border-radius: 8px; padding: 1rem 1.25rem; margin: 1rem 0; }
12
+ .metric { font-size: 2rem; font-weight: 700; }
13
+ label { display: flex; align-items: center; gap: .5rem; margin: 1rem 0; }
14
+ #status { opacity: .7; font-size: .9rem; }
15
+ code { background: #8882; padding: .1em .4em; border-radius: 4px; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <h1>fount 节点人口统计</h1>
20
+ <p>基于 Nostr census 事件(kind 30789,<code>t=fount</code> / <code>x=census</code>)的采样估计。本页为独立前端实现,仅做展示:在线节点数 = <code>Σ(1/p)</code>(Horvitz–Thompson 估计)。</p>
21
+ <label>
22
+ <input type="checkbox" id="toggle" checked>
23
+ 人口统计(census)
24
+ </label>
25
+ <div class="card">
26
+ <div>在线节点数估计</div>
27
+ <div class="metric" id="estimate">0</div>
28
+ <div id="status">初始化…</div>
29
+ </div>
30
+ <div class="card">
31
+ <div>采样事件数 <span id="sample-size">0</span> · 窗口内事件 <span id="window-events">0</span></div>
32
+ </div>
33
+ <footer id="footer" style="opacity:.6;font-size:.85rem"></footer>
34
+ <script type="module" src="./app.mjs"></script>
35
+ </body>
36
+ </html>
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * 信誉纯内存算子(模拟器与磁盘 store 共用)。
3
3
  */
4
- import { normalizeHex64 } from '../core/hexIds.mjs'
5
-
6
4
  import {
7
5
  clampReputationScore,
8
6
  computeRepMaxEff,
@@ -178,7 +176,7 @@ export function pruneReputationFile(data, tunables = reputationTunables, now = D
178
176
  * @returns {boolean} 是否已加分
179
177
  */
180
178
  export function bumpReputationOnRelayPure(data, peerNodeHash, dedupeKey, now = Date.now(), tunables = reputationTunables) {
181
- const id = normalizeHex64(peerNodeHash)
179
+ const id = peerNodeHash
182
180
  if (!id) return false
183
181
  const key = dedupeKey || `conn:${id}`
184
182
  data.relayBumpSeen = data.relayBumpSeen.filter(hit => now - hit.t <= tunables.relayBumpDedupeMs)
@@ -215,7 +213,7 @@ export function recordGossipAllUnknownWantPure(data, peerNodeHash, now = Date.no
215
213
  * @returns {void}
216
214
  */
217
215
  export function recordMessageRateViolationPure(data, peerNodeHash, tunables = reputationTunables, excessRatio = 1) {
218
- const id = normalizeHex64(peerNodeHash)
216
+ const id = peerNodeHash
219
217
  if (!id) return
220
218
  const ratio = Math.max(0, Math.min(1, Number(excessRatio) || 0))
221
219
  if (ratio <= 0) return
@@ -229,7 +227,7 @@ export function recordMessageRateViolationPure(data, peerNodeHash, tunables = re
229
227
  * @returns {void}
230
228
  */
231
229
  export function bumpChunkStorageReputationPure(data, storagePeerKey, tunables = reputationTunables) {
232
- const id = normalizeHex64(storagePeerKey)
230
+ const id = storagePeerKey
233
231
  if (!id) return
234
232
  adjustNodeReputation(data, id, tunables.chunkStoreRepBump)
235
233
  }
@@ -241,7 +239,7 @@ export function bumpChunkStorageReputationPure(data, storagePeerKey, tunables =
241
239
  * @returns {void}
242
240
  */
243
241
  export function penalizeChunkStorageFailurePure(data, blamePeerKey, tunables = reputationTunables) {
244
- const id = normalizeHex64(blamePeerKey)
242
+ const id = blamePeerKey
245
243
  if (!id) return
246
244
  adjustNodeReputation(data, id, -tunables.chunkFetchFailPenalty)
247
245
  }
@@ -253,7 +251,7 @@ export function penalizeChunkStorageFailurePure(data, blamePeerKey, tunables = r
253
251
  * @returns {void}
254
252
  */
255
253
  export function penalizeArchiveServeMismatchPure(data, peerNodeHash, tunables = reputationTunables) {
256
- const id = normalizeHex64(peerNodeHash)
254
+ const id = peerNodeHash
257
255
  if (!id) return
258
256
  adjustNodeReputation(data, id, -tunables.archiveServeMismatchPenalty)
259
257
  }
@@ -282,7 +280,7 @@ export function applySubjectiveSlashPure(data, target, sender, claim, verified,
282
280
  * @returns {Array<{ hop: number, node: string, dRep: number }>} 已应用的上游惩罚
283
281
  */
284
282
  export function applyDecayCollusionAfterSlashPure(data, targetPubKeyHash, inviteEdges, tunables = reputationTunables) {
285
- const target = normalizeHex64(targetPubKeyHash)
283
+ const target = targetPubKeyHash
286
284
  /** @type {Array<{ hop: number, node: string, dRep: number }>} */
287
285
  const applied = []
288
286
  let frontier = new Set([target])
@@ -303,8 +301,7 @@ export function applyDecayCollusionAfterSlashPure(data, targetPubKeyHash, invite
303
301
  for (let hop = 1; hop <= effectiveMaxHop(); hop++) {
304
302
  const upstream = new Set()
305
303
  for (const edge of inviteEdges) {
306
- const to = normalizeHex64(edge.to)
307
- const from = normalizeHex64(edge.from)
304
+ const { to, from } = edge
308
305
  if (frontier.has(to) && from) upstream.add(from)
309
306
  }
310
307
  if (!upstream.size) break
@@ -333,7 +330,7 @@ export function applyDecayCollusionAfterSlashPure(data, targetPubKeyHash, invite
333
330
  * @returns {void}
334
331
  */
335
332
  export function applyReputationResetToScoresPure(data, targetPubKeyHash) {
336
- const t = normalizeHex64(targetPubKeyHash)
333
+ const t = targetPubKeyHash
337
334
  const row = repRow(data, t)
338
335
  row.score = 0
339
336
  delete row.offenseStreak
@@ -353,8 +350,8 @@ export function applyReputationResetToScoresPure(data, targetPubKeyHash) {
353
350
  * @returns {void}
354
351
  */
355
352
  export function seedMemberReputationFromIntroducerPure(data, memberPubKeyHash, introducerPubKeyHash, repEdge, tunables = reputationTunables, powBonus = 0) {
356
- const memberKey = normalizeHex64(memberPubKeyHash)
357
- const introducerKey = normalizeHex64(introducerPubKeyHash)
353
+ const memberKey = memberPubKeyHash
354
+ const introducerKey = introducerPubKeyHash
358
355
  if (data.byNodeHash[memberKey]) return
359
356
  const introducerReputation = introducerKey
360
357
  ? Number(data.byNodeHash[introducerKey]?.score ?? 0)
@@ -394,7 +391,7 @@ function baselineRow(row) {
394
391
  * @returns {{ z: number, anomaly: boolean }} 偏离度
395
392
  */
396
393
  export function observeBehaviorSamplePure(data, peerNodeHash, sample, now = Date.now(), tunables = reputationTunables) {
397
- const id = normalizeHex64(peerNodeHash)
394
+ const id = peerNodeHash
398
395
  if (!id) return { z: 0, anomaly: false }
399
396
  const row = repRow(data, id)
400
397
  const baseline = baselineRow(row)
@@ -429,7 +426,7 @@ export function observeBehaviorSamplePure(data, peerNodeHash, sample, now = Date
429
426
  * @returns {void}
430
427
  */
431
428
  export function applyQuarantinePure(data, peerNodeHash, now = Date.now(), tunables = reputationTunables) {
432
- const id = normalizeHex64(peerNodeHash)
429
+ const id = peerNodeHash
433
430
  if (!id) return
434
431
  const row = repRow(data, id)
435
432
  row.quarantinedUntil = now + Number(tunables.quarantineTtlMs ?? 900_000)
@@ -443,7 +440,7 @@ export function applyQuarantinePure(data, peerNodeHash, now = Date.now(), tunabl
443
440
  * @returns {boolean} 是否处于本地隔离
444
441
  */
445
442
  export function isQuarantinedPure(data, peerNodeHash, now = Date.now()) {
446
- const id = normalizeHex64(peerNodeHash)
443
+ const id = peerNodeHash
447
444
  const until = Number(data.byNodeHash?.[id]?.quarantinedUntil ?? 0)
448
445
  return Number.isFinite(until) && until > now
449
446
  }
@@ -1,4 +1,4 @@
1
- import { assertHex64, isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
1
+ import { assertHex64, isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
2
2
  import { isPlainObject } from '../core/object.mjs'
3
3
 
4
4
  const MAX_DISCOVERY_ADS = 64
@@ -10,16 +10,15 @@ const MAX_DISCOVERY_ADS = 64
10
10
  function sanitizeDiscoveryAdvertisement(ad) {
11
11
  if (!isPlainObject(ad)) return null
12
12
  const groupId = String(ad.groupId || '')
13
- const advertiserPubKeyHash = normalizeHex64(ad.advertiserPubKeyHash)
14
- const signature = String(ad.signature || '')
15
- if (!groupId || !isHex64(advertiserPubKeyHash) || !isSignatureHex128(signature)) return null
16
- const advertiserNodeHash = normalizeHex64(ad.advertiserNodeHash)
13
+ const advertiserPubKeyHash = isHex64(ad.advertiserPubKeyHash)
14
+ const signature = isSignatureHex128(ad.signature)
15
+ if (!groupId || !advertiserPubKeyHash || !signature) return null
17
16
  const body = {
18
17
  groupId,
19
18
  title: String(ad.title || '').slice(0, 200),
20
19
  blurb: String(ad.blurb || '').slice(0, 500),
21
20
  advertiserPubKeyHash,
22
- advertiserNodeHash: isHex64(advertiserNodeHash) || String(ad.advertiserNodeHash || ''),
21
+ advertiserNodeHash: isHex64(ad.advertiserNodeHash) || '',
23
22
  observedAt: Number(ad.observedAt) || 0,
24
23
  signature,
25
24
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 联邦补拉 attestation / HPKE 响应 wire 解析(无 attestation/envelope 即丢弃)。
3
3
  */
4
- import { isHex64, isSignatureHex128, normalizeHex64 } from '../core/hexIds.mjs'
4
+ import { isHex64, isSignatureHex128 } from '../core/hexIds.mjs'
5
5
  import { isPlainObject } from '../core/object.mjs'
6
6
  import { EVENT_ID_HEX } from '../dag/index.mjs'
7
7
 
@@ -23,15 +23,15 @@ function isEnvelopeBlob(value) {
23
23
  */
24
24
  export function parsePullAttestation(attestation) {
25
25
  if (!isPlainObject(attestation)) return null
26
- const requesterPubKeyHash = normalizeHex64(attestation.requesterPubKeyHash)
26
+ const requesterPubKeyHash = isHex64(attestation.requesterPubKeyHash)
27
27
  const groupId = String(attestation.groupId || '')
28
28
  const requestId = String(attestation.requestId || '')
29
29
  const timestamp = Number(attestation.timestamp)
30
- const signature = String(attestation.signature || '')
31
- if (!isHex64(requesterPubKeyHash) || !groupId || !Number.isFinite(timestamp) || !isSignatureHex128(signature))
30
+ const signature = isSignatureHex128(attestation.signature)
31
+ if (!requesterPubKeyHash || !groupId || !Number.isFinite(timestamp) || !signature)
32
32
  return null
33
33
  const wantIds = attestation.wantIds?.length
34
- ? [...new Set(attestation.wantIds.map(id => normalizeHex64(id)).filter(id => EVENT_ID_HEX.test(id)))]
34
+ ? [...new Set(attestation.wantIds.filter(id => EVENT_ID_HEX.test(id)))]
35
35
  : undefined
36
36
  return {
37
37
  requesterPubKeyHash,
@@ -50,13 +50,13 @@ export function parsePullAttestation(attestation) {
50
50
  export function parsePullResponseEnvelope(envelope) {
51
51
  if (!isPlainObject(envelope)) return null
52
52
  const requestId = String(envelope.requestId || '')
53
- const requesterPubKeyHash = normalizeHex64(envelope.requesterPubKeyHash)
53
+ const requesterPubKeyHash = isHex64(envelope.requesterPubKeyHash)
54
54
  const requesterNodeHash = String(envelope.requesterNodeHash || '')
55
55
  const ephemPub = String(envelope.ephemPub || '')
56
56
  const iv = String(envelope.iv || '')
57
57
  const ciphertext = String(envelope.ciphertext || '')
58
58
  const authTag = String(envelope.authTag || '')
59
- if (!requestId || !isHex64(requesterPubKeyHash) || !requesterNodeHash) return null
59
+ if (!requestId || !requesterPubKeyHash || !requesterNodeHash) return null
60
60
  if (!isEnvelopeBlob(ephemPub) || !isEnvelopeBlob(iv) || !isEnvelopeBlob(ciphertext) || !isEnvelopeBlob(authTag))
61
61
  return null
62
62
  return {
@@ -1,4 +1,4 @@
1
- import { normalizeHex64, HEX_ID_64 } from '../core/hexIds.mjs'
1
+ import { isHex64 } from '../core/hexIds.mjs'
2
2
 
3
3
  /**
4
4
  * @typedef {{ ok: true, value: string }} MailboxWireOk
@@ -17,8 +17,8 @@ import { normalizeHex64, HEX_ID_64 } from '../core/hexIds.mjs'
17
17
  * @returns {MailboxWireResult} 规范化 hex64 或结构化错误
18
18
  */
19
19
  export function assertMailboxPubKeyHash(value) {
20
- const normalized = normalizeHex64(value)
21
- if (!HEX_ID_64.test(normalized))
20
+ const normalized = isHex64(value)
21
+ if (!normalized)
22
22
  return { ok: false, code: 'invalid_hex64', field: 'toPubKeyHash' }
23
23
  return { ok: true, value: normalized }
24
24
  }