@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/README.md +1 -1
- package/dist/index.d.mts +0 -2
- package/dist/index.d.ts +0 -2
- package/dist/index.js +107 -13153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +87 -13125
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
- package/src/extraction/statistics.ts +27 -3
- package/src/identity/anchor.ts +1 -1
- package/src/proof/prover.ts +3 -4
- package/src/pulse.ts +58 -30
- package/src/sensor/audio.ts +3 -1
- package/src/sensor/motion.ts +1 -1
- package/src/sensor/types.ts +4 -0
- package/src/submit/relayer.ts +2 -0
- package/src/submit/wallet.ts +20 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iam-protocol/pulse-sdk",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
|
|
61
|
-
|
|
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
|
}
|
package/src/identity/anchor.ts
CHANGED
|
@@ -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
|
-
[
|
|
19
|
+
[new TextEncoder().encode("identity"), new PublicKey(walletPubkey).toBuffer()],
|
|
20
20
|
programId
|
|
21
21
|
);
|
|
22
22
|
|
package/src/proof/prover.ts
CHANGED
|
@@ -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
|
-
|
|
84
|
+
vkey: Record<string, unknown>
|
|
84
85
|
): Promise<boolean> {
|
|
85
86
|
const snarkjs = await getSnarkjs();
|
|
86
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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()
|
|
275
|
-
|
|
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))
|
|
408
|
-
() =>
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
)
|
|
439
|
+
new Promise<void>((r) => setTimeout(r, DEFAULT_CAPTURE_MS))
|
|
440
|
+
.then(() => session.stopAudio())
|
|
441
|
+
.then(() => {})
|
|
412
442
|
);
|
|
413
|
-
} catch {
|
|
414
|
-
|
|
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))
|
|
422
|
-
() =>
|
|
423
|
-
|
|
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))
|
|
437
|
-
() =>
|
|
438
|
-
|
|
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();
|
package/src/sensor/audio.ts
CHANGED
|
@@ -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[] = [];
|
package/src/sensor/motion.ts
CHANGED
|
@@ -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[] = [];
|
package/src/sensor/types.ts
CHANGED
|
@@ -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 */
|
package/src/submit/relayer.ts
CHANGED
package/src/submit/wallet.ts
CHANGED
|
@@ -14,10 +14,9 @@ export async function submitViaWallet(
|
|
|
14
14
|
proof: SolanaProof,
|
|
15
15
|
commitment: Uint8Array,
|
|
16
16
|
options: {
|
|
17
|
-
wallet: any;
|
|
18
|
-
connection: any;
|
|
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
|
-
|
|
41
|
+
new TextEncoder().encode("challenge"),
|
|
43
42
|
provider.wallet.publicKey.toBuffer(),
|
|
44
|
-
|
|
43
|
+
new Uint8Array(nonce),
|
|
45
44
|
],
|
|
46
45
|
verifierProgramId
|
|
47
46
|
);
|
|
48
47
|
|
|
49
48
|
const [verificationPda] = PublicKey.findProgramAddressSync(
|
|
50
49
|
[
|
|
51
|
-
|
|
50
|
+
new TextEncoder().encode("verification"),
|
|
52
51
|
provider.wallet.publicKey.toBuffer(),
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
108
|
+
[new TextEncoder().encode("identity"), provider.wallet.publicKey.toBuffer()],
|
|
110
109
|
anchorProgramId
|
|
111
110
|
);
|
|
112
111
|
const [mintPda] = PublicKey.findProgramAddressSync(
|
|
113
|
-
[
|
|
112
|
+
[new TextEncoder().encode("mint"), provider.wallet.publicKey.toBuffer()],
|
|
114
113
|
anchorProgramId
|
|
115
114
|
);
|
|
116
115
|
const [mintAuthority] = PublicKey.findProgramAddressSync(
|
|
117
|
-
[
|
|
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
|
-
[
|
|
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)
|
|
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
|
}
|