soulprint-network 0.4.3 → 0.4.5

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.
@@ -0,0 +1,46 @@
1
+ export declare const NULLIFIER_REGISTRY_RPC = "https://sepolia.base.org";
2
+ export declare const NULLIFIER_REGISTRY_ADDRESS: string;
3
+ export interface NullifierEntry {
4
+ nullifier: string;
5
+ did: string;
6
+ score: number;
7
+ timestamp: number;
8
+ }
9
+ export declare class NullifierRegistryClient {
10
+ private provider;
11
+ private contract;
12
+ private wallet?;
13
+ private cache;
14
+ private cacheAt;
15
+ private cacheTTLMs;
16
+ private address;
17
+ constructor(opts?: {
18
+ rpc?: string;
19
+ address?: string;
20
+ privateKey?: string;
21
+ cacheTTLMs?: number;
22
+ });
23
+ /**
24
+ * Sign a nullifier payload with the validator's private key and register on-chain.
25
+ * Non-blocking — logs warning on failure.
26
+ *
27
+ * The contract verifies the ECDSA signature over keccak256(nullifier, did, score).
28
+ */
29
+ registerNullifier(opts: {
30
+ nullifier: string;
31
+ did: string;
32
+ score?: number;
33
+ }): Promise<void>;
34
+ /**
35
+ * Check if a nullifier is registered on-chain.
36
+ */
37
+ isRegistered(nullifier: string): Promise<boolean>;
38
+ /**
39
+ * Get all registered nullifiers (cached 2 min).
40
+ */
41
+ getAllNullifiers(): Promise<NullifierEntry[]>;
42
+ refreshNullifiers(): Promise<NullifierEntry[]>;
43
+ getCount(): Promise<number>;
44
+ get contractAddress(): string;
45
+ }
46
+ export declare const nullifierRegistryClient: NullifierRegistryClient;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * NullifierRegistryClient
3
+ * On-chain nullifier registry for Soulprint (Base Sepolia).
4
+ *
5
+ * The soulprint.digital validator (authorizedValidator) signs every nullifier
6
+ * before writing it on-chain. Read-only access is public — anyone can verify
7
+ * isRegistered(nullifier) without trust in any third party.
8
+ *
9
+ * Architecture (v0.5.0):
10
+ * - WRITE: only soulprint.digital validator (ADMIN_PRIVATE_KEY)
11
+ * - READ: anyone, cached 2 min
12
+ */
13
+ import { ethers } from "ethers";
14
+ import { readFileSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ export const NULLIFIER_REGISTRY_RPC = "https://sepolia.base.org";
19
+ function loadAddress() {
20
+ try {
21
+ const f = join(__dirname, "addresses.json");
22
+ const d = JSON.parse(readFileSync(f, "utf8"));
23
+ return d["NullifierRegistry"] ?? "";
24
+ }
25
+ catch {
26
+ return "";
27
+ }
28
+ }
29
+ export const NULLIFIER_REGISTRY_ADDRESS = loadAddress();
30
+ const ABI = [
31
+ "function registerNullifier(bytes32 nullifier, string did, uint256 score, bytes sig) external",
32
+ "function isRegistered(bytes32 nullifier) external view returns (bool)",
33
+ "function getNullifier(bytes32 nullifier) external view returns (bytes32 nul, string did, uint256 score, uint256 timestamp)",
34
+ "function getAllNullifiers() external view returns (tuple(bytes32 nullifier, string did, uint256 score, uint256 timestamp)[])",
35
+ "function getNullifierCount() external view returns (uint256)",
36
+ "function authorizedValidator() external view returns (address)",
37
+ "event NullifierRegistered(bytes32 indexed nullifier, string did, uint256 score, uint256 timestamp)",
38
+ ];
39
+ export class NullifierRegistryClient {
40
+ provider;
41
+ contract;
42
+ wallet;
43
+ cache = null;
44
+ cacheAt = 0;
45
+ cacheTTLMs;
46
+ address;
47
+ constructor(opts) {
48
+ this.address = opts?.address ?? NULLIFIER_REGISTRY_ADDRESS;
49
+ this.cacheTTLMs = opts?.cacheTTLMs ?? 2 * 60 * 1000; // 2 min
50
+ const rpc = opts?.rpc ?? NULLIFIER_REGISTRY_RPC;
51
+ this.provider = new ethers.JsonRpcProvider(rpc);
52
+ if (!this.address) {
53
+ console.warn("[nullifier-registry] ⚠️ No contract address — registry disabled");
54
+ this.contract = null;
55
+ return;
56
+ }
57
+ if (opts?.privateKey) {
58
+ this.wallet = new ethers.Wallet(opts.privateKey, this.provider);
59
+ this.contract = new ethers.Contract(this.address, ABI, this.wallet);
60
+ }
61
+ else {
62
+ this.contract = new ethers.Contract(this.address, ABI, this.provider);
63
+ }
64
+ }
65
+ /**
66
+ * Sign a nullifier payload with the validator's private key and register on-chain.
67
+ * Non-blocking — logs warning on failure.
68
+ *
69
+ * The contract verifies the ECDSA signature over keccak256(nullifier, did, score).
70
+ */
71
+ async registerNullifier(opts) {
72
+ if (!this.wallet) {
73
+ console.warn("[nullifier-registry] ⚠️ No private key — cannot register nullifier");
74
+ return;
75
+ }
76
+ if (!this.address) {
77
+ console.warn("[nullifier-registry] ⚠️ No contract address — skipping registration");
78
+ return;
79
+ }
80
+ try {
81
+ const nullifierBytes = opts.nullifier.startsWith("0x")
82
+ ? opts.nullifier
83
+ : `0x${opts.nullifier}`;
84
+ const score = BigInt(opts.score ?? 0);
85
+ // Create the message hash matching what the contract verifies
86
+ const msgHash = ethers.keccak256(ethers.solidityPacked(["bytes32", "string", "uint256"], [nullifierBytes, opts.did, score]));
87
+ // Sign with Ethereum personal_sign prefix (matches contract's \x19Ethereum Signed Message:\n32)
88
+ const sig = await this.wallet.signMessage(ethers.getBytes(msgHash));
89
+ const feeData = await this.provider.getFeeData();
90
+ const tx = await this.contract.registerNullifier(nullifierBytes, opts.did, score, sig, { gasPrice: feeData.gasPrice });
91
+ console.log(`[nullifier-registry] 📡 Registering nullifier on-chain... tx: ${tx.hash}`);
92
+ await tx.wait();
93
+ console.log(`[nullifier-registry] ✅ Nullifier registered: ${nullifierBytes.slice(0, 18)}... did=${opts.did.slice(0, 20)}...`);
94
+ // Invalidate cache
95
+ this.cache = null;
96
+ }
97
+ catch (err) {
98
+ console.warn(`[nullifier-registry] ⚠️ Registration failed (non-fatal): ${err.shortMessage ?? err.message}`);
99
+ }
100
+ }
101
+ /**
102
+ * Check if a nullifier is registered on-chain.
103
+ */
104
+ async isRegistered(nullifier) {
105
+ if (!this.address)
106
+ return false;
107
+ try {
108
+ const n = nullifier.startsWith("0x") ? nullifier : `0x${nullifier}`;
109
+ return await this.contract.isRegistered(n);
110
+ }
111
+ catch (err) {
112
+ console.warn(`[nullifier-registry] ⚠️ isRegistered failed: ${err.shortMessage ?? err.message}`);
113
+ return false;
114
+ }
115
+ }
116
+ /**
117
+ * Get all registered nullifiers (cached 2 min).
118
+ */
119
+ async getAllNullifiers() {
120
+ if (this.cache && Date.now() - this.cacheAt < this.cacheTTLMs) {
121
+ return this.cache;
122
+ }
123
+ return this.refreshNullifiers();
124
+ }
125
+ async refreshNullifiers() {
126
+ if (!this.address)
127
+ return [];
128
+ try {
129
+ const raw = await this.contract.getAllNullifiers();
130
+ this.cache = raw.map((e) => ({
131
+ nullifier: e.nullifier,
132
+ did: e.did,
133
+ score: Number(e.score),
134
+ timestamp: Number(e.timestamp),
135
+ }));
136
+ this.cacheAt = Date.now();
137
+ return this.cache;
138
+ }
139
+ catch (err) {
140
+ console.warn(`[nullifier-registry] ⚠️ getAllNullifiers failed: ${err.shortMessage ?? err.message}`);
141
+ return this.cache ?? [];
142
+ }
143
+ }
144
+ async getCount() {
145
+ if (!this.address)
146
+ return 0;
147
+ try {
148
+ const n = await this.contract.getNullifierCount();
149
+ return Number(n);
150
+ }
151
+ catch {
152
+ return 0;
153
+ }
154
+ }
155
+ get contractAddress() { return this.address; }
156
+ }
157
+ export const nullifierRegistryClient = new NullifierRegistryClient();
@@ -0,0 +1,45 @@
1
+ export declare const REPUTATION_REGISTRY_RPC = "https://sepolia.base.org";
2
+ export declare const REPUTATION_REGISTRY_ADDRESS: string;
3
+ export interface ScoreEntry {
4
+ did: string;
5
+ score: number;
6
+ context: string;
7
+ updatedAt: number;
8
+ }
9
+ export declare class ReputationRegistryClient {
10
+ private provider;
11
+ private contract;
12
+ private wallet?;
13
+ private cache;
14
+ private cacheAt;
15
+ private cacheTTLMs;
16
+ private address;
17
+ constructor(opts?: {
18
+ rpc?: string;
19
+ address?: string;
20
+ privateKey?: string;
21
+ cacheTTLMs?: number;
22
+ });
23
+ /**
24
+ * Set or update a reputation score for a DID.
25
+ * Only works if the wallet is the authorized validator.
26
+ * Non-blocking — logs warning on failure.
27
+ */
28
+ setScore(opts: {
29
+ did: string;
30
+ score: number;
31
+ context?: string;
32
+ }): Promise<void>;
33
+ /**
34
+ * Get reputation score for a specific DID.
35
+ */
36
+ getScore(did: string): Promise<ScoreEntry | null>;
37
+ /**
38
+ * Get all DID scores (cached 2 min).
39
+ */
40
+ getAllScores(): Promise<ScoreEntry[]>;
41
+ refreshScores(): Promise<ScoreEntry[]>;
42
+ getCount(): Promise<number>;
43
+ get contractAddress(): string;
44
+ }
45
+ export declare const reputationRegistryClient: ReputationRegistryClient;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * ReputationRegistryClient
3
+ * On-chain reputation scores for DIDs (Base Sepolia).
4
+ *
5
+ * Only the soulprint.digital validator (authorizedValidator) can write scores.
6
+ * Anyone can read scores from the public ledger.
7
+ *
8
+ * Architecture (v0.5.0):
9
+ * - WRITE: only soulprint.digital validator wallet (ADMIN_PRIVATE_KEY)
10
+ * - READ: anyone, cached 2 min
11
+ */
12
+ import { ethers } from "ethers";
13
+ import { readFileSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ export const REPUTATION_REGISTRY_RPC = "https://sepolia.base.org";
18
+ function loadAddress() {
19
+ try {
20
+ const f = join(__dirname, "addresses.json");
21
+ const d = JSON.parse(readFileSync(f, "utf8"));
22
+ return d["ReputationRegistry"] ?? "";
23
+ }
24
+ catch {
25
+ return "";
26
+ }
27
+ }
28
+ export const REPUTATION_REGISTRY_ADDRESS = loadAddress();
29
+ const ABI = [
30
+ "function setScore(string did, uint256 score, string context) external",
31
+ "function getScore(string did) external view returns (string retDid, uint256 score, string context, uint256 updatedAt)",
32
+ "function getAllScores() external view returns (tuple(string did, uint256 score, string context, uint256 updatedAt)[])",
33
+ "function getScoreCount() external view returns (uint256)",
34
+ "function authorizedValidators(address) external view returns (bool)",
35
+ "event ScoreUpdated(string indexed did, uint256 score, string context, uint256 updatedAt)",
36
+ ];
37
+ export class ReputationRegistryClient {
38
+ provider;
39
+ contract;
40
+ wallet;
41
+ cache = null;
42
+ cacheAt = 0;
43
+ cacheTTLMs;
44
+ address;
45
+ constructor(opts) {
46
+ this.address = opts?.address ?? REPUTATION_REGISTRY_ADDRESS;
47
+ this.cacheTTLMs = opts?.cacheTTLMs ?? 2 * 60 * 1000; // 2 min
48
+ const rpc = opts?.rpc ?? REPUTATION_REGISTRY_RPC;
49
+ this.provider = new ethers.JsonRpcProvider(rpc);
50
+ if (!this.address) {
51
+ console.warn("[reputation-registry] ⚠️ No contract address — registry disabled");
52
+ this.contract = null;
53
+ return;
54
+ }
55
+ if (opts?.privateKey) {
56
+ this.wallet = new ethers.Wallet(opts.privateKey, this.provider);
57
+ this.contract = new ethers.Contract(this.address, ABI, this.wallet);
58
+ }
59
+ else {
60
+ this.contract = new ethers.Contract(this.address, ABI, this.provider);
61
+ }
62
+ }
63
+ /**
64
+ * Set or update a reputation score for a DID.
65
+ * Only works if the wallet is the authorized validator.
66
+ * Non-blocking — logs warning on failure.
67
+ */
68
+ async setScore(opts) {
69
+ if (!this.wallet) {
70
+ console.warn("[reputation-registry] ⚠️ No private key — cannot write score");
71
+ return;
72
+ }
73
+ if (!this.address) {
74
+ console.warn("[reputation-registry] ⚠️ No contract address — skipping");
75
+ return;
76
+ }
77
+ try {
78
+ const feeData = await this.provider.getFeeData();
79
+ const tx = await this.contract.setScore(opts.did, BigInt(Math.round(opts.score)), opts.context ?? "soulprint:v1", { gasPrice: feeData.gasPrice });
80
+ console.log(`[reputation-registry] 📡 Setting score on-chain... tx: ${tx.hash}`);
81
+ await tx.wait();
82
+ console.log(`[reputation-registry] ✅ Score set: did=${opts.did.slice(0, 20)}... score=${opts.score}`);
83
+ // Invalidate cache
84
+ this.cache = null;
85
+ }
86
+ catch (err) {
87
+ console.warn(`[reputation-registry] ⚠️ setScore failed (non-fatal): ${err.shortMessage ?? err.message}`);
88
+ }
89
+ }
90
+ /**
91
+ * Get reputation score for a specific DID.
92
+ */
93
+ async getScore(did) {
94
+ if (!this.address)
95
+ return null;
96
+ try {
97
+ const r = await this.contract.getScore(did);
98
+ return {
99
+ did: r.retDid,
100
+ score: Number(r.score),
101
+ context: r.context,
102
+ updatedAt: Number(r.updatedAt),
103
+ };
104
+ }
105
+ catch (err) {
106
+ console.warn(`[reputation-registry] ⚠️ getScore failed: ${err.shortMessage ?? err.message}`);
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Get all DID scores (cached 2 min).
112
+ */
113
+ async getAllScores() {
114
+ if (this.cache && Date.now() - this.cacheAt < this.cacheTTLMs) {
115
+ return this.cache;
116
+ }
117
+ return this.refreshScores();
118
+ }
119
+ async refreshScores() {
120
+ if (!this.address)
121
+ return [];
122
+ try {
123
+ const raw = await this.contract.getAllScores();
124
+ this.cache = raw.map((e) => ({
125
+ did: e.did,
126
+ score: Number(e.score),
127
+ context: e.context,
128
+ updatedAt: Number(e.updatedAt),
129
+ }));
130
+ this.cacheAt = Date.now();
131
+ return this.cache;
132
+ }
133
+ catch (err) {
134
+ console.warn(`[reputation-registry] ⚠️ getAllScores failed: ${err.shortMessage ?? err.message}`);
135
+ return this.cache ?? [];
136
+ }
137
+ }
138
+ async getCount() {
139
+ if (!this.address)
140
+ return 0;
141
+ try {
142
+ const n = await this.contract.getScoreCount();
143
+ return Number(n);
144
+ }
145
+ catch {
146
+ return 0;
147
+ }
148
+ }
149
+ get contractAddress() { return this.address; }
150
+ }
151
+ export const reputationRegistryClient = new ReputationRegistryClient();
@@ -1,10 +1,12 @@
1
1
  {
2
- "codeHash": "7f708d77d85f66abb7951d5e2761d23b3f07a64c5e71fdb4db02d6bdb958bbd9",
3
- "codeHashHex": "0x7f708d77d85f66abb7951d5e2761d23b3f07a64c5e71fdb4db02d6bdb958bbd9",
4
- "computedAt": "2026-03-01T01:59:34.396Z",
5
- "fileCount": 21,
2
+ "codeHash": "c256c2701bea12bbb2f50552b268bda6065a58d187f7bde6d6be86d82eb73f06",
3
+ "codeHashHex": "0xc256c2701bea12bbb2f50552b268bda6065a58d187f7bde6d6be86d82eb73f06",
4
+ "computedAt": "2026-03-01T02:53:10.893Z",
5
+ "fileCount": 24,
6
6
  "files": [
7
+ "blockchain/NullifierRegistryClient.ts",
7
8
  "blockchain/PeerRegistryClient.ts",
9
+ "blockchain/ReputationRegistryClient.ts",
8
10
  "blockchain/blockchain-anchor.ts",
9
11
  "blockchain/blockchain-client.ts",
10
12
  "blockchain/protocol-thresholds-client.ts",
@@ -24,6 +26,7 @@
24
26
  "p2p.ts",
25
27
  "peer-challenge.ts",
26
28
  "server.ts",
29
+ "state/StateStore.ts",
27
30
  "validator.ts"
28
31
  ]
29
32
  }
package/dist/server.js CHANGED
@@ -6,7 +6,8 @@
6
6
  * 1. HTTP server (port 4888) — clientes y legado
7
7
  * 2. libp2p P2P node (port 6888) — Kademlia DHT + GossipSub + mDNS
8
8
  */
9
- import { startValidatorNode, setP2PNode, setPeerRegistryClient } from "./validator.js";
9
+ import { startValidatorNode, setP2PNode, setPeerRegistryClient, getNodeState, setLastSyncTs } from "./validator.js";
10
+ import { computeHash, saveState } from "./state/StateStore.js";
10
11
  import { createSoulprintP2PNode, MAINNET_BOOTSTRAP, stopP2PNode } from "./p2p.js";
11
12
  import { PeerRegistryClient } from "./blockchain/PeerRegistryClient.js";
12
13
  // ─── Config ──────────────────────────────────────────────────────────────────
@@ -148,6 +149,58 @@ if (httpBootstraps.length > 0) {
148
149
  }
149
150
  }, 2_000);
150
151
  }
152
+ // ─── Anti-entropy sync loop (v0.4.4) ─────────────────────────────────────────
153
+ // Every 60 seconds: compare state hash with each known peer.
154
+ // If diverged, fetch full state and merge locally.
155
+ setInterval(async () => {
156
+ const { nullifiers, repStore, peers: knownPeers } = getNodeState();
157
+ if (knownPeers.length === 0)
158
+ return;
159
+ const localHash = computeHash(Object.keys(nullifiers));
160
+ for (const peerUrl of knownPeers) {
161
+ try {
162
+ const hashRes = await fetch(`${peerUrl}/state/hash`, { signal: AbortSignal.timeout(5_000) });
163
+ if (!hashRes.ok)
164
+ continue;
165
+ const hashData = await hashRes.json();
166
+ const peerHash = hashData.hash;
167
+ if (peerHash === localHash) {
168
+ console.log(`[sync] peer ${peerUrl}: hash match ✅`);
169
+ continue;
170
+ }
171
+ // Hashes differ — fetch full state and merge
172
+ const exportRes = await fetch(`${peerUrl}/state/export`, { signal: AbortSignal.timeout(10_000) });
173
+ if (!exportRes.ok) {
174
+ console.warn(`[sync] peer ${peerUrl}: export failed (${exportRes.status})`);
175
+ continue;
176
+ }
177
+ const peerState = await exportRes.json();
178
+ const mergeRes = await fetch(`http://localhost:${HTTP_PORT}/state/merge`, {
179
+ method: "POST",
180
+ headers: { "Content-Type": "application/json" },
181
+ body: JSON.stringify(peerState),
182
+ signal: AbortSignal.timeout(5_000),
183
+ });
184
+ const merged = await mergeRes.json();
185
+ console.log(`[sync] peer ${peerUrl}: diverged → merged ${merged.new_nullifiers ?? 0} nullifiers, ${merged.new_attestations ?? 0} attestations`);
186
+ // Persist updated state
187
+ const { nullifiers: n2, repStore: r2, peers: p2 } = getNodeState();
188
+ const ts = Date.now();
189
+ saveState({
190
+ nullifiers: Object.keys(n2),
191
+ reputation: Object.fromEntries(Object.entries(r2).map(([d, e]) => [d, e.score])),
192
+ attestations: Object.values(r2).flatMap((e) => e.attestations ?? []),
193
+ peers: p2,
194
+ lastSync: ts,
195
+ stateHash: computeHash(Object.keys(n2)),
196
+ }, 0);
197
+ setLastSyncTs(ts);
198
+ }
199
+ catch (e) {
200
+ console.warn(`[sync] peer ${peerUrl}: error — ${e.message}`);
201
+ }
202
+ }
203
+ }, 60_000);
151
204
  // ─── Graceful shutdown ────────────────────────────────────────────────────────
152
205
  async function shutdown(signal) {
153
206
  console.log(`\n${signal} recibido — cerrando...`);
@@ -0,0 +1,22 @@
1
+ export interface StoredAttestation {
2
+ issuer_did: string;
3
+ target_did: string;
4
+ value: number;
5
+ context: string;
6
+ timestamp: number;
7
+ sig: string;
8
+ }
9
+ export interface NodeState {
10
+ nullifiers: string[];
11
+ reputation: Record<string, number>;
12
+ attestations: StoredAttestation[];
13
+ peers: string[];
14
+ lastSync: number;
15
+ stateHash: string;
16
+ }
17
+ /** sha256(JSON.stringify(nullifiers.sort())) */
18
+ export declare function computeHash(nullifiers: string[]): string;
19
+ /** Load state from disk. Returns default if file missing or corrupt. */
20
+ export declare function loadState(): NodeState;
21
+ /** Write state to disk — debounced 2 s by default. */
22
+ export declare function saveState(state: NodeState, debounceMs?: number): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * StateStore — persistent state for P2P sync
3
+ * Saves/loads full node state to disk with debounced writes.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ const STATE_PATH = process.env.SOULPRINT_STATE_PATH
10
+ ?? join(homedir(), ".soulprint", "node", "state.json");
11
+ const DEFAULT_STATE = {
12
+ nullifiers: [],
13
+ reputation: {},
14
+ attestations: [],
15
+ peers: [],
16
+ lastSync: 0,
17
+ stateHash: "",
18
+ };
19
+ let saveTimer = null;
20
+ /** sha256(JSON.stringify(nullifiers.sort())) */
21
+ export function computeHash(nullifiers) {
22
+ const sorted = [...nullifiers].sort();
23
+ return createHash("sha256").update(JSON.stringify(sorted)).digest("hex");
24
+ }
25
+ /** Load state from disk. Returns default if file missing or corrupt. */
26
+ export function loadState() {
27
+ if (!existsSync(STATE_PATH)) {
28
+ return { ...DEFAULT_STATE, stateHash: computeHash([]) };
29
+ }
30
+ try {
31
+ const raw = JSON.parse(readFileSync(STATE_PATH, "utf8"));
32
+ const nullifiers = raw.nullifiers ?? [];
33
+ return {
34
+ nullifiers,
35
+ reputation: raw.reputation ?? {},
36
+ attestations: raw.attestations ?? [],
37
+ peers: raw.peers ?? [],
38
+ lastSync: raw.lastSync ?? 0,
39
+ stateHash: raw.stateHash ?? computeHash(nullifiers),
40
+ };
41
+ }
42
+ catch {
43
+ return { ...DEFAULT_STATE, stateHash: computeHash([]) };
44
+ }
45
+ }
46
+ /** Write state to disk — debounced 2 s by default. */
47
+ export function saveState(state, debounceMs = 2000) {
48
+ if (saveTimer)
49
+ clearTimeout(saveTimer);
50
+ saveTimer = setTimeout(() => {
51
+ try {
52
+ const dir = dirname(STATE_PATH);
53
+ if (!existsSync(dir))
54
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
55
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
56
+ }
57
+ catch (e) {
58
+ console.error("[state] Failed to save state:", e);
59
+ }
60
+ }, debounceMs);
61
+ }
@@ -2,6 +2,8 @@ import { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { BotAttestation, BotReputation } from "soulprint-core";
3
3
  import { type SoulprintP2PNode } from "./p2p.js";
4
4
  import { PeerRegistryClient } from "./blockchain/PeerRegistryClient.js";
5
+ import { NullifierRegistryClient } from "./blockchain/NullifierRegistryClient.js";
6
+ import { ReputationRegistryClient } from "./blockchain/ReputationRegistryClient.js";
5
7
  export declare function getLiveThresholds(): {
6
8
  SCORE_FLOOR: number;
7
9
  VERIFIED_SCORE_FLOOR: number;
@@ -14,6 +16,10 @@ export declare function getLiveThresholds(): {
14
16
  source: "blockchain" | "local_fallback";
15
17
  };
16
18
  export declare function setPeerRegistryClient(client: PeerRegistryClient): void;
19
+ export declare function setNullifierRegistry(client: NullifierRegistryClient): void;
20
+ export declare function setReputationRegistry(client: ReputationRegistryClient): void;
21
+ export declare function getNullifierRegistry(): NullifierRegistryClient | null;
22
+ export declare function getReputationRegistry(): ReputationRegistryClient | null;
17
23
  /**
18
24
  * Inyecta el nodo libp2p al validador.
19
25
  * Cuando se llama:
@@ -21,6 +27,18 @@ export declare function setPeerRegistryClient(client: PeerRegistryClient): void;
21
27
  * 2. Desde ese momento, gossipAttestation() también publica por P2P
22
28
  */
23
29
  export declare function setP2PNode(node: SoulprintP2PNode): void;
30
+ /**
31
+ * Per-DID reputation: score (0-20) + attestation history.
32
+ * Persisted to disk - survives node restarts.
33
+ */
34
+ interface ReputeEntry {
35
+ score: number;
36
+ base: number;
37
+ attestations: BotAttestation[];
38
+ last_updated: number;
39
+ identityScore: number;
40
+ hasDocumentVerified: boolean;
41
+ }
24
42
  /**
25
43
  * Aplica una nueva attestation al DID objetivo y persiste.
26
44
  *
@@ -50,3 +68,14 @@ export declare function getBotReputation(nodeUrl: string, did: string): Promise<
50
68
  export declare function getNodeInfo(nodeUrl: string): Promise<any>;
51
69
  export declare const BOOTSTRAP_NODES: string[];
52
70
  export { applyAttestation };
71
+ /** Used by anti-entropy loop in server.ts */
72
+ export declare function getNodeState(): {
73
+ nullifiers: Record<string, {
74
+ did: string;
75
+ verified_at: number;
76
+ }>;
77
+ repStore: Record<string, ReputeEntry>;
78
+ peers: string[];
79
+ lastSyncTs: number;
80
+ };
81
+ export declare function setLastSyncTs(ts: number): void;
package/dist/validator.js CHANGED
@@ -2,6 +2,7 @@ import { createServer } from "node:http";
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
+ import { computeHash, loadState, saveState } from "./state/StateStore.js";
5
6
  import { generateKeypair, keypairFromPrivateKey, decodeToken, sign, createToken, TOKEN_LIFETIME_SECONDS, TOKEN_RENEW_PREEMPTIVE_SECS, TOKEN_RENEW_GRACE_SECS, TOKEN_RENEW_COOLDOWN_SECS, NonceStore, verifyAttestation, computeReputation, defaultReputation, PROTOCOL, PROTOCOL_HASH, isProtocolHashCompatible, computeTotalScoreWithFloor, checkFarming, recordApprovedGain, recordFarmingStrike, loadAuditStore, exportAuditStore, } from "soulprint-core";
6
7
  import { verifyProof, deserializeProof } from "soulprint-zkp";
7
8
  import { buildChallengeResponse, verifyPeerBehavior, } from "./peer-challenge.js";
@@ -75,6 +76,23 @@ let peerRegistryClient = null;
75
76
  export function setPeerRegistryClient(client) {
76
77
  peerRegistryClient = client;
77
78
  }
79
+ // ── On-Chain Registries (v0.5.0) ───────────────────────────────────────────────
80
+ // The blockchain IS the shared state. These are the single source of truth.
81
+ // Only soulprint.digital validator can WRITE; anyone can READ.
82
+ let nullifierRegistry = null;
83
+ let reputationRegistry = null;
84
+ export function setNullifierRegistry(client) {
85
+ nullifierRegistry = client;
86
+ }
87
+ export function setReputationRegistry(client) {
88
+ reputationRegistry = client;
89
+ }
90
+ export function getNullifierRegistry() {
91
+ return nullifierRegistry;
92
+ }
93
+ export function getReputationRegistry() {
94
+ return reputationRegistry;
95
+ }
78
96
  /**
79
97
  * Inyecta el nodo libp2p al validador.
80
98
  * Cuando se llama:
@@ -204,8 +222,18 @@ function applyAttestation(att) {
204
222
  hasDocumentVerified: hasDocument,
205
223
  };
206
224
  saveReputation();
225
+ // ── Write reputation to on-chain ReputationRegistry (v0.5.0) — non-blocking
226
+ if (reputationRegistry) {
227
+ reputationRegistry.setScore({
228
+ did: att.target_did,
229
+ score: finalRepScore,
230
+ context: "soulprint:v1",
231
+ }).catch(e => console.warn("[reputation-registry] ⚠️ On-chain write failed:", e.message));
232
+ }
207
233
  return { score: finalRepScore, attestations: allAtts.length, last_updated: rep.last_updated };
208
234
  }
235
+ // ── P2P state sync metadata ───────────────────────────────────────────────────
236
+ let lastSyncTs = 0; // timestamp (ms) of last successful anti-entropy sync
209
237
  // ── Peers registry (P2P gossip) ───────────────────────────────────────────────
210
238
  let peers = []; // URLs de otros nodos (ej: "http://node2.example.com:4888")
211
239
  function loadPeers() {
@@ -242,6 +270,9 @@ async function gossipAttestation(att, excludeUrl) {
242
270
  if (targets.length < peers.length - (excludeUrl ? 1 : 0)) {
243
271
  console.log(routingStats(peers.length, targets.length, att.target_did));
244
272
  }
273
+ if (targets.length > 0) {
274
+ console.log(`[gossip] broadcasted to ${targets.length} peers`);
275
+ }
245
276
  // Cifrar el payload con AES-256-GCM antes de enviar
246
277
  // Solo nodos con PROTOCOL_HASH correcto pueden descifrar
247
278
  const encrypted = encryptGossip({ attestation: att, from_peer: true });
@@ -686,6 +717,17 @@ async function handleVerify(req, res, nodeKeypair, ip) {
686
717
  else {
687
718
  nullifiers[zkResult.nullifier] = { did: token.did, verified_at: now };
688
719
  saveNullifiers();
720
+ // ── Write to on-chain NullifierRegistry (v0.5.0) — non-blocking ──────────
721
+ // soulprint.digital validator signs and certifies the identity on-chain.
722
+ // Anyone can now verify isRegistered(nullifier) without trusting this node.
723
+ if (nullifierRegistry) {
724
+ const score = repStore[token.did]?.score ?? 0;
725
+ nullifierRegistry.registerNullifier({
726
+ nullifier: zkResult.nullifier,
727
+ did: token.did,
728
+ score,
729
+ }).catch(e => console.warn("[nullifier-registry] ⚠️ On-chain write failed:", e.message));
730
+ }
689
731
  }
690
732
  const coSig = sign({ nullifier: zkResult.nullifier, did: token.did, timestamp: now }, nodeKeypair.privateKey);
691
733
  // Incluir reputación actual del bot en la respuesta
@@ -831,6 +873,30 @@ function handleNullifierCheck(res, nullifier) {
831
873
  }
832
874
  // ── Server ────────────────────────────────────────────────────────────────────
833
875
  export function startValidatorNode(port = PORT) {
876
+ // ── Load persistent state from disk (v0.4.4) ───────────────────────────────
877
+ const persisted = loadState();
878
+ if (persisted.nullifiers.length > 0 || Object.keys(persisted.reputation).length > 0) {
879
+ console.log(`[state] Loaded ${persisted.nullifiers.length} nullifiers, ${Object.keys(persisted.reputation).length} reputation entries from disk`);
880
+ // Merge persisted nullifiers into in-memory store
881
+ for (const n of persisted.nullifiers) {
882
+ if (!nullifiers[n]) {
883
+ nullifiers[n] = { did: `did:soulprint:recovered:${n.slice(0, 8)}`, verified_at: persisted.lastSync || Date.now() };
884
+ }
885
+ }
886
+ // Merge persisted reputation
887
+ for (const [did, score] of Object.entries(persisted.reputation)) {
888
+ if (!repStore[did]) {
889
+ repStore[did] = {
890
+ score,
891
+ base: 10,
892
+ attestations: [],
893
+ last_updated: persisted.lastSync || Date.now(),
894
+ identityScore: 0,
895
+ hasDocumentVerified: false,
896
+ };
897
+ }
898
+ }
899
+ }
834
900
  loadNullifiers();
835
901
  loadReputation();
836
902
  loadPeers();
@@ -1020,6 +1086,8 @@ export function startValidatorNode(port = PORT) {
1020
1086
  const httpPeers = peers.length;
1021
1087
  const libp2pPeers = p2pStats?.peers ?? 0;
1022
1088
  let registeredPeers = 0;
1089
+ let nullifiersOnchain = 0;
1090
+ let reputationOnchain = 0;
1023
1091
  try {
1024
1092
  if (peerRegistryClient) {
1025
1093
  const chainPeers = await peerRegistryClient.getAllPeers();
@@ -1027,23 +1095,38 @@ export function startValidatorNode(port = PORT) {
1027
1095
  }
1028
1096
  }
1029
1097
  catch { /* non-fatal */ }
1098
+ try {
1099
+ if (nullifierRegistry)
1100
+ nullifiersOnchain = await nullifierRegistry.getCount();
1101
+ }
1102
+ catch { /* non-fatal */ }
1103
+ try {
1104
+ if (reputationRegistry)
1105
+ reputationOnchain = await reputationRegistry.getCount();
1106
+ }
1107
+ catch { /* non-fatal */ }
1030
1108
  return json(res, 200, {
1031
1109
  node_did: nodeKeypair.did.slice(0, 20) + "...",
1032
1110
  version: VERSION,
1033
1111
  protocol_hash: PROTOCOL_HASH.slice(0, 16) + "...",
1034
- // identidades y reputación
1112
+ // identidades y reputación (in-memory cache)
1035
1113
  verified_identities: Object.keys(nullifiers).length,
1036
1114
  reputation_profiles: Object.keys(repStore).length,
1037
- // peers HTTP gossip (funciona en todos los entornos)
1115
+ // on-chain state (v0.5.0) blockchain IS the shared state
1116
+ nullifiers_onchain: nullifiersOnchain,
1117
+ reputation_onchain: reputationOnchain,
1118
+ // peers — HTTP gossip
1038
1119
  known_peers: httpPeers,
1039
- // peers — libp2p P2P (requiere multicast/bootstrap; 0 en WSL2)
1120
+ // peers — libp2p P2P
1040
1121
  p2p_peers: libp2pPeers,
1041
1122
  p2p_pubsub_peers: p2pStats?.pubsubPeers ?? 0,
1042
1123
  p2p_enabled: !!p2pNode,
1043
- // total = max de ambas capas (HTTP gossip es el piso mínimo garantizado)
1044
1124
  total_peers: Math.max(httpPeers, libp2pPeers),
1045
1125
  // on-chain registered peers (PeerRegistry)
1046
1126
  registered_peers: registeredPeers,
1127
+ // state sync (v0.5.0 — blockchain is source of truth)
1128
+ state_hash: computeHash(Object.keys(nullifiers)).slice(0, 16) + "...",
1129
+ last_sync: lastSyncTs,
1047
1130
  // estado general
1048
1131
  uptime_ms: Date.now() - (globalThis._startTime ?? Date.now()),
1049
1132
  timestamp: Date.now(),
@@ -1052,6 +1135,134 @@ export function startValidatorNode(port = PORT) {
1052
1135
  }
1053
1136
  if (cleanUrl === "/verify" && req.method === "POST")
1054
1137
  return handleVerify(req, res, nodeKeypair, ip);
1138
+ // ── State endpoints (v0.5.0) — blockchain IS the shared state ────────────
1139
+ // GET /state/hash — hash of on-chain nullifier count + reputation count
1140
+ if (cleanUrl === "/state/hash" && req.method === "GET") {
1141
+ const currentNullifiers = Object.keys(nullifiers);
1142
+ let onchainNullifiers = currentNullifiers.length;
1143
+ let onchainReputation = Object.keys(repStore).length;
1144
+ try {
1145
+ if (nullifierRegistry)
1146
+ onchainNullifiers = await nullifierRegistry.getCount();
1147
+ }
1148
+ catch { }
1149
+ try {
1150
+ if (reputationRegistry)
1151
+ onchainReputation = await reputationRegistry.getCount();
1152
+ }
1153
+ catch { }
1154
+ // Hash includes on-chain counts for consensus
1155
+ const hash = computeHash([...currentNullifiers, `onchain:${onchainNullifiers}:${onchainReputation}`]);
1156
+ return json(res, 200, {
1157
+ hash,
1158
+ nullifier_count: currentNullifiers.length,
1159
+ nullifier_count_onchain: onchainNullifiers,
1160
+ reputation_count: Object.keys(repStore).length,
1161
+ reputation_count_onchain: onchainReputation,
1162
+ attestation_count: Object.values(repStore).reduce((n, e) => n + (e.attestations?.length ?? 0), 0),
1163
+ timestamp: Date.now(),
1164
+ });
1165
+ }
1166
+ // GET /state/export — full state export including on-chain data
1167
+ if (cleanUrl === "/state/export" && req.method === "GET") {
1168
+ const allAttestations = Object.values(repStore).flatMap(e => e.attestations ?? []);
1169
+ // Read on-chain data (cached — won't hit RPC on every call)
1170
+ const [onchainNullifiers, onchainScores] = await Promise.all([
1171
+ nullifierRegistry ? nullifierRegistry.getAllNullifiers().catch(() => []) : Promise.resolve([]),
1172
+ reputationRegistry ? reputationRegistry.getAllScores().catch(() => []) : Promise.resolve([]),
1173
+ ]);
1174
+ return json(res, 200, {
1175
+ // Local in-memory state
1176
+ nullifiers: Object.keys(nullifiers),
1177
+ reputation: Object.fromEntries(Object.entries(repStore).map(([did, e]) => [did, e.score])),
1178
+ attestations: allAttestations,
1179
+ peers,
1180
+ lastSync: lastSyncTs,
1181
+ stateHash: computeHash(Object.keys(nullifiers)),
1182
+ timestamp: Date.now(),
1183
+ // On-chain state (v0.5.0) — canonical source of truth
1184
+ onchain: {
1185
+ nullifiers: onchainNullifiers,
1186
+ reputation: onchainScores,
1187
+ nullifier_count: onchainNullifiers.length,
1188
+ reputation_count: onchainScores.length,
1189
+ },
1190
+ });
1191
+ }
1192
+ // POST /state/merge — merge partial state from a peer
1193
+ if (cleanUrl === "/state/merge" && req.method === "POST") {
1194
+ let body;
1195
+ try {
1196
+ body = await readBody(req);
1197
+ }
1198
+ catch (e) {
1199
+ return json(res, 400, { error: e.message });
1200
+ }
1201
+ const incoming = body ?? {};
1202
+ let newNullifiers = 0;
1203
+ let newAttestations = 0;
1204
+ // Merge nullifiers (union)
1205
+ if (Array.isArray(incoming.nullifiers)) {
1206
+ for (const n of incoming.nullifiers) {
1207
+ if (typeof n === "string" && !nullifiers[n]) {
1208
+ nullifiers[n] = { did: `did:soulprint:synced:${n.slice(0, 8)}`, verified_at: Date.now() };
1209
+ newNullifiers++;
1210
+ }
1211
+ }
1212
+ if (newNullifiers > 0)
1213
+ saveNullifiers();
1214
+ }
1215
+ // Merge reputation (take max score)
1216
+ if (incoming.reputation && typeof incoming.reputation === "object") {
1217
+ for (const [did, score] of Object.entries(incoming.reputation)) {
1218
+ if (typeof score !== "number")
1219
+ continue;
1220
+ if (!repStore[did]) {
1221
+ repStore[did] = { score, base: 10, attestations: [], last_updated: Date.now(), identityScore: 0, hasDocumentVerified: false };
1222
+ }
1223
+ else if (score > repStore[did].score) {
1224
+ repStore[did].score = score;
1225
+ repStore[did].last_updated = Date.now();
1226
+ }
1227
+ }
1228
+ if (Object.keys(incoming.reputation).length > 0)
1229
+ saveReputation();
1230
+ }
1231
+ // Merge attestations (dedup by issuer+timestamp+context)
1232
+ if (Array.isArray(incoming.attestations)) {
1233
+ for (const att of incoming.attestations) {
1234
+ if (!att?.issuer_did || !att?.target_did)
1235
+ continue;
1236
+ const existing = repStore[att.target_did];
1237
+ const prevAtts = existing?.attestations ?? [];
1238
+ const isDup = prevAtts.some(a => a.issuer_did === att.issuer_did &&
1239
+ a.timestamp === att.timestamp &&
1240
+ a.context === att.context);
1241
+ if (!isDup) {
1242
+ applyAttestation(att);
1243
+ newAttestations++;
1244
+ }
1245
+ }
1246
+ }
1247
+ // Persist new unified state
1248
+ if (newNullifiers > 0 || newAttestations > 0) {
1249
+ const snapshot = {
1250
+ nullifiers: Object.keys(nullifiers),
1251
+ reputation: Object.fromEntries(Object.entries(repStore).map(([d, e]) => [d, e.score])),
1252
+ attestations: Object.values(repStore).flatMap(e => e.attestations ?? []),
1253
+ peers,
1254
+ lastSync: Date.now(),
1255
+ stateHash: computeHash(Object.keys(nullifiers)),
1256
+ };
1257
+ saveState(snapshot);
1258
+ lastSyncTs = Date.now();
1259
+ }
1260
+ return json(res, 200, {
1261
+ ok: true,
1262
+ new_nullifiers: newNullifiers,
1263
+ new_attestations: newAttestations,
1264
+ });
1265
+ }
1055
1266
  if (cleanUrl === "/token/renew" && req.method === "POST")
1056
1267
  return handleTokenRenew(req, res, nodeKeypair);
1057
1268
  if (cleanUrl === "/challenge" && req.method === "POST")
@@ -1433,3 +1644,8 @@ export async function getNodeInfo(nodeUrl) {
1433
1644
  }
1434
1645
  export const BOOTSTRAP_NODES = [];
1435
1646
  export { applyAttestation };
1647
+ /** Used by anti-entropy loop in server.ts */
1648
+ export function getNodeState() {
1649
+ return { nullifiers, repStore, peers, lastSyncTs };
1650
+ }
1651
+ export function setLastSyncTs(ts) { lastSyncTs = ts; }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soulprint-network",
3
- "version": "0.4.3",
4
- "description": "Soulprint validator node HTTP server that verifies ZK proofs, co-signs SPTs, anti-Sybil registry",
3
+ "version": "0.4.5",
4
+ "description": "Soulprint validator node \u2014 HTTP server that verifies ZK proofs, co-signs SPTs, anti-Sybil registry",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "bin": {
@@ -51,8 +51,8 @@
51
51
  "nodemailer": "^8.0.1",
52
52
  "otpauth": "^9.5.0",
53
53
  "otplib": "^13.3.0",
54
- "soulprint-core": "workspace:*",
55
- "soulprint-zkp": "workspace:*",
54
+ "soulprint-core": "0.1.11",
55
+ "soulprint-zkp": "0.1.5",
56
56
  "uint8arrays": "5.1.0"
57
57
  },
58
58
  "devDependencies": {