@iam-protocol/pulse-sdk 0.3.2 → 0.3.4

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.3.2",
3
+ "version": "0.3.4",
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",
@@ -26,6 +26,7 @@
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@coral-xyz/anchor": "^0.32.1",
29
+ "@solana/spl-token": "^0.4.0",
29
30
  "@solana/wallet-adapter-base": "^0.9.0",
30
31
  "@solana/web3.js": "^1.98.0"
31
32
  },
@@ -33,6 +34,9 @@
33
34
  "@coral-xyz/anchor": {
34
35
  "optional": true
35
36
  },
37
+ "@solana/spl-token": {
38
+ "optional": true
39
+ },
36
40
  "@solana/web3.js": {
37
41
  "optional": true
38
42
  },
@@ -57,8 +57,12 @@ export function condense(values: number[]): StatsSummary {
57
57
  */
58
58
  export function entropy(values: number[], bins: number = 16): number {
59
59
  if (values.length < 2) return 0;
60
- const min = Math.min(...values);
61
- const max = Math.max(...values);
60
+ let min = values[0]!;
61
+ let max = values[0]!;
62
+ for (let i = 1; i < values.length; i++) {
63
+ if (values[i]! < min) min = values[i]!;
64
+ if (values[i]! > max) max = values[i]!;
65
+ }
62
66
  if (min === max) return 0;
63
67
 
64
68
  const counts = new Array(bins).fill(0);
@@ -96,10 +100,30 @@ export function autocorrelation(values: number[], lag: number = 1): number {
96
100
  return sum / ((values.length - lag) * v);
97
101
  }
98
102
 
103
+ /**
104
+ * Normalize a feature group to zero mean and unit variance.
105
+ * Ensures each modality (audio, motion, touch) contributes equally
106
+ * to SimHash hyperplane projections regardless of raw magnitude scale.
107
+ */
108
+ function normalizeGroup(features: number[]): number[] {
109
+ if (features.length === 0) return features;
110
+
111
+ let sum = 0;
112
+ for (const v of features) sum += v;
113
+ const mean = sum / features.length;
114
+
115
+ let sqSum = 0;
116
+ for (const v of features) sqSum += (v - mean) * (v - mean);
117
+ const std = Math.sqrt(sqSum / features.length);
118
+
119
+ if (std === 0) return features.map(() => 0);
120
+ return features.map((v) => (v - mean) / std);
121
+ }
122
+
99
123
  export function fuseFeatures(
100
124
  audio: number[],
101
125
  motion: number[],
102
126
  touch: number[]
103
127
  ): number[] {
104
- return [...audio, ...motion, ...touch];
128
+ return [...normalizeGroup(audio), ...normalizeGroup(motion), ...normalizeGroup(touch)];
105
129
  }
@@ -16,7 +16,7 @@ export async function fetchIdentityState(
16
16
 
17
17
  const programId = new PublicKey(PROGRAM_IDS.iamAnchor);
18
18
  const [identityPda] = PublicKey.findProgramAddressSync(
19
- [Buffer.from("identity"), new PublicKey(walletPubkey).toBuffer()],
19
+ [new TextEncoder().encode("identity"), new PublicKey(walletPubkey).toBuffer()],
20
20
  programId
21
21
  );
22
22
 
@@ -76,14 +76,13 @@ export async function generateSolanaProof(
76
76
 
77
77
  /**
78
78
  * Verify a proof locally using snarkjs (for debugging/testing).
79
+ * Caller is responsible for loading the verification key.
79
80
  */
80
81
  export async function verifyProofLocally(
81
82
  proof: any,
82
83
  publicSignals: string[],
83
- vkeyPath: string
84
+ vkey: Record<string, unknown>
84
85
  ): Promise<boolean> {
85
86
  const snarkjs = await getSnarkjs();
86
- const fs = await import("fs");
87
- const vk = JSON.parse(fs.readFileSync(vkeyPath, "utf-8"));
88
- return snarkjs.groth16.verify(vk, publicSignals, proof);
87
+ return snarkjs.groth16.verify(vkey, publicSignals, proof);
89
88
  }
package/src/pulse.ts CHANGED
@@ -7,7 +7,7 @@ import type { VerificationResult } from "./submit/types";
7
7
  import type { StoredVerificationData } from "./identity/types";
8
8
 
9
9
  import { captureAudio } from "./sensor/audio";
10
- import { captureMotion } from "./sensor/motion";
10
+ import { captureMotion, requestMotionPermission } from "./sensor/motion";
11
11
  import { captureTouch } from "./sensor/touch";
12
12
  import { extractSpeakerFeatures, SPEAKER_FEATURE_COUNT } from "./extraction/speaker";
13
13
  import {
@@ -34,9 +34,10 @@ type ResolvedConfig = Required<Pick<PulseConfig, "cluster" | "threshold">> &
34
34
  * Extract features from sensor data and fuse into a single vector.
35
35
  */
36
36
  async function extractFeatures(data: SensorData): Promise<number[]> {
37
- const audioFeatures = data.audio
38
- ? await extractSpeakerFeatures(data.audio)
39
- : new Array(SPEAKER_FEATURE_COUNT).fill(0);
37
+ if (!data.audio) {
38
+ throw new Error("Audio data required for feature extraction");
39
+ }
40
+ const audioFeatures = await extractSpeakerFeatures(data.audio);
40
41
 
41
42
  const hasMotion = data.motion.length >= MIN_MOTION_SAMPLES;
42
43
  const motionFeatures = hasMotion
@@ -59,6 +60,7 @@ const MIN_TOUCH_SAMPLES = 10;
59
60
  async function processSensorData(
60
61
  sensorData: SensorData,
61
62
  config: ResolvedConfig,
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Solana types are optional peer deps
62
64
  wallet?: any,
63
65
  connection?: any
64
66
  ): Promise<VerificationResult> {
@@ -254,12 +256,30 @@ export class PulseSession {
254
256
  async startAudio(onAudioLevel?: (rms: number) => void): Promise<void> {
255
257
  if (this.audioStageState !== "idle")
256
258
  throw new Error("Audio capture already started");
259
+
260
+ // Acquire microphone permission within the user gesture context.
261
+ // Awaited so the caller knows audio is ready before proceeding.
262
+ // State transitions happen AFTER permission succeeds to avoid zombie state.
263
+ const stream = await navigator.mediaDevices.getUserMedia({
264
+ audio: {
265
+ sampleRate: 16000,
266
+ channelCount: 1,
267
+ echoCancellation: false,
268
+ noiseSuppression: false,
269
+ autoGainControl: false,
270
+ },
271
+ });
272
+
257
273
  this.audioStageState = "capturing";
258
274
  this.audioController = new AbortController();
259
275
  this.audioPromise = captureAudio({
260
276
  signal: this.audioController.signal,
261
277
  onAudioLevel,
262
- }).catch(() => null);
278
+ stream,
279
+ }).catch(() => {
280
+ stream.getTracks().forEach((t) => t.stop());
281
+ return null;
282
+ });
263
283
  }
264
284
 
265
285
  async stopAudio(): Promise<AudioCapture | null> {
@@ -271,21 +291,28 @@ export class PulseSession {
271
291
  return this.audioData;
272
292
  }
273
293
 
274
- skipAudio(): void {
275
- if (this.audioStageState !== "idle")
276
- throw new Error("Audio capture already started");
277
- this.audioStageState = "skipped";
278
- }
294
+ // Audio is mandatory — no skipAudio() method.
295
+ // If startAudio() fails, the verification cannot proceed.
279
296
 
280
297
  // --- Motion ---
281
298
 
282
299
  async startMotion(): Promise<void> {
283
300
  if (this.motionStageState !== "idle")
284
301
  throw new Error("Motion capture already started");
302
+
303
+ // Request motion permission within the user gesture context (iOS 13+).
304
+ // Awaited so the capture timer doesn't start before the user approves.
305
+ const hasPermission = await requestMotionPermission();
306
+ if (!hasPermission) {
307
+ this.motionStageState = "skipped";
308
+ return;
309
+ }
310
+
285
311
  this.motionStageState = "capturing";
286
312
  this.motionController = new AbortController();
287
313
  this.motionPromise = captureMotion({
288
314
  signal: this.motionController.signal,
315
+ permissionGranted: true,
289
316
  }).catch(() => []);
290
317
  }
291
318
 
@@ -304,6 +331,10 @@ export class PulseSession {
304
331
  this.motionStageState = "skipped";
305
332
  }
306
333
 
334
+ isMotionCapturing(): boolean {
335
+ return this.motionStageState === "capturing";
336
+ }
337
+
307
338
  // --- Touch ---
308
339
 
309
340
  async startTouch(): Promise<void> {
@@ -335,6 +366,7 @@ export class PulseSession {
335
366
 
336
367
  // --- Complete ---
337
368
 
369
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Solana types are optional peer deps
338
370
  async complete(wallet?: any, connection?: any): Promise<VerificationResult> {
339
371
  const active: string[] = [];
340
372
  if (this.audioStageState === "capturing") active.push("audio");
@@ -404,28 +436,26 @@ export class PulseSDK {
404
436
  try {
405
437
  await session.startAudio();
406
438
  stopPromises.push(
407
- new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS)).then(
408
- () => {
409
- session.stopAudio();
410
- }
411
- )
439
+ new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS))
440
+ .then(() => session.stopAudio())
441
+ .then(() => {})
412
442
  );
413
- } catch {
414
- session.skipAudio();
443
+ } catch (err: any) {
444
+ throw new Error(`Audio capture failed: ${err?.message ?? "microphone unavailable"}`);
415
445
  }
416
446
 
417
- // Motion
447
+ // Motion — startMotion auto-skips if permission denied (no throw)
418
448
  try {
419
449
  await session.startMotion();
450
+ } catch {
451
+ /* unexpected error — motion already skipped or idle */
452
+ }
453
+ if (session.isMotionCapturing()) {
420
454
  stopPromises.push(
421
- new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS)).then(
422
- () => {
423
- session.stopMotion();
424
- }
425
- )
455
+ new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS))
456
+ .then(() => session.stopMotion())
457
+ .then(() => {})
426
458
  );
427
- } catch {
428
- session.skipMotion();
429
459
  }
430
460
 
431
461
  // Touch
@@ -433,11 +463,9 @@ export class PulseSDK {
433
463
  try {
434
464
  await session.startTouch();
435
465
  stopPromises.push(
436
- new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS)).then(
437
- () => {
438
- session.stopTouch();
439
- }
440
- )
466
+ new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS))
467
+ .then(() => session.stopTouch())
468
+ .then(() => {})
441
469
  );
442
470
  } catch {
443
471
  session.skipTouch();
@@ -20,9 +20,10 @@ export async function captureAudio(
20
20
  minDurationMs = MIN_CAPTURE_MS,
21
21
  maxDurationMs = MAX_CAPTURE_MS,
22
22
  onAudioLevel,
23
+ stream: preAcquiredStream,
23
24
  } = options;
24
25
 
25
- const stream = await navigator.mediaDevices.getUserMedia({
26
+ const stream = preAcquiredStream ?? await navigator.mediaDevices.getUserMedia({
26
27
  audio: {
27
28
  sampleRate: TARGET_SAMPLE_RATE,
28
29
  channelCount: 1,
@@ -33,6 +34,7 @@ export async function captureAudio(
33
34
  });
34
35
 
35
36
  const ctx = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
37
+ await ctx.resume(); // Required on iOS — AudioContext may be suspended outside user gesture
36
38
  const capturedSampleRate = ctx.sampleRate;
37
39
  const source = ctx.createMediaStreamSource(stream);
38
40
  const chunks: Float32Array[] = [];
@@ -31,7 +31,7 @@ export async function captureMotion(
31
31
  maxDurationMs = MAX_CAPTURE_MS,
32
32
  } = options;
33
33
 
34
- const hasPermission = await requestMotionPermission();
34
+ const hasPermission = options.permissionGranted ?? await requestMotionPermission();
35
35
  if (!hasPermission) return [];
36
36
 
37
37
  const samples: MotionSample[] = [];
@@ -36,6 +36,10 @@ export interface CaptureOptions {
36
36
  maxDurationMs?: number;
37
37
  /** Called with RMS audio level (0-1) on each buffer during audio capture (~4x per second). */
38
38
  onAudioLevel?: (rms: number) => void;
39
+ /** Pre-acquired MediaStream. If provided, captureAudio skips getUserMedia. */
40
+ stream?: MediaStream;
41
+ /** If true, captureMotion skips requestMotionPermission (already acquired). */
42
+ permissionGranted?: boolean;
39
43
  }
40
44
 
41
45
  /** Stage of a capture session */
@@ -53,6 +53,8 @@ export async function submitViaRelayer(
53
53
  const result = (await response.json()) as {
54
54
  success?: boolean;
55
55
  tx_signature?: string;
56
+ verified?: boolean;
57
+ registered?: boolean;
56
58
  };
57
59
 
58
60
  if (result.success !== true) {
@@ -14,10 +14,9 @@ export async function submitViaWallet(
14
14
  proof: SolanaProof,
15
15
  commitment: Uint8Array,
16
16
  options: {
17
- wallet: any; // WalletAdapter
18
- connection: any; // Connection
17
+ wallet: any;
18
+ connection: any;
19
19
  isFirstVerification: boolean;
20
- trustScore?: number;
21
20
  }
22
21
  ): Promise<SubmissionResult> {
23
22
  try {
@@ -39,18 +38,18 @@ export async function submitViaWallet(
39
38
  // Derive PDAs
40
39
  const [challengePda] = PublicKey.findProgramAddressSync(
41
40
  [
42
- Buffer.from("challenge"),
41
+ new TextEncoder().encode("challenge"),
43
42
  provider.wallet.publicKey.toBuffer(),
44
- Buffer.from(nonce),
43
+ new Uint8Array(nonce),
45
44
  ],
46
45
  verifierProgramId
47
46
  );
48
47
 
49
48
  const [verificationPda] = PublicKey.findProgramAddressSync(
50
49
  [
51
- Buffer.from("verification"),
50
+ new TextEncoder().encode("verification"),
52
51
  provider.wallet.publicKey.toBuffer(),
53
- Buffer.from(nonce),
52
+ new Uint8Array(nonce),
54
53
  ],
55
54
  verifierProgramId
56
55
  );
@@ -83,7 +82,7 @@ export async function submitViaWallet(
83
82
  // 2. Verify proof
84
83
  const txSig = await verifierProgram.methods
85
84
  .verifyProof(
86
- Buffer.from(proof.proofBytes),
85
+ Array.from(proof.proofBytes),
87
86
  proof.publicInputs.map((pi) => Array.from(pi)),
88
87
  nonce
89
88
  )
@@ -106,15 +105,15 @@ export async function submitViaWallet(
106
105
 
107
106
  if (options.isFirstVerification) {
108
107
  const [identityPda] = PublicKey.findProgramAddressSync(
109
- [Buffer.from("identity"), provider.wallet.publicKey.toBuffer()],
108
+ [new TextEncoder().encode("identity"), provider.wallet.publicKey.toBuffer()],
110
109
  anchorProgramId
111
110
  );
112
111
  const [mintPda] = PublicKey.findProgramAddressSync(
113
- [Buffer.from("mint"), provider.wallet.publicKey.toBuffer()],
112
+ [new TextEncoder().encode("mint"), provider.wallet.publicKey.toBuffer()],
114
113
  anchorProgramId
115
114
  );
116
115
  const [mintAuthority] = PublicKey.findProgramAddressSync(
117
- [Buffer.from("mint_authority")],
116
+ [new TextEncoder().encode("mint_authority")],
118
117
  anchorProgramId
119
118
  );
120
119
 
@@ -150,15 +149,23 @@ export async function submitViaWallet(
150
149
  .rpc();
151
150
  } else {
152
151
  const [identityPda] = PublicKey.findProgramAddressSync(
153
- [Buffer.from("identity"), provider.wallet.publicKey.toBuffer()],
152
+ [new TextEncoder().encode("identity"), provider.wallet.publicKey.toBuffer()],
154
153
  anchorProgramId
155
154
  );
156
155
 
156
+ // Derive iam-registry ProtocolConfig PDA for trust score computation
157
+ const registryProgramId = new PublicKey(PROGRAM_IDS.iamRegistry);
158
+ const [protocolConfigPda] = PublicKey.findProgramAddressSync(
159
+ [new TextEncoder().encode("protocol_config")],
160
+ registryProgramId
161
+ );
162
+
157
163
  await anchorProgram.methods
158
- .updateAnchor(Array.from(commitment), options.trustScore ?? 0)
164
+ .updateAnchor(Array.from(commitment))
159
165
  .accounts({
160
166
  authority: provider.wallet.publicKey,
161
167
  identityState: identityPda,
168
+ protocolConfig: protocolConfigPda,
162
169
  })
163
170
  .rpc();
164
171
  }