solana-age-verify-sdk 2.0.0-beta.2

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.
Files changed (47) hide show
  1. package/README.md +185 -0
  2. package/dist/adapters/blazeface.d.ts +15 -0
  3. package/dist/adapters/blazeface.js +258 -0
  4. package/dist/adapters/mediapipe.d.ts +7 -0
  5. package/dist/adapters/mediapipe.js +55 -0
  6. package/dist/adapters/onnx.d.ts +10 -0
  7. package/dist/adapters/onnx.js +171 -0
  8. package/dist/camera.d.ts +15 -0
  9. package/dist/camera.js +76 -0
  10. package/dist/embedding/descriptor.d.ts +22 -0
  11. package/dist/embedding/descriptor.js +134 -0
  12. package/dist/hashing/facehash.d.ts +3 -0
  13. package/dist/hashing/facehash.js +27 -0
  14. package/dist/hashing/webcrypto.d.ts +1 -0
  15. package/dist/hashing/webcrypto.js +1 -0
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.js +7 -0
  18. package/dist/liveness/challenges.d.ts +3 -0
  19. package/dist/liveness/challenges.js +34 -0
  20. package/dist/liveness/scorer.d.ts +1 -0
  21. package/dist/liveness/scorer.js +3 -0
  22. package/dist/liveness/texture.d.ts +72 -0
  23. package/dist/liveness/texture.js +266 -0
  24. package/dist/types.d.ts +86 -0
  25. package/dist/types.js +9 -0
  26. package/dist/verify.d.ts +4 -0
  27. package/dist/verify.js +892 -0
  28. package/dist/worker/frame.d.ts +5 -0
  29. package/dist/worker/frame.js +1 -0
  30. package/dist/worker/infer.d.ts +4 -0
  31. package/dist/worker/infer.js +22 -0
  32. package/dist/worker/worker.d.ts +0 -0
  33. package/dist/worker/worker.js +61 -0
  34. package/package.json +46 -0
  35. package/public/models/age_gender.onnx +1446 -0
  36. package/public/models/age_gender_model-weights_manifest.json +62 -0
  37. package/public/models/age_gender_model.shard1 +1447 -0
  38. package/public/models/face_landmark_68_model-weights_manifest.json +60 -0
  39. package/public/models/face_landmark_68_model.shard1 +1447 -0
  40. package/public/models/face_recognition_model-weights_manifest.json +128 -0
  41. package/public/models/face_recognition_model.shard1 +1447 -0
  42. package/public/models/face_recognition_model.shard2 +1447 -0
  43. package/public/models/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  44. package/public/models/ort-wasm-simd-threaded.jsep.wasm +0 -0
  45. package/public/models/ort-wasm-simd-threaded.wasm +0 -0
  46. package/public/models/tiny_face_detector_model-weights_manifest.json +30 -0
  47. package/public/models/tiny_face_detector_model.shard1 +1447 -0
package/dist/verify.js ADDED
@@ -0,0 +1,892 @@
1
+ import { DEFAULT_CONFIG } from './types';
2
+ import { Transaction, SystemProgram, LAMPORTS_PER_SOL, PublicKey, TransactionInstruction } from '@solana/web3.js';
3
+ import { Camera } from './camera';
4
+ import { computeFaceHash, generateSalt, toHex } from './hashing/facehash';
5
+ import { generateChallengeSequence } from './liveness/challenges';
6
+ // Default worker location - in production this might be different
7
+ // @ts-ignore - Base64 encoded platform security constants
8
+ const _P_S_C = {
9
+ t: "OUJLV3dwUG9WSHVIVXNqbzltdmRRNlBaWG5uc0FFd0NzVlRCMTJOWER0aGo=",
10
+ f: "MC4wMQ=="
11
+ };
12
+ const TREASURY_ADDRESS = atob(_P_S_C.t);
13
+ const PROTOCOL_FEE_SOL = parseFloat(atob(_P_S_C.f));
14
+ // Lazy getter to avoid top-level PublicKey construction before polyfills load
15
+ let _memoProgramId = null;
16
+ function getMemoProgramId() {
17
+ if (!_memoProgramId) {
18
+ _memoProgramId = new PublicKey('MemoSq4gqABAXib96qFbncnscymPme7yS4AtGf4Vb7');
19
+ }
20
+ return _memoProgramId;
21
+ }
22
+ export async function verifyHost18Plus(options) {
23
+ const config = { ...DEFAULT_CONFIG, ...options.config };
24
+ // Cooldown & Retry Check
25
+ const storageKey = `talkchain_verify_retries_${options.walletPubkeyBase58}`;
26
+ const cooldownKey = `talkchain_verify_cooldown_${options.walletPubkeyBase58}`;
27
+ const now = Date.now();
28
+ const cooldownUntil = parseInt(localStorage.getItem(cooldownKey) || '0');
29
+ const currentRetries = parseInt(localStorage.getItem(storageKey) || '0');
30
+ const cooldownCountKey = `talkchain_verify_cooldown_count_${options.walletPubkeyBase58}`;
31
+ const currentCooldownCount = parseInt(localStorage.getItem(cooldownCountKey) || '0');
32
+ if (cooldownUntil > now) {
33
+ const remainingSec = Math.ceil((cooldownUntil - now) / 1000);
34
+ const remainingMin = Math.ceil(remainingSec / 60);
35
+ if (options.uiMountEl) {
36
+ options.uiMountEl.innerHTML = `
37
+ <div style="position: relative; height: 100%; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; font-family: sans-serif; color: white; text-align: center; padding: 20px;">
38
+ <div style="font-size: 48px; margin-bottom: 20px;">⏳</div>
39
+ <div style="font-size: 24px; font-weight: 700; margin-bottom: 12px;">Security Cooldown</div>
40
+ <div style="font-size: 16px; color: #94a3b8; line-height: 1.6;">
41
+ Too many failed attempts. <br>
42
+ Please wait <b>${remainingMin} minute${remainingMin > 1 ? 's' : ''}</b> before trying again.
43
+ </div>
44
+ </div>
45
+ `;
46
+ }
47
+ throw new Error(`Cooldown active. Try again in ${remainingMin} minutes.`);
48
+ }
49
+ const camera = new Camera(options.videoElement);
50
+ const salt = generateSalt();
51
+ const sessionNonce = generateSalt();
52
+ // 1. Pre-flight Balance Check
53
+ if (options.wallet && options.connection) {
54
+ try {
55
+ const balance = await options.connection.getBalance(options.wallet.publicKey);
56
+ const requiredBytes = PROTOCOL_FEE_SOL * LAMPORTS_PER_SOL;
57
+ const gasBuffer = 0.0005 * LAMPORTS_PER_SOL; // Small buffer for transaction fee
58
+ if (balance < (requiredBytes + gasBuffer)) {
59
+ const balanceSol = balance / LAMPORTS_PER_SOL;
60
+ const neededSol = (requiredBytes + gasBuffer) / LAMPORTS_PER_SOL;
61
+ if (options.uiMountEl) {
62
+ options.uiMountEl.innerHTML = `
63
+ <div style="position: relative; height: 100%; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; font-family: sans-serif; color: white; text-align: center; padding: 40px;">
64
+ <div style="font-size: 48px; margin-bottom: 24px;">💰</div>
65
+ <div style="font-size: 24px; font-weight: 700; margin-bottom: 16px;">Insufficient Balance</div>
66
+ <div style="font-size: 16px; color: #94a3b8; line-height: 1.6; max-width: 320px;">
67
+ You need at least <b>${neededSol.toFixed(4)} SOL</b> to cover the protocol fee and transaction costs.<br><br>
68
+ <span style="font-size: 14px; opacity: 0.8;">Current Balance: ${balanceSol.toFixed(4)} SOL</span>
69
+ </div>
70
+ <button onclick="window.location.reload()" style="margin-top: 32px; padding: 12px 24px; background: #3b82f6; border: none; border-radius: 12px; color: white; font-weight: 600; cursor: pointer;">
71
+ Reload & Try Again
72
+ </button>
73
+ </div>
74
+ `;
75
+ }
76
+ throw new Error(`Insufficient balance. Found ${balanceSol.toFixed(4)} SOL, need ~${neededSol.toFixed(4)} SOL.`);
77
+ }
78
+ }
79
+ catch (e) {
80
+ if (e.message.includes('Insufficient balance'))
81
+ throw e;
82
+ console.warn('Pre-flight balance check failed (network error), continuing...', e);
83
+ }
84
+ }
85
+ await camera.start();
86
+ let worker;
87
+ if (options.workerFactory) {
88
+ worker = options.workerFactory();
89
+ }
90
+ else {
91
+ // Attempt default resolution - split string to avoid some bundlers eagerly resolving
92
+ const pkgName = 'solana-age-verify-worker';
93
+ worker = new Worker(new URL(pkgName, import.meta.url), { type: 'module' });
94
+ }
95
+ // Helper to send/receive from worker
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ const sendToWorker = (req, timeoutMs = 30000) => {
98
+ return new Promise((resolve, reject) => {
99
+ const timeout = setTimeout(() => {
100
+ worker.removeEventListener('message', handler);
101
+ worker.removeEventListener('error', errorHandler);
102
+ reject(new Error(`Worker request ${req.type} timed out`));
103
+ }, timeoutMs);
104
+ const errorHandler = (e) => {
105
+ clearTimeout(timeout);
106
+ worker.removeEventListener('message', handler);
107
+ worker.removeEventListener('error', errorHandler);
108
+ reject(new Error(`Worker Error: ${e.message}`));
109
+ };
110
+ const handler = (e) => {
111
+ const { type, payload, error } = e.data;
112
+ if (type === 'ERROR') {
113
+ clearTimeout(timeout);
114
+ worker.removeEventListener('message', handler);
115
+ worker.removeEventListener('error', errorHandler);
116
+ reject(new Error(error));
117
+ }
118
+ // Simple request-response matching for this linear flow
119
+ if (type === 'LOADED' && req.type === 'LOAD_MODELS') {
120
+ clearTimeout(timeout);
121
+ worker.removeEventListener('message', handler);
122
+ worker.removeEventListener('error', errorHandler);
123
+ resolve(true);
124
+ }
125
+ if (type === 'RESULT' && req.type === 'PROCESS_FRAME') {
126
+ clearTimeout(timeout);
127
+ worker.removeEventListener('message', handler);
128
+ worker.removeEventListener('error', errorHandler);
129
+ resolve(payload);
130
+ }
131
+ };
132
+ worker.addEventListener('message', handler);
133
+ worker.addEventListener('error', errorHandler);
134
+ worker.postMessage(req);
135
+ });
136
+ };
137
+ if (options.signal?.aborted) {
138
+ worker.terminate();
139
+ await camera.stop();
140
+ throw new Error('Aborted');
141
+ }
142
+ try {
143
+ // Show introductory messages
144
+ const showIntroductoryMessages = async () => {
145
+ if (!options.uiMountEl)
146
+ return;
147
+ // Premium Design Styles - transparent to show video behind
148
+ const commonStyles = `
149
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
150
+ background: rgba(15, 23, 42, 0.85);
151
+ color: #f8fafc;
152
+ `;
153
+ const cardStyle = `
154
+ background: rgba(255, 255, 255, 0.03);
155
+ backdrop-filter: blur(16px);
156
+ border: 1px solid rgba(255, 255, 255, 0.08);
157
+ border-radius: 24px;
158
+ padding: 48px;
159
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
160
+ max-width: 500px;
161
+ width: 90%;
162
+ text-align: center;
163
+ animation: fadeInOut 2s ease-in-out forwards;
164
+ `;
165
+ // First message
166
+ options.uiMountEl.innerHTML = `
167
+ <div style="position: relative; height: 100%; width: 100%; pointer-events: none; display: flex; flex-direction: column; align-items: center; justify-content: center; ${commonStyles}">
168
+ <div style="${cardStyle}">
169
+ <div style="font-size: 32px; font-weight: 700; background: linear-gradient(to right, #60a5fa, #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 24px; letter-spacing: -0.02em;">
170
+ Age Verification
171
+ </div>
172
+ <div style="font-size: 18px; color: #cbd5e1; line-height: 1.6; font-weight: 400;">
173
+ You must be an adult to use this service.<br>
174
+ Your privacy is preserved.
175
+ </div>
176
+ </div>
177
+ </div>
178
+ <style>
179
+ @keyframes fadeInOut {
180
+ 0% { opacity: 0; transform: translateY(10px) scale(0.95); }
181
+ 15% { opacity: 1; transform: translateY(0) scale(1); }
182
+ 85% { opacity: 1; transform: translateY(0) scale(1); }
183
+ 100% { opacity: 0; transform: translateY(-10px) scale(0.95); }
184
+ }
185
+ </style>
186
+ `;
187
+ await new Promise(r => setTimeout(r, 2200));
188
+ // Second message
189
+ options.uiMountEl.innerHTML = `
190
+ <div style="position: relative; height: 100%; width: 100%; pointer-events: none; display: flex; flex-direction: column; align-items: center; justify-content: center; ${commonStyles}">
191
+ <div style="${cardStyle}">
192
+ <div style="font-size: 20px; font-weight: 600; color: #f8fafc; margin-bottom: 20px;">
193
+ We do this once
194
+ </div>
195
+ <div style="font-size: 16px; color: #94a3b8; line-height: 1.6;">
196
+ Results are cryptographically secured on the blockchain. <br>
197
+ <span style="color: #64748b; font-size: 14px; display: block; margin-top: 12px;">(Minimal transaction fee applies)</span>
198
+ </div>
199
+ </div>
200
+ <!-- Powered By Branding -->
201
+ <div style="position: absolute; bottom: 32px; font-size: 12px; color: #475569; letter-spacing: 0.05em; font-weight: 600; text-transform: uppercase;">
202
+ Powered by <span style="color: #60a5fa;">TalkChain</span> Verify
203
+ </div>
204
+ </div>
205
+ `;
206
+ await new Promise(r => setTimeout(r, 2200));
207
+ };
208
+ await showIntroductoryMessages();
209
+ // Load Models
210
+ if (options.onChallenge)
211
+ options.onChallenge('Loading models...');
212
+ // Show loading screen
213
+ if (options.uiMountEl) {
214
+ options.uiMountEl.innerHTML = `
215
+ <div style="position: relative; height: 100%; width: 100%; pointer-events: none; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.85);">
216
+ <div style="max-width: 600px; padding: 40px; text-align: center; animation: fadeIn 0.5s ease-out;">
217
+ <div style="font-size: 24px; font-weight: 600; background: linear-gradient(to right, #e2e8f0, #94a3b8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 32px; letter-spacing: -0.01em;">
218
+ Initializing Neural Engine...
219
+ </div>
220
+ <div style="width: 240px; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; margin: 0 auto; position: relative;">
221
+ <div style="position: absolute; top: 0; left: 0; height: 100%; width: 100%; background: linear-gradient(90deg, transparent, #3b82f6, transparent); animation: shimmer 1.5s infinite;">
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ <style>
227
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
228
+ @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
229
+ </style>
230
+ `;
231
+ }
232
+ await sendToWorker({
233
+ type: 'LOAD_MODELS',
234
+ payload: { basePath: options.modelPath || '/models' }
235
+ });
236
+ if (options.onChallenge)
237
+ options.onChallenge('Neural engine initialized.');
238
+ const startTime = Date.now();
239
+ const challengeResults = [];
240
+ let ageEstimateAccumulator = 0;
241
+ let ageEstimateCount = 0;
242
+ let ageConfidenceAccumulator = 0;
243
+ let ageConfidenceCount = 0;
244
+ let embedding = [];
245
+ let livenessAccumulator = 0;
246
+ let textureScoreAccumulator = 0;
247
+ let textureAnalysisCount = 0;
248
+ let lastTextureFeatures = undefined;
249
+ let ageMethod = 'unknown';
250
+ let ageConfidence = 0;
251
+ // Challenge Loop
252
+ // Audio Feedback
253
+ // Map challenge types to user-friendly instructions
254
+ const getChallengeInstruction = (type) => {
255
+ const instructions = {
256
+ 'turn_left': 'Turn your head left slowly until you hear a beep. Hold for a second beep.',
257
+ 'turn_right': 'Turn your head right slowly until you hear a beep. Hold for a second beep.',
258
+ 'look_up': 'Look up slowly until you hear a beep. Hold for the second beep.',
259
+ 'look_down': 'Look down slowly until you hear a beep. Hold for the second beep.',
260
+ 'nod_yes': "Nod your head 'yes' until you hear a beep.",
261
+ 'shake_no': "Shake your head 'no' until you hear a beep."
262
+ };
263
+ return instructions[type] || type.replace('_', ' ').toUpperCase();
264
+ };
265
+ const playBeep = (freq, duration) => {
266
+ console.log(`Audio Feedback: Playing beep at ${freq}Hz for ${duration}ms`);
267
+ try {
268
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
269
+ const osc = ctx.createOscillator();
270
+ const gain = ctx.createGain();
271
+ osc.type = 'sine';
272
+ osc.frequency.setValueAtTime(freq, ctx.currentTime);
273
+ gain.gain.setValueAtTime(0.1, ctx.currentTime);
274
+ osc.connect(gain);
275
+ gain.connect(ctx.destination);
276
+ osc.start();
277
+ osc.stop(ctx.currentTime + duration / 1000);
278
+ setTimeout(() => ctx.close(), duration + 500);
279
+ }
280
+ catch (e) {
281
+ console.warn('Audio feedback failed', e);
282
+ }
283
+ };
284
+ const updateHUD = (currentIdx, currentProgress = 0) => {
285
+ if (!options.uiMountEl)
286
+ return;
287
+ const currentType = challengeQueue[currentIdx];
288
+ // Get user-friendly instruction for current challenge
289
+ const instruction = getChallengeInstruction(currentType);
290
+ options.uiMountEl.innerHTML = `
291
+ <div style="position: relative; height: 100%; width: 100%; pointer-events: none; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: -apple-system, system-ui, sans-serif;">
292
+
293
+ <!-- Premium Face Guide -->
294
+ <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 300px; height: 400px; border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; border: 2px solid rgba(255,255,255,0.15); box-shadow: 0 0 0 9999px rgba(3, 7, 18, 0.85), inset 0 0 40px rgba(0,0,0,0.5); pointer-events: none;">
295
+ <!-- Scanning Line Animation -->
296
+ <div style="position: absolute; width: 100%; height: 2px; background: linear-gradient(90deg, transparent, #60a5fa, transparent); top: 50%; animation: scan 3s ease-in-out infinite alternate; opacity: 0.5;"></div>
297
+ <!-- Corner Markers -->
298
+ <div style="position: absolute; top: 20px; left: 20px; width: 20px; height: 20px; border-top: 2px solid #60a5fa; border-left: 2px solid #60a5fa; border-radius: 4px 0 0 0;"></div>
299
+ <div style="position: absolute; top: 20px; right: 20px; width: 20px; height: 20px; border-top: 2px solid #60a5fa; border-right: 2px solid #60a5fa; border-radius: 0 4px 0 0;"></div>
300
+ <div style="position: absolute; bottom: 20px; left: 20px; width: 20px; height: 20px; border-bottom: 2px solid #60a5fa; border-left: 2px solid #60a5fa; border-radius: 0 0 0 4px;"></div>
301
+ <div style="position: absolute; bottom: 20px; right: 20px; width: 20px; height: 20px; border-bottom: 2px solid #60a5fa; border-right: 2px solid #60a5fa; border-radius: 0 0 4px 0;"></div>
302
+ </div>
303
+
304
+ <!-- Instruction & Results Container -->
305
+ <div style="z-index: 20; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; padding-bottom: 60px; width: 100%;">
306
+
307
+ <!-- Main Instruction -->
308
+ <div style="background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(12px); padding: 16px 32px; borderRadius: 40px; border: 1px solid rgba(255,255,255,0.1); margin-bottom: 24px; box-shadow: 0 10px 25px rgba(0,0,0,0.3); transform: translateY(0); transition: all 0.3s ease;">
309
+ <div style="font-size: 18px; font-weight: 500; color: #fff; text-align: center; letter-spacing: 0.01em; text-shadow: 0 1px 2px rgba(0,0,0,0.5);">
310
+ ${instruction}
311
+ </div>
312
+ </div>
313
+
314
+ <!-- Progress Bar -->
315
+ <div style="width: 200px; height: 6px; background: rgba(255,255,255,0.15); border-radius: 3px; margin-bottom: 20px; overflow: hidden; position: relative;">
316
+ <div style="position: absolute; left: 0; top: 0; height: 100%; width: ${currentProgress}%; background: linear-gradient(90deg, #3b82f6, #60a5fa); transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);"></div>
317
+ </div>
318
+
319
+ <!-- Checklist -->
320
+ <div style="display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; max-width: 600px;">
321
+ ${challengeQueue.map((c, idx) => {
322
+ const s = challengesStatus[idx];
323
+ const isPassed = s === 'passed';
324
+ const isCurrent = idx === currentIdx;
325
+ const opacity = isPassed || isCurrent ? '1' : '0.5';
326
+ const bgColor = isPassed ? 'rgba(34, 197, 94, 0.2)' : (isCurrent ? 'rgba(59, 130, 246, 0.1)' : 'rgba(255,255,255,0.05)');
327
+ const borderColor = isPassed ? '#22c55e' : (isCurrent ? '#3b82f6' : 'rgba(255,255,255,0.1)');
328
+ const icon = isPassed ? '✓' : '';
329
+ const label = c.replace('_', ' ').toUpperCase();
330
+ return `
331
+ <div style="display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: ${bgColor}; border: 1px solid ${borderColor}; border-radius: 20px; font-size: 11px; font-weight: 600; color: ${isPassed ? '#4ade80' : 'rgba(255,255,255,0.8)'}; opacity: ${opacity}; transition: all 0.3s;">
332
+ ${isPassed ? `<span>${icon}</span>` : ''}
333
+ <span>${label}</span>
334
+ </div>`;
335
+ }).join('')}
336
+ </div>
337
+
338
+ <!-- Powered By Branding -->
339
+ <div style="margin-top: 32px; font-size: 11px; color: #475569; letter-spacing: 0.1em; font-weight: 700; text-transform: uppercase; pointer-events: none;">
340
+ Powered by <span style="color: #3b82f6;">TalkChain</span> Verify
341
+ </div>
342
+ </div>
343
+ </div>
344
+ <style>
345
+ @keyframes scan {
346
+ 0% { top: 30%; opacity: 0; }
347
+ 50% { opacity: 0.5; }
348
+ 100% { top: 70%; opacity: 0; }
349
+ }
350
+ </style>
351
+ `;
352
+ };
353
+ const checkChallenge = (type, res) => {
354
+ if (!res.faceFound || !res.landmarks || res.landmarks.length < 18)
355
+ return false;
356
+ const eyeR = { x: res.landmarks[0], y: res.landmarks[1] };
357
+ const eyeL = { x: res.landmarks[3], y: res.landmarks[4] };
358
+ const nose = { x: res.landmarks[6], y: res.landmarks[7] };
359
+ const eyeMidX = (eyeR.x + eyeL.x) / 2;
360
+ const eyeMidY = (eyeR.y + eyeL.y) / 2;
361
+ // Robust eye distance
362
+ const eyeDist = Math.sqrt(Math.pow(eyeR.x - eyeL.x, 2) + Math.pow(eyeR.y - eyeL.y, 2));
363
+ const diffX = nose.x - eyeMidX;
364
+ const diffY = nose.y - eyeMidY;
365
+ // Thresholds & Sensitivity
366
+ const isGesture = type === 'nod_yes' || type === 'shake_no';
367
+ // "Shake" requires less rotation than a full "Turn Left" command to be ergonomic
368
+ // "Nod" requires less vertical extension than "Look Up"
369
+ const turnMult = isGesture ? 0.2 : 0.3;
370
+ // For gestures, we make the thresholds "closer to neutral" (easier to trigger)
371
+ // Static UP is < 0.35, Gesture UP is < 0.40 (easier)
372
+ // Static DOWN is > 0.45, Gesture DOWN is > 0.42 (easier)
373
+ const upMult = isGesture ? 0.40 : 0.35;
374
+ const downMult = isGesture ? 0.42 : 0.45;
375
+ const turnThreshold = eyeDist * turnMult;
376
+ const upThreshold = eyeDist * upMult;
377
+ const downThreshold = eyeDist * downMult;
378
+ // State helpers for gestures
379
+ const isLeft = diffX > turnThreshold;
380
+ const isRight = diffX < -turnThreshold;
381
+ const isUp = diffY < upThreshold; // diffY is smaller than threshold (closer to eyes)
382
+ const isDown = diffY > downThreshold; // diffY is larger than threshold (further from eyes)
383
+ // Debug ratios for tuning (throttle)
384
+ if (Math.random() < 0.01) {
385
+ console.log(`Liveness: ${type} (isGesture=${isGesture})`, {
386
+ diffY_Ratio: (diffY / eyeDist).toFixed(3),
387
+ diffX_Ratio: (diffX / eyeDist).toFixed(3),
388
+ upThresh: upMult,
389
+ downThresh: downMult,
390
+ currentPose: isLeft ? 'left' : (isRight ? 'right' : (isUp ? 'up' : (isDown ? 'down' : 'center')))
391
+ });
392
+ }
393
+ // Static Challenges
394
+ if (type === 'turn_left')
395
+ return isLeft;
396
+ if (type === 'turn_right')
397
+ return isRight;
398
+ if (type === 'look_up')
399
+ return isUp;
400
+ if (type === 'look_down')
401
+ return isDown;
402
+ // Dynamic Gestures (stateful)
403
+ const state = res.gestureState || { sequence: [], seenPoses: new Set() };
404
+ const currentPose = isLeft ? 'left' : (isRight ? 'right' : (isUp ? 'up' : (isDown ? 'down' : 'center')));
405
+ // Update Sequence (Transitions only)
406
+ if (state.sequence.length === 0 || state.sequence[state.sequence.length - 1] !== currentPose) {
407
+ state.sequence.push(currentPose);
408
+ if (state.sequence.length > 50)
409
+ state.sequence.shift(); // Larger buffer
410
+ }
411
+ // Update Latch State (Remember we saw this pose in this attempt)
412
+ if (state.seenPoses && state.seenPoses.add) {
413
+ state.seenPoses.add(currentPose);
414
+ }
415
+ if (type === 'nod_yes') {
416
+ // Traditional nod: seen both UP and DOWN
417
+ if (state.seenPoses.has('up') && state.seenPoses.has('down'))
418
+ return true;
419
+ // "Lower and Straighten" nod: User lowers head (down) and returns to neutral (center)
420
+ // We check the sequence for a [..., 'down', 'center'] transition
421
+ if (state.sequence.length >= 2) {
422
+ const lastPose = state.sequence[state.sequence.length - 1];
423
+ const prevPose = state.sequence[state.sequence.length - 2];
424
+ if (prevPose === 'down' && lastPose === 'center')
425
+ return true;
426
+ }
427
+ // Multiple downward nods: User does multiple downs
428
+ const downCount = state.sequence.filter((p) => p === 'down').length;
429
+ if (downCount >= 2)
430
+ return true;
431
+ return false;
432
+ }
433
+ if (type === 'shake_no') {
434
+ if (state.seenPoses) {
435
+ return state.seenPoses.has('left') && state.seenPoses.has('right');
436
+ }
437
+ return state.sequence.includes('left') && state.sequence.includes('right');
438
+ }
439
+ return false;
440
+ };
441
+ // Initialize Dynamic Queue
442
+ // Use config challenges if provided, else generate random sequence
443
+ const challengeQueue = (config.challenges && config.challenges.length > 0)
444
+ ? [...config.challenges]
445
+ : generateChallengeSequence();
446
+ // Update status map to use actual queue length and match types
447
+ const challengesStatus = challengeQueue.map(() => 'pending');
448
+ let penaltyAdded = false;
449
+ // Process queue
450
+ let queueIndex = 0;
451
+ // Safety break
452
+ while (queueIndex < challengeQueue.length) {
453
+ const challengeType = challengeQueue[queueIndex];
454
+ if (Date.now() - startTime > config.timeoutMs)
455
+ throw new Error('Timeout');
456
+ if (options.signal?.aborted)
457
+ throw new Error('Aborted');
458
+ let passed = false;
459
+ let retries = 0;
460
+ const maxRetries = 1;
461
+ while (!passed && retries <= maxRetries) {
462
+ if (retries > 0) {
463
+ updateHUD(queueIndex, 0); // Still show the type but tied to index
464
+ await new Promise(r => setTimeout(r, 1000));
465
+ }
466
+ if (options.onChallenge)
467
+ options.onChallenge(challengeType);
468
+ updateHUD(queueIndex, 0);
469
+ let attempts = 0;
470
+ let consecutivePassed = 0;
471
+ let startBeepPlayed = false;
472
+ let gestureStartBeepPlayed = false;
473
+ const requiredConsecutive = (challengeType === 'nod_yes' || challengeType === 'shake_no') ? 10 : 15;
474
+ // Reset gesture state for this attempt
475
+ const gestureState = { sequence: [], seenPoses: new Set() };
476
+ while (!passed && attempts < 150) {
477
+ const frame = camera.captureFrame();
478
+ const res = await sendToWorker({ type: 'PROCESS_FRAME', payload: frame });
479
+ // Inject state for stateful checks
480
+ res.gestureState = gestureState;
481
+ // Capture texture analysis if available
482
+ if (res.textureScore !== undefined) {
483
+ textureScoreAccumulator += res.textureScore;
484
+ textureAnalysisCount++;
485
+ if (res.textureFeatures) {
486
+ lastTextureFeatures = res.textureFeatures;
487
+ }
488
+ }
489
+ if (res.ageEstimate !== undefined && res.ageEstimate > 0) {
490
+ // Weighted average based on age confidence if available, else face confidence
491
+ const weight = res.ageConfidence || res.confidence || 1.0;
492
+ ageEstimateAccumulator += res.ageEstimate * weight;
493
+ ageEstimateCount += weight;
494
+ if (res.ageConfidence !== undefined) {
495
+ ageConfidenceAccumulator += res.ageConfidence;
496
+ ageConfidenceCount++;
497
+ }
498
+ else if (res.confidence !== undefined) {
499
+ // Fallback to face detection confidence for geometric/demo path
500
+ ageConfidenceAccumulator += res.confidence;
501
+ ageConfidenceCount++;
502
+ }
503
+ if (res.embedding)
504
+ embedding = res.embedding;
505
+ }
506
+ if (res.ageMethod)
507
+ ageMethod = res.ageMethod;
508
+ // GESTURE PROGRESS FEEDBACK AND AUDIO
509
+ if ((challengeType === 'nod_yes' || challengeType === 'shake_no') && gestureState.seenPoses) {
510
+ // Audio Feedback removed for start of gesture to simplify instruction
511
+ // (User is now told to nod/shake UNTIL they hear a beep)
512
+ if (gestureState.seenPoses.size > 0 && !gestureStartBeepPlayed) {
513
+ // playBeep(440, 100);
514
+ gestureStartBeepPlayed = true;
515
+ }
516
+ // Visual Feedback
517
+ if (challengeType === 'nod_yes') {
518
+ if (gestureState.seenPoses.has('up') && !gestureState.seenPoses.has('down')) {
519
+ updateHUD(queueIndex, 50);
520
+ }
521
+ else if (gestureState.seenPoses.has('down') && !gestureState.seenPoses.has('up')) {
522
+ updateHUD(queueIndex, 50);
523
+ }
524
+ }
525
+ if (challengeType === 'shake_no') {
526
+ if (gestureState.seenPoses.has('left') && !gestureState.seenPoses.has('right')) {
527
+ updateHUD(queueIndex, 50);
528
+ }
529
+ else if (gestureState.seenPoses.has('right') && !gestureState.seenPoses.has('left')) {
530
+ updateHUD(queueIndex, 50);
531
+ }
532
+ }
533
+ }
534
+ if (checkChallenge(challengeType, res)) {
535
+ consecutivePassed++;
536
+ if (consecutivePassed === 1 && !startBeepPlayed) {
537
+ playBeep(440, 100);
538
+ startBeepPlayed = true;
539
+ }
540
+ const progress = Math.min(100, (consecutivePassed / requiredConsecutive) * 100);
541
+ updateHUD(queueIndex, progress);
542
+ if (consecutivePassed >= requiredConsecutive) {
543
+ passed = true;
544
+ playBeep(880, 100);
545
+ livenessAccumulator += 1.0;
546
+ // Capture evidence
547
+ if (res.embedding)
548
+ embedding = res.embedding;
549
+ }
550
+ }
551
+ else {
552
+ if (challengeType !== 'nod_yes' && challengeType !== 'shake_no') {
553
+ consecutivePassed = 0;
554
+ startBeepPlayed = false;
555
+ updateHUD(queueIndex, 0);
556
+ }
557
+ // For gestures, we don't reset consecutivePassed on failure frames, we just wait for sequence completion
558
+ await new Promise(r => setTimeout(r, 100)); // lighter loop
559
+ }
560
+ attempts++;
561
+ }
562
+ if (!passed)
563
+ retries++;
564
+ }
565
+ if (!passed) {
566
+ challengesStatus[queueIndex] = 'failed';
567
+ challengeResults.push({ type: challengeType, passed: false, score: 0 });
568
+ // PENALTY LOGIC
569
+ if (!penaltyAdded) {
570
+ const penaltyChallenge = generateChallengeSequence(1)[0];
571
+ console.log(`Adding penalty challenge: ${penaltyChallenge}`);
572
+ challengeQueue.push(penaltyChallenge);
573
+ challengesStatus.push('pending'); // Add new status slot for penalty
574
+ penaltyAdded = true;
575
+ // Provide feedback
576
+ updateHUD(queueIndex, 0);
577
+ await new Promise(r => setTimeout(r, 1500));
578
+ }
579
+ // If it was already a penalty step or max penalties reached, we treat this as a fail,
580
+ // BUT we continue to the next one? Or stop?
581
+ // Usually liveness fails if too many fail.
582
+ // Let's count total failures.
583
+ // If we fail > 1 challenge (original + penalty), liveness score will tank.
584
+ }
585
+ else {
586
+ challengesStatus[queueIndex] = 'passed';
587
+ challengeResults.push({ type: challengeType, passed: true, score: 1.0 });
588
+ updateHUD(queueIndex, 100);
589
+ await new Promise(r => setTimeout(r, 800));
590
+ }
591
+ // 15 Frame Pause in between challenges
592
+ if (queueIndex < challengeQueue.length - 1) {
593
+ console.log('Pause between challenges: Capturing 15 buffer frames');
594
+ for (let i = 0; i < 15; i++) {
595
+ const frame = camera.captureFrame();
596
+ await sendToWorker({ type: 'PROCESS_FRAME', payload: frame });
597
+ await new Promise(r => setTimeout(r, 50)); // ~20fps pace for pause
598
+ }
599
+ }
600
+ queueIndex++;
601
+ }
602
+ // Calculate average age estimate across all frames
603
+ const ageEstimate = ageEstimateCount > 0
604
+ ? ageEstimateAccumulator / ageEstimateCount
605
+ : 0;
606
+ ageConfidence = ageConfidenceCount > 0
607
+ ? ageConfidenceAccumulator / ageConfidenceCount
608
+ : 0;
609
+ // Calculate Score based on total challenges attempted
610
+ const livenessScore = livenessAccumulator / challengeQueue.length;
611
+ // Calculate average texture score
612
+ const avgTextureScore = textureAnalysisCount > 0
613
+ ? textureScoreAccumulator / textureAnalysisCount
614
+ : undefined;
615
+ const isAgeValid = ageEstimate >= config.minAgeEstimate;
616
+ const isLivenessValid = livenessScore >= config.minLivenessScore;
617
+ const isConfidenceValid = ageConfidence >= config.minAgeConfidence;
618
+ // DEBUG LOGGING
619
+ console.log('═══════════════════════════════════════════');
620
+ console.log('VERIFICATION VALIDATION SUMMARY');
621
+ console.log('═══════════════════════════════════════════');
622
+ console.log(`Age Estimate: ${ageEstimate.toFixed(1)} (threshold: ${config.minAgeEstimate}) → ${isAgeValid ? '✓ PASS' : '✗ FAIL'}`);
623
+ console.log(`Age Confidence: ${(ageConfidence * 100).toFixed(1)}% (threshold: ${config.minAgeConfidence * 100}%) → ${isConfidenceValid ? '✓ PASS' : '✗ FAIL'}`);
624
+ console.log(`Liveness Score: ${(livenessScore * 100).toFixed(1)}% (threshold: ${config.minLivenessScore * 100}%) → ${isLivenessValid ? '✓ PASS' : '✗ FAIL'}`);
625
+ console.log(`Age Method: ${ageMethod}`);
626
+ console.log(`Frames with age data: ${ageEstimateCount.toFixed(0)}`);
627
+ console.log('═══════════════════════════════════════════');
628
+ let isOver18 = isAgeValid && isLivenessValid && isConfidenceValid;
629
+ let failureReason = '';
630
+ if (!isOver18) {
631
+ if (!isAgeValid)
632
+ failureReason = `Estimated age (${Math.round(ageEstimate)}) is below the required ${config.minAgeEstimate}.`;
633
+ else if (!isLivenessValid)
634
+ failureReason = 'Liveness detection failed.';
635
+ else if (!isConfidenceValid)
636
+ failureReason = 'Age estimation confidence too low.';
637
+ console.log(`FAILURE REASON: ${failureReason}`);
638
+ }
639
+ else {
640
+ console.log('✓ ALL CHECKS PASSED');
641
+ }
642
+ const verifiedAt = new Date().toISOString();
643
+ // PROTOCOL FEE (MONETIZATION) & ON-CHAIN PROOF
644
+ let protocolFeePaid = false;
645
+ let protocolFeeTxId = '';
646
+ let facehash = ''; // Only computed if wallet signs
647
+ // Strike 3 of session 3 = 9th fail
648
+ const isFinalStrike = (currentCooldownCount >= 2 && (currentRetries + 1) >= config.maxRetries);
649
+ const shouldWriteOnChain = isOver18 || isFinalStrike;
650
+ // Record on chain ONLY if wallet is present and user signs
651
+ if (shouldWriteOnChain && options.wallet && options.connection) {
652
+ if (options.uiMountEl) {
653
+ options.uiMountEl.innerHTML = `
654
+ <div style="position: relative; height: 100%; width: 100%; pointer-events: none; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.85); font-family: -apple-system, system-ui, sans-serif;">
655
+ <div style="max-width: 500px; padding: 48px; text-align: center; background: rgba(255,255,255,0.03); backdrop-filter: blur(16px); border-radius: 24px; border: 1px solid rgba(255,255,255,0.1); color: white;">
656
+ <div style="font-size: 48px; margin-bottom: 24px;">💳</div>
657
+ <div style="font-size: 24px; font-weight: 700; margin-bottom: 16px;">Protocol Fee Required</div>
658
+ <div style="font-size: 16px; color: #94a3b8; line-height: 1.6; margin-bottom: 32px;">
659
+ To record your verification on-chain, a minimal protocol fee of <b>${PROTOCOL_FEE_SOL} SOL</b> is required.<br>
660
+ Please approve the transaction in your wallet.
661
+ </div>
662
+ <div style="display: flex; align-items: center; justify-content: center; gap: 12px; color: #60a5fa; font-weight: 600;">
663
+ <div style="width: 16px; height: 16px; border: 2px solid #60a5fa; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite;"></div>
664
+ Waiting for signature...
665
+ </div>
666
+ </div>
667
+ </div>
668
+ <style>
669
+ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
670
+ </style>
671
+ `;
672
+ }
673
+ try {
674
+ const treasury = new PublicKey(TREASURY_ADDRESS);
675
+ const fromPubkey = options.wallet.publicKey;
676
+ // ONLY compute facehash right before signing - ensures it's tied to wallet consent
677
+ if (embedding.length > 0) {
678
+ facehash = await computeFaceHash(options.walletPubkeyBase58, salt, embedding);
679
+ }
680
+ const transaction = new Transaction();
681
+ // 1. Protocol Fee Transfer
682
+ transaction.add(SystemProgram.transfer({
683
+ fromPubkey,
684
+ toPubkey: treasury,
685
+ lamports: PROTOCOL_FEE_SOL * LAMPORTS_PER_SOL,
686
+ }));
687
+ // 2. Add Verification Memo (only includes facehash after user initiates signing)
688
+ const statusStr = isOver18 ? 'OVER18' : (isFinalStrike ? 'FINAL_FAILURE' : 'UNDER18');
689
+ const memoText = `TalkChain-Live-Verify | Solana-Age-Registry-V1-beta | ${statusStr} | HASH: ${facehash} | ${verifiedAt}`;
690
+ transaction.add(new TransactionInstruction({
691
+ keys: [{ pubkey: fromPubkey, isSigner: true, isWritable: false }],
692
+ programId: getMemoProgramId(),
693
+ data: new TextEncoder().encode(memoText),
694
+ }));
695
+ const { blockhash } = await options.connection.getLatestBlockhash();
696
+ transaction.recentBlockhash = blockhash;
697
+ transaction.feePayer = fromPubkey;
698
+ const signedTx = await options.wallet.signTransaction(transaction);
699
+ protocolFeeTxId = await options.connection.sendRawTransaction(signedTx.serialize());
700
+ await options.connection.confirmTransaction(protocolFeeTxId);
701
+ protocolFeePaid = true;
702
+ console.log('✓ On-chain proof recorded with wallet signature');
703
+ }
704
+ catch (e) {
705
+ console.error('Protocol fee payment failed or rejected:', e);
706
+ // User rejected or tx failed - clear the facehash since it wasn't recorded
707
+ facehash = '';
708
+ // For Community Version, we fail the verification if payment is rejected
709
+ isOver18 = false;
710
+ failureReason = `Protocol fee payment failed: ${e.message || 'Verification rejected or network error'}`;
711
+ }
712
+ }
713
+ else if (isOver18 && (!options.wallet || !options.connection)) {
714
+ // If no wallet is provided, verification passes but no on-chain record
715
+ console.log('Verification passed but no wallet provided - no on-chain record created');
716
+ }
717
+ // Returns actual result from models!
718
+ // Note: facehash is only populated if wallet signed and tx succeeded
719
+ const result = {
720
+ over18: isOver18,
721
+ facehash: facehash, // Empty string if not signed/recorded
722
+ description: isOver18 ? 'User is confidently over age 18' : (failureReason || 'Verification Failed'),
723
+ verifiedAt,
724
+ protocolFeePaid,
725
+ protocolFeeTxId,
726
+ evidence: {
727
+ ageEstimate,
728
+ ageConfidence,
729
+ livenessScore,
730
+ textureScore: avgTextureScore,
731
+ textureFeatures: lastTextureFeatures,
732
+ ageMethod: ageMethod,
733
+ challenges: challengeResults,
734
+ modelVersions: { face: 'blazeface-v1' },
735
+ saltHex: protocolFeePaid ? toHex(salt) : '', // Only reveal salt if signed
736
+ sessionNonceHex: protocolFeePaid ? toHex(sessionNonce) : '' // Only reveal nonce if signed
737
+ }
738
+ };
739
+ // SUCCESS UI & SOUNDS
740
+ if (isOver18) {
741
+ // Reset everything on success
742
+ localStorage.removeItem(storageKey);
743
+ localStorage.removeItem(cooldownKey);
744
+ localStorage.removeItem(cooldownCountKey);
745
+ // 1. Play Success Sequence (440 -> 880 -> 1760)
746
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
747
+ const now = ctx.currentTime;
748
+ const playTone = (freq, start, dur) => {
749
+ const osc = ctx.createOscillator();
750
+ const gain = ctx.createGain();
751
+ osc.frequency.value = freq;
752
+ gain.gain.value = 0.1;
753
+ osc.connect(gain);
754
+ gain.connect(ctx.destination);
755
+ osc.start(start);
756
+ osc.stop(start + dur);
757
+ };
758
+ playTone(440, now, 0.1);
759
+ playTone(880, now + 0.1, 0.1);
760
+ playTone(1760, now + 0.2, 0.2);
761
+ setTimeout(() => ctx.close(), 1000);
762
+ // 2. Show Results UI
763
+ if (options.uiMountEl) {
764
+ options.uiMountEl.innerHTML = `
765
+ <div style="position: relative; height: 100%; width: 100%; pointer-events: none; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.85); animation: fadeIn 0.5s ease-out; font-family: -apple-system, system-ui, sans-serif;">
766
+ <div style="position: relative; max-width: 600px; width: 90%; padding: 40px; text-align: center; color: white;">
767
+
768
+ <!-- Success Icon with Glow -->
769
+ <div style="position: relative; width: 100px; height: 100px; margin: 0 auto 32px; display: flex; align-items: center; justify-content: center;">
770
+ <div style="position: absolute; inset: 0; background: #22c55e; filter: blur(40px); opacity: 0.3;"></div>
771
+ <div style="font-size: 64px; position: relative; z-index: 10; text-shadow: 0 4px 12px rgba(0,0,0,0.3);">✅</div>
772
+ </div>
773
+
774
+ <div style="font-size: 36px; font-weight: 800; margin-bottom: 8px; letter-spacing: -0.02em; background: linear-gradient(to bottom, #ffffff, #cbd5e1); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Verification Complete</div>
775
+ <div style="font-size: 18px; color: #4ade80; margin-bottom: 40px; font-weight: 500;">Identity & Liveness Confirmed</div>
776
+
777
+ <div style="background: rgba(255,255,255,0.03); backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.1); border-radius: 20px; padding: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; text-align: left; box-shadow: 0 20px 40px -10px rgba(0,0,0,0.5);">
778
+ <div>
779
+ <div style="font-size: 11px; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; font-weight: 600;">Est. Age</div>
780
+ <div style="font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; color: #f8fafc;">${Math.round(ageEstimate)}</div>
781
+ </div>
782
+ <div>
783
+ <div style="font-size: 11px; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; font-weight: 600;">Confidence</div>
784
+ <div style="font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; color: #f8fafc;">${(ageConfidence * 100).toFixed(1)}%</div>
785
+ </div>
786
+ <div style="grid-column: span 2; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.05);">
787
+ <div style="font-size: 11px; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 8px; font-weight: 600;">Secure Face Hash</div>
788
+ <div style="font-size: 14px; font-family: 'SF Mono', Monaco, Consolas, monospace; word-break: break-all; color: #64748b; background: rgba(0,0,0,0.2); padding: 8px 12px; border-radius: 8px;">${facehash.substring(0, 24)}...</div>
789
+ </div>
790
+ ${protocolFeePaid ? `
791
+ <div style="grid-column: span 2; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between;">
792
+ <div>
793
+ <div style="font-size: 11px; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 4px; font-weight: 600;">Protocol Fee</div>
794
+ <div style="font-size: 14px; color: #4ade80; font-weight: 600;">${PROTOCOL_FEE_SOL} SOL Paid</div>
795
+ </div>
796
+ <div style="font-size: 11px; color: #60a5fa; font-weight: 600; background: rgba(59, 130, 246, 0.1); padding: 4px 10px; border-radius: 12px; border: 1px solid rgba(59, 130, 246, 0.2);">Verified On-Chain</div>
797
+ </div>` : ''}
798
+ </div>
799
+
800
+ <div style="margin-top: 40px; font-size: 15px; color: #94a3b8; display: flex; align-items: center; justify-content: center; gap: 12px; font-weight: 500;">
801
+ <span style="display: flex; width: 12px; height: 12px; position: relative;">
802
+ <span style="position: absolute; inset: 0; background: #22c55e; border-radius: 50%; opacity: 0.75; animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;"></span>
803
+ <span style="position: relative; display: inline-block; width: 12px; height: 12px; background: #22c55e; border-radius: 50%;"></span>
804
+ </span>
805
+ Verification Finalized
806
+ </div>
807
+
808
+ <!-- Powered By Branding -->
809
+ <div style="margin-top: 48px; font-size: 12px; color: #475569; letter-spacing: 0.05em; font-weight: 600; text-transform: uppercase;">
810
+ Powered by <span style="color: #60a5fa;">TalkChain</span> Verify
811
+ </div>
812
+ </div>
813
+ </div>
814
+ <style>
815
+ @keyframes fadeIn { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
816
+ @keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } }
817
+ </style>
818
+ `;
819
+ }
820
+ // 3. Wait for User to see it (and "simulate" recording time)
821
+ await new Promise(r => setTimeout(r, 4000));
822
+ }
823
+ else {
824
+ // FAILED ATTEMPT LOGIC
825
+ const updatedRetries = currentRetries + 1;
826
+ if (updatedRetries >= config.maxRetries) {
827
+ const updatedCooldownCount = currentCooldownCount + 1;
828
+ localStorage.setItem(cooldownCountKey, updatedCooldownCount.toString());
829
+ localStorage.setItem(storageKey, '0'); // Reset retries for next round (post-cooldown)
830
+ const cooldownEnd = Date.now() + (config.cooldownMinutes * 60 * 1000);
831
+ localStorage.setItem(cooldownKey, cooldownEnd.toString());
832
+ console.warn(`Max retries reached. Cooldown round ${updatedCooldownCount} set for ${config.cooldownMinutes} minutes.`);
833
+ }
834
+ else {
835
+ localStorage.setItem(storageKey, updatedRetries.toString());
836
+ }
837
+ // Detailed Log for Developer
838
+ console.log('--- VERIFICATION FAILED ---');
839
+ console.log('Reason: Liveness or Age below threshold');
840
+ console.log(`Detected Age: ${ageEstimate.toFixed(1)} (Threshold: ${config.minAgeEstimate})`);
841
+ console.log(`Age Confidence: ${(ageConfidence * 100).toFixed(1)}%`);
842
+ console.log(`Liveness Score: ${(livenessScore * 100).toFixed(1)}%`);
843
+ console.log(`Attempt: ${updatedRetries} / ${config.maxRetries} (Session: ${currentCooldownCount + 1} / 3)`);
844
+ // Failed UI
845
+ if (options.uiMountEl) {
846
+ options.uiMountEl.innerHTML = `
847
+ <div style="position: relative; height: 100%; width: 100%; pointer-events: none; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(127, 29, 29, 0.85); font-family: -apple-system, system-ui, sans-serif;">
848
+ <div style="max-width: 500px; padding: 48px; text-align: center; background: rgba(0,0,0,0.2); backdrop-filter: blur(12px); border-radius: 24px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);">
849
+ <div style="font-size: 64px; margin-bottom: 24px; text-shadow: 0 4px 12px rgba(0,0,0,0.3);">❌</div>
850
+ <div style="font-size: 28px; font-weight: 700; color: white; margin-bottom: 12px; letter-spacing: -0.01em;">Verification Failed</div>
851
+ <div style="font-size: 16px; color: #fca5a5; line-height: 1.6;">
852
+ We could not verify your age or liveness.<br>
853
+ Please try again in a well-lit environment.
854
+ <br><br>
855
+ <span style="color: #fff; font-size: 14px;">
856
+ Round ${currentCooldownCount + 1} of 3 | Attempt ${updatedRetries} of ${config.maxRetries}
857
+ </span>
858
+ </div>
859
+ <!-- Powered By Branding -->
860
+ <div style="margin-top: 32px; font-size: 11px; color: #991b1b; letter-spacing: 0.05em; font-weight: 700; text-transform: uppercase; opacity: 0.8;">
861
+ Powered by TalkChain Verify
862
+ </div>
863
+ </div>
864
+ </div>`;
865
+ }
866
+ await new Promise(r => setTimeout(r, 3000));
867
+ }
868
+ return result;
869
+ }
870
+ finally {
871
+ await camera.stop();
872
+ worker.terminate();
873
+ if (options.uiMountEl)
874
+ options.uiMountEl.innerText = '';
875
+ }
876
+ }
877
+ export function createVerificationUI() {
878
+ const div = document.createElement('div');
879
+ div.className = 'x402-verify-ui';
880
+ div.style.position = 'absolute';
881
+ div.style.top = '0';
882
+ div.style.left = '0';
883
+ div.style.width = '100%';
884
+ div.style.height = '100%';
885
+ div.style.pointerEvents = 'none';
886
+ div.style.zIndex = '100';
887
+ return div;
888
+ }
889
+ export function setExecutionBackend(backend) {
890
+ console.log(`Setting backend to ${backend}`);
891
+ // Implementation would switch adapter references here
892
+ }