@iam-protocol/pulse-sdk 0.2.3 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iam-protocol/pulse-sdk",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Client-side SDK for IAM Protocol — sensor capture, TBH generation, ZK proof construction",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -28,11 +28,13 @@ export function randomLissajousParams(): LissajousParams {
28
28
  [3, 5],
29
29
  [4, 5],
30
30
  ];
31
- const pair = ratios[Math.floor(Math.random() * ratios.length)]!;
31
+ const arr = new Uint32Array(2);
32
+ crypto.getRandomValues(arr);
33
+ const pair = ratios[arr[0]! % ratios.length]!;
32
34
  return {
33
35
  a: pair[0]!,
34
36
  b: pair[1]!,
35
- delta: Math.PI * (0.25 + Math.random() * 0.5),
37
+ delta: Math.PI * (0.25 + (arr[1]! / 0xFFFFFFFF) * 0.5),
36
38
  points: 200,
37
39
  };
38
40
  }
@@ -67,15 +69,25 @@ export function generateLissajousSequence(
67
69
  [1, 3], [2, 5], [5, 6], [3, 7], [4, 7],
68
70
  ];
69
71
 
70
- const shuffled = [...allRatios].sort(() => Math.random() - 0.5);
72
+ // Fisher-Yates shuffle with crypto randomness
73
+ const shuffled = [...allRatios];
74
+ for (let i = shuffled.length - 1; i > 0; i--) {
75
+ const arr = new Uint32Array(1);
76
+ crypto.getRandomValues(arr);
77
+ const j = arr[0]! % (i + 1);
78
+ [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
79
+ }
80
+
71
81
  const sequence: { params: LissajousParams; points: Point2D[] }[] = [];
72
82
 
73
83
  for (let i = 0; i < count; i++) {
74
84
  const pair = shuffled[i % shuffled.length]!;
85
+ const deltaArr = new Uint32Array(1);
86
+ crypto.getRandomValues(deltaArr);
75
87
  const params: LissajousParams = {
76
88
  a: pair[0],
77
89
  b: pair[1],
78
- delta: Math.PI * (0.1 + Math.random() * 0.8),
90
+ delta: Math.PI * (0.1 + (deltaArr[0]! / 0xFFFFFFFF) * 0.8),
79
91
  points: 200,
80
92
  };
81
93
  sequence.push({ params, points: generateLissajousPoints(params) });
@@ -10,18 +10,25 @@ const SYLLABLES = [
10
10
  "fu", "gu", "ku", "lu", "mu", "nu", "pu", "ru", "su", "tu",
11
11
  ];
12
12
 
13
+ /** Cryptographically random integer in [0, max) */
14
+ function secureRandom(max: number): number {
15
+ const arr = new Uint32Array(1);
16
+ crypto.getRandomValues(arr);
17
+ return arr[0]! % max;
18
+ }
19
+
13
20
  /**
14
21
  * Generate a random phonetically-balanced phrase for the voice challenge.
15
22
  * Each phrase is 5-6 syllable pairs, forming nonsensical but speakable words.
23
+ * Uses crypto.getRandomValues for unpredictable challenge generation.
16
24
  */
17
25
  export function generatePhrase(wordCount: number = 5): string {
18
26
  const words: string[] = [];
19
27
  for (let w = 0; w < wordCount; w++) {
20
- const syllableCount = 2 + Math.floor(Math.random() * 2);
28
+ const syllableCount = 2 + secureRandom(2);
21
29
  let word = "";
22
30
  for (let s = 0; s < syllableCount; s++) {
23
- const idx = Math.floor(Math.random() * SYLLABLES.length);
24
- word += SYLLABLES[idx];
31
+ word += SYLLABLES[secureRandom(SYLLABLES.length)];
25
32
  }
26
33
  words.push(word);
27
34
  }
@@ -48,10 +55,10 @@ export function generatePhraseSequence(
48
55
 
49
56
  const words: string[] = [];
50
57
  for (let w = 0; w < wordCount; w++) {
51
- const syllableCount = 2 + Math.floor(Math.random() * 2);
58
+ const syllableCount = 2 + secureRandom(2);
52
59
  let word = "";
53
60
  for (let s = 0; s < syllableCount; s++) {
54
- word += subset[Math.floor(Math.random() * subset.length)];
61
+ word += subset[secureRandom(subset.length)];
55
62
  }
56
63
  words.push(word);
57
64
  }
@@ -6,6 +6,20 @@ const FRAME_SIZE = 400; // 25ms at 16kHz
6
6
  const HOP_SIZE = 160; // 10ms hop
7
7
  const NUM_MFCC = 13;
8
8
 
9
+ // Dynamic import cache for Meyda (works in both browser and Node.js)
10
+ let meydaModule: any = null;
11
+
12
+ async function getMeyda(): Promise<any> {
13
+ if (!meydaModule) {
14
+ try {
15
+ meydaModule = await import("meyda");
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ return meydaModule.default ?? meydaModule;
21
+ }
22
+
9
23
  /**
10
24
  * Extract MFCC features from audio data.
11
25
  * Computes 13 MFCCs per frame, plus delta and delta-delta coefficients,
@@ -13,21 +27,22 @@ const NUM_MFCC = 13;
13
27
  *
14
28
  * Returns: 13 coefficients × 3 (raw + delta + delta-delta) × 4 stats + 13 entropy values = 169 values
15
29
  */
16
- export function extractMFCC(audio: AudioCapture): number[] {
30
+ export async function extractMFCC(audio: AudioCapture): Promise<number[]> {
17
31
  const { samples, sampleRate } = audio;
18
32
 
19
- // Lazy import of Meyda (browser/Node compatible)
20
- let Meyda: any;
21
- try {
22
- Meyda = require("meyda");
23
- } catch {
24
- // Meyda not available — return zeros (fallback for environments without it)
33
+ const Meyda = await getMeyda();
34
+ if (!Meyda) {
35
+ // Meyda genuinely unavailable — this is a real problem, not a silent fallback
36
+ console.warn("[IAM SDK] Meyda library failed to load. Audio features will be zeros.");
25
37
  return new Array(NUM_MFCC * 3 * 4 + NUM_MFCC).fill(0);
26
38
  }
27
39
 
28
40
  // Extract MFCCs per frame
29
41
  const numFrames = Math.floor((samples.length - FRAME_SIZE) / HOP_SIZE) + 1;
30
- if (numFrames < 3) return new Array(NUM_MFCC * 3 * 4 + NUM_MFCC).fill(0);
42
+ if (numFrames < 3) {
43
+ console.warn(`[IAM SDK] Too few audio frames (${numFrames}). Need at least 3.`);
44
+ return new Array(NUM_MFCC * 3 * 4 + NUM_MFCC).fill(0);
45
+ }
31
46
 
32
47
  const mfccFrames: number[][] = [];
33
48
 
@@ -50,7 +65,7 @@ export function extractMFCC(audio: AudioCapture): number[] {
50
65
  }
51
66
  }
52
67
 
53
- if (mfccFrames.length < 3) return new Array(NUM_MFCC * 3 * 4).fill(0);
68
+ if (mfccFrames.length < 3) return new Array(NUM_MFCC * 3 * 4 + NUM_MFCC).fill(0);
54
69
 
55
70
  // Compute delta (1st derivative) and delta-delta (2nd derivative)
56
71
  const deltaFrames = computeDeltas(mfccFrames);
@@ -60,7 +75,6 @@ export function extractMFCC(audio: AudioCapture): number[] {
60
75
  const features: number[] = [];
61
76
 
62
77
  for (let c = 0; c < NUM_MFCC; c++) {
63
- // Raw MFCC coefficient c across all frames
64
78
  const raw = mfccFrames.map((f) => f[c] ?? 0);
65
79
  const stats = condense(raw);
66
80
  features.push(stats.mean, stats.variance, stats.skewness, stats.kurtosis);
@@ -78,8 +92,7 @@ export function extractMFCC(audio: AudioCapture): number[] {
78
92
  features.push(stats.mean, stats.variance, stats.skewness, stats.kurtosis);
79
93
  }
80
94
 
81
- // Entropy per MFCC coefficient: measures information density across frames.
82
- // Real speech has moderate, varied entropy. Synthetic audio is too uniform or too structured.
95
+ // Entropy per MFCC coefficient
83
96
  for (let c = 0; c < NUM_MFCC; c++) {
84
97
  const raw = mfccFrames.map((f) => f[c] ?? 0);
85
98
  features.push(entropy(raw));
@@ -52,11 +52,20 @@ function getHyperplanes(dimension: number): number[][] {
52
52
  * Uses deterministic random hyperplanes seeded from the protocol constant.
53
53
  * Similar feature vectors produce fingerprints with low Hamming distance.
54
54
  */
55
+ const EXPECTED_FEATURE_DIMENSION = 259; // 169 audio + 54 motion + 36 touch
56
+
55
57
  export function simhash(features: number[]): TemporalFingerprint {
56
58
  if (features.length === 0) {
57
59
  return new Array(FINGERPRINT_BITS).fill(0);
58
60
  }
59
61
 
62
+ if (features.length !== EXPECTED_FEATURE_DIMENSION) {
63
+ console.warn(
64
+ `[IAM SDK] Feature vector has ${features.length} dimensions, expected ${EXPECTED_FEATURE_DIMENSION}. ` +
65
+ `Fingerprint quality may be degraded.`
66
+ );
67
+ }
68
+
60
69
  const planes = getHyperplanes(features.length);
61
70
  const fingerprint: TemporalFingerprint = [];
62
71
 
@@ -41,6 +41,12 @@ export function serializeProof(
41
41
  proof: RawProof,
42
42
  publicSignals: string[]
43
43
  ): SolanaProof {
44
+ if (publicSignals.length !== NUM_PUBLIC_INPUTS) {
45
+ throw new Error(
46
+ `Expected ${NUM_PUBLIC_INPUTS} public signals, got ${publicSignals.length}`
47
+ );
48
+ }
49
+
44
50
  // proof_a: x (32 bytes) + negated y (32 bytes)
45
51
  const a0 = toBigEndian32(proof.pi_a[0]!);
46
52
  const a1 = negateG1Y(proof.pi_a[1]!);
package/src/pulse.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from "./extraction/kinematic";
17
17
  import { fuseFeatures } from "./extraction/statistics";
18
18
  import { simhash } from "./hashing/simhash";
19
- import { generateTBH } from "./hashing/poseidon";
19
+ import { generateTBH, bigintToBytes32 } from "./hashing/poseidon";
20
20
  import { prepareCircuitInput, generateProof } from "./proof/prover";
21
21
  import { serializeProof } from "./proof/serializer";
22
22
  import { submitViaWallet } from "./submit/wallet";
@@ -32,9 +32,9 @@ type ResolvedConfig = Required<Pick<PulseConfig, "cluster" | "threshold">> &
32
32
  /**
33
33
  * Extract features from sensor data and fuse into a single vector.
34
34
  */
35
- function extractFeatures(data: SensorData): number[] {
35
+ async function extractFeatures(data: SensorData): Promise<number[]> {
36
36
  const audioFeatures = data.audio
37
- ? extractMFCC(data.audio)
37
+ ? await extractMFCC(data.audio)
38
38
  : new Array(169).fill(0);
39
39
  const motionFeatures = extractMotionFeatures(data.motion);
40
40
  const touchFeatures = extractTouchFeatures(data.touch);
@@ -85,7 +85,7 @@ async function processSensorData(
85
85
  }
86
86
 
87
87
  // Extract features
88
- const features = extractFeatures(sensorData);
88
+ const features = await extractFeatures(sensorData);
89
89
 
90
90
  // Generate fingerprint via SimHash
91
91
  const fingerprint = simhash(features);
@@ -104,7 +104,7 @@ async function processSensorData(
104
104
  fingerprint: previousData.fingerprint,
105
105
  salt: BigInt(previousData.salt),
106
106
  commitment: BigInt(previousData.commitment),
107
- commitmentBytes: new Uint8Array(32),
107
+ commitmentBytes: bigintToBytes32(BigInt(previousData.commitment)),
108
108
  };
109
109
 
110
110
  const circuitInput = prepareCircuitInput(
@@ -113,8 +113,17 @@ async function processSensorData(
113
113
  config.threshold
114
114
  );
115
115
 
116
- const wasmPath = config.wasmUrl ?? "";
117
- const zkeyPath = config.zkeyUrl ?? "";
116
+ const wasmPath = config.wasmUrl;
117
+ const zkeyPath = config.zkeyUrl;
118
+
119
+ if (!wasmPath || !zkeyPath) {
120
+ return {
121
+ success: false,
122
+ commitment: tbh.commitmentBytes,
123
+ isFirstVerification: false,
124
+ error: "wasmUrl and zkeyUrl must be configured for re-verification proof generation",
125
+ };
126
+ }
118
127
 
119
128
  const { proof, publicSignals } = await generateProof(
120
129
  circuitInput,
@@ -33,6 +33,7 @@ export async function captureAudio(
33
33
  });
34
34
 
35
35
  const ctx = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
36
+ const capturedSampleRate = ctx.sampleRate;
36
37
  const source = ctx.createMediaStreamSource(stream);
37
38
  const chunks: Float32Array[] = [];
38
39
  const startTime = performance.now();
@@ -76,8 +77,8 @@ export async function captureAudio(
76
77
 
77
78
  resolve({
78
79
  samples,
79
- sampleRate: ctx.sampleRate,
80
- duration: totalLength / ctx.sampleRate,
80
+ sampleRate: capturedSampleRate,
81
+ duration: totalLength / capturedSampleRate,
81
82
  });
82
83
  }
83
84
 
@@ -1,12 +1,12 @@
1
1
  import type { SolanaProof } from "../proof/types";
2
2
  import type { SubmissionResult } from "./types";
3
3
 
4
+ const RELAYER_TIMEOUT_MS = 30_000;
5
+
4
6
  /**
5
7
  * Submit a proof via the IAM relayer API (walletless mode).
6
8
  * The relayer submits the on-chain transaction using the integrator's funded account.
7
9
  * The user needs no wallet, no SOL, no crypto knowledge.
8
- *
9
- * In Phase 3, the relayer endpoint is configurable (stub until executor-node in Phase 4).
10
10
  */
11
11
  export async function submitViaRelayer(
12
12
  proof: SolanaProof,
@@ -33,12 +33,18 @@ export async function submitViaRelayer(
33
33
  headers["X-API-Key"] = options.apiKey;
34
34
  }
35
35
 
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), RELAYER_TIMEOUT_MS);
38
+
36
39
  const response = await fetch(options.relayerUrl, {
37
40
  method: "POST",
38
41
  headers,
39
42
  body: JSON.stringify(body),
43
+ signal: controller.signal,
40
44
  });
41
45
 
46
+ clearTimeout(timer);
47
+
42
48
  if (!response.ok) {
43
49
  const errorText = await response.text();
44
50
  return { success: false, error: `Relayer error: ${response.status} ${errorText}` };
@@ -48,11 +54,19 @@ export async function submitViaRelayer(
48
54
  success?: boolean;
49
55
  tx_signature?: string;
50
56
  };
57
+
58
+ if (result.success !== true) {
59
+ return { success: false, error: "Relayer returned unsuccessful response" };
60
+ }
61
+
51
62
  return {
52
- success: result.success ?? true,
63
+ success: true,
53
64
  txSignature: result.tx_signature,
54
65
  };
55
66
  } catch (err: any) {
67
+ if (err.name === "AbortError") {
68
+ return { success: false, error: "Relayer request timed out" };
69
+ }
56
70
  return { success: false, error: err.message ?? String(err) };
57
71
  }
58
72
  }
@@ -97,7 +97,11 @@ export async function submitViaWallet(
97
97
 
98
98
  // 3. Mint or update anchor
99
99
  const anchorIdl = await anchor.Program.fetchIdl(anchorProgramId, provider);
100
- if (anchorIdl) {
100
+ if (!anchorIdl) {
101
+ return { success: false, error: "Failed to fetch IAM Anchor program IDL" };
102
+ }
103
+
104
+ {
101
105
  const anchorProgram: any = new anchor.Program(anchorIdl, provider);
102
106
 
103
107
  if (options.isFirstVerification) {