@orangecheck/sdk 1.14.0 → 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 -61
- package/dist/attestation.js.map +1 -1
- package/dist/attestation.mjs +307 -61
- 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 -615
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +675 -616
- 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,612 +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
|
-
for (const event of events) {
|
|
754
|
-
const envelope = parseAttestationFromEvent(event);
|
|
755
|
-
if (envelope && !eventIndexesEnvelope(event, envelope)) {
|
|
756
|
-
log3.warn({ eventId: event.id }, "Skipping event whose index tags disagree with its envelope");
|
|
757
|
-
} else if (envelope) {
|
|
758
|
-
const isValid = await verifyAttestationEnvelope(envelope);
|
|
759
|
-
if (isValid) {
|
|
760
|
-
envelopes.push(envelope);
|
|
761
|
-
} else {
|
|
762
|
-
log3.warn(
|
|
763
|
-
{ attestationId: envelope.attestation_id },
|
|
764
|
-
"Skipping invalid attestation envelope"
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
log3.info({ eventCount: events.length, validCount: envelopes.length }, "Discovery complete");
|
|
770
|
-
return envelopes;
|
|
771
|
-
}
|
|
772
|
-
async function verifyAttestationById(attestationId, relays = DEFAULT_RELAYS) {
|
|
773
|
-
log3.info({ attestationId }, "Verifying attestation by ID");
|
|
774
|
-
const envelopes = await discoverAttestations({ attestationId, relays });
|
|
775
|
-
if (envelopes.length === 0) {
|
|
776
|
-
log3.warn({ attestationId }, "Attestation not found on any relay");
|
|
777
|
-
return null;
|
|
778
|
-
}
|
|
779
|
-
if (envelopes.length > 1) {
|
|
780
|
-
log3.warn(
|
|
781
|
-
{ attestationId, count: envelopes.length },
|
|
782
|
-
"Multiple attestations found with same ID - using first"
|
|
783
|
-
);
|
|
784
|
-
}
|
|
785
|
-
return envelopes[0] || null;
|
|
786
|
-
}
|
|
787
|
-
async function getAttestationsForAddress(address, relays = DEFAULT_RELAYS) {
|
|
788
|
-
log3.info({ address }, "Getting all attestations for address");
|
|
789
|
-
return discoverAttestations({ address, relays });
|
|
790
|
-
}
|
|
791
|
-
async function getAttestationsForIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
|
|
792
|
-
log3.info({ protocol, identifier }, "Getting all attestations for identity");
|
|
793
|
-
return discoverAttestations({ identity: { protocol, identifier }, relays });
|
|
794
|
-
}
|
|
795
|
-
function formatIdentitiesForDisplay(identities) {
|
|
796
|
-
return identities.map((id) => `${id.protocol}:${id.identifier}`).join(", ");
|
|
1020
|
+
}
|
|
797
1021
|
}
|
|
798
|
-
function
|
|
799
|
-
|
|
1022
|
+
function base64Url(text) {
|
|
1023
|
+
const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(text)));
|
|
1024
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
800
1025
|
}
|
|
801
|
-
function
|
|
1026
|
+
function signedEnvelope(envelope) {
|
|
1027
|
+
let parsed;
|
|
802
1028
|
try {
|
|
803
|
-
|
|
804
|
-
const m = u.pathname.match(/^\/attest\/([0-9a-f]{64})\/?$/i);
|
|
805
|
-
if (m) return m[1].toLowerCase();
|
|
806
|
-
const q = u.searchParams.get("id");
|
|
807
|
-
if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase();
|
|
808
|
-
return null;
|
|
1029
|
+
parsed = parseCanonicalMessage(envelope.message);
|
|
809
1030
|
} catch {
|
|
810
1031
|
return null;
|
|
811
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
|
+
};
|
|
812
1043
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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]);
|
|
834
1071
|
}
|
|
1072
|
+
return kept;
|
|
835
1073
|
}
|
|
836
|
-
function
|
|
837
|
-
|
|
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
|
+
}
|
|
838
1082
|
}
|
|
839
1083
|
|
|
840
|
-
// src/
|
|
841
|
-
var
|
|
842
|
-
function
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
function toBase64Maybe(sig) {
|
|
846
|
-
if (looksLikeHex(sig)) {
|
|
847
|
-
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;
|
|
848
1089
|
}
|
|
849
|
-
return
|
|
1090
|
+
return !!window.nostr;
|
|
850
1091
|
}
|
|
851
|
-
function
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
return
|
|
1092
|
+
async function getNostrPublicKey() {
|
|
1093
|
+
if (!isNip07Available()) {
|
|
1094
|
+
log3.warn("NIP-07 extension not available");
|
|
1095
|
+
return null;
|
|
855
1096
|
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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;
|
|
860
1105
|
}
|
|
861
|
-
return "bip322";
|
|
862
1106
|
}
|
|
863
|
-
async function
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
scheme
|
|
868
|
-
}) {
|
|
869
|
-
const detectedScheme = scheme || detectSignatureScheme(addr, sig);
|
|
870
|
-
log4.debug(
|
|
871
|
-
{ addr, msgLength: msg.length, sigLength: sig.length, scheme: detectedScheme },
|
|
872
|
-
"Verifying signature"
|
|
873
|
-
);
|
|
874
|
-
if (detectedScheme === "bip322") {
|
|
875
|
-
const sigBase64 = toBase64Maybe(sig);
|
|
876
|
-
log4.debug(
|
|
877
|
-
{ sigBase64: sigBase64.substring(0, 50) + "..." },
|
|
878
|
-
"Signature converted to base64"
|
|
879
|
-
);
|
|
880
|
-
log4.debug(
|
|
881
|
-
{
|
|
882
|
-
msgFull: msg,
|
|
883
|
-
msgLines: msg.split("\n").length,
|
|
884
|
-
msgFirstLine: msg.split("\n")[0],
|
|
885
|
-
msgLastLine: msg.split("\n")[msg.split("\n").length - 1]
|
|
886
|
-
},
|
|
887
|
-
"Message being verified"
|
|
888
|
-
);
|
|
889
|
-
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
890
|
-
log4.info({ addr, ok }, "BIP-322 verification result");
|
|
891
|
-
if (!ok) {
|
|
892
|
-
const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
|
|
893
|
-
if (isSegWit && sigBase64.length === 88) {
|
|
894
|
-
log4.warn(
|
|
895
|
-
{ addr, wallet: "possibly Electrum", sigLength: sigBase64.length },
|
|
896
|
-
"BIP-322 verification failed - likely Electrum wallet (does not support BIP-322 for SegWit)"
|
|
897
|
-
);
|
|
898
|
-
} else {
|
|
899
|
-
log4.error(
|
|
900
|
-
{
|
|
901
|
-
addr,
|
|
902
|
-
sigLength: sigBase64.length,
|
|
903
|
-
msgLength: msg.length,
|
|
904
|
-
msgPreview: msg.substring(0, 200)
|
|
905
|
-
},
|
|
906
|
-
"BIP-322 verification failed - signature does not match message"
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
return ok ? "sig_ok_bip322" : "sig_invalid";
|
|
911
|
-
}
|
|
912
|
-
if (detectedScheme === "legacy") {
|
|
913
|
-
const sigBase64 = toBase64Maybe(sig);
|
|
914
|
-
if (!addr.startsWith("1") && !addr.startsWith("m") && !addr.startsWith("n")) {
|
|
915
|
-
log4.warn({ addr }, "Legacy signature attempted on non-P2PKH address");
|
|
916
|
-
return "sig_unsupported_script";
|
|
917
|
-
}
|
|
918
|
-
log4.debug(
|
|
919
|
-
{
|
|
920
|
-
addr,
|
|
921
|
-
sigBase64: sigBase64.substring(0, 50) + "...",
|
|
922
|
-
msgPreview: msg.substring(0, 100) + "...",
|
|
923
|
-
msgLength: msg.length
|
|
924
|
-
},
|
|
925
|
-
"Verifying legacy signature"
|
|
926
|
-
);
|
|
927
|
-
const ok = bip322.verifySignature(addr, msg, sigBase64);
|
|
928
|
-
log4.info({ addr, ok, sigBase64Length: sigBase64.length }, "Legacy verification result");
|
|
929
|
-
if (!ok) {
|
|
930
|
-
log4.error(
|
|
931
|
-
{
|
|
932
|
-
addr,
|
|
933
|
-
msgFirstLine: msg.split("\n")[0],
|
|
934
|
-
sigFormat: sigBase64.substring(0, 10)
|
|
935
|
-
},
|
|
936
|
-
"Legacy signature verification failed - check message format and signature"
|
|
937
|
-
);
|
|
938
|
-
}
|
|
939
|
-
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;
|
|
940
1111
|
}
|
|
941
|
-
return "invalid_scheme";
|
|
942
|
-
}
|
|
943
|
-
function parseCanonicalMessage(msg) {
|
|
944
|
-
const lines = msg.split("\n").map((l) => l.trim());
|
|
945
|
-
const getVal = (prefix) => {
|
|
946
|
-
const needle = prefix + ":";
|
|
947
|
-
const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
|
|
948
|
-
if (!hit) return void 0;
|
|
949
|
-
const idx = hit.indexOf(":");
|
|
950
|
-
return hit.slice(idx + 1).trim();
|
|
951
|
-
};
|
|
952
|
-
const address = getVal("address") || "";
|
|
953
|
-
const identitiesStr = getVal("identities") || "";
|
|
954
|
-
let identities = [];
|
|
955
1112
|
try {
|
|
956
|
-
|
|
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;
|
|
957
1117
|
} catch (err) {
|
|
958
|
-
|
|
1118
|
+
log3.error({ error: err }, "Failed to sign Nostr event");
|
|
1119
|
+
return null;
|
|
959
1120
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
"purpose",
|
|
965
|
-
"nonce",
|
|
966
|
-
"issued_at",
|
|
967
|
-
"ack"
|
|
968
|
-
]);
|
|
969
|
-
const extensions = {};
|
|
970
|
-
for (const line of lines) {
|
|
971
|
-
const idx = line.indexOf(":");
|
|
972
|
-
if (idx === -1) continue;
|
|
973
|
-
const key = line.slice(0, idx).trim().toLowerCase();
|
|
974
|
-
const val = line.slice(idx + 1).trim();
|
|
975
|
-
if (!coreKeys.has(key) && val) extensions[key] = val;
|
|
1121
|
+
}
|
|
1122
|
+
function getNip07Info() {
|
|
1123
|
+
if (!isNip07Available()) {
|
|
1124
|
+
return { available: false };
|
|
976
1125
|
}
|
|
977
|
-
const
|
|
978
|
-
const bondStr = extensions["bond"];
|
|
979
|
-
const bond = bondStr ? parseInt(bondStr, 10) : void 0;
|
|
1126
|
+
const nostr = window.nostr;
|
|
980
1127
|
return {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
|
|
1128
|
+
available: true,
|
|
1129
|
+
name: nostr._metadata?.name,
|
|
1130
|
+
version: nostr._metadata?.version
|
|
985
1131
|
};
|
|
986
1132
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
-
for (const utxo of data) {
|
|
1016
|
-
if (typeof utxo.value !== "number" || typeof utxo.status !== "object" || typeof utxo.status.confirmed !== "boolean" || typeof utxo.txid !== "string" || typeof utxo.vout !== "number") {
|
|
1017
|
-
throw new Error("Invalid UTXO structure");
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
log4.info({ base, count: data.length, network }, "UTXOs fetched successfully");
|
|
1021
|
-
return data;
|
|
1022
|
-
} catch (err) {
|
|
1023
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1024
|
-
log4.warn(
|
|
1025
|
-
{ base, error: lastError.message, network },
|
|
1026
|
-
"Esplora endpoint failed, trying next"
|
|
1027
|
-
);
|
|
1028
|
-
}
|
|
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
|
+
);
|
|
1029
1160
|
}
|
|
1030
|
-
log4.
|
|
1031
|
-
|
|
1161
|
+
log4.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
|
|
1162
|
+
return envelope;
|
|
1032
1163
|
}
|
|
1033
|
-
function
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
if (a.txid !== b.txid) return a.txid.localeCompare(b.txid);
|
|
1045
|
-
return a.vout - b.vout;
|
|
1046
|
-
});
|
|
1047
|
-
let sum = 0;
|
|
1048
|
-
let youngestTime = 0;
|
|
1049
|
-
for (const utxo of sorted) {
|
|
1050
|
-
sum += utxo.value;
|
|
1051
|
-
youngestTime = Math.max(youngestTime, utxo.status.block_time || 0);
|
|
1052
|
-
if (sum >= bondAmount) break;
|
|
1053
|
-
}
|
|
1054
|
-
const days_unspent2 = Math.max(
|
|
1055
|
-
0,
|
|
1056
|
-
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"
|
|
1057
1175
|
);
|
|
1058
|
-
return {
|
|
1059
|
-
sats_bonded: bondAmount,
|
|
1060
|
-
// Use exactly bond amount (ignore surplus)
|
|
1061
|
-
days_unspent: days_unspent2,
|
|
1062
|
-
confirmed_balance
|
|
1063
|
-
};
|
|
1064
1176
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
);
|
|
1070
|
-
let sum = 0;
|
|
1071
|
-
let youngestTime = 0;
|
|
1072
|
-
for (const utxo of sorted) {
|
|
1073
|
-
sum += utxo.value;
|
|
1074
|
-
youngestTime = Math.max(youngestTime, utxo.status.block_time || Date.now() / 1e3);
|
|
1075
|
-
if (sum >= coverSats) break;
|
|
1076
|
-
}
|
|
1077
|
-
const days_unspent2 = Math.max(
|
|
1078
|
-
0,
|
|
1079
|
-
Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
|
|
1080
|
-
);
|
|
1081
|
-
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");
|
|
1082
1181
|
}
|
|
1083
|
-
const
|
|
1084
|
-
|
|
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"
|
|
1085
1190
|
);
|
|
1086
|
-
|
|
1087
|
-
return { sats_bonded, days_unspent, confirmed_balance };
|
|
1191
|
+
return result;
|
|
1088
1192
|
}
|
|
1089
|
-
|
|
1090
|
-
const
|
|
1091
|
-
|
|
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) {
|
|
1092
1252
|
try {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
codes.push("bad_request");
|
|
1102
|
-
return { ok: false, codes, network, attestation_id };
|
|
1103
|
-
}
|
|
1104
|
-
if ((network === "testnet" || network === "signet") && !opts.testMode) {
|
|
1105
|
-
codes.push("network_testmode");
|
|
1106
|
-
return { ok: false, codes, network, attestation_id };
|
|
1107
|
-
}
|
|
1108
|
-
if (opts.expectedAud) {
|
|
1109
|
-
const aud = parsed.extensions["aud"];
|
|
1110
|
-
if (!aud || aud !== opts.expectedAud) {
|
|
1111
|
-
codes.push("aud_mismatch");
|
|
1112
|
-
return { ok: false, codes, network, attestation_id };
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
if (parsed.extensions["expires"]) {
|
|
1116
|
-
const exp = Date.parse(parsed.extensions["expires"]);
|
|
1117
|
-
if (!Number.isNaN(exp) && exp < Date.now()) {
|
|
1118
|
-
codes.push("expired");
|
|
1119
|
-
return { ok: false, codes, network, attestation_id };
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
const sigCode = await verifySignature({
|
|
1123
|
-
addr: input.addr,
|
|
1124
|
-
msg: input.msg,
|
|
1125
|
-
sig: input.sig,
|
|
1126
|
-
...input.scheme && { scheme: input.scheme }
|
|
1127
|
-
});
|
|
1128
|
-
codes.push(sigCode);
|
|
1129
|
-
const sigOk = sigCode === "sig_ok_bip322" || sigCode === "sig_ok_legacy";
|
|
1130
|
-
if (!sigOk) {
|
|
1131
|
-
return {
|
|
1132
|
-
ok: false,
|
|
1133
|
-
codes,
|
|
1134
|
-
network,
|
|
1135
|
-
attestation_id,
|
|
1136
|
-
identities: parsed.core.identities
|
|
1137
|
-
};
|
|
1138
|
-
}
|
|
1139
|
-
try {
|
|
1140
|
-
const utxos = await getAddressUtxos(input.addr, network, {
|
|
1141
|
-
esploraMainnetBase: opts.esploraMainnetBase,
|
|
1142
|
-
esploraSignetBase: opts.esploraSignetBase
|
|
1143
|
-
});
|
|
1144
|
-
const { sats_bonded, days_unspent, confirmed_balance } = computeMetrics(
|
|
1145
|
-
utxos,
|
|
1146
|
-
parsed.bond,
|
|
1147
|
-
opts.coverSats
|
|
1148
|
-
);
|
|
1149
|
-
if (parsed.bond !== void 0 && parsed.bond > 0) {
|
|
1150
|
-
if (confirmed_balance < parsed.bond) {
|
|
1151
|
-
codes.push("bond_insufficient");
|
|
1152
|
-
log4.warn(
|
|
1153
|
-
{
|
|
1154
|
-
bond: parsed.bond,
|
|
1155
|
-
confirmed_balance,
|
|
1156
|
-
address: input.addr
|
|
1157
|
-
},
|
|
1158
|
-
"Bond extension: insufficient balance"
|
|
1159
|
-
);
|
|
1160
|
-
return {
|
|
1161
|
-
ok: false,
|
|
1162
|
-
codes,
|
|
1163
|
-
network,
|
|
1164
|
-
attestation_id,
|
|
1165
|
-
identities: parsed.core.identities
|
|
1166
|
-
};
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
if (sats_bonded <= 0) {
|
|
1170
|
-
codes.push("bond_zero");
|
|
1171
|
-
if (utxos.some((u) => !u.status.confirmed)) {
|
|
1172
|
-
codes.push("bond_pending");
|
|
1173
|
-
}
|
|
1174
|
-
return {
|
|
1175
|
-
ok: false,
|
|
1176
|
-
codes,
|
|
1177
|
-
network,
|
|
1178
|
-
attestation_id,
|
|
1179
|
-
identities: parsed.core.identities
|
|
1180
|
-
};
|
|
1181
|
-
} else {
|
|
1182
|
-
codes.push("bond_confirmed");
|
|
1183
|
-
}
|
|
1184
|
-
const metrics = {
|
|
1185
|
-
sats_bonded,
|
|
1186
|
-
days_unspent,
|
|
1187
|
-
score: computeScore(sats_bonded, days_unspent, { algorithm: "v0" })
|
|
1188
|
-
};
|
|
1189
|
-
return {
|
|
1190
|
-
ok: true,
|
|
1191
|
-
codes,
|
|
1192
|
-
network,
|
|
1193
|
-
attestation_id,
|
|
1194
|
-
identities: parsed.core.identities,
|
|
1195
|
-
metrics
|
|
1196
|
-
};
|
|
1197
|
-
} catch (utxoError) {
|
|
1198
|
-
log4.error({ err: utxoError }, "UTXO fetch failed");
|
|
1199
|
-
codes.push("bad_request");
|
|
1200
|
-
return { ok: false, codes, network, attestation_id };
|
|
1201
|
-
}
|
|
1202
|
-
} catch (e) {
|
|
1203
|
-
const msg = e instanceof Error ? e.message : String(e ?? "decode_error");
|
|
1204
|
-
if (msg === "decode_error") {
|
|
1205
|
-
codes.push("decode_error");
|
|
1206
|
-
} else {
|
|
1207
|
-
codes.push("bad_request");
|
|
1208
|
-
}
|
|
1209
|
-
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;
|
|
1210
1261
|
}
|
|
1211
1262
|
}
|
|
1212
1263
|
|
|
1213
1264
|
// src/check.ts
|
|
1265
|
+
var ISSUED_AHEAD_MS = 12e4;
|
|
1266
|
+
var MAX_CANDIDATES = 20;
|
|
1214
1267
|
async function check(params) {
|
|
1215
1268
|
const {
|
|
1216
1269
|
addr,
|
|
@@ -1232,14 +1285,17 @@ async function check(params) {
|
|
|
1232
1285
|
} else if (identity) {
|
|
1233
1286
|
found = await getAttestationsForIdentity(identity.protocol, identity.identifier, relays);
|
|
1234
1287
|
}
|
|
1235
|
-
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);
|
|
1236
1290
|
if (envelopes.length === 0) {
|
|
1237
1291
|
return { ok: false, sats: 0, days: 0, score: 0, reasons: ["not_found"] };
|
|
1238
1292
|
}
|
|
1239
|
-
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
+
}
|
|
1243
1299
|
}
|
|
1244
1300
|
const shared = identity ? await backsOtherIdentity(env, identity, relays) : false;
|
|
1245
1301
|
const outcome = await verify(
|
|
@@ -1267,7 +1323,7 @@ async function check(params) {
|
|
|
1267
1323
|
score,
|
|
1268
1324
|
attestation_id: env.attestation_id,
|
|
1269
1325
|
address: env.address,
|
|
1270
|
-
identities: env
|
|
1326
|
+
identities: signedIdentities(env),
|
|
1271
1327
|
network: outcome.network,
|
|
1272
1328
|
...reasons.length ? { reasons } : {}
|
|
1273
1329
|
};
|
|
@@ -1303,16 +1359,19 @@ function signedMatch(env, q) {
|
|
|
1303
1359
|
}
|
|
1304
1360
|
return false;
|
|
1305
1361
|
}
|
|
1362
|
+
function signedBy(e) {
|
|
1363
|
+
return signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme });
|
|
1364
|
+
}
|
|
1306
1365
|
async function backsOtherIdentity(env, identity, relays) {
|
|
1307
1366
|
const want = identityKey(identity);
|
|
1308
1367
|
const protocol = identity.protocol.trim().toLowerCase();
|
|
1309
1368
|
const siblings = await getAttestationsForAddress(env.address, relays);
|
|
1310
1369
|
for (const e of [env, ...siblings]) {
|
|
1311
1370
|
if (!signedMatch(e, { addr: env.address })) continue;
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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;
|
|
1316
1375
|
}
|
|
1317
1376
|
return false;
|
|
1318
1377
|
}
|
|
@@ -2482,6 +2541,8 @@ exports.queryByIdentity = queryByIdentity;
|
|
|
2482
2541
|
exports.queryRecent = queryRecent;
|
|
2483
2542
|
exports.requiresManualVerification = requiresManualVerification;
|
|
2484
2543
|
exports.signNostrEvent = signNostrEvent;
|
|
2544
|
+
exports.signatureIsValid = signatureIsValid;
|
|
2545
|
+
exports.signedEnvelope = signedEnvelope;
|
|
2485
2546
|
exports.supportsAutomaticVerification = supportsAutomaticVerification;
|
|
2486
2547
|
exports.verify = verify;
|
|
2487
2548
|
exports.verifyAllIdentities = verifyAllIdentities;
|