@entros/pulse-sdk 1.2.0 → 1.4.0

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/dist/index.mjs CHANGED
@@ -64,14 +64,25 @@ async function captureAudio(options = {}) {
64
64
  autoGainControl: false
65
65
  }
66
66
  });
67
- const ctx = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
68
- await ctx.resume();
69
- const capturedSampleRate = ctx.sampleRate;
70
- const source = ctx.createMediaStreamSource(stream);
67
+ let ctx;
68
+ let source;
69
+ let capturedSampleRate;
70
+ try {
71
+ ctx = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
72
+ await ctx.resume();
73
+ capturedSampleRate = ctx.sampleRate;
74
+ source = ctx.createMediaStreamSource(stream);
75
+ } catch (err) {
76
+ if (!preAcquiredStream) {
77
+ stream.getTracks().forEach((t) => t.stop());
78
+ }
79
+ throw err;
80
+ }
71
81
  const chunks = [];
72
82
  const startTime = performance.now();
73
83
  return new Promise((resolve) => {
74
84
  let stopped = false;
85
+ let abortTimer = null;
75
86
  const bufferSize = 4096;
76
87
  const processor = ctx.createScriptProcessor(bufferSize, 1, 1);
77
88
  processor.onaudioprocess = (e) => {
@@ -89,6 +100,7 @@ async function captureAudio(options = {}) {
89
100
  if (stopped) return;
90
101
  stopped = true;
91
102
  clearTimeout(maxTimer);
103
+ if (abortTimer !== null) clearTimeout(abortTimer);
92
104
  processor.disconnect();
93
105
  source.disconnect();
94
106
  stream.getTracks().forEach((t) => t.stop());
@@ -110,14 +122,14 @@ async function captureAudio(options = {}) {
110
122
  const maxTimer = setTimeout(stopCapture, maxDurationMs);
111
123
  if (signal) {
112
124
  if (signal.aborted) {
113
- setTimeout(stopCapture, minDurationMs);
125
+ abortTimer = setTimeout(stopCapture, minDurationMs);
114
126
  } else {
115
127
  signal.addEventListener(
116
128
  "abort",
117
129
  () => {
118
130
  const elapsed = performance.now() - startTime;
119
131
  const remaining = Math.max(0, minDurationMs - elapsed);
120
- setTimeout(stopCapture, remaining);
132
+ abortTimer = setTimeout(stopCapture, remaining);
121
133
  },
122
134
  { once: true }
123
135
  );
@@ -169,6 +181,7 @@ async function captureMotion(options = {}) {
169
181
  const startTime = performance.now();
170
182
  return new Promise((resolve) => {
171
183
  let stopped = false;
184
+ let abortTimer = null;
172
185
  const handler = (e) => {
173
186
  samples.push({
174
187
  timestamp: performance.now(),
@@ -184,6 +197,7 @@ async function captureMotion(options = {}) {
184
197
  if (stopped) return;
185
198
  stopped = true;
186
199
  clearTimeout(maxTimer);
200
+ if (abortTimer !== null) clearTimeout(abortTimer);
187
201
  window.removeEventListener("devicemotion", handler);
188
202
  resolve(samples);
189
203
  }
@@ -191,14 +205,14 @@ async function captureMotion(options = {}) {
191
205
  const maxTimer = setTimeout(stopCapture, maxDurationMs);
192
206
  if (signal) {
193
207
  if (signal.aborted) {
194
- setTimeout(stopCapture, minDurationMs);
208
+ abortTimer = setTimeout(stopCapture, minDurationMs);
195
209
  } else {
196
210
  signal.addEventListener(
197
211
  "abort",
198
212
  () => {
199
213
  const elapsed = performance.now() - startTime;
200
214
  const remaining = Math.max(0, minDurationMs - elapsed);
201
- setTimeout(stopCapture, remaining);
215
+ abortTimer = setTimeout(stopCapture, remaining);
202
216
  },
203
217
  { once: true }
204
218
  );
@@ -218,6 +232,7 @@ function captureTouch(element, options = {}) {
218
232
  const startTime = performance.now();
219
233
  return new Promise((resolve) => {
220
234
  let stopped = false;
235
+ let abortTimer = null;
221
236
  const handler = (e) => {
222
237
  samples.push({
223
238
  timestamp: performance.now(),
@@ -232,6 +247,7 @@ function captureTouch(element, options = {}) {
232
247
  if (stopped) return;
233
248
  stopped = true;
234
249
  clearTimeout(maxTimer);
250
+ if (abortTimer !== null) clearTimeout(abortTimer);
235
251
  element.removeEventListener("pointermove", handler);
236
252
  element.removeEventListener("pointerdown", handler);
237
253
  sdkLog(`[Entros SDK] Touch capture stopped: ${samples.length} samples collected`);
@@ -243,14 +259,14 @@ function captureTouch(element, options = {}) {
243
259
  const maxTimer = setTimeout(stopCapture, maxDurationMs);
244
260
  if (signal) {
245
261
  if (signal.aborted) {
246
- setTimeout(stopCapture, minDurationMs);
262
+ abortTimer = setTimeout(stopCapture, minDurationMs);
247
263
  } else {
248
264
  signal.addEventListener(
249
265
  "abort",
250
266
  () => {
251
267
  const elapsed = performance.now() - startTime;
252
268
  const remaining = Math.max(0, minDurationMs - elapsed);
253
- setTimeout(stopCapture, remaining);
269
+ abortTimer = setTimeout(stopCapture, remaining);
254
270
  },
255
271
  { once: true }
256
272
  );
@@ -1257,6 +1273,45 @@ async function generateSolanaProof(current, previous, wasmPath, zkeyPath, thresh
1257
1273
  return serializeProof(proof, publicSignals);
1258
1274
  }
1259
1275
 
1276
+ // src/submit/receipt.ts
1277
+ var PUBKEY_BYTES = 32;
1278
+ var SIGNATURE_BYTES = 64;
1279
+ var MESSAGE_BYTES = 72;
1280
+ function bytesToHex(bytes) {
1281
+ let out = "";
1282
+ for (let i = 0; i < bytes.length; i += 1) {
1283
+ out += (bytes[i] ?? 0).toString(16).padStart(2, "0");
1284
+ }
1285
+ return out;
1286
+ }
1287
+ function hexToBytes(hex, expectedLen) {
1288
+ const trimmed = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex;
1289
+ if (trimmed.length !== expectedLen * 2) return null;
1290
+ if (!/^[0-9a-fA-F]+$/.test(trimmed)) return null;
1291
+ const out = new Uint8Array(expectedLen);
1292
+ for (let i = 0; i < expectedLen; i += 1) {
1293
+ out[i] = parseInt(trimmed.substr(i * 2, 2), 16);
1294
+ }
1295
+ return out;
1296
+ }
1297
+ function decodeSignedReceipt(receipt) {
1298
+ const publicKey = hexToBytes(receipt.validator_pubkey_hex, PUBKEY_BYTES);
1299
+ const signature = hexToBytes(receipt.signature_hex, SIGNATURE_BYTES);
1300
+ const message = hexToBytes(receipt.message_hex, MESSAGE_BYTES);
1301
+ if (!publicKey || !signature || !message) return null;
1302
+ return { publicKey, signature, message };
1303
+ }
1304
+ async function buildEd25519ReceiptIx(receipt) {
1305
+ const decoded = decodeSignedReceipt(receipt);
1306
+ if (!decoded) return null;
1307
+ const { Ed25519Program } = await import("@solana/web3.js");
1308
+ return Ed25519Program.createInstructionWithPublicKey({
1309
+ publicKey: decoded.publicKey,
1310
+ message: decoded.message,
1311
+ signature: decoded.signature
1312
+ });
1313
+ }
1314
+
1260
1315
  // src/submit/wallet.ts
1261
1316
  async function requestSasAttestation(wallet, walletAddress, relayerUrl, relayerApiKey, serverNonce) {
1262
1317
  try {
@@ -1298,14 +1353,22 @@ async function requestSasAttestation(wallet, walletAddress, relayerUrl, relayerA
1298
1353
  return attestData.attestation_tx;
1299
1354
  }
1300
1355
  }
1301
- } catch {
1356
+ } catch (err) {
1357
+ const msg = err instanceof Error ? err.message : String(err);
1358
+ sdkWarn(`[Entros SDK] SAS attestation request failed: ${msg}`);
1302
1359
  }
1303
1360
  return void 0;
1304
1361
  }
1305
1362
  async function submitViaWallet(proof, commitment, options) {
1306
1363
  try {
1307
1364
  const anchor = await import("@coral-xyz/anchor");
1308
- const { PublicKey, SystemProgram, Transaction, ComputeBudgetProgram } = await import("@solana/web3.js");
1365
+ const {
1366
+ PublicKey,
1367
+ SystemProgram,
1368
+ Transaction,
1369
+ ComputeBudgetProgram,
1370
+ SYSVAR_INSTRUCTIONS_PUBKEY
1371
+ } = await import("@solana/web3.js");
1309
1372
  const provider = new anchor.AnchorProvider(
1310
1373
  options.connection,
1311
1374
  options.wallet,
@@ -1474,7 +1537,7 @@ async function submitViaWallet(proof, commitment, options) {
1474
1537
  false,
1475
1538
  TOKEN_2022_PROGRAM_ID
1476
1539
  );
1477
- await anchorProgram.methods.mintAnchor(Array.from(commitment)).accounts({
1540
+ const mintAnchorIx = await anchorProgram.methods.mintAnchor(Array.from(commitment)).accounts({
1478
1541
  user: provider.wallet.publicKey,
1479
1542
  identityState: identityPda,
1480
1543
  mint: mintPda,
@@ -1486,8 +1549,35 @@ async function submitViaWallet(proof, commitment, options) {
1486
1549
  tokenProgram: TOKEN_2022_PROGRAM_ID,
1487
1550
  systemProgram: SystemProgram.programId,
1488
1551
  protocolConfig: protocolConfigPda,
1489
- treasury: treasuryPda
1490
- }).rpc();
1552
+ treasury: treasuryPda,
1553
+ instructionsSysvar: SYSVAR_INSTRUCTIONS_PUBKEY
1554
+ }).instruction();
1555
+ let ed25519Ix = null;
1556
+ if (options.signedReceipt) {
1557
+ ed25519Ix = await buildEd25519ReceiptIx(options.signedReceipt);
1558
+ if (ed25519Ix) {
1559
+ sdkLog(
1560
+ "[Entros SDK] Bundling validator-signed mint receipt before mint_anchor"
1561
+ );
1562
+ } else {
1563
+ sdkWarn(
1564
+ "[Entros SDK] signedReceipt provided but failed to decode; minting without binding"
1565
+ );
1566
+ }
1567
+ } else {
1568
+ sdkLog(
1569
+ "[Entros SDK] No validator receipt available; minting without binding (Phase 3 log-only)"
1570
+ );
1571
+ }
1572
+ const tx = new Transaction();
1573
+ if (ed25519Ix) tx.add(ed25519Ix);
1574
+ tx.add(mintAnchorIx);
1575
+ tx.feePayer = provider.wallet.publicKey;
1576
+ tx.recentBlockhash = (await options.connection.getLatestBlockhash("confirmed")).blockhash;
1577
+ txSig = await options.wallet.sendTransaction(tx, options.connection, {
1578
+ skipPreflight: true
1579
+ });
1580
+ await options.connection.confirmTransaction(txSig, "confirmed");
1491
1581
  }
1492
1582
  const attestationTx = options.relayerUrl ? await requestSasAttestation(
1493
1583
  options.wallet,
@@ -1729,11 +1819,24 @@ function fromBase64(b64) {
1729
1819
  var STORAGE_KEY = "entros-protocol-verification-data";
1730
1820
  var ENCRYPTED_VERSION = 2;
1731
1821
  var inMemoryStore = null;
1822
+ var privacyFallbackCallback = null;
1823
+ function setPrivacyFallback(cb) {
1824
+ privacyFallbackCallback = cb ?? null;
1825
+ }
1732
1826
  function isEncryptedEnvelope(obj) {
1733
- return typeof obj === "object" && obj !== null && obj.v === ENCRYPTED_VERSION && typeof obj.iv === "string" && typeof obj.ct === "string";
1827
+ if (typeof obj !== "object" || obj === null) return false;
1828
+ const o = obj;
1829
+ return o.v === ENCRYPTED_VERSION && typeof o.iv === "string" && o.iv.length > 0 && typeof o.ct === "string" && o.ct.length > 0;
1734
1830
  }
1735
1831
  function isPlaintextData(obj) {
1736
- return typeof obj === "object" && obj !== null && Array.isArray(obj.fingerprint);
1832
+ if (typeof obj !== "object" || obj === null) return false;
1833
+ const o = obj;
1834
+ if (!Array.isArray(o.fingerprint)) return false;
1835
+ if (!o.fingerprint.every((bit) => typeof bit === "number")) return false;
1836
+ if (typeof o.salt !== "string" || o.salt.length === 0) return false;
1837
+ if (typeof o.commitment !== "string" || o.commitment.length === 0) return false;
1838
+ if (typeof o.timestamp !== "number" || !Number.isFinite(o.timestamp)) return false;
1839
+ return true;
1737
1840
  }
1738
1841
  async function fetchIdentityState(walletPubkey, connection) {
1739
1842
  try {
@@ -1772,14 +1875,34 @@ async function fetchIdentityState(walletPubkey, connection) {
1772
1875
  async function storeVerificationData(data) {
1773
1876
  try {
1774
1877
  if (!hasCryptoSupport()) {
1775
- sdkWarn("[Entros SDK] Crypto unavailable \u2014 verification data stored unencrypted");
1776
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
1878
+ const allowPlaintext = privacyFallbackCallback ? await privacyFallbackCallback().catch(() => false) : false;
1879
+ if (allowPlaintext) {
1880
+ sdkWarn(
1881
+ "[Entros SDK] Crypto unavailable; user-approved plaintext storage"
1882
+ );
1883
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
1884
+ } else {
1885
+ sdkWarn(
1886
+ "[Entros SDK] Crypto unavailable and no privacy-fallback approval \u2014 using in-memory storage (data lost on reload)"
1887
+ );
1888
+ inMemoryStore = data;
1889
+ }
1777
1890
  return;
1778
1891
  }
1779
1892
  const key = await getOrCreateEncryptionKey();
1780
1893
  if (!key) {
1781
- sdkWarn("[Entros SDK] Encryption key unavailable \u2014 storing unencrypted");
1782
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
1894
+ const allowPlaintext = privacyFallbackCallback ? await privacyFallbackCallback().catch(() => false) : false;
1895
+ if (allowPlaintext) {
1896
+ sdkWarn(
1897
+ "[Entros SDK] Encryption key unavailable; user-approved plaintext storage"
1898
+ );
1899
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
1900
+ } else {
1901
+ sdkWarn(
1902
+ "[Entros SDK] Encryption key unavailable and no privacy-fallback approval \u2014 using in-memory storage"
1903
+ );
1904
+ inMemoryStore = data;
1905
+ }
1783
1906
  return;
1784
1907
  }
1785
1908
  const { iv, ct } = await encrypt(JSON.stringify(data), key);
@@ -1865,6 +1988,9 @@ async function extractFingerprintAndValidate(sensorData, config, walletAddress,
1865
1988
  sdkLog(
1866
1989
  `[Entros SDK] Feature vector: ${features.length} dimensions, ${nonZero} non-zero. Audio[0..43]: ${features.slice(0, 44).filter((v) => v !== 0).length} non-zero. Motion/Mouse[44..97]: ${features.slice(44, 98).filter((v) => v !== 0).length} non-zero. Touch[98..133]: ${features.slice(98, 134).filter((v) => v !== 0).length} non-zero.`
1867
1990
  );
1991
+ const fingerprint = simhash(normalizedFeatures);
1992
+ const tbh = await generateTBH(fingerprint);
1993
+ let signedReceipt;
1868
1994
  onProgress?.("Validating...");
1869
1995
  if (config.relayerUrl && walletAddress) {
1870
1996
  try {
@@ -1876,6 +2002,7 @@ async function extractFingerprintAndValidate(sensorData, config, walletAddress,
1876
2002
  }
1877
2003
  const audioSamplesB64 = sensorData.audio?.samples ? encodeAudioAsBase64(sensorData.audio.samples) : void 0;
1878
2004
  const audioSampleRateHz = sensorData.audio?.sampleRate;
2005
+ const commitmentNewHex = bytesToHex(tbh.commitmentBytes);
1879
2006
  const validateController = new AbortController();
1880
2007
  const validateTimer = setTimeout(() => validateController.abort(), 15e3);
1881
2008
  const validateResponse = await fetch(validateUrl, {
@@ -1887,32 +2014,39 @@ async function extractFingerprintAndValidate(sensorData, config, walletAddress,
1887
2014
  accel_magnitude: accelMagnitude,
1888
2015
  wallet_id: walletAddress,
1889
2016
  audio_samples_b64: audioSamplesB64,
1890
- audio_sample_rate_hz: audioSampleRateHz
2017
+ audio_sample_rate_hz: audioSampleRateHz,
2018
+ commitment_new_hex: commitmentNewHex
1891
2019
  }),
1892
2020
  signal: validateController.signal
1893
2021
  });
1894
2022
  clearTimeout(validateTimer);
1895
2023
  if (!validateResponse.ok) {
1896
2024
  const errorBody = await validateResponse.json().catch(() => ({}));
1897
- const body = errorBody;
1898
- const reason = typeof body.reason === "string" ? body.reason : void 0;
1899
- sdkWarn(
1900
- `[Entros SDK] Feature validation rejected by server${reason ? ` (reason: ${reason})` : ""}`
1901
- );
2025
+ sdkWarn("[Entros SDK] Feature validation rejected by server");
1902
2026
  return {
1903
2027
  ok: false,
1904
- error: body.error || "Feature validation failed",
1905
- reason
2028
+ error: errorBody.error || "Feature validation failed",
2029
+ reason: errorBody.reason
1906
2030
  };
1907
2031
  }
2032
+ try {
2033
+ const successBody = await validateResponse.json();
2034
+ if (successBody.signed_receipt) {
2035
+ signedReceipt = successBody.signed_receipt;
2036
+ }
2037
+ } catch {
2038
+ }
1908
2039
  } catch (err) {
1909
2040
  const msg = err instanceof Error ? err.message : String(err);
1910
- sdkWarn(`[Entros SDK] Feature validation unavailable: ${msg}, proceeding without server validation`);
2041
+ sdkWarn(`[Entros SDK] Feature validation unavailable: ${msg}`);
2042
+ return {
2043
+ ok: false,
2044
+ error: "Validation service unreachable. Please check your connection and try again.",
2045
+ reason: "validation_unavailable"
2046
+ };
1911
2047
  }
1912
2048
  }
1913
- const fingerprint = simhash(normalizedFeatures);
1914
- const tbh = await generateTBH(fingerprint);
1915
- return { ok: true, features, f0Contour, accelMagnitude, fingerprint, tbh };
2049
+ return { ok: true, features, f0Contour, accelMagnitude, fingerprint, tbh, signedReceipt };
1916
2050
  }
1917
2051
  async function processSensorData(sensorData, config, wallet, connection, onProgress) {
1918
2052
  const audioSamples = sensorData.audio?.samples.length ?? 0;
@@ -1983,7 +2117,7 @@ async function processSensorData(sensorData, config, wallet, connection, onProgr
1983
2117
  reason: extraction.reason
1984
2118
  };
1985
2119
  }
1986
- const { fingerprint, tbh, features } = extraction;
2120
+ const { fingerprint, tbh, features, signedReceipt } = extraction;
1987
2121
  let isFirstVerification;
1988
2122
  const previousData = await loadVerificationData();
1989
2123
  if (wallet && connection) {
@@ -2073,7 +2207,14 @@ async function processSensorData(sensorData, config, wallet, connection, onProgr
2073
2207
  submission = await submitViaWallet(
2074
2208
  solanaProof ?? { proofBytes: new Uint8Array(0), publicInputs: [] },
2075
2209
  tbh.commitmentBytes,
2076
- { wallet, connection, isFirstVerification: true, relayerUrl: config.relayerUrl, relayerApiKey: config.relayerApiKey }
2210
+ {
2211
+ wallet,
2212
+ connection,
2213
+ isFirstVerification: true,
2214
+ relayerUrl: config.relayerUrl,
2215
+ relayerApiKey: config.relayerApiKey,
2216
+ signedReceipt
2217
+ }
2077
2218
  );
2078
2219
  } else {
2079
2220
  submission = await submitViaWallet(solanaProof, tbh.commitmentBytes, {
@@ -2375,70 +2516,6 @@ var PulseSession = class {
2375
2516
  this.motionStageState = "captured";
2376
2517
  this.touchStageState = "captured";
2377
2518
  }
2378
- /**
2379
- * @internal
2380
- *
2381
- * Run the validation step of the verify pipeline only: feature extraction
2382
- * + `/validate-features` POST. Returns the validation outcome without ever
2383
- * touching the on-chain submission path. Mirrors the production user
2384
- * flow's pre-payment gate — the validation server runs without requiring
2385
- * the wallet to have SOL, just like a real user gets a validation result
2386
- * before being prompted to sign the on-chain mint.
2387
- *
2388
- * Note: this is a strict subset of `complete()`. It skips the data-quality
2389
- * gates and re-verification check that `processSensorData` performs. The
2390
- * validation server still runs its full pipeline (Tier 1 + Tier 2 +
2391
- * phrase binding); only the client-side pre-flight checks differ.
2392
- *
2393
- * Use case: red team campaigns measuring server-side validation at scale
2394
- * without per-attempt SOL funding. Build-time gated identically to
2395
- * `__injectSensorData`; throws in production builds.
2396
- */
2397
- async __validateOnly(walletAddress) {
2398
- if (true) {
2399
- throw new Error(
2400
- "PulseSession.__validateOnly is only available in internal test builds. Set IAM_INTERNAL_TEST=1 when building pulse-sdk from source."
2401
- );
2402
- }
2403
- if (typeof walletAddress !== "string" || walletAddress.length === 0) {
2404
- throw new Error(
2405
- "__validateOnly requires a non-empty walletAddress string (used as wallet_id in the /validate-features payload)."
2406
- );
2407
- }
2408
- const active = [];
2409
- if (this.audioStageState === "capturing") active.push("audio");
2410
- if (this.motionStageState === "capturing") active.push("motion");
2411
- if (this.touchStageState === "capturing") active.push("touch");
2412
- if (active.length > 0) {
2413
- throw new Error(
2414
- `Cannot validate: stages still capturing: ${active.join(", ")}`
2415
- );
2416
- }
2417
- if (!this.audioData || this.motionData.length === 0 || this.touchData.length === 0) {
2418
- throw new Error(
2419
- "__validateOnly requires sensor data first \u2014 call __injectSensorData() before this."
2420
- );
2421
- }
2422
- const sensorData = {
2423
- audio: this.audioData,
2424
- motion: this.motionData,
2425
- touch: this.touchData,
2426
- modalities: {
2427
- audio: true,
2428
- motion: true,
2429
- touch: true
2430
- }
2431
- };
2432
- const extraction = await extractFingerprintAndValidate(
2433
- sensorData,
2434
- this.config,
2435
- walletAddress
2436
- );
2437
- if (!extraction.ok) {
2438
- return { validated: false, error: extraction.error, reason: extraction.reason };
2439
- }
2440
- return { validated: true };
2441
- }
2442
2519
  // --- Complete ---
2443
2520
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Solana types are optional peer deps
2444
2521
  async complete(wallet, connection, onProgress) {
@@ -2513,6 +2590,7 @@ var PulseSDK = class {
2513
2590
  ...config
2514
2591
  };
2515
2592
  setDebug(config.debug ?? false);
2593
+ setPrivacyFallback(config.onPrivacyFallback);
2516
2594
  }
2517
2595
  /**
2518
2596
  * Create a staged capture session for event-driven control.