@iam-protocol/pulse-sdk 0.2.2 → 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.2",
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
  }
package/src/config.ts CHANGED
@@ -35,6 +35,7 @@ export interface PulseConfig {
35
35
  cluster: "devnet" | "mainnet-beta" | "localnet";
36
36
  rpcEndpoint?: string;
37
37
  relayerUrl?: string;
38
+ relayerApiKey?: string;
38
39
  zkeyUrl?: string;
39
40
  wasmUrl?: string;
40
41
  threshold?: number;
@@ -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);
@@ -45,14 +45,47 @@ function extractFeatures(data: SensorData): number[] {
45
45
  * Shared pipeline: features → simhash → TBH → proof → submit.
46
46
  * Used by both PulseSDK.verify() and PulseSession.complete().
47
47
  */
48
+ // Minimum sample counts for meaningful feature extraction
49
+ const MIN_AUDIO_SAMPLES = 16000; // ~1 second at 16kHz
50
+ const MIN_MOTION_SAMPLES = 10;
51
+ const MIN_TOUCH_SAMPLES = 10;
52
+
48
53
  async function processSensorData(
49
54
  sensorData: SensorData,
50
55
  config: ResolvedConfig,
51
56
  wallet?: any,
52
57
  connection?: any
53
58
  ): Promise<VerificationResult> {
59
+ // Data quality gate: reject if insufficient behavioral data captured
60
+ const audioSamples = sensorData.audio?.samples.length ?? 0;
61
+ const motionSamples = sensorData.motion.length;
62
+ const touchSamples = sensorData.touch.length;
63
+
64
+ // Need at least audio OR (motion + touch) to produce a meaningful fingerprint
65
+ const hasAudio = audioSamples >= MIN_AUDIO_SAMPLES;
66
+ const hasMotion = motionSamples >= MIN_MOTION_SAMPLES;
67
+ const hasTouch = touchSamples >= MIN_TOUCH_SAMPLES;
68
+
69
+ if (!hasAudio && !hasMotion && !hasTouch) {
70
+ return {
71
+ success: false,
72
+ commitment: new Uint8Array(32),
73
+ isFirstVerification: true,
74
+ error: "Insufficient behavioral data. Please speak the phrase and trace the curve during capture.",
75
+ };
76
+ }
77
+
78
+ if (!hasAudio) {
79
+ return {
80
+ success: false,
81
+ commitment: new Uint8Array(32),
82
+ isFirstVerification: true,
83
+ error: "No voice data detected. Please speak the phrase clearly during capture.",
84
+ };
85
+ }
86
+
54
87
  // Extract features
55
- const features = extractFeatures(sensorData);
88
+ const features = await extractFeatures(sensorData);
56
89
 
57
90
  // Generate fingerprint via SimHash
58
91
  const fingerprint = simhash(features);
@@ -71,7 +104,7 @@ async function processSensorData(
71
104
  fingerprint: previousData.fingerprint,
72
105
  salt: BigInt(previousData.salt),
73
106
  commitment: BigInt(previousData.commitment),
74
- commitmentBytes: new Uint8Array(32),
107
+ commitmentBytes: bigintToBytes32(BigInt(previousData.commitment)),
75
108
  };
76
109
 
77
110
  const circuitInput = prepareCircuitInput(
@@ -80,8 +113,17 @@ async function processSensorData(
80
113
  config.threshold
81
114
  );
82
115
 
83
- const wasmPath = config.wasmUrl ?? "";
84
- 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
+ }
85
127
 
86
128
  const { proof, publicSignals } = await generateProof(
87
129
  circuitInput,
@@ -113,7 +155,7 @@ async function processSensorData(
113
155
  submission = await submitViaRelayer(
114
156
  solanaProof ?? { proofBytes: new Uint8Array(0), publicInputs: [] },
115
157
  tbh.commitmentBytes,
116
- { relayerUrl: config.relayerUrl, isFirstVerification }
158
+ { relayerUrl: config.relayerUrl, apiKey: config.relayerApiKey, isFirstVerification }
117
159
  );
118
160
  } else {
119
161
  return {
@@ -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) {