@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.
- package/AGENTS.md +2 -2
- package/core/entity_id.mjs +6 -6
- package/core/entity_id_parse.mjs +7 -7
- package/core/hexIds.mjs +6 -18
- package/crypto/checkpoint_sign.mjs +4 -4
- package/dag/canonicalize_row.mjs +4 -7
- package/discovery/adverts.mjs +17 -10
- package/discovery/bt/index.mjs +11 -11
- package/discovery/bt/peer_hints.mjs +4 -7
- package/discovery/index.mjs +8 -12
- package/discovery/internal/signal_crypto.mjs +1 -2
- package/discovery/lan.mjs +7 -9
- package/discovery/lan_peer_hints.mjs +5 -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/discovery/peer_clue.mjs +1 -4
- package/docs/nostr_relay_discovery.md +92 -0
- package/federation/entity_key_chain.mjs +10 -9
- package/federation/message_rate_limit.mjs +1 -3
- package/files/manifest/fetch.mjs +4 -6
- package/files/manifest/normalize.mjs +2 -2
- package/files/manifest/public.mjs +5 -5
- package/governance/branch.mjs +1 -2
- package/governance/join_pow.mjs +2 -4
- package/governance/owner_succession_ballot.mjs +5 -5
- package/index.mjs +11 -4
- package/infra/priority.mjs +1 -2
- package/link/handshake.mjs +153 -41
- package/link/pipe.mjs +1 -2
- package/link/providers/ble_gatt.mjs +2 -3
- package/link/providers/lan_tcp.mjs +2 -3
- package/link/providers/link_id_pipe.mjs +3 -3
- package/link/providers/{nostr.mjs → nostr/index.mjs} +22 -17
- package/mailbox/deliver_or_store.mjs +11 -12
- package/mailbox/prune.mjs +3 -4
- package/mailbox/rate.mjs +3 -2
- package/mailbox/store.mjs +6 -9
- package/node/denylist.mjs +25 -28
- package/node/feature_config.mjs +30 -0
- package/node/identity.mjs +6 -6
- package/node/instance.mjs +21 -0
- package/node/network.mjs +12 -12
- package/node/personal_block.mjs +4 -4
- package/node/reputation_store.mjs +13 -14
- package/node/reputation_sync.mjs +9 -10
- 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 +13 -16
- package/schemas/discovery.mjs +5 -6
- package/schemas/federation_pull.mjs +7 -7
- package/schemas/mailbox.mjs +3 -3
- package/schemas/part_query.mjs +5 -5
- package/timeline/verify_remote.mjs +7 -7
- package/transport/link_registry.mjs +16 -19
- package/transport/mesh_keepalive.mjs +8 -11
- package/transport/offer_answer.mjs +3 -3
- package/transport/peer_health.mjs +12 -15
- package/transport/runtime_bootstrap.mjs +39 -3
- package/trust_graph/send.mjs +3 -3
- package/discovery/nostr.mjs +0 -1074
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertHex64, isHex64
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
191
|
-
const sender =
|
|
192
|
-
if (!
|
|
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 =
|
|
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 =
|
|
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:
|
|
263
|
+
target: targetPubKeyHash,
|
|
265
264
|
upstreamCount: applied.length,
|
|
266
265
|
hops: applied.map(row => row.hop),
|
|
267
266
|
})
|
package/node/reputation_sync.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
|
|
3
|
-
import { isHex64
|
|
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 (
|
|
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(
|
|
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 =
|
|
95
|
-
if (!id
|
|
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 =
|
|
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 (
|
|
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 =
|
|
275
|
-
if (!donor
|
|
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.
|
|
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()
|
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
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
357
|
-
const introducerKey =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
443
|
+
const id = peerNodeHash
|
|
447
444
|
const until = Number(data.byNodeHash?.[id]?.quarantinedUntil ?? 0)
|
|
448
445
|
return Number.isFinite(until) && until > now
|
|
449
446
|
}
|
package/schemas/discovery.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertHex64, isHex64, isSignatureHex128
|
|
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 =
|
|
14
|
-
const signature =
|
|
15
|
-
if (!groupId || !
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
31
|
-
if (!
|
|
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.
|
|
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 =
|
|
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 || !
|
|
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 {
|
package/schemas/mailbox.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
21
|
-
if (!
|
|
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
|
}
|