@iam-protocol/pulse-sdk 0.2.3 → 0.2.6
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 +1 -7
- package/dist/index.d.ts +1 -7
- package/dist/index.js +90 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +90 -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/extraction/mfcc.ts +26 -13
- package/src/hashing/simhash.ts +9 -0
- package/src/proof/serializer.ts +6 -0
- package/src/pulse.ts +16 -7
- 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/extraction/mfcc.ts
CHANGED
|
@@ -2,10 +2,24 @@ import type { AudioCapture } from "../sensor/types";
|
|
|
2
2
|
import { condense, entropy } from "./statistics";
|
|
3
3
|
|
|
4
4
|
// Frame parameters matching the research paper spec
|
|
5
|
-
const FRAME_SIZE =
|
|
5
|
+
const FRAME_SIZE = 512; // ~32ms at 16kHz (must be power of 2 for Meyda FFT)
|
|
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);
|
|
@@ -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:
|
|
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,
|
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) {
|