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.
- package/README.md +185 -0
- package/dist/adapters/blazeface.d.ts +15 -0
- package/dist/adapters/blazeface.js +258 -0
- package/dist/adapters/mediapipe.d.ts +7 -0
- package/dist/adapters/mediapipe.js +55 -0
- package/dist/adapters/onnx.d.ts +10 -0
- package/dist/adapters/onnx.js +171 -0
- package/dist/camera.d.ts +15 -0
- package/dist/camera.js +76 -0
- package/dist/embedding/descriptor.d.ts +22 -0
- package/dist/embedding/descriptor.js +134 -0
- package/dist/hashing/facehash.d.ts +3 -0
- package/dist/hashing/facehash.js +27 -0
- package/dist/hashing/webcrypto.d.ts +1 -0
- package/dist/hashing/webcrypto.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/liveness/challenges.d.ts +3 -0
- package/dist/liveness/challenges.js +34 -0
- package/dist/liveness/scorer.d.ts +1 -0
- package/dist/liveness/scorer.js +3 -0
- package/dist/liveness/texture.d.ts +72 -0
- package/dist/liveness/texture.js +266 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +9 -0
- package/dist/verify.d.ts +4 -0
- package/dist/verify.js +892 -0
- package/dist/worker/frame.d.ts +5 -0
- package/dist/worker/frame.js +1 -0
- package/dist/worker/infer.d.ts +4 -0
- package/dist/worker/infer.js +22 -0
- package/dist/worker/worker.d.ts +0 -0
- package/dist/worker/worker.js +61 -0
- package/package.json +46 -0
- package/public/models/age_gender.onnx +1446 -0
- package/public/models/age_gender_model-weights_manifest.json +62 -0
- package/public/models/age_gender_model.shard1 +1447 -0
- package/public/models/face_landmark_68_model-weights_manifest.json +60 -0
- package/public/models/face_landmark_68_model.shard1 +1447 -0
- package/public/models/face_recognition_model-weights_manifest.json +128 -0
- package/public/models/face_recognition_model.shard1 +1447 -0
- package/public/models/face_recognition_model.shard2 +1447 -0
- package/public/models/ort-wasm-simd-threaded.asyncify.wasm +0 -0
- package/public/models/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/public/models/ort-wasm-simd-threaded.wasm +0 -0
- package/public/models/tiny_face_detector_model-weights_manifest.json +30 -0
- 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
|
+
}
|