@steve02081504/fount-p2p 0.0.36 → 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.
- package/AGENTS.md +1 -1
- package/discovery/adverts.mjs +16 -8
- package/discovery/nostr/census.mjs +281 -0
- package/discovery/nostr/census_math.mjs +54 -0
- package/discovery/nostr/constants.mjs +43 -0
- package/discovery/nostr/index.mjs +612 -0
- package/discovery/nostr/relays.mjs +1071 -0
- package/discovery/nostr/selection.mjs +224 -0
- package/discovery/nostr/session.mjs +584 -0
- package/docs/nostr_relay_discovery.md +92 -0
- package/files/manifest/fetch.mjs +4 -6
- package/governance/branch.mjs +1 -1
- package/governance/join_pow.mjs +2 -2
- package/index.mjs +11 -4
- package/link/handshake.mjs +135 -23
- package/link/providers/link_id_pipe.mjs +1 -1
- package/link/providers/{nostr.mjs → nostr/index.mjs} +19 -14
- package/mailbox/deliver_or_store.mjs +3 -4
- package/mailbox/store.mjs +1 -1
- package/node/feature_config.mjs +30 -0
- package/node/instance.mjs +21 -0
- package/node/reputation_store.mjs +1 -2
- package/node/storage.mjs +23 -0
- package/package.json +10 -4
- package/pages/app.mjs +172 -0
- package/pages/index.html +36 -0
- package/reputation/engine.mjs +1 -2
- package/transport/runtime_bootstrap.mjs +39 -3
- package/discovery/nostr.mjs +0 -1073
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()
|
package/pages/index.html
ADDED
|
@@ -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>
|
package/reputation/engine.mjs
CHANGED
|
@@ -301,8 +301,7 @@ export function applyDecayCollusionAfterSlashPure(data, targetPubKeyHash, invite
|
|
|
301
301
|
for (let hop = 1; hop <= effectiveMaxHop(); hop++) {
|
|
302
302
|
const upstream = new Set()
|
|
303
303
|
for (const edge of inviteEdges) {
|
|
304
|
-
const to = edge
|
|
305
|
-
const from = edge.from
|
|
304
|
+
const { to, from } = edge
|
|
306
305
|
if (frontier.has(to) && from) upstream.add(from)
|
|
307
306
|
}
|
|
308
307
|
if (!upstream.size) break
|
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
unregisterDiscoveryProvider,
|
|
11
11
|
} from '../discovery/index.mjs'
|
|
12
12
|
import { createLanDiscoveryProvider } from '../discovery/lan.mjs'
|
|
13
|
-
import {
|
|
13
|
+
import { MAX_ADVERT_RELAY_POOL } from '../discovery/nostr/constants.mjs'
|
|
14
|
+
import {
|
|
15
|
+
createNostrDiscoveryProvider,
|
|
16
|
+
resolveNostrRelayUrls,
|
|
17
|
+
} from '../discovery/nostr/index.mjs'
|
|
18
|
+
import { getListenRelays, getWorkingRelays, loadRelayPool, startNostrRelayDiscovery } from '../discovery/nostr/relays.mjs'
|
|
14
19
|
import { createBleGattLinkProvider } from '../link/providers/ble_gatt.mjs'
|
|
15
20
|
import {
|
|
16
21
|
listLinkProviders,
|
|
@@ -18,7 +23,7 @@ import {
|
|
|
18
23
|
unregisterLinkProvider,
|
|
19
24
|
} from '../link/providers/index.mjs'
|
|
20
25
|
import { createLanTcpLinkProvider } from '../link/providers/lan_tcp.mjs'
|
|
21
|
-
import { createNostrLinkProvider } from '../link/providers/nostr.mjs'
|
|
26
|
+
import { createNostrLinkProvider } from '../link/providers/nostr/index.mjs'
|
|
22
27
|
import { createWebRtcLinkProvider } from '../link/providers/webrtc.mjs'
|
|
23
28
|
import { getSignalingRuntimeConfig, onNodeChange } from '../node/instance.mjs'
|
|
24
29
|
import { isConnectivityDebug, nodeDebug, shortHash } from '../node/log.mjs'
|
|
@@ -88,6 +93,8 @@ export function createRuntimeBootstrap(deps) {
|
|
|
88
93
|
let stopPresence = null
|
|
89
94
|
/** @type {(() => void) | null} */
|
|
90
95
|
let stopSignalListener = null
|
|
96
|
+
/** @type {(() => void) | null} */
|
|
97
|
+
let stopRelayDiscovery = null
|
|
91
98
|
/** @type {Map<string, () => void>} */
|
|
92
99
|
const stopLinkListeners = new Map()
|
|
93
100
|
/** @type {ReturnType<typeof createLanTcpLinkProvider> | null} */
|
|
@@ -153,7 +160,21 @@ export function createRuntimeBootstrap(deps) {
|
|
|
153
160
|
async function buildLocalAdvert(scope = 'node') {
|
|
154
161
|
await whenListening()
|
|
155
162
|
const tcpPort = lanTcpPort()
|
|
156
|
-
|
|
163
|
+
// 仅 network 域注入 relay 字段;LAN 域传空对象(签名消息仍含空的 relays: 段)。
|
|
164
|
+
return buildSignedAdvertForScope(
|
|
165
|
+
scope,
|
|
166
|
+
localIdentity,
|
|
167
|
+
tcpPort ?? undefined,
|
|
168
|
+
scope === 'network'
|
|
169
|
+
? {
|
|
170
|
+
pool: getWorkingRelays()
|
|
171
|
+
.filter(entry => entry.rttMs != null)
|
|
172
|
+
.slice(0, MAX_ADVERT_RELAY_POOL)
|
|
173
|
+
.map(entry => ({ url: entry.url, rttMs: entry.rttMs })),
|
|
174
|
+
listen: getListenRelays().map(entry => entry.url),
|
|
175
|
+
}
|
|
176
|
+
: { pool: [], listen: [] },
|
|
177
|
+
)
|
|
157
178
|
}
|
|
158
179
|
|
|
159
180
|
/**
|
|
@@ -296,6 +317,10 @@ export function createRuntimeBootstrap(deps) {
|
|
|
296
317
|
stopSignalListener?.()
|
|
297
318
|
stopPresence = null
|
|
298
319
|
stopSignalListener = null
|
|
320
|
+
// 重启 NIP-66 发现(不重新 loadRelayPool:避免用磁盘旧数据覆盖内存中的 poolEntries/peerRoutes)。
|
|
321
|
+
stopRelayDiscovery?.()
|
|
322
|
+
stopRelayDiscovery = null
|
|
323
|
+
if (isChannelEnabled('nostr')) stopRelayDiscovery = startNostrRelayDiscovery()
|
|
299
324
|
reconcileLinkProviders()
|
|
300
325
|
reconcileDiscoveryProviders()
|
|
301
326
|
if (generation !== gen || !isLive()) return
|
|
@@ -337,6 +362,15 @@ export function createRuntimeBootstrap(deps) {
|
|
|
337
362
|
runtimeStart = (async () => {
|
|
338
363
|
runtimeStarted = true
|
|
339
364
|
const gen = generation
|
|
365
|
+
// 先加载/播种 relay 池,再启动 NIP-66 发现(首轮异步,不阻塞 startup)。
|
|
366
|
+
loadRelayPool()
|
|
367
|
+
if (isChannelEnabled('nostr')) {
|
|
368
|
+
if (!stopRelayDiscovery) stopRelayDiscovery = startNostrRelayDiscovery()
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
stopRelayDiscovery?.()
|
|
372
|
+
stopRelayDiscovery = null
|
|
373
|
+
}
|
|
340
374
|
reconcileLinkProviders()
|
|
341
375
|
if (autoRegisterDiscoveryProviders)
|
|
342
376
|
reconcileDiscoveryProviders()
|
|
@@ -407,6 +441,8 @@ export function createRuntimeBootstrap(deps) {
|
|
|
407
441
|
stopSignalListener?.()
|
|
408
442
|
stopPresence = null
|
|
409
443
|
stopSignalListener = null
|
|
444
|
+
stopRelayDiscovery?.()
|
|
445
|
+
stopRelayDiscovery = null
|
|
410
446
|
await Promise.race([
|
|
411
447
|
runtimeWarm?.catch(() => { }) ?? Promise.resolve(),
|
|
412
448
|
new Promise(resolve => setTimeout(resolve, 500)),
|