soulprint-network 0.3.0 → 0.3.2

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,112 @@
1
+ /**
2
+ * blockchain-anchor.ts — Backup asíncrono de datos P2P a blockchain.
3
+ *
4
+ * ARQUITECTURA HÍBRIDA:
5
+ * ─────────────────────────────────────────────────────────────────────────────
6
+ * PRIMARIO → BFT P2P Consensus (fast, $0, real-time)
7
+ * BACKUP → Base Sepolia / Base mainnet (permanent, auditable, free testnet)
8
+ *
9
+ * FLUJO NORMAL:
10
+ * 1. BFT P2P: PROPOSE → VOTE → COMMIT (~2s, sin gas)
11
+ * 2. onCommitted() → blockchainClient.registerIdentity() (async, no bloquea)
12
+ * 3. Si blockchain falla → P2P sigue operando normal
13
+ *
14
+ * FLUJO DE RESTAURACIÓN (nodo arranca sin peers P2P):
15
+ * 1. StateSyncManager no encuentra peers
16
+ * 2. BlockchainAnchor.restoreFromBlockchain() carga nullifiers desde on-chain
17
+ * 3. Nodo queda sincronizado aunque todos los peers P2P estuvieran caídos
18
+ *
19
+ * RETRY POLICY:
20
+ * - Reintentos con backoff exponencial (3 intentos)
21
+ * - Después de 3 fallos: guarda en pendingQueue para reintentar al reconectar
22
+ * - La queue persiste en disco (blockchain-pending.json)
23
+ */
24
+ import { EventEmitter } from "node:events";
25
+ export interface PendingNullifier {
26
+ nullifier: string;
27
+ did: string;
28
+ zkProof: object;
29
+ enqueuedAt: number;
30
+ attempts: number;
31
+ }
32
+ export interface PendingAttestation {
33
+ issuerDid: string;
34
+ targetDid: string;
35
+ value: 1 | -1;
36
+ context: string;
37
+ signature: string;
38
+ enqueuedAt: number;
39
+ attempts: number;
40
+ }
41
+ export interface AnchorStats {
42
+ nullifiersAnchored: number;
43
+ attestsAnchored: number;
44
+ pendingNullifiers: number;
45
+ pendingAttests: number;
46
+ blockchainConnected: boolean;
47
+ lastAnchorTs: number;
48
+ }
49
+ export interface BlockchainAnchorOptions {
50
+ storePath: string;
51
+ }
52
+ export declare class BlockchainAnchor extends EventEmitter {
53
+ private client;
54
+ private connected;
55
+ private storePath;
56
+ private pendingNullifiers;
57
+ private pendingAttests;
58
+ private stats;
59
+ constructor(opts: BlockchainAnchorOptions);
60
+ /**
61
+ * Conecta al blockchain (Base Sepolia o mainnet).
62
+ * Si no hay config → modo P2P-only (sin backup blockchain).
63
+ */
64
+ connect(): Promise<boolean>;
65
+ /**
66
+ * Ancla un nullifier committed en P2P al blockchain.
67
+ * NO bloqueante — el usuario ya recibió su respuesta del P2P.
68
+ */
69
+ anchorNullifier(params: {
70
+ nullifier: string;
71
+ did: string;
72
+ documentVerified: boolean;
73
+ faceVerified: boolean;
74
+ zkProof: {
75
+ a: [bigint, bigint];
76
+ b: [[bigint, bigint], [bigint, bigint]];
77
+ c: [bigint, bigint];
78
+ inputs: [bigint, bigint];
79
+ };
80
+ }): void;
81
+ /**
82
+ * Ancla una attestation al blockchain.
83
+ * NO bloqueante.
84
+ */
85
+ anchorAttestation(params: {
86
+ issuerDid: string;
87
+ targetDid: string;
88
+ value: 1 | -1;
89
+ context: string;
90
+ signature: string;
91
+ }): void;
92
+ /**
93
+ * Intenta restaurar nullifiers desde blockchain cuando P2P no tiene peers.
94
+ * Útil cuando todos los nodos P2P están caídos pero blockchain sigue vivo.
95
+ *
96
+ * @returns Lista de nullifiers restaurados (para importar en NullifierConsensus)
97
+ */
98
+ restoreNullifersFromBlockchain(): Promise<Array<{
99
+ nullifier: string;
100
+ did: string;
101
+ score: number;
102
+ }>>;
103
+ /**
104
+ * Retorna las estadísticas del anchor.
105
+ */
106
+ getStats(): AnchorStats;
107
+ private anchorNullifierWithRetry;
108
+ private anchorAttestWithRetry;
109
+ private flushQueue;
110
+ private loadQueue;
111
+ private saveQueue;
112
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * blockchain-anchor.ts — Backup asíncrono de datos P2P a blockchain.
3
+ *
4
+ * ARQUITECTURA HÍBRIDA:
5
+ * ─────────────────────────────────────────────────────────────────────────────
6
+ * PRIMARIO → BFT P2P Consensus (fast, $0, real-time)
7
+ * BACKUP → Base Sepolia / Base mainnet (permanent, auditable, free testnet)
8
+ *
9
+ * FLUJO NORMAL:
10
+ * 1. BFT P2P: PROPOSE → VOTE → COMMIT (~2s, sin gas)
11
+ * 2. onCommitted() → blockchainClient.registerIdentity() (async, no bloquea)
12
+ * 3. Si blockchain falla → P2P sigue operando normal
13
+ *
14
+ * FLUJO DE RESTAURACIÓN (nodo arranca sin peers P2P):
15
+ * 1. StateSyncManager no encuentra peers
16
+ * 2. BlockchainAnchor.restoreFromBlockchain() carga nullifiers desde on-chain
17
+ * 3. Nodo queda sincronizado aunque todos los peers P2P estuvieran caídos
18
+ *
19
+ * RETRY POLICY:
20
+ * - Reintentos con backoff exponencial (3 intentos)
21
+ * - Después de 3 fallos: guarda en pendingQueue para reintentar al reconectar
22
+ * - La queue persiste en disco (blockchain-pending.json)
23
+ */
24
+ import { EventEmitter } from "node:events";
25
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
26
+ import { SoulprintBlockchainClient, loadBlockchainConfig, } from "./blockchain-client.js";
27
+ // ── Constantes ────────────────────────────────────────────────────────────────
28
+ const MAX_ATTEMPTS = 3;
29
+ const RETRY_DELAY_MS = [0, 2000, 8000]; // backoff: 0s, 2s, 8s
30
+ const FLUSH_INTERVAL = 60_000; // reintentar pendientes cada 60s
31
+ // ── BlockchainAnchor ──────────────────────────────────────────────────────────
32
+ export class BlockchainAnchor extends EventEmitter {
33
+ client = null;
34
+ connected = false;
35
+ storePath;
36
+ pendingNullifiers = [];
37
+ pendingAttests = [];
38
+ stats = {
39
+ nullifiersAnchored: 0,
40
+ attestsAnchored: 0,
41
+ pendingNullifiers: 0,
42
+ pendingAttests: 0,
43
+ blockchainConnected: false,
44
+ lastAnchorTs: 0,
45
+ };
46
+ constructor(opts) {
47
+ super();
48
+ this.storePath = opts.storePath;
49
+ this.loadQueue();
50
+ }
51
+ // ── Inicialización ──────────────────────────────────────────────────────────
52
+ /**
53
+ * Conecta al blockchain (Base Sepolia o mainnet).
54
+ * Si no hay config → modo P2P-only (sin backup blockchain).
55
+ */
56
+ async connect() {
57
+ const config = loadBlockchainConfig();
58
+ if (!config) {
59
+ console.log("[anchor] No blockchain config — P2P-only mode (no backup)");
60
+ console.log("[anchor] Set SOULPRINT_RPC_URL + SOULPRINT_PRIVATE_KEY to enable backup");
61
+ return false;
62
+ }
63
+ this.client = new SoulprintBlockchainClient(config);
64
+ this.connected = await this.client.connect();
65
+ this.stats.blockchainConnected = this.connected;
66
+ if (this.connected) {
67
+ console.log("[anchor] ✅ Blockchain backup enabled — Base Sepolia");
68
+ // Flush queue pendiente al conectar
69
+ await this.flushQueue();
70
+ // Programar flush periódico
71
+ setInterval(() => this.flushQueue(), FLUSH_INTERVAL);
72
+ }
73
+ return this.connected;
74
+ }
75
+ // ── Anchor P2P → Blockchain ─────────────────────────────────────────────────
76
+ /**
77
+ * Ancla un nullifier committed en P2P al blockchain.
78
+ * NO bloqueante — el usuario ya recibió su respuesta del P2P.
79
+ */
80
+ anchorNullifier(params) {
81
+ if (!this.connected || !this.client) {
82
+ // Guardar en queue para cuando se conecte
83
+ this.pendingNullifiers.push({
84
+ nullifier: params.nullifier,
85
+ did: params.did,
86
+ zkProof: params,
87
+ enqueuedAt: Date.now(),
88
+ attempts: 0,
89
+ });
90
+ this.saveQueue();
91
+ this.emit("queued", "nullifier", params.nullifier);
92
+ return;
93
+ }
94
+ // Fire-and-forget con retry
95
+ this.anchorNullifierWithRetry(params).catch(err => {
96
+ console.warn(`[anchor] Nullifier ${params.nullifier.slice(0, 12)}... enqueued (${err.message?.slice(0, 40)})`);
97
+ });
98
+ }
99
+ /**
100
+ * Ancla una attestation al blockchain.
101
+ * NO bloqueante.
102
+ */
103
+ anchorAttestation(params) {
104
+ if (!this.connected || !this.client) {
105
+ this.pendingAttests.push({
106
+ ...params,
107
+ enqueuedAt: Date.now(),
108
+ attempts: 0,
109
+ });
110
+ this.saveQueue();
111
+ this.emit("queued", "attestation", params.issuerDid);
112
+ return;
113
+ }
114
+ this.anchorAttestWithRetry(params).catch(err => {
115
+ console.warn(`[anchor] Attestation ${params.issuerDid.slice(0, 16)}→${params.targetDid.slice(0, 16)} enqueued`);
116
+ });
117
+ }
118
+ // ── Restauración desde blockchain ───────────────────────────────────────────
119
+ /**
120
+ * Intenta restaurar nullifiers desde blockchain cuando P2P no tiene peers.
121
+ * Útil cuando todos los nodos P2P están caídos pero blockchain sigue vivo.
122
+ *
123
+ * @returns Lista de nullifiers restaurados (para importar en NullifierConsensus)
124
+ */
125
+ async restoreNullifersFromBlockchain() {
126
+ if (!this.connected || !this.client)
127
+ return [];
128
+ console.log("[anchor] Attempting restore from blockchain...");
129
+ // TODO: implementar cuando el contrato tenga un getter de todos los nullifiers
130
+ // Por ahora el contrato solo tiene isRegistered(bytes32) y identityScore(string)
131
+ // En v0.4: agregar getAllNullifiers() al contrato o usar eventos
132
+ console.log("[anchor] Restore not yet implemented — use P2P sync");
133
+ return [];
134
+ }
135
+ /**
136
+ * Retorna las estadísticas del anchor.
137
+ */
138
+ getStats() {
139
+ this.stats.pendingNullifiers = this.pendingNullifiers.length;
140
+ this.stats.pendingAttests = this.pendingAttests.length;
141
+ return { ...this.stats };
142
+ }
143
+ // ── Internos ────────────────────────────────────────────────────────────────
144
+ async anchorNullifierWithRetry(params) {
145
+ let lastErr = null;
146
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
147
+ if (attempt > 0) {
148
+ await sleep(RETRY_DELAY_MS[attempt] ?? 8000);
149
+ }
150
+ try {
151
+ const txHash = await this.client.registerIdentity({
152
+ nullifier: params.nullifier,
153
+ did: params.did,
154
+ documentVerified: params.documentVerified ?? true,
155
+ faceVerified: params.faceVerified ?? true,
156
+ zkProof: params.zkProof ?? {
157
+ a: [0n, 0n],
158
+ b: [[0n, 0n], [0n, 0n]],
159
+ c: [0n, 0n],
160
+ inputs: [BigInt(params.nullifier.replace(/^0x/, "").slice(0, 8) || "1"), 1n],
161
+ },
162
+ });
163
+ if (txHash) {
164
+ this.stats.nullifiersAnchored++;
165
+ this.stats.lastAnchorTs = Date.now();
166
+ this.emit("anchored", "nullifier", params.nullifier, txHash);
167
+ console.log(`[anchor] ✅ nullifier ${params.nullifier.slice(0, 12)}... → tx ${txHash.slice(0, 12)}...`);
168
+ return;
169
+ }
170
+ }
171
+ catch (err) {
172
+ lastErr = err;
173
+ // Nullifier ya registrado on-chain — no es un error real
174
+ if (err.message?.includes("NullifierAlreadyUsed")) {
175
+ console.log(`[anchor] Nullifier ${params.nullifier.slice(0, 12)}... already on-chain — OK`);
176
+ return;
177
+ }
178
+ }
179
+ }
180
+ // Después de 3 intentos → queue
181
+ this.pendingNullifiers.push({
182
+ nullifier: params.nullifier,
183
+ did: params.did,
184
+ zkProof: params,
185
+ enqueuedAt: Date.now(),
186
+ attempts: MAX_ATTEMPTS,
187
+ });
188
+ this.saveQueue();
189
+ }
190
+ async anchorAttestWithRetry(params) {
191
+ let lastErr = null;
192
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
193
+ if (attempt > 0)
194
+ await sleep(RETRY_DELAY_MS[attempt] ?? 8000);
195
+ try {
196
+ const txHash = await this.client.attest({
197
+ issuerDid: params.issuerDid,
198
+ targetDid: params.targetDid,
199
+ value: params.value,
200
+ context: params.context,
201
+ signature: params.signature ?? "0x",
202
+ });
203
+ if (txHash) {
204
+ this.stats.attestsAnchored++;
205
+ this.stats.lastAnchorTs = Date.now();
206
+ this.emit("anchored", "attestation", params.issuerDid, txHash);
207
+ console.log(`[anchor] ✅ attest ${params.issuerDid.slice(0, 12)}→${params.targetDid.slice(0, 12)} → tx ${txHash.slice(0, 12)}...`);
208
+ return;
209
+ }
210
+ }
211
+ catch (err) {
212
+ lastErr = err;
213
+ if (err.message?.includes("CooldownActive")) {
214
+ console.log(`[anchor] Attestation cooldown on-chain — skipping`);
215
+ return;
216
+ }
217
+ }
218
+ }
219
+ this.pendingAttests.push({ ...params, enqueuedAt: Date.now(), attempts: MAX_ATTEMPTS });
220
+ this.saveQueue();
221
+ }
222
+ async flushQueue() {
223
+ if (!this.connected || !this.client)
224
+ return;
225
+ const nullifiers = [...this.pendingNullifiers];
226
+ const attests = [...this.pendingAttests];
227
+ this.pendingNullifiers = [];
228
+ this.pendingAttests = [];
229
+ for (const p of nullifiers) {
230
+ await this.anchorNullifierWithRetry(p).catch(() => { });
231
+ }
232
+ for (const a of attests) {
233
+ await this.anchorAttestWithRetry(a).catch(() => { });
234
+ }
235
+ this.saveQueue();
236
+ }
237
+ // ── Persistencia de queue ───────────────────────────────────────────────────
238
+ loadQueue() {
239
+ const nullFile = this.storePath + "-nullifiers.json";
240
+ const attFile = this.storePath + "-attestations.json";
241
+ try {
242
+ if (existsSync(nullFile))
243
+ this.pendingNullifiers = JSON.parse(readFileSync(nullFile, "utf8"));
244
+ if (existsSync(attFile))
245
+ this.pendingAttests = JSON.parse(readFileSync(attFile, "utf8"));
246
+ const total = this.pendingNullifiers.length + this.pendingAttests.length;
247
+ if (total > 0)
248
+ console.log(`[anchor] Loaded ${total} pending items from queue`);
249
+ }
250
+ catch { /* queue vacía o corrupta */ }
251
+ }
252
+ saveQueue() {
253
+ try {
254
+ writeFileSync(this.storePath + "-nullifiers.json", JSON.stringify(this.pendingNullifiers, null, 2));
255
+ writeFileSync(this.storePath + "-attestations.json", JSON.stringify(this.pendingAttests, null, 2));
256
+ }
257
+ catch { /* non-critical */ }
258
+ }
259
+ }
260
+ // ── Helpers ────────────────────────────────────────────────────────────────────
261
+ function sleep(ms) {
262
+ return new Promise(r => setTimeout(r, ms));
263
+ }
package/dist/validator.js CHANGED
@@ -8,6 +8,7 @@ import { handleCredentialRoute } from "./credentials/index.js";
8
8
  import { encryptGossip, decryptGossip } from "./crypto/gossip-cipher.js";
9
9
  import { selectGossipPeers, routingStats } from "./crypto/peer-router.js";
10
10
  import { NullifierConsensus, AttestationConsensus, StateSyncManager } from "./consensus/index.js";
11
+ import { BlockchainAnchor } from "./blockchain/blockchain-anchor.js";
11
12
  import { publishAttestationP2P, onAttestationReceived, getP2PStats, } from "./p2p.js";
12
13
  // ── Config ────────────────────────────────────────────────────────────────────
13
14
  const PORT = parseInt(process.env.SOULPRINT_PORT ?? String(PROTOCOL.DEFAULT_HTTP_PORT));
@@ -662,6 +663,40 @@ export function startValidatorNode(port = PORT) {
662
663
  }).catch(() => { });
663
664
  // Actualizar peer count en nullifierConsensus al cambiar peers
664
665
  setInterval(() => nullifierConsensus.setPeerCount(peers.length), 5_000);
666
+ // ── Blockchain backup (P2P primario + blockchain como backup) ─────────────
667
+ // P2P confirma primero → blockchain ancla async (no bloquea al usuario)
668
+ const anchor = new BlockchainAnchor({
669
+ storePath: join(NODE_DIR, "blockchain-queue"),
670
+ });
671
+ // Conectar en background (no bloquea el arranque del nodo)
672
+ anchor.connect().catch(() => { });
673
+ // Escuchar eventos del consenso y anclar async
674
+ nullifierConsensus.on("committed", (entry) => {
675
+ anchor.anchorNullifier({
676
+ nullifier: entry.nullifier,
677
+ did: entry.did,
678
+ documentVerified: true,
679
+ faceVerified: true,
680
+ zkProof: {
681
+ a: [0n, 0n],
682
+ b: [[0n, 0n], [0n, 0n]],
683
+ c: [0n, 0n],
684
+ inputs: [BigInt("0x" + entry.nullifier.replace(/^0x/, "").slice(0, 16).padEnd(16, "0") || "1"), 1n],
685
+ },
686
+ });
687
+ });
688
+ attestConsensus.on("attested", (entry) => {
689
+ anchor.anchorAttestation({
690
+ issuerDid: entry.issuerDid,
691
+ targetDid: entry.targetDid,
692
+ value: entry.value,
693
+ context: entry.context,
694
+ signature: entry.sig,
695
+ });
696
+ });
697
+ anchor.on("anchored", (type, id, txHash) => {
698
+ console.log(`[anchor] ${type} ${id.slice(0, 12)}... → blockchain tx ${txHash.slice(0, 12)}...`);
699
+ });
665
700
  // ── Credential context (para el router de credenciales) ───────────────────
666
701
  const credentialCtx = {
667
702
  nodeKeypair,
@@ -705,6 +740,10 @@ export function startValidatorNode(port = PORT) {
705
740
  return handleGetReputation(res, decodeURIComponent(cleanUrl.replace("/reputation/", "")));
706
741
  if (cleanUrl.startsWith("/nullifier/") && req.method === "GET")
707
742
  return handleNullifierCheck(res, decodeURIComponent(cleanUrl.replace("/nullifier/", "")));
743
+ // ── Blockchain anchor status ──────────────────────────────────────────────
744
+ if (cleanUrl === "/anchor/stats" && req.method === "GET") {
745
+ return json(res, 200, anchor.getStats());
746
+ }
708
747
  // ── Consensus P2P endpoints (sin EVM, sin gas) ─────────────────────────
709
748
  // GET /consensus/state-info — handshake para state-sync
710
749
  if (cleanUrl === "/consensus/state-info" && req.method === "GET") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "soulprint-network",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Soulprint validator node — 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",