@orangecheck/sdk 1.14.1 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attestation.js +307 -64
- package/dist/attestation.js.map +1 -1
- package/dist/attestation.mjs +307 -64
- package/dist/attestation.mjs.map +1 -1
- package/dist/identity.js +19 -17
- package/dist/identity.js.map +1 -1
- package/dist/identity.mjs +19 -17
- package/dist/identity.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +676 -618
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +675 -619
- package/dist/index.mjs.map +1 -1
- package/dist/nostr.d.mts +3 -1
- package/dist/nostr.d.ts +3 -1
- package/dist/nostr.js +294 -38
- package/dist/nostr.js.map +1 -1
- package/dist/nostr.mjs +293 -39
- package/dist/nostr.mjs.map +1 -1
- package/dist/verify.d.mts +11 -2
- package/dist/verify.d.ts +11 -2
- package/dist/verify.js +24 -4
- package/dist/verify.js.map +1 -1
- package/dist/verify.mjs +24 -5
- package/dist/verify.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -214,8 +214,426 @@ function logVerificationError(protocol, identifier, error, context) {
|
|
|
214
214
|
);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// src/utils/time.ts
|
|
218
|
+
var RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
219
|
+
function rfc3339Ms(value) {
|
|
220
|
+
if (!value || !RFC3339.test(value)) return null;
|
|
221
|
+
const ms = Date.parse(value);
|
|
222
|
+
return Number.isNaN(ms) ? null : ms;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/scoring.ts
|
|
226
|
+
function scoreV0(sats, days) {
|
|
227
|
+
if (sats <= 0 || days <= 0) return 0;
|
|
228
|
+
return Math.round(Math.log(1 + sats) * (1 + days / 30) * 100) / 100;
|
|
229
|
+
}
|
|
230
|
+
function scoreTier(sats, days) {
|
|
231
|
+
if (sats >= 1e7 && days >= 365) return "platinum";
|
|
232
|
+
if (sats >= 1e6 && days >= 180) return "gold";
|
|
233
|
+
if (sats >= 1e5 && days >= 90) return "silver";
|
|
234
|
+
if (sats >= 1e4 && days >= 30) return "bronze";
|
|
235
|
+
return "none";
|
|
236
|
+
}
|
|
237
|
+
function computeScore(sats, days, config) {
|
|
238
|
+
switch (config.algorithm) {
|
|
239
|
+
case "v0":
|
|
240
|
+
return scoreV0(sats, days);
|
|
241
|
+
case "tier":
|
|
242
|
+
return scoreTier(sats, days);
|
|
243
|
+
case "none":
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function computeAllScores(sats, days) {
|
|
248
|
+
return { v0: scoreV0(sats, days), tier: scoreTier(sats, days) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/verify.ts
|
|
252
|
+
var log = createLogger("ocp/verify");
|
|
253
|
+
function looksLikeHex(s) {
|
|
254
|
+
return /^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0;
|
|
255
|
+
}
|
|
256
|
+
function toBase64Maybe(sig) {
|
|
257
|
+
if (looksLikeHex(sig)) {
|
|
258
|
+
return buffer.Buffer.from(sig, "hex").toString("base64");
|
|
259
|
+
}
|
|
260
|
+
return sig;
|
|
261
|
+
}
|
|
262
|
+
function detectSignatureScheme(addr, sig) {
|
|
263
|
+
const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
|
|
264
|
+
if (isSegWit) {
|
|
265
|
+
return "bip322";
|
|
266
|
+
}
|
|
267
|
+
const sigBase64 = toBase64Maybe(sig);
|
|
268
|
+
const looksLikeLegacy = sigBase64.length === 88 && /^[IHG]/.test(sigBase64);
|
|
269
|
+
if (looksLikeLegacy) {
|
|
270
|
+
return "legacy";
|
|
271
|
+
}
|
|
272
|
+
return "bip322";
|
|
273
|
+
}
|
|
274
|
+
async function verifySignature({
|
|
275
|
+
addr,
|
|
276
|
+
msg,
|
|
277
|
+
sig,
|
|
278
|
+
scheme
|
|
279
|
+
}) {
|
|
280
|
+
const detectedScheme = scheme || detectSignatureScheme(addr, sig);
|
|
281
|
+
log.debug(
|
|
282
|
+
{ addr, msgLength: msg.length, sigLength: sig.length, scheme: detectedScheme },
|
|
283
|
+
"Verifying signature"
|
|
284
|
+
);
|
|
285
|
+
if (detectedScheme === "bip322") {
|
|
286
|
+
const sigBase64 = toBase64Maybe(sig);
|
|
287
|
+
log.debug(
|
|
288
|
+
{ sigBase64: sigBase64.substring(0, 50) + "..." },
|
|
289
|
+
"Signature converted to base64"
|
|
290
|
+
);
|
|
291
|
+
log.debug(
|
|
292
|
+
{
|
|
293
|
+
msgFull: msg,
|
|
294
|
+
msgLines: msg.split("\n").length,
|
|
295
|
+
msgFirstLine: msg.split("\n")[0],
|
|
296
|
+
msgLastLine: msg.split("\n")[msg.split("\n").length - 1]
|
|
297
|
+
},
|
|
298
|
+
"Message being verified"
|
|
299
|
+
);
|
|
300
|
+
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
301
|
+
log.info({ addr, ok }, "BIP-322 verification result");
|
|
302
|
+
if (!ok) {
|
|
303
|
+
const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
|
|
304
|
+
if (isSegWit && sigBase64.length === 88) {
|
|
305
|
+
log.warn(
|
|
306
|
+
{ addr, wallet: "possibly Electrum", sigLength: sigBase64.length },
|
|
307
|
+
"BIP-322 verification failed - likely Electrum wallet (does not support BIP-322 for SegWit)"
|
|
308
|
+
);
|
|
309
|
+
} else {
|
|
310
|
+
log.error(
|
|
311
|
+
{
|
|
312
|
+
addr,
|
|
313
|
+
sigLength: sigBase64.length,
|
|
314
|
+
msgLength: msg.length,
|
|
315
|
+
msgPreview: msg.substring(0, 200)
|
|
316
|
+
},
|
|
317
|
+
"BIP-322 verification failed - signature does not match message"
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return ok ? "sig_ok_bip322" : "sig_invalid";
|
|
322
|
+
}
|
|
323
|
+
if (detectedScheme === "legacy") {
|
|
324
|
+
const sigBase64 = toBase64Maybe(sig);
|
|
325
|
+
if (!addr.startsWith("1") && !addr.startsWith("m") && !addr.startsWith("n")) {
|
|
326
|
+
log.warn({ addr }, "Legacy signature attempted on non-P2PKH address");
|
|
327
|
+
return "sig_unsupported_script";
|
|
328
|
+
}
|
|
329
|
+
log.debug(
|
|
330
|
+
{
|
|
331
|
+
addr,
|
|
332
|
+
sigBase64: sigBase64.substring(0, 50) + "...",
|
|
333
|
+
msgPreview: msg.substring(0, 100) + "...",
|
|
334
|
+
msgLength: msg.length
|
|
335
|
+
},
|
|
336
|
+
"Verifying legacy signature"
|
|
337
|
+
);
|
|
338
|
+
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
339
|
+
log.info({ addr, ok, sigBase64Length: sigBase64.length }, "Legacy verification result");
|
|
340
|
+
if (!ok) {
|
|
341
|
+
log.error(
|
|
342
|
+
{
|
|
343
|
+
addr,
|
|
344
|
+
msgFirstLine: msg.split("\n")[0],
|
|
345
|
+
sigFormat: sigBase64.substring(0, 10)
|
|
346
|
+
},
|
|
347
|
+
"Legacy signature verification failed - check message format and signature"
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return ok ? "sig_ok_legacy" : "sig_invalid";
|
|
351
|
+
}
|
|
352
|
+
return "invalid_scheme";
|
|
353
|
+
}
|
|
354
|
+
async function signatureIsValid(input) {
|
|
355
|
+
try {
|
|
356
|
+
const code = await verifySignature(input);
|
|
357
|
+
return code === "sig_ok_bip322" || code === "sig_ok_legacy";
|
|
358
|
+
} catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function parseCanonicalMessage(msg) {
|
|
363
|
+
const lines = msg.split("\n").map((l) => l.trim());
|
|
364
|
+
const getVal = (prefix) => {
|
|
365
|
+
const needle = prefix + ":";
|
|
366
|
+
const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
|
|
367
|
+
if (!hit) return void 0;
|
|
368
|
+
const idx = hit.indexOf(":");
|
|
369
|
+
return hit.slice(idx + 1).trim();
|
|
370
|
+
};
|
|
371
|
+
const address = getVal("address") || "";
|
|
372
|
+
const identitiesStr = getVal("identities") || "";
|
|
373
|
+
const issued_at = getVal("issued_at") || "";
|
|
374
|
+
const expires = getVal("expires");
|
|
375
|
+
let identities = [];
|
|
376
|
+
try {
|
|
377
|
+
identities = parseIdentities(identitiesStr);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
log.warn({ identitiesStr, error: err }, "Failed to parse identities field");
|
|
380
|
+
}
|
|
381
|
+
const coreKeys = /* @__PURE__ */ new Set([
|
|
382
|
+
"orangecheck",
|
|
383
|
+
"identities",
|
|
384
|
+
"address",
|
|
385
|
+
"purpose",
|
|
386
|
+
"nonce",
|
|
387
|
+
"issued_at",
|
|
388
|
+
"ack"
|
|
389
|
+
]);
|
|
390
|
+
const extensions = {};
|
|
391
|
+
for (const line of lines) {
|
|
392
|
+
const idx = line.indexOf(":");
|
|
393
|
+
if (idx === -1) continue;
|
|
394
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
395
|
+
const val = line.slice(idx + 1).trim();
|
|
396
|
+
if (!coreKeys.has(key) && val) extensions[key] = val;
|
|
397
|
+
}
|
|
398
|
+
const network = extensions["network"] || "mainnet";
|
|
399
|
+
const bondStr = extensions["bond"];
|
|
400
|
+
const bond = bondStr ? parseInt(bondStr, 10) : void 0;
|
|
401
|
+
return {
|
|
402
|
+
core: { address, identities, issued_at },
|
|
403
|
+
expires,
|
|
404
|
+
network,
|
|
405
|
+
extensions,
|
|
406
|
+
bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
async function getAddressUtxos(address, network, opts) {
|
|
410
|
+
const endpoints = network === "signet" ? [
|
|
411
|
+
opts?.esploraSignetBase || "https://mempool.space/signet/api",
|
|
412
|
+
"https://blockstream.info/signet/api"
|
|
413
|
+
] : network === "testnet" ? [
|
|
414
|
+
opts?.esploraTestnetBase || "https://mempool.space/testnet/api",
|
|
415
|
+
"https://blockstream.info/testnet/api"
|
|
416
|
+
] : [
|
|
417
|
+
opts?.esploraMainnetBase || "https://mempool.space/api",
|
|
418
|
+
"https://blockstream.info/api"
|
|
419
|
+
];
|
|
420
|
+
let lastError = null;
|
|
421
|
+
for (const base of endpoints) {
|
|
422
|
+
try {
|
|
423
|
+
const url = `${base}/address/${address}/utxo`;
|
|
424
|
+
log.debug({ url, network }, "Fetching UTXOs");
|
|
425
|
+
const resp = await fetch(url, {
|
|
426
|
+
method: "GET",
|
|
427
|
+
headers: { Accept: "application/json" },
|
|
428
|
+
signal: AbortSignal.timeout(5e3)
|
|
429
|
+
});
|
|
430
|
+
if (!resp.ok) {
|
|
431
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
432
|
+
}
|
|
433
|
+
const data = await resp.json();
|
|
434
|
+
if (!Array.isArray(data)) {
|
|
435
|
+
throw new Error("Invalid response: expected array");
|
|
436
|
+
}
|
|
437
|
+
for (const utxo of data) {
|
|
438
|
+
if (typeof utxo.value !== "number" || typeof utxo.status !== "object" || typeof utxo.status.confirmed !== "boolean" || typeof utxo.txid !== "string" || typeof utxo.vout !== "number") {
|
|
439
|
+
throw new Error("Invalid UTXO structure");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
log.info({ base, count: data.length, network }, "UTXOs fetched successfully");
|
|
443
|
+
return data;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
446
|
+
log.warn(
|
|
447
|
+
{ base, error: lastError.message, network },
|
|
448
|
+
"Esplora endpoint failed, trying next"
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
log.error({ endpoints, error: lastError?.message, network }, "All Esplora endpoints failed");
|
|
453
|
+
throw new Error(`esplora_all_failed: ${lastError?.message || "unknown"}`);
|
|
454
|
+
}
|
|
455
|
+
function computeMetrics(utxos, bondAmount, coverSats) {
|
|
456
|
+
const confirmedUtxos = utxos.filter((u) => u.status.confirmed);
|
|
457
|
+
if (confirmedUtxos.length === 0) {
|
|
458
|
+
return { sats_bonded: 0, days_unspent: 0, confirmed_balance: 0 };
|
|
459
|
+
}
|
|
460
|
+
const confirmed_balance = confirmedUtxos.reduce((sum, u) => sum + u.value, 0);
|
|
461
|
+
if (bondAmount !== void 0 && bondAmount > 0) {
|
|
462
|
+
const sorted = [...confirmedUtxos].sort((a, b) => {
|
|
463
|
+
const heightA = a.status.block_height ?? Infinity;
|
|
464
|
+
const heightB = b.status.block_height ?? Infinity;
|
|
465
|
+
if (heightA !== heightB) return heightA - heightB;
|
|
466
|
+
if (a.txid !== b.txid) return a.txid.localeCompare(b.txid);
|
|
467
|
+
return a.vout - b.vout;
|
|
468
|
+
});
|
|
469
|
+
let sum = 0;
|
|
470
|
+
let youngestTime = 0;
|
|
471
|
+
for (const utxo of sorted) {
|
|
472
|
+
sum += utxo.value;
|
|
473
|
+
youngestTime = Math.max(youngestTime, utxo.status.block_time || 0);
|
|
474
|
+
if (sum >= bondAmount) break;
|
|
475
|
+
}
|
|
476
|
+
const days_unspent2 = Math.max(
|
|
477
|
+
0,
|
|
478
|
+
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
479
|
+
);
|
|
480
|
+
return {
|
|
481
|
+
sats_bonded: bondAmount,
|
|
482
|
+
// Use exactly bond amount (ignore surplus)
|
|
483
|
+
days_unspent: days_unspent2,
|
|
484
|
+
confirmed_balance
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const sats_bonded = confirmed_balance;
|
|
488
|
+
if (coverSats !== void 0 && coverSats > 0) {
|
|
489
|
+
const sorted = [...confirmedUtxos].sort(
|
|
490
|
+
(a, b) => (a.status.block_height ?? Infinity) - (b.status.block_height ?? Infinity)
|
|
491
|
+
);
|
|
492
|
+
let sum = 0;
|
|
493
|
+
let youngestTime = 0;
|
|
494
|
+
for (const utxo of sorted) {
|
|
495
|
+
sum += utxo.value;
|
|
496
|
+
youngestTime = Math.max(youngestTime, utxo.status.block_time || Date.now() / 1e3);
|
|
497
|
+
if (sum >= coverSats) break;
|
|
498
|
+
}
|
|
499
|
+
const days_unspent2 = Math.max(
|
|
500
|
+
0,
|
|
501
|
+
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
502
|
+
);
|
|
503
|
+
return { sats_bonded, days_unspent: days_unspent2, confirmed_balance };
|
|
504
|
+
}
|
|
505
|
+
const oldestTime = Math.min(
|
|
506
|
+
...confirmedUtxos.map((u) => u.status.block_time || Date.now() / 1e3)
|
|
507
|
+
);
|
|
508
|
+
const days_unspent = Math.max(0, Math.floor((Date.now() / 1e3 - oldestTime) / (24 * 60 * 60)));
|
|
509
|
+
return { sats_bonded, days_unspent, confirmed_balance };
|
|
510
|
+
}
|
|
511
|
+
async function verify(input, opts = {}) {
|
|
512
|
+
const codes = [];
|
|
513
|
+
let network = "mainnet";
|
|
514
|
+
try {
|
|
515
|
+
if (!input.msg || !input.addr || !input.sig) {
|
|
516
|
+
codes.push("bad_request");
|
|
517
|
+
return { ok: false, codes, network };
|
|
518
|
+
}
|
|
519
|
+
const parsed = parseCanonicalMessage(input.msg);
|
|
520
|
+
network = parsed.network || "mainnet";
|
|
521
|
+
const attestation_id = await generateAttestationId(input.msg);
|
|
522
|
+
if (parsed.core.address !== input.addr) {
|
|
523
|
+
codes.push("bad_request");
|
|
524
|
+
return { ok: false, codes, network, attestation_id };
|
|
525
|
+
}
|
|
526
|
+
if ((network === "testnet" || network === "signet") && !opts.testMode) {
|
|
527
|
+
codes.push("network_testmode");
|
|
528
|
+
return { ok: false, codes, network, attestation_id };
|
|
529
|
+
}
|
|
530
|
+
if (opts.expectedAud) {
|
|
531
|
+
const aud = parsed.extensions["aud"];
|
|
532
|
+
if (!aud || aud !== opts.expectedAud) {
|
|
533
|
+
codes.push("aud_mismatch");
|
|
534
|
+
return { ok: false, codes, network, attestation_id };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (parsed.expires !== void 0) {
|
|
538
|
+
const exp = rfc3339Ms(parsed.expires);
|
|
539
|
+
if (exp === null || exp < Date.now()) {
|
|
540
|
+
codes.push("expired");
|
|
541
|
+
return { ok: false, codes, network, attestation_id };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const sigCode = await verifySignature({
|
|
545
|
+
addr: input.addr,
|
|
546
|
+
msg: input.msg,
|
|
547
|
+
sig: input.sig,
|
|
548
|
+
...input.scheme && { scheme: input.scheme }
|
|
549
|
+
});
|
|
550
|
+
codes.push(sigCode);
|
|
551
|
+
const sigOk = sigCode === "sig_ok_bip322" || sigCode === "sig_ok_legacy";
|
|
552
|
+
if (!sigOk) {
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
codes,
|
|
556
|
+
network,
|
|
557
|
+
attestation_id,
|
|
558
|
+
identities: parsed.core.identities
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
const utxos = await getAddressUtxos(input.addr, network, {
|
|
563
|
+
esploraMainnetBase: opts.esploraMainnetBase,
|
|
564
|
+
esploraSignetBase: opts.esploraSignetBase
|
|
565
|
+
});
|
|
566
|
+
const { sats_bonded, days_unspent, confirmed_balance } = computeMetrics(
|
|
567
|
+
utxos,
|
|
568
|
+
parsed.bond,
|
|
569
|
+
opts.coverSats
|
|
570
|
+
);
|
|
571
|
+
if (parsed.bond !== void 0 && parsed.bond > 0) {
|
|
572
|
+
if (confirmed_balance < parsed.bond) {
|
|
573
|
+
codes.push("bond_insufficient");
|
|
574
|
+
log.warn(
|
|
575
|
+
{
|
|
576
|
+
bond: parsed.bond,
|
|
577
|
+
confirmed_balance,
|
|
578
|
+
address: input.addr
|
|
579
|
+
},
|
|
580
|
+
"Bond extension: insufficient balance"
|
|
581
|
+
);
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
codes,
|
|
585
|
+
network,
|
|
586
|
+
attestation_id,
|
|
587
|
+
identities: parsed.core.identities
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (sats_bonded <= 0) {
|
|
592
|
+
codes.push("bond_zero");
|
|
593
|
+
if (utxos.some((u) => !u.status.confirmed)) {
|
|
594
|
+
codes.push("bond_pending");
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
codes,
|
|
599
|
+
network,
|
|
600
|
+
attestation_id,
|
|
601
|
+
identities: parsed.core.identities
|
|
602
|
+
};
|
|
603
|
+
} else {
|
|
604
|
+
codes.push("bond_confirmed");
|
|
605
|
+
}
|
|
606
|
+
const metrics = {
|
|
607
|
+
sats_bonded,
|
|
608
|
+
days_unspent,
|
|
609
|
+
score: computeScore(sats_bonded, days_unspent, { algorithm: "v0" })
|
|
610
|
+
};
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
codes,
|
|
614
|
+
network,
|
|
615
|
+
attestation_id,
|
|
616
|
+
identities: parsed.core.identities,
|
|
617
|
+
metrics
|
|
618
|
+
};
|
|
619
|
+
} catch (utxoError) {
|
|
620
|
+
log.error({ err: utxoError }, "UTXO fetch failed");
|
|
621
|
+
codes.push("bad_request");
|
|
622
|
+
return { ok: false, codes, network, attestation_id };
|
|
623
|
+
}
|
|
624
|
+
} catch (e) {
|
|
625
|
+
const msg = e instanceof Error ? e.message : String(e ?? "decode_error");
|
|
626
|
+
if (msg === "decode_error") {
|
|
627
|
+
codes.push("decode_error");
|
|
628
|
+
} else {
|
|
629
|
+
codes.push("bad_request");
|
|
630
|
+
}
|
|
631
|
+
return { ok: false, codes, network };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
217
635
|
// src/nostr.ts
|
|
218
|
-
var
|
|
636
|
+
var log2 = createLogger("ocp/nostr");
|
|
219
637
|
var ATTESTATION_EVENT_KIND = 30078;
|
|
220
638
|
var DEFAULT_RELAYS = [
|
|
221
639
|
"wss://relay.damus.io",
|
|
@@ -268,7 +686,7 @@ function createAttestationEvent(envelope, pubkey) {
|
|
|
268
686
|
async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
269
687
|
const success = [];
|
|
270
688
|
const failed = [];
|
|
271
|
-
|
|
689
|
+
log2.info({ eventId: event.id, relayCount: relays.length }, "Publishing event to relays");
|
|
272
690
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
273
691
|
const publishPromises = relays.map(async (relayUrl) => {
|
|
274
692
|
let ws = null;
|
|
@@ -307,7 +725,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
307
725
|
cleanup();
|
|
308
726
|
resolve();
|
|
309
727
|
} else {
|
|
310
|
-
|
|
728
|
+
log2.warn(
|
|
311
729
|
{ relay: relayUrl, reason: data[3] },
|
|
312
730
|
"Relay rejected event"
|
|
313
731
|
);
|
|
@@ -317,7 +735,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
317
735
|
}
|
|
318
736
|
}
|
|
319
737
|
} catch (err) {
|
|
320
|
-
|
|
738
|
+
log2.error(
|
|
321
739
|
{ relay: relayUrl, error: err },
|
|
322
740
|
"Failed to parse relay response"
|
|
323
741
|
);
|
|
@@ -327,7 +745,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
327
745
|
}
|
|
328
746
|
};
|
|
329
747
|
wsRef.onerror = (err) => {
|
|
330
|
-
|
|
748
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error");
|
|
331
749
|
recordFailure();
|
|
332
750
|
cleanup();
|
|
333
751
|
reject(err);
|
|
@@ -337,7 +755,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
337
755
|
};
|
|
338
756
|
});
|
|
339
757
|
} catch (err) {
|
|
340
|
-
|
|
758
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to publish to relay");
|
|
341
759
|
recordFailure();
|
|
342
760
|
} finally {
|
|
343
761
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
@@ -346,12 +764,12 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
|
|
|
346
764
|
}
|
|
347
765
|
});
|
|
348
766
|
await Promise.allSettled(publishPromises);
|
|
349
|
-
|
|
767
|
+
log2.info({ successCount: success.length, failedCount: failed.length }, "Publishing complete");
|
|
350
768
|
return { success, failed };
|
|
351
769
|
}
|
|
352
770
|
async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
353
771
|
const events = [];
|
|
354
|
-
|
|
772
|
+
log2.info({ attestationId, relayCount: relays.length }, "Querying relays for attestation");
|
|
355
773
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
356
774
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
357
775
|
let ws = null;
|
|
@@ -391,11 +809,11 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
|
391
809
|
resolve();
|
|
392
810
|
}
|
|
393
811
|
} catch (err) {
|
|
394
|
-
|
|
812
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
395
813
|
}
|
|
396
814
|
};
|
|
397
815
|
wsRef.onerror = (err) => {
|
|
398
|
-
|
|
816
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
399
817
|
cleanup();
|
|
400
818
|
reject(err);
|
|
401
819
|
};
|
|
@@ -404,7 +822,7 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
|
404
822
|
};
|
|
405
823
|
});
|
|
406
824
|
} catch (err) {
|
|
407
|
-
|
|
825
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
408
826
|
} finally {
|
|
409
827
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
410
828
|
ws.close();
|
|
@@ -412,12 +830,12 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
|
|
|
412
830
|
}
|
|
413
831
|
});
|
|
414
832
|
await Promise.allSettled(queryPromises);
|
|
415
|
-
|
|
833
|
+
log2.info({ eventCount: events.length }, "Query complete");
|
|
416
834
|
return events;
|
|
417
835
|
}
|
|
418
836
|
async function queryByAddress(address, relays = DEFAULT_RELAYS) {
|
|
419
837
|
const events = [];
|
|
420
|
-
|
|
838
|
+
log2.info({ address, relayCount: relays.length }, "Querying relays for address");
|
|
421
839
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
422
840
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
423
841
|
try {
|
|
@@ -459,28 +877,28 @@ async function queryByAddress(address, relays = DEFAULT_RELAYS) {
|
|
|
459
877
|
resolve();
|
|
460
878
|
}
|
|
461
879
|
} catch (err) {
|
|
462
|
-
|
|
880
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
463
881
|
}
|
|
464
882
|
};
|
|
465
883
|
ws.onerror = (err) => {
|
|
466
884
|
clearTimeout(timeout);
|
|
467
|
-
|
|
885
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
468
886
|
reject(err);
|
|
469
887
|
};
|
|
470
888
|
});
|
|
471
889
|
} catch (err) {
|
|
472
|
-
|
|
890
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
473
891
|
}
|
|
474
892
|
});
|
|
475
893
|
await Promise.allSettled(queryPromises);
|
|
476
|
-
|
|
894
|
+
log2.info({ eventCount: events.length }, "Query complete");
|
|
477
895
|
return events;
|
|
478
896
|
}
|
|
479
897
|
async function queryByIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
480
898
|
const events = [];
|
|
481
899
|
const identityTag = `${protocol}:${identifier}`;
|
|
482
900
|
const identityTags = protocol === "nostr" ? nostrIdentifierForms(identifier).map((f) => `nostr:${f}`) : [identityTag];
|
|
483
|
-
|
|
901
|
+
log2.info({ protocol, identifier, relayCount: relays.length }, "Querying relays for identity");
|
|
484
902
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
485
903
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
486
904
|
try {
|
|
@@ -515,26 +933,26 @@ async function queryByIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
|
515
933
|
resolve();
|
|
516
934
|
}
|
|
517
935
|
} catch (err) {
|
|
518
|
-
|
|
936
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
519
937
|
}
|
|
520
938
|
};
|
|
521
939
|
ws.onerror = (err) => {
|
|
522
940
|
clearTimeout(timeout);
|
|
523
|
-
|
|
941
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
524
942
|
reject(err);
|
|
525
943
|
};
|
|
526
944
|
});
|
|
527
945
|
} catch (err) {
|
|
528
|
-
|
|
946
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
529
947
|
}
|
|
530
948
|
});
|
|
531
949
|
await Promise.allSettled(queryPromises);
|
|
532
|
-
|
|
950
|
+
log2.info({ eventCount: events.length }, "Query complete");
|
|
533
951
|
return events;
|
|
534
952
|
}
|
|
535
953
|
async function queryRecent(relays = DEFAULT_RELAYS, limit = 200) {
|
|
536
954
|
const events = [];
|
|
537
|
-
|
|
955
|
+
log2.info({ relayCount: relays.length, limit }, "Querying relays for recent attestations");
|
|
538
956
|
const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
|
|
539
957
|
const queryPromises = relays.map(async (relayUrl) => {
|
|
540
958
|
try {
|
|
@@ -570,30 +988,22 @@ async function queryRecent(relays = DEFAULT_RELAYS, limit = 200) {
|
|
|
570
988
|
resolve();
|
|
571
989
|
}
|
|
572
990
|
} catch (err) {
|
|
573
|
-
|
|
991
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
|
|
574
992
|
}
|
|
575
993
|
};
|
|
576
994
|
ws.onerror = (err) => {
|
|
577
995
|
clearTimeout(timeout);
|
|
578
|
-
|
|
996
|
+
log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
|
|
579
997
|
reject(err);
|
|
580
998
|
};
|
|
581
999
|
});
|
|
582
1000
|
} catch (err) {
|
|
583
|
-
|
|
1001
|
+
log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
|
|
584
1002
|
}
|
|
585
1003
|
});
|
|
586
1004
|
await Promise.allSettled(queryPromises);
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
for (const ev of events) {
|
|
590
|
-
const envelope = parseAttestationFromEvent(ev);
|
|
591
|
-
if (!envelope || seen.has(envelope.attestation_id)) continue;
|
|
592
|
-
if (!await verifyAttestationEnvelope(envelope)) continue;
|
|
593
|
-
seen.add(envelope.attestation_id);
|
|
594
|
-
envelopes.push(envelope);
|
|
595
|
-
}
|
|
596
|
-
log.info({ eventCount: events.length, unique: envelopes.length }, "Recent query complete");
|
|
1005
|
+
const envelopes = await collectSignedEnvelopes(events);
|
|
1006
|
+
log2.info({ eventCount: events.length, unique: envelopes.length }, "Recent query complete");
|
|
597
1007
|
return envelopes;
|
|
598
1008
|
}
|
|
599
1009
|
async function verifyAttestationEnvelope(envelope) {
|
|
@@ -605,615 +1015,255 @@ async function verifyAttestationEnvelope(envelope) {
|
|
|
605
1015
|
const computedId = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
606
1016
|
return computedId === envelope.attestation_id;
|
|
607
1017
|
} catch (err) {
|
|
608
|
-
|
|
609
|
-
return false;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
function parseAttestationFromEvent(event) {
|
|
613
|
-
try {
|
|
614
|
-
const envelope = JSON.parse(event.content);
|
|
615
|
-
return envelope;
|
|
616
|
-
} catch (err) {
|
|
617
|
-
log.error({ eventId: event.id, error: err }, "Failed to parse attestation from event");
|
|
618
|
-
return null;
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// src/nostr-crypto.ts
|
|
623
|
-
var log2 = createLogger("ocp/nostr-crypto");
|
|
624
|
-
function isNip07Available() {
|
|
625
|
-
if (typeof window === "undefined") {
|
|
1018
|
+
log2.error({ error: err }, "Failed to verify attestation envelope");
|
|
626
1019
|
return false;
|
|
627
|
-
}
|
|
628
|
-
return !!window.nostr;
|
|
629
|
-
}
|
|
630
|
-
async function getNostrPublicKey() {
|
|
631
|
-
if (!isNip07Available()) {
|
|
632
|
-
log2.warn("NIP-07 extension not available");
|
|
633
|
-
return null;
|
|
634
|
-
}
|
|
635
|
-
try {
|
|
636
|
-
const nostr = window.nostr;
|
|
637
|
-
const pubkey = await nostr.getPublicKey();
|
|
638
|
-
log2.info({ pubkey }, "Got Nostr public key from NIP-07");
|
|
639
|
-
return pubkey;
|
|
640
|
-
} catch (err) {
|
|
641
|
-
log2.error({ error: err }, "Failed to get Nostr public key");
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
async function signNostrEvent(event) {
|
|
646
|
-
if (!isNip07Available()) {
|
|
647
|
-
log2.warn("NIP-07 extension not available");
|
|
648
|
-
return null;
|
|
649
|
-
}
|
|
650
|
-
try {
|
|
651
|
-
const nostr = window.nostr;
|
|
652
|
-
const signedEvent = await nostr.signEvent(event);
|
|
653
|
-
log2.info({ eventId: signedEvent.id }, "Signed Nostr event with NIP-07");
|
|
654
|
-
return signedEvent;
|
|
655
|
-
} catch (err) {
|
|
656
|
-
log2.error({ error: err }, "Failed to sign Nostr event");
|
|
657
|
-
return null;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
function getNip07Info() {
|
|
661
|
-
if (!isNip07Available()) {
|
|
662
|
-
return { available: false };
|
|
663
|
-
}
|
|
664
|
-
const nostr = window.nostr;
|
|
665
|
-
return {
|
|
666
|
-
available: true,
|
|
667
|
-
name: nostr._metadata?.name,
|
|
668
|
-
version: nostr._metadata?.version
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// src/attestation.ts
|
|
673
|
-
var log3 = createLogger("ocp/attestation");
|
|
674
|
-
var AttestationError = class extends Error {
|
|
675
|
-
constructor(message, code) {
|
|
676
|
-
super(message);
|
|
677
|
-
this.code = code;
|
|
678
|
-
this.name = "AttestationError";
|
|
679
|
-
}
|
|
680
|
-
};
|
|
681
|
-
async function createAttestation(options) {
|
|
682
|
-
const { message, signature, scheme, address, identities = [], extensions = {} } = options;
|
|
683
|
-
log3.info({ address, identityCount: identities.length }, "Creating attestation");
|
|
684
|
-
const envelope = await createAttestationEnvelope(
|
|
685
|
-
message,
|
|
686
|
-
signature,
|
|
687
|
-
scheme,
|
|
688
|
-
address,
|
|
689
|
-
identities,
|
|
690
|
-
extensions
|
|
691
|
-
);
|
|
692
|
-
const isValid = await verifyAttestationEnvelope(envelope);
|
|
693
|
-
if (!isValid) {
|
|
694
|
-
throw new AttestationError(
|
|
695
|
-
"Attestation envelope integrity check failed",
|
|
696
|
-
"INTEGRITY_CHECK_FAILED"
|
|
697
|
-
);
|
|
698
|
-
}
|
|
699
|
-
log3.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
|
|
700
|
-
return envelope;
|
|
701
|
-
}
|
|
702
|
-
async function publishAttestation(options) {
|
|
703
|
-
const { envelope, npub, relays = DEFAULT_RELAYS } = options;
|
|
704
|
-
log3.info(
|
|
705
|
-
{ attestationId: envelope.attestation_id, relayCount: relays.length },
|
|
706
|
-
"Publishing attestation"
|
|
707
|
-
);
|
|
708
|
-
const unsignedEvent = createAttestationEvent(envelope, npub);
|
|
709
|
-
if (!isNip07Available()) {
|
|
710
|
-
log3.warn("NIP-07 unavailable \u2014 skipping Nostr publish");
|
|
711
|
-
throw new Error(
|
|
712
|
-
"nostr_signer_unavailable: install a NIP-07 extension (e.g. nos2x, Alby) to publish"
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
log3.info("Using NIP-07 to sign event");
|
|
716
|
-
const signedEvent = await signNostrEvent(unsignedEvent);
|
|
717
|
-
if (!signedEvent) {
|
|
718
|
-
throw new Error("nostr_signing_failed: NIP-07 extension rejected or errored");
|
|
719
|
-
}
|
|
720
|
-
const result = await publishToRelays(signedEvent, relays);
|
|
721
|
-
log3.info(
|
|
722
|
-
{
|
|
723
|
-
attestationId: envelope.attestation_id,
|
|
724
|
-
successCount: result.success.length,
|
|
725
|
-
failedCount: result.failed.length
|
|
726
|
-
},
|
|
727
|
-
"Publishing complete"
|
|
728
|
-
);
|
|
729
|
-
return result;
|
|
730
|
-
}
|
|
731
|
-
function eventIndexesEnvelope(event, envelope) {
|
|
732
|
-
const d = event.tags.find((t) => t[0] === "d")?.[1];
|
|
733
|
-
const id = envelope.attestation_id;
|
|
734
|
-
if (d !== id && d !== `orangecheck:${id}`) return false;
|
|
735
|
-
return event.tags.some((t) => t[0] === "t" && t[1] === envelope.address);
|
|
736
|
-
}
|
|
737
|
-
async function discoverAttestations(options) {
|
|
738
|
-
const { attestationId, address, identity, relays = DEFAULT_RELAYS } = options;
|
|
739
|
-
let events = [];
|
|
740
|
-
if (attestationId) {
|
|
741
|
-
log3.info({ attestationId }, "Discovering by attestation ID");
|
|
742
|
-
events = await queryByAttestationId(attestationId, relays);
|
|
743
|
-
} else if (address) {
|
|
744
|
-
log3.info({ address }, "Discovering by Bitcoin address");
|
|
745
|
-
events = await queryByAddress(address, relays);
|
|
746
|
-
} else if (identity) {
|
|
747
|
-
log3.info({ identity }, "Discovering by identity");
|
|
748
|
-
events = await queryByIdentity(identity.protocol, identity.identifier, relays);
|
|
749
|
-
} else {
|
|
750
|
-
throw new Error("Must provide attestationId, address, or identity");
|
|
751
|
-
}
|
|
752
|
-
const envelopes = [];
|
|
753
|
-
const kept = /* @__PURE__ */ new Set();
|
|
754
|
-
for (const event of events) {
|
|
755
|
-
const envelope = parseAttestationFromEvent(event);
|
|
756
|
-
if (envelope && kept.has(envelope.attestation_id)) continue;
|
|
757
|
-
if (envelope && !eventIndexesEnvelope(event, envelope)) {
|
|
758
|
-
log3.warn({ eventId: event.id }, "Skipping event whose index tags disagree with its envelope");
|
|
759
|
-
} else if (envelope) {
|
|
760
|
-
const isValid = await verifyAttestationEnvelope(envelope);
|
|
761
|
-
if (isValid) {
|
|
762
|
-
kept.add(envelope.attestation_id);
|
|
763
|
-
envelopes.push(envelope);
|
|
764
|
-
} else {
|
|
765
|
-
log3.warn(
|
|
766
|
-
{ attestationId: envelope.attestation_id },
|
|
767
|
-
"Skipping invalid attestation envelope"
|
|
768
|
-
);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
log3.info({ eventCount: events.length, validCount: envelopes.length }, "Discovery complete");
|
|
773
|
-
return envelopes;
|
|
774
|
-
}
|
|
775
|
-
async function verifyAttestationById(attestationId, relays = DEFAULT_RELAYS) {
|
|
776
|
-
log3.info({ attestationId }, "Verifying attestation by ID");
|
|
777
|
-
const envelopes = await discoverAttestations({ attestationId, relays });
|
|
778
|
-
if (envelopes.length === 0) {
|
|
779
|
-
log3.warn({ attestationId }, "Attestation not found on any relay");
|
|
780
|
-
return null;
|
|
781
|
-
}
|
|
782
|
-
if (envelopes.length > 1) {
|
|
783
|
-
log3.warn(
|
|
784
|
-
{ attestationId, count: envelopes.length },
|
|
785
|
-
"Multiple attestations found with same ID - using first"
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
return envelopes[0] || null;
|
|
789
|
-
}
|
|
790
|
-
async function getAttestationsForAddress(address, relays = DEFAULT_RELAYS) {
|
|
791
|
-
log3.info({ address }, "Getting all attestations for address");
|
|
792
|
-
return discoverAttestations({ address, relays });
|
|
793
|
-
}
|
|
794
|
-
async function getAttestationsForIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
795
|
-
log3.info({ protocol, identifier }, "Getting all attestations for identity");
|
|
796
|
-
return discoverAttestations({ identity: { protocol, identifier }, relays });
|
|
797
|
-
}
|
|
798
|
-
function formatIdentitiesForDisplay(identities) {
|
|
799
|
-
return identities.map((id) => `${id.protocol}:${id.identifier}`).join(", ");
|
|
1020
|
+
}
|
|
800
1021
|
}
|
|
801
|
-
function
|
|
802
|
-
|
|
1022
|
+
function base64Url(text) {
|
|
1023
|
+
const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(text)));
|
|
1024
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
803
1025
|
}
|
|
804
|
-
function
|
|
1026
|
+
function signedEnvelope(envelope) {
|
|
1027
|
+
let parsed;
|
|
805
1028
|
try {
|
|
806
|
-
|
|
807
|
-
const m = u.pathname.match(/^\/attest\/([0-9a-f]{64})\/?$/i);
|
|
808
|
-
if (m) return m[1].toLowerCase();
|
|
809
|
-
const q = u.searchParams.get("id");
|
|
810
|
-
if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase();
|
|
811
|
-
return null;
|
|
1029
|
+
parsed = parseCanonicalMessage(envelope.message);
|
|
812
1030
|
} catch {
|
|
813
1031
|
return null;
|
|
814
1032
|
}
|
|
1033
|
+
if (!parsed.core.address || rfc3339Ms(parsed.core.issued_at) === null) return null;
|
|
1034
|
+
const { expires_at: _copy, ...rest } = envelope;
|
|
1035
|
+
return {
|
|
1036
|
+
...rest,
|
|
1037
|
+
address: parsed.core.address,
|
|
1038
|
+
identities: parsed.core.identities,
|
|
1039
|
+
issued_at: parsed.core.issued_at,
|
|
1040
|
+
...parsed.expires !== void 0 ? { expires_at: parsed.expires } : {},
|
|
1041
|
+
message_b64url: base64Url(envelope.message)
|
|
1042
|
+
};
|
|
815
1043
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1044
|
+
async function collectSignedEnvelopes(events, accept = () => true) {
|
|
1045
|
+
const copies = /* @__PURE__ */ new Map();
|
|
1046
|
+
for (const event of events) {
|
|
1047
|
+
const raw = parseAttestationFromEvent(event);
|
|
1048
|
+
if (!raw || !await verifyAttestationEnvelope(raw)) continue;
|
|
1049
|
+
const envelope = signedEnvelope(raw);
|
|
1050
|
+
if (!envelope || !accept(event, envelope)) continue;
|
|
1051
|
+
const same = copies.get(envelope.attestation_id) ?? [];
|
|
1052
|
+
if (!same.some((e) => e.signature === envelope.signature && e.scheme === envelope.scheme)) {
|
|
1053
|
+
same.push(envelope);
|
|
1054
|
+
}
|
|
1055
|
+
copies.set(envelope.attestation_id, same);
|
|
1056
|
+
}
|
|
1057
|
+
const kept = [];
|
|
1058
|
+
for (const same of copies.values()) {
|
|
1059
|
+
if (same.length === 1) {
|
|
1060
|
+
kept.push(same[0]);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
let valid;
|
|
1064
|
+
for (const e of same) {
|
|
1065
|
+
if (await signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme })) {
|
|
1066
|
+
valid = e;
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
kept.push(valid ?? same[0]);
|
|
837
1071
|
}
|
|
1072
|
+
return kept;
|
|
838
1073
|
}
|
|
839
|
-
function
|
|
840
|
-
|
|
1074
|
+
function parseAttestationFromEvent(event) {
|
|
1075
|
+
try {
|
|
1076
|
+
const envelope = JSON.parse(event.content);
|
|
1077
|
+
return envelope;
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
log2.error({ eventId: event.id, error: err }, "Failed to parse attestation from event");
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
841
1082
|
}
|
|
842
1083
|
|
|
843
|
-
// src/
|
|
844
|
-
var
|
|
845
|
-
function
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
function toBase64Maybe(sig) {
|
|
849
|
-
if (looksLikeHex(sig)) {
|
|
850
|
-
return buffer.Buffer.from(sig, "hex").toString("base64");
|
|
1084
|
+
// src/nostr-crypto.ts
|
|
1085
|
+
var log3 = createLogger("ocp/nostr-crypto");
|
|
1086
|
+
function isNip07Available() {
|
|
1087
|
+
if (typeof window === "undefined") {
|
|
1088
|
+
return false;
|
|
851
1089
|
}
|
|
852
|
-
return
|
|
1090
|
+
return !!window.nostr;
|
|
853
1091
|
}
|
|
854
|
-
function
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
return
|
|
1092
|
+
async function getNostrPublicKey() {
|
|
1093
|
+
if (!isNip07Available()) {
|
|
1094
|
+
log3.warn("NIP-07 extension not available");
|
|
1095
|
+
return null;
|
|
858
1096
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1097
|
+
try {
|
|
1098
|
+
const nostr = window.nostr;
|
|
1099
|
+
const pubkey = await nostr.getPublicKey();
|
|
1100
|
+
log3.info({ pubkey }, "Got Nostr public key from NIP-07");
|
|
1101
|
+
return pubkey;
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
log3.error({ error: err }, "Failed to get Nostr public key");
|
|
1104
|
+
return null;
|
|
863
1105
|
}
|
|
864
|
-
return "bip322";
|
|
865
1106
|
}
|
|
866
|
-
async function
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
scheme
|
|
871
|
-
}) {
|
|
872
|
-
const detectedScheme = scheme || detectSignatureScheme(addr, sig);
|
|
873
|
-
log4.debug(
|
|
874
|
-
{ addr, msgLength: msg.length, sigLength: sig.length, scheme: detectedScheme },
|
|
875
|
-
"Verifying signature"
|
|
876
|
-
);
|
|
877
|
-
if (detectedScheme === "bip322") {
|
|
878
|
-
const sigBase64 = toBase64Maybe(sig);
|
|
879
|
-
log4.debug(
|
|
880
|
-
{ sigBase64: sigBase64.substring(0, 50) + "..." },
|
|
881
|
-
"Signature converted to base64"
|
|
882
|
-
);
|
|
883
|
-
log4.debug(
|
|
884
|
-
{
|
|
885
|
-
msgFull: msg,
|
|
886
|
-
msgLines: msg.split("\n").length,
|
|
887
|
-
msgFirstLine: msg.split("\n")[0],
|
|
888
|
-
msgLastLine: msg.split("\n")[msg.split("\n").length - 1]
|
|
889
|
-
},
|
|
890
|
-
"Message being verified"
|
|
891
|
-
);
|
|
892
|
-
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
893
|
-
log4.info({ addr, ok }, "BIP-322 verification result");
|
|
894
|
-
if (!ok) {
|
|
895
|
-
const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
|
|
896
|
-
if (isSegWit && sigBase64.length === 88) {
|
|
897
|
-
log4.warn(
|
|
898
|
-
{ addr, wallet: "possibly Electrum", sigLength: sigBase64.length },
|
|
899
|
-
"BIP-322 verification failed - likely Electrum wallet (does not support BIP-322 for SegWit)"
|
|
900
|
-
);
|
|
901
|
-
} else {
|
|
902
|
-
log4.error(
|
|
903
|
-
{
|
|
904
|
-
addr,
|
|
905
|
-
sigLength: sigBase64.length,
|
|
906
|
-
msgLength: msg.length,
|
|
907
|
-
msgPreview: msg.substring(0, 200)
|
|
908
|
-
},
|
|
909
|
-
"BIP-322 verification failed - signature does not match message"
|
|
910
|
-
);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
return ok ? "sig_ok_bip322" : "sig_invalid";
|
|
914
|
-
}
|
|
915
|
-
if (detectedScheme === "legacy") {
|
|
916
|
-
const sigBase64 = toBase64Maybe(sig);
|
|
917
|
-
if (!addr.startsWith("1") && !addr.startsWith("m") && !addr.startsWith("n")) {
|
|
918
|
-
log4.warn({ addr }, "Legacy signature attempted on non-P2PKH address");
|
|
919
|
-
return "sig_unsupported_script";
|
|
920
|
-
}
|
|
921
|
-
log4.debug(
|
|
922
|
-
{
|
|
923
|
-
addr,
|
|
924
|
-
sigBase64: sigBase64.substring(0, 50) + "...",
|
|
925
|
-
msgPreview: msg.substring(0, 100) + "...",
|
|
926
|
-
msgLength: msg.length
|
|
927
|
-
},
|
|
928
|
-
"Verifying legacy signature"
|
|
929
|
-
);
|
|
930
|
-
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
931
|
-
log4.info({ addr, ok, sigBase64Length: sigBase64.length }, "Legacy verification result");
|
|
932
|
-
if (!ok) {
|
|
933
|
-
log4.error(
|
|
934
|
-
{
|
|
935
|
-
addr,
|
|
936
|
-
msgFirstLine: msg.split("\n")[0],
|
|
937
|
-
sigFormat: sigBase64.substring(0, 10)
|
|
938
|
-
},
|
|
939
|
-
"Legacy signature verification failed - check message format and signature"
|
|
940
|
-
);
|
|
941
|
-
}
|
|
942
|
-
return ok ? "sig_ok_legacy" : "sig_invalid";
|
|
1107
|
+
async function signNostrEvent(event) {
|
|
1108
|
+
if (!isNip07Available()) {
|
|
1109
|
+
log3.warn("NIP-07 extension not available");
|
|
1110
|
+
return null;
|
|
943
1111
|
}
|
|
944
|
-
return "invalid_scheme";
|
|
945
|
-
}
|
|
946
|
-
function parseCanonicalMessage(msg) {
|
|
947
|
-
const lines = msg.split("\n").map((l) => l.trim());
|
|
948
|
-
const getVal = (prefix) => {
|
|
949
|
-
const needle = prefix + ":";
|
|
950
|
-
const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
|
|
951
|
-
if (!hit) return void 0;
|
|
952
|
-
const idx = hit.indexOf(":");
|
|
953
|
-
return hit.slice(idx + 1).trim();
|
|
954
|
-
};
|
|
955
|
-
const address = getVal("address") || "";
|
|
956
|
-
const identitiesStr = getVal("identities") || "";
|
|
957
|
-
let identities = [];
|
|
958
1112
|
try {
|
|
959
|
-
|
|
1113
|
+
const nostr = window.nostr;
|
|
1114
|
+
const signedEvent = await nostr.signEvent(event);
|
|
1115
|
+
log3.info({ eventId: signedEvent.id }, "Signed Nostr event with NIP-07");
|
|
1116
|
+
return signedEvent;
|
|
960
1117
|
} catch (err) {
|
|
961
|
-
|
|
1118
|
+
log3.error({ error: err }, "Failed to sign Nostr event");
|
|
1119
|
+
return null;
|
|
962
1120
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
"purpose",
|
|
968
|
-
"nonce",
|
|
969
|
-
"issued_at",
|
|
970
|
-
"ack"
|
|
971
|
-
]);
|
|
972
|
-
const extensions = {};
|
|
973
|
-
for (const line of lines) {
|
|
974
|
-
const idx = line.indexOf(":");
|
|
975
|
-
if (idx === -1) continue;
|
|
976
|
-
const key = line.slice(0, idx).trim().toLowerCase();
|
|
977
|
-
const val = line.slice(idx + 1).trim();
|
|
978
|
-
if (!coreKeys.has(key) && val) extensions[key] = val;
|
|
1121
|
+
}
|
|
1122
|
+
function getNip07Info() {
|
|
1123
|
+
if (!isNip07Available()) {
|
|
1124
|
+
return { available: false };
|
|
979
1125
|
}
|
|
980
|
-
const
|
|
981
|
-
const bondStr = extensions["bond"];
|
|
982
|
-
const bond = bondStr ? parseInt(bondStr, 10) : void 0;
|
|
1126
|
+
const nostr = window.nostr;
|
|
983
1127
|
return {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
|
|
1128
|
+
available: true,
|
|
1129
|
+
name: nostr._metadata?.name,
|
|
1130
|
+
version: nostr._metadata?.version
|
|
988
1131
|
};
|
|
989
1132
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
for (const utxo of data) {
|
|
1019
|
-
if (typeof utxo.value !== "number" || typeof utxo.status !== "object" || typeof utxo.status.confirmed !== "boolean" || typeof utxo.txid !== "string" || typeof utxo.vout !== "number") {
|
|
1020
|
-
throw new Error("Invalid UTXO structure");
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
log4.info({ base, count: data.length, network }, "UTXOs fetched successfully");
|
|
1024
|
-
return data;
|
|
1025
|
-
} catch (err) {
|
|
1026
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1027
|
-
log4.warn(
|
|
1028
|
-
{ base, error: lastError.message, network },
|
|
1029
|
-
"Esplora endpoint failed, trying next"
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1133
|
+
|
|
1134
|
+
// src/attestation.ts
|
|
1135
|
+
var log4 = createLogger("ocp/attestation");
|
|
1136
|
+
var AttestationError = class extends Error {
|
|
1137
|
+
constructor(message, code) {
|
|
1138
|
+
super(message);
|
|
1139
|
+
this.code = code;
|
|
1140
|
+
this.name = "AttestationError";
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
async function createAttestation(options) {
|
|
1144
|
+
const { message, signature, scheme, address, identities = [], extensions = {} } = options;
|
|
1145
|
+
log4.info({ address, identityCount: identities.length }, "Creating attestation");
|
|
1146
|
+
const envelope = await createAttestationEnvelope(
|
|
1147
|
+
message,
|
|
1148
|
+
signature,
|
|
1149
|
+
scheme,
|
|
1150
|
+
address,
|
|
1151
|
+
identities,
|
|
1152
|
+
extensions
|
|
1153
|
+
);
|
|
1154
|
+
const isValid = await verifyAttestationEnvelope(envelope);
|
|
1155
|
+
if (!isValid) {
|
|
1156
|
+
throw new AttestationError(
|
|
1157
|
+
"Attestation envelope integrity check failed",
|
|
1158
|
+
"INTEGRITY_CHECK_FAILED"
|
|
1159
|
+
);
|
|
1032
1160
|
}
|
|
1033
|
-
log4.
|
|
1034
|
-
|
|
1161
|
+
log4.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
|
|
1162
|
+
return envelope;
|
|
1035
1163
|
}
|
|
1036
|
-
function
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
if (a.txid !== b.txid) return a.txid.localeCompare(b.txid);
|
|
1048
|
-
return a.vout - b.vout;
|
|
1049
|
-
});
|
|
1050
|
-
let sum = 0;
|
|
1051
|
-
let youngestTime = 0;
|
|
1052
|
-
for (const utxo of sorted) {
|
|
1053
|
-
sum += utxo.value;
|
|
1054
|
-
youngestTime = Math.max(youngestTime, utxo.status.block_time || 0);
|
|
1055
|
-
if (sum >= bondAmount) break;
|
|
1056
|
-
}
|
|
1057
|
-
const days_unspent2 = Math.max(
|
|
1058
|
-
0,
|
|
1059
|
-
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
1164
|
+
async function publishAttestation(options) {
|
|
1165
|
+
const { envelope, npub, relays = DEFAULT_RELAYS } = options;
|
|
1166
|
+
log4.info(
|
|
1167
|
+
{ attestationId: envelope.attestation_id, relayCount: relays.length },
|
|
1168
|
+
"Publishing attestation"
|
|
1169
|
+
);
|
|
1170
|
+
const unsignedEvent = createAttestationEvent(envelope, npub);
|
|
1171
|
+
if (!isNip07Available()) {
|
|
1172
|
+
log4.warn("NIP-07 unavailable \u2014 skipping Nostr publish");
|
|
1173
|
+
throw new Error(
|
|
1174
|
+
"nostr_signer_unavailable: install a NIP-07 extension (e.g. nos2x, Alby) to publish"
|
|
1060
1175
|
);
|
|
1061
|
-
return {
|
|
1062
|
-
sats_bonded: bondAmount,
|
|
1063
|
-
// Use exactly bond amount (ignore surplus)
|
|
1064
|
-
days_unspent: days_unspent2,
|
|
1065
|
-
confirmed_balance
|
|
1066
|
-
};
|
|
1067
1176
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
);
|
|
1073
|
-
let sum = 0;
|
|
1074
|
-
let youngestTime = 0;
|
|
1075
|
-
for (const utxo of sorted) {
|
|
1076
|
-
sum += utxo.value;
|
|
1077
|
-
youngestTime = Math.max(youngestTime, utxo.status.block_time || Date.now() / 1e3);
|
|
1078
|
-
if (sum >= coverSats) break;
|
|
1079
|
-
}
|
|
1080
|
-
const days_unspent2 = Math.max(
|
|
1081
|
-
0,
|
|
1082
|
-
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
1083
|
-
);
|
|
1084
|
-
return { sats_bonded, days_unspent: days_unspent2, confirmed_balance };
|
|
1177
|
+
log4.info("Using NIP-07 to sign event");
|
|
1178
|
+
const signedEvent = await signNostrEvent(unsignedEvent);
|
|
1179
|
+
if (!signedEvent) {
|
|
1180
|
+
throw new Error("nostr_signing_failed: NIP-07 extension rejected or errored");
|
|
1085
1181
|
}
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1182
|
+
const result = await publishToRelays(signedEvent, relays);
|
|
1183
|
+
log4.info(
|
|
1184
|
+
{
|
|
1185
|
+
attestationId: envelope.attestation_id,
|
|
1186
|
+
successCount: result.success.length,
|
|
1187
|
+
failedCount: result.failed.length
|
|
1188
|
+
},
|
|
1189
|
+
"Publishing complete"
|
|
1088
1190
|
);
|
|
1089
|
-
|
|
1090
|
-
return { sats_bonded, days_unspent, confirmed_balance };
|
|
1191
|
+
return result;
|
|
1091
1192
|
}
|
|
1092
|
-
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1193
|
+
function eventIndexesEnvelope(event, envelope) {
|
|
1194
|
+
const d = event.tags.find((t) => t[0] === "d")?.[1];
|
|
1195
|
+
const id = envelope.attestation_id;
|
|
1196
|
+
if (d !== id && d !== `orangecheck:${id}`) return false;
|
|
1197
|
+
return event.tags.some((t) => t[0] === "t" && t[1] === envelope.address);
|
|
1198
|
+
}
|
|
1199
|
+
async function discoverAttestations(options) {
|
|
1200
|
+
const { attestationId, address, identity, relays = DEFAULT_RELAYS } = options;
|
|
1201
|
+
let events = [];
|
|
1202
|
+
if (attestationId) {
|
|
1203
|
+
log4.info({ attestationId }, "Discovering by attestation ID");
|
|
1204
|
+
events = await queryByAttestationId(attestationId, relays);
|
|
1205
|
+
} else if (address) {
|
|
1206
|
+
log4.info({ address }, "Discovering by Bitcoin address");
|
|
1207
|
+
events = await queryByAddress(address, relays);
|
|
1208
|
+
} else if (identity) {
|
|
1209
|
+
log4.info({ identity }, "Discovering by identity");
|
|
1210
|
+
events = await queryByIdentity(identity.protocol, identity.identifier, relays);
|
|
1211
|
+
} else {
|
|
1212
|
+
throw new Error("Must provide attestationId, address, or identity");
|
|
1213
|
+
}
|
|
1214
|
+
const envelopes = await collectSignedEnvelopes(events, (event, envelope) => {
|
|
1215
|
+
if (eventIndexesEnvelope(event, envelope)) return true;
|
|
1216
|
+
log4.warn({ eventId: event.id }, "Skipping event whose index tags disagree with its envelope");
|
|
1217
|
+
return false;
|
|
1218
|
+
});
|
|
1219
|
+
log4.info({ eventCount: events.length, validCount: envelopes.length }, "Discovery complete");
|
|
1220
|
+
return envelopes;
|
|
1221
|
+
}
|
|
1222
|
+
async function verifyAttestationById(attestationId, relays = DEFAULT_RELAYS) {
|
|
1223
|
+
log4.info({ attestationId }, "Verifying attestation by ID");
|
|
1224
|
+
const envelopes = await discoverAttestations({ attestationId, relays });
|
|
1225
|
+
if (envelopes.length === 0) {
|
|
1226
|
+
log4.warn({ attestationId }, "Attestation not found on any relay");
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1229
|
+
if (envelopes.length > 1) {
|
|
1230
|
+
log4.warn(
|
|
1231
|
+
{ attestationId, count: envelopes.length },
|
|
1232
|
+
"Multiple attestations found with same ID - using first"
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
return envelopes[0] || null;
|
|
1236
|
+
}
|
|
1237
|
+
async function getAttestationsForAddress(address, relays = DEFAULT_RELAYS) {
|
|
1238
|
+
log4.info({ address }, "Getting all attestations for address");
|
|
1239
|
+
return discoverAttestations({ address, relays });
|
|
1240
|
+
}
|
|
1241
|
+
async function getAttestationsForIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
1242
|
+
log4.info({ protocol, identifier }, "Getting all attestations for identity");
|
|
1243
|
+
return discoverAttestations({ identity: { protocol, identifier }, relays });
|
|
1244
|
+
}
|
|
1245
|
+
function formatIdentitiesForDisplay(identities) {
|
|
1246
|
+
return identities.map((id) => `${id.protocol}:${id.identifier}`).join(", ");
|
|
1247
|
+
}
|
|
1248
|
+
function getVerificationUrl(attestationId, baseUrl = "https://attest.ochk.io") {
|
|
1249
|
+
return `${baseUrl}/attest/${attestationId}`;
|
|
1250
|
+
}
|
|
1251
|
+
function extractAttestationIdFromUrl(url) {
|
|
1095
1252
|
try {
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
codes.push("bad_request");
|
|
1105
|
-
return { ok: false, codes, network, attestation_id };
|
|
1106
|
-
}
|
|
1107
|
-
if ((network === "testnet" || network === "signet") && !opts.testMode) {
|
|
1108
|
-
codes.push("network_testmode");
|
|
1109
|
-
return { ok: false, codes, network, attestation_id };
|
|
1110
|
-
}
|
|
1111
|
-
if (opts.expectedAud) {
|
|
1112
|
-
const aud = parsed.extensions["aud"];
|
|
1113
|
-
if (!aud || aud !== opts.expectedAud) {
|
|
1114
|
-
codes.push("aud_mismatch");
|
|
1115
|
-
return { ok: false, codes, network, attestation_id };
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
if (parsed.extensions["expires"]) {
|
|
1119
|
-
const exp = Date.parse(parsed.extensions["expires"]);
|
|
1120
|
-
if (!Number.isNaN(exp) && exp < Date.now()) {
|
|
1121
|
-
codes.push("expired");
|
|
1122
|
-
return { ok: false, codes, network, attestation_id };
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
const sigCode = await verifySignature({
|
|
1126
|
-
addr: input.addr,
|
|
1127
|
-
msg: input.msg,
|
|
1128
|
-
sig: input.sig,
|
|
1129
|
-
...input.scheme && { scheme: input.scheme }
|
|
1130
|
-
});
|
|
1131
|
-
codes.push(sigCode);
|
|
1132
|
-
const sigOk = sigCode === "sig_ok_bip322" || sigCode === "sig_ok_legacy";
|
|
1133
|
-
if (!sigOk) {
|
|
1134
|
-
return {
|
|
1135
|
-
ok: false,
|
|
1136
|
-
codes,
|
|
1137
|
-
network,
|
|
1138
|
-
attestation_id,
|
|
1139
|
-
identities: parsed.core.identities
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
try {
|
|
1143
|
-
const utxos = await getAddressUtxos(input.addr, network, {
|
|
1144
|
-
esploraMainnetBase: opts.esploraMainnetBase,
|
|
1145
|
-
esploraSignetBase: opts.esploraSignetBase
|
|
1146
|
-
});
|
|
1147
|
-
const { sats_bonded, days_unspent, confirmed_balance } = computeMetrics(
|
|
1148
|
-
utxos,
|
|
1149
|
-
parsed.bond,
|
|
1150
|
-
opts.coverSats
|
|
1151
|
-
);
|
|
1152
|
-
if (parsed.bond !== void 0 && parsed.bond > 0) {
|
|
1153
|
-
if (confirmed_balance < parsed.bond) {
|
|
1154
|
-
codes.push("bond_insufficient");
|
|
1155
|
-
log4.warn(
|
|
1156
|
-
{
|
|
1157
|
-
bond: parsed.bond,
|
|
1158
|
-
confirmed_balance,
|
|
1159
|
-
address: input.addr
|
|
1160
|
-
},
|
|
1161
|
-
"Bond extension: insufficient balance"
|
|
1162
|
-
);
|
|
1163
|
-
return {
|
|
1164
|
-
ok: false,
|
|
1165
|
-
codes,
|
|
1166
|
-
network,
|
|
1167
|
-
attestation_id,
|
|
1168
|
-
identities: parsed.core.identities
|
|
1169
|
-
};
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
if (sats_bonded <= 0) {
|
|
1173
|
-
codes.push("bond_zero");
|
|
1174
|
-
if (utxos.some((u) => !u.status.confirmed)) {
|
|
1175
|
-
codes.push("bond_pending");
|
|
1176
|
-
}
|
|
1177
|
-
return {
|
|
1178
|
-
ok: false,
|
|
1179
|
-
codes,
|
|
1180
|
-
network,
|
|
1181
|
-
attestation_id,
|
|
1182
|
-
identities: parsed.core.identities
|
|
1183
|
-
};
|
|
1184
|
-
} else {
|
|
1185
|
-
codes.push("bond_confirmed");
|
|
1186
|
-
}
|
|
1187
|
-
const metrics = {
|
|
1188
|
-
sats_bonded,
|
|
1189
|
-
days_unspent,
|
|
1190
|
-
score: computeScore(sats_bonded, days_unspent, { algorithm: "v0" })
|
|
1191
|
-
};
|
|
1192
|
-
return {
|
|
1193
|
-
ok: true,
|
|
1194
|
-
codes,
|
|
1195
|
-
network,
|
|
1196
|
-
attestation_id,
|
|
1197
|
-
identities: parsed.core.identities,
|
|
1198
|
-
metrics
|
|
1199
|
-
};
|
|
1200
|
-
} catch (utxoError) {
|
|
1201
|
-
log4.error({ err: utxoError }, "UTXO fetch failed");
|
|
1202
|
-
codes.push("bad_request");
|
|
1203
|
-
return { ok: false, codes, network, attestation_id };
|
|
1204
|
-
}
|
|
1205
|
-
} catch (e) {
|
|
1206
|
-
const msg = e instanceof Error ? e.message : String(e ?? "decode_error");
|
|
1207
|
-
if (msg === "decode_error") {
|
|
1208
|
-
codes.push("decode_error");
|
|
1209
|
-
} else {
|
|
1210
|
-
codes.push("bad_request");
|
|
1211
|
-
}
|
|
1212
|
-
return { ok: false, codes, network };
|
|
1253
|
+
const u = new URL(url);
|
|
1254
|
+
const m = u.pathname.match(/^\/attest\/([0-9a-f]{64})\/?$/i);
|
|
1255
|
+
if (m) return m[1].toLowerCase();
|
|
1256
|
+
const q = u.searchParams.get("id");
|
|
1257
|
+
if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase();
|
|
1258
|
+
return null;
|
|
1259
|
+
} catch {
|
|
1260
|
+
return null;
|
|
1213
1261
|
}
|
|
1214
1262
|
}
|
|
1215
1263
|
|
|
1216
1264
|
// src/check.ts
|
|
1265
|
+
var ISSUED_AHEAD_MS = 12e4;
|
|
1266
|
+
var MAX_CANDIDATES = 20;
|
|
1217
1267
|
async function check(params) {
|
|
1218
1268
|
const {
|
|
1219
1269
|
addr,
|
|
@@ -1235,14 +1285,17 @@ async function check(params) {
|
|
|
1235
1285
|
} else if (identity) {
|
|
1236
1286
|
found = await getAttestationsForIdentity(identity.protocol, identity.identifier, relays);
|
|
1237
1287
|
}
|
|
1238
|
-
const
|
|
1288
|
+
const latest = Date.now() + ISSUED_AHEAD_MS;
|
|
1289
|
+
const envelopes = found.map(signedEnvelope).filter((e) => e !== null).filter((e) => signedMatch(e, { addr, id, identity })).map((e) => ({ e, at: rfc3339Ms(e.issued_at) })).filter(({ at }) => at <= latest).sort((a, b) => b.at - a.at || (a.e.attestation_id < b.e.attestation_id ? -1 : 1)).map(({ e }) => e);
|
|
1239
1290
|
if (envelopes.length === 0) {
|
|
1240
1291
|
return { ok: false, sats: 0, days: 0, score: 0, reasons: ["not_found"] };
|
|
1241
1292
|
}
|
|
1242
|
-
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1293
|
+
let env = envelopes[0];
|
|
1294
|
+
for (const candidate of envelopes.slice(0, MAX_CANDIDATES)) {
|
|
1295
|
+
if (await signedBy(candidate)) {
|
|
1296
|
+
env = candidate;
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1246
1299
|
}
|
|
1247
1300
|
const shared = identity ? await backsOtherIdentity(env, identity, relays) : false;
|
|
1248
1301
|
const outcome = await verify(
|
|
@@ -1270,7 +1323,7 @@ async function check(params) {
|
|
|
1270
1323
|
score,
|
|
1271
1324
|
attestation_id: env.attestation_id,
|
|
1272
1325
|
address: env.address,
|
|
1273
|
-
identities: env
|
|
1326
|
+
identities: signedIdentities(env),
|
|
1274
1327
|
network: outcome.network,
|
|
1275
1328
|
...reasons.length ? { reasons } : {}
|
|
1276
1329
|
};
|
|
@@ -1306,16 +1359,19 @@ function signedMatch(env, q) {
|
|
|
1306
1359
|
}
|
|
1307
1360
|
return false;
|
|
1308
1361
|
}
|
|
1362
|
+
function signedBy(e) {
|
|
1363
|
+
return signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme });
|
|
1364
|
+
}
|
|
1309
1365
|
async function backsOtherIdentity(env, identity, relays) {
|
|
1310
1366
|
const want = identityKey(identity);
|
|
1311
1367
|
const protocol = identity.protocol.trim().toLowerCase();
|
|
1312
1368
|
const siblings = await getAttestationsForAddress(env.address, relays);
|
|
1313
1369
|
for (const e of [env, ...siblings]) {
|
|
1314
1370
|
if (!signedMatch(e, { addr: env.address })) continue;
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1371
|
+
const other = signedIdentities(e).some(
|
|
1372
|
+
(b) => b.protocol.trim().toLowerCase() === protocol && identityKey(b) !== want
|
|
1373
|
+
);
|
|
1374
|
+
if (other && (e === env || await signedBy(e))) return true;
|
|
1319
1375
|
}
|
|
1320
1376
|
return false;
|
|
1321
1377
|
}
|
|
@@ -2485,6 +2541,8 @@ exports.queryByIdentity = queryByIdentity;
|
|
|
2485
2541
|
exports.queryRecent = queryRecent;
|
|
2486
2542
|
exports.requiresManualVerification = requiresManualVerification;
|
|
2487
2543
|
exports.signNostrEvent = signNostrEvent;
|
|
2544
|
+
exports.signatureIsValid = signatureIsValid;
|
|
2545
|
+
exports.signedEnvelope = signedEnvelope;
|
|
2488
2546
|
exports.supportsAutomaticVerification = supportsAutomaticVerification;
|
|
2489
2547
|
exports.verify = verify;
|
|
2490
2548
|
exports.verifyAllIdentities = verifyAllIdentities;
|