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