@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/dist/index.d.mts +2 -7
- package/dist/index.d.ts +2 -7
- package/dist/index.js +115 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +115 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/challenge/lissajous.ts +16 -4
- package/src/challenge/phrase.ts +12 -5
- package/src/config.ts +1 -0
- package/src/extraction/mfcc.ts +25 -12
- package/src/hashing/simhash.ts +9 -0
- package/src/proof/serializer.ts +6 -0
- package/src/pulse.ts +50 -8
- package/src/sensor/audio.ts +3 -2
- package/src/submit/relayer.ts +17 -3
- package/src/submit/wallet.ts +5 -1
package/package.json
CHANGED
|
@@ -28,11 +28,13 @@ export function randomLissajousParams(): LissajousParams {
|
|
|
28
28
|
[3, 5],
|
|
29
29
|
[4, 5],
|
|
30
30
|
];
|
|
31
|
-
const
|
|
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 +
|
|
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
|
-
|
|
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 +
|
|
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) });
|
package/src/challenge/phrase.ts
CHANGED
|
@@ -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 +
|
|
28
|
+
const syllableCount = 2 + secureRandom(2);
|
|
21
29
|
let word = "";
|
|
22
30
|
for (let s = 0; s < syllableCount; s++) {
|
|
23
|
-
|
|
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 +
|
|
58
|
+
const syllableCount = 2 + secureRandom(2);
|
|
52
59
|
let word = "";
|
|
53
60
|
for (let s = 0; s < syllableCount; s++) {
|
|
54
|
-
word += subset[
|
|
61
|
+
word += subset[secureRandom(subset.length)];
|
|
55
62
|
}
|
|
56
63
|
words.push(word);
|
|
57
64
|
}
|
package/src/config.ts
CHANGED
package/src/extraction/mfcc.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
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)
|
|
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
|
|
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));
|
package/src/hashing/simhash.ts
CHANGED
|
@@ -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
|
|
package/src/proof/serializer.ts
CHANGED
|
@@ -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:
|
|
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 {
|
package/src/sensor/audio.ts
CHANGED
|
@@ -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:
|
|
80
|
-
duration: totalLength /
|
|
80
|
+
sampleRate: capturedSampleRate,
|
|
81
|
+
duration: totalLength / capturedSampleRate,
|
|
81
82
|
});
|
|
82
83
|
}
|
|
83
84
|
|
package/src/submit/relayer.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/submit/wallet.ts
CHANGED
|
@@ -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) {
|