@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/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 log = createLogger("ocp/nostr");
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
- log.info({ eventId: event.id, relayCount: relays.length }, "Publishing event to relays");
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
- log.warn(
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
- log.error(
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
- log.error({ relay: relayUrl, error: err }, "WebSocket error");
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
- log.error({ relay: relayUrl, error: err }, "Failed to publish to relay");
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
- log.info({ successCount: success.length, failedCount: failed.length }, "Publishing complete");
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
- log.info({ attestationId, relayCount: relays.length }, "Querying relays for attestation");
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
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
812
+ log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
395
813
  }
396
814
  };
397
815
  wsRef.onerror = (err) => {
398
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
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
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
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
- log.info({ eventCount: events.length }, "Query complete");
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
- log.info({ address, relayCount: relays.length }, "Querying relays for address");
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
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
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
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
885
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
468
886
  reject(err);
469
887
  };
470
888
  });
471
889
  } catch (err) {
472
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
890
+ log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
473
891
  }
474
892
  });
475
893
  await Promise.allSettled(queryPromises);
476
- log.info({ eventCount: events.length }, "Query complete");
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
- log.info({ protocol, identifier, relayCount: relays.length }, "Querying relays for identity");
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
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
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
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
941
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
524
942
  reject(err);
525
943
  };
526
944
  });
527
945
  } catch (err) {
528
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
946
+ log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
529
947
  }
530
948
  });
531
949
  await Promise.allSettled(queryPromises);
532
- log.info({ eventCount: events.length }, "Query complete");
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
- log.info({ relayCount: relays.length, limit }, "Querying relays for recent attestations");
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
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
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
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
996
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
579
997
  reject(err);
580
998
  };
581
999
  });
582
1000
  } catch (err) {
583
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
1001
+ log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
584
1002
  }
585
1003
  });
586
1004
  await Promise.allSettled(queryPromises);
587
- const seen = /* @__PURE__ */ new Set();
588
- const envelopes = [];
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
- log.error({ error: err }, "Failed to verify attestation envelope");
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 getVerificationUrl(attestationId, baseUrl = "https://attest.ochk.io") {
799
- return `${baseUrl}/attest/${attestationId}`;
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 extractAttestationIdFromUrl(url) {
1026
+ function signedEnvelope(envelope) {
1027
+ let parsed;
802
1028
  try {
803
- const u = new URL(url);
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
- // src/scoring.ts
815
- function scoreV0(sats, days) {
816
- if (sats <= 0 || days <= 0) return 0;
817
- return Math.round(Math.log(1 + sats) * (1 + days / 30) * 100) / 100;
818
- }
819
- function scoreTier(sats, days) {
820
- if (sats >= 1e7 && days >= 365) return "platinum";
821
- if (sats >= 1e6 && days >= 180) return "gold";
822
- if (sats >= 1e5 && days >= 90) return "silver";
823
- if (sats >= 1e4 && days >= 30) return "bronze";
824
- return "none";
825
- }
826
- function computeScore(sats, days, config) {
827
- switch (config.algorithm) {
828
- case "v0":
829
- return scoreV0(sats, days);
830
- case "tier":
831
- return scoreTier(sats, days);
832
- case "none":
833
- return null;
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 computeAllScores(sats, days) {
837
- return { v0: scoreV0(sats, days), tier: scoreTier(sats, days) };
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/verify.ts
841
- var log4 = createLogger("ocp/verify");
842
- function looksLikeHex(s) {
843
- return /^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0;
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 sig;
1090
+ return !!window.nostr;
850
1091
  }
851
- function detectSignatureScheme(addr, sig) {
852
- const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
853
- if (isSegWit) {
854
- return "bip322";
1092
+ async function getNostrPublicKey() {
1093
+ if (!isNip07Available()) {
1094
+ log3.warn("NIP-07 extension not available");
1095
+ return null;
855
1096
  }
856
- const sigBase64 = toBase64Maybe(sig);
857
- const looksLikeLegacy = sigBase64.length === 88 && /^[IHG]/.test(sigBase64);
858
- if (looksLikeLegacy) {
859
- return "legacy";
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 verifySignature({
864
- addr,
865
- msg,
866
- sig,
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
- identities = parseIdentities(identitiesStr);
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
- log4.warn({ identitiesStr, error: err }, "Failed to parse identities field");
1118
+ log3.error({ error: err }, "Failed to sign Nostr event");
1119
+ return null;
959
1120
  }
960
- const coreKeys = /* @__PURE__ */ new Set([
961
- "orangecheck",
962
- "identities",
963
- "address",
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 network = extensions["network"] || "mainnet";
978
- const bondStr = extensions["bond"];
979
- const bond = bondStr ? parseInt(bondStr, 10) : void 0;
1126
+ const nostr = window.nostr;
980
1127
  return {
981
- core: { address, identities },
982
- network,
983
- extensions,
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
- async function getAddressUtxos(address, network, opts) {
988
- const endpoints = network === "signet" ? [
989
- opts?.esploraSignetBase || "https://mempool.space/signet/api",
990
- "https://blockstream.info/signet/api"
991
- ] : network === "testnet" ? [
992
- opts?.esploraTestnetBase || "https://mempool.space/testnet/api",
993
- "https://blockstream.info/testnet/api"
994
- ] : [
995
- opts?.esploraMainnetBase || "https://mempool.space/api",
996
- "https://blockstream.info/api"
997
- ];
998
- let lastError = null;
999
- for (const base of endpoints) {
1000
- try {
1001
- const url = `${base}/address/${address}/utxo`;
1002
- log4.debug({ url, network }, "Fetching UTXOs");
1003
- const resp = await fetch(url, {
1004
- method: "GET",
1005
- headers: { Accept: "application/json" },
1006
- signal: AbortSignal.timeout(5e3)
1007
- });
1008
- if (!resp.ok) {
1009
- throw new Error(`HTTP ${resp.status}`);
1010
- }
1011
- const data = await resp.json();
1012
- if (!Array.isArray(data)) {
1013
- throw new Error("Invalid response: expected array");
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.error({ endpoints, error: lastError?.message, network }, "All Esplora endpoints failed");
1031
- throw new Error(`esplora_all_failed: ${lastError?.message || "unknown"}`);
1161
+ log4.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
1162
+ return envelope;
1032
1163
  }
1033
- function computeMetrics(utxos, bondAmount, coverSats) {
1034
- const confirmedUtxos = utxos.filter((u) => u.status.confirmed);
1035
- if (confirmedUtxos.length === 0) {
1036
- return { sats_bonded: 0, days_unspent: 0, confirmed_balance: 0 };
1037
- }
1038
- const confirmed_balance = confirmedUtxos.reduce((sum, u) => sum + u.value, 0);
1039
- if (bondAmount !== void 0 && bondAmount > 0) {
1040
- const sorted = [...confirmedUtxos].sort((a, b) => {
1041
- const heightA = a.status.block_height ?? Infinity;
1042
- const heightB = b.status.block_height ?? Infinity;
1043
- if (heightA !== heightB) return heightA - heightB;
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
- const sats_bonded = confirmed_balance;
1066
- if (coverSats !== void 0 && coverSats > 0) {
1067
- const sorted = [...confirmedUtxos].sort(
1068
- (a, b) => (a.status.block_height ?? Infinity) - (b.status.block_height ?? Infinity)
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 oldestTime = Math.min(
1084
- ...confirmedUtxos.map((u) => u.status.block_time || Date.now() / 1e3)
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
- const days_unspent = Math.max(0, Math.floor((Date.now() / 1e3 - oldestTime) / (24 * 60 * 60)));
1087
- return { sats_bonded, days_unspent, confirmed_balance };
1191
+ return result;
1088
1192
  }
1089
- async function verify(input, opts = {}) {
1090
- const codes = [];
1091
- let network = "mainnet";
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
- if (!input.msg || !input.addr || !input.sig) {
1094
- codes.push("bad_request");
1095
- return { ok: false, codes, network };
1096
- }
1097
- const parsed = parseCanonicalMessage(input.msg);
1098
- network = parsed.network || "mainnet";
1099
- const attestation_id = await generateAttestationId(input.msg);
1100
- if (parsed.core.address !== input.addr) {
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 envelopes = found.filter((e) => signedMatch(e, { addr, id, identity }));
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
- envelopes.sort((a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime());
1240
- const env = envelopes[0];
1241
- if (!env) {
1242
- return { ok: false, sats: 0, days: 0, score: 0, reasons: ["not_found"] };
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.identities,
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
- for (const b of signedIdentities(e)) {
1313
- if (b.protocol.trim().toLowerCase() !== protocol) continue;
1314
- if (identityKey(b) !== want) return true;
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;