@orangecheck/sdk 1.14.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,615 +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
- const kept = /* @__PURE__ */ new Set();
752
- for (const event of events) {
753
- const envelope = parseAttestationFromEvent(event);
754
- if (envelope && kept.has(envelope.attestation_id)) continue;
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
- kept.add(envelope.attestation_id);
761
- envelopes.push(envelope);
762
- } else {
763
- log3.warn(
764
- { attestationId: envelope.attestation_id },
765
- "Skipping invalid attestation envelope"
766
- );
767
- }
768
- }
769
- }
770
- log3.info({ eventCount: events.length, validCount: envelopes.length }, "Discovery complete");
771
- return envelopes;
772
- }
773
- async function verifyAttestationById(attestationId, relays = DEFAULT_RELAYS) {
774
- log3.info({ attestationId }, "Verifying attestation by ID");
775
- const envelopes = await discoverAttestations({ attestationId, relays });
776
- if (envelopes.length === 0) {
777
- log3.warn({ attestationId }, "Attestation not found on any relay");
778
- return null;
779
- }
780
- if (envelopes.length > 1) {
781
- log3.warn(
782
- { attestationId, count: envelopes.length },
783
- "Multiple attestations found with same ID - using first"
784
- );
785
- }
786
- return envelopes[0] || null;
787
- }
788
- async function getAttestationsForAddress(address, relays = DEFAULT_RELAYS) {
789
- log3.info({ address }, "Getting all attestations for address");
790
- return discoverAttestations({ address, relays });
791
- }
792
- async function getAttestationsForIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
793
- log3.info({ protocol, identifier }, "Getting all attestations for identity");
794
- return discoverAttestations({ identity: { protocol, identifier }, relays });
795
- }
796
- function formatIdentitiesForDisplay(identities) {
797
- return identities.map((id) => `${id.protocol}:${id.identifier}`).join(", ");
1018
+ }
798
1019
  }
799
- function getVerificationUrl(attestationId, baseUrl = "https://attest.ochk.io") {
800
- 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, "");
801
1023
  }
802
- function extractAttestationIdFromUrl(url) {
1024
+ function signedEnvelope(envelope) {
1025
+ let parsed;
803
1026
  try {
804
- const u = new URL(url);
805
- const m = u.pathname.match(/^\/attest\/([0-9a-f]{64})\/?$/i);
806
- if (m) return m[1].toLowerCase();
807
- const q = u.searchParams.get("id");
808
- if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase();
809
- return null;
1027
+ parsed = parseCanonicalMessage(envelope.message);
810
1028
  } catch {
811
1029
  return null;
812
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
+ };
813
1041
  }
814
-
815
- // src/scoring.ts
816
- function scoreV0(sats, days) {
817
- if (sats <= 0 || days <= 0) return 0;
818
- return Math.round(Math.log(1 + sats) * (1 + days / 30) * 100) / 100;
819
- }
820
- function scoreTier(sats, days) {
821
- if (sats >= 1e7 && days >= 365) return "platinum";
822
- if (sats >= 1e6 && days >= 180) return "gold";
823
- if (sats >= 1e5 && days >= 90) return "silver";
824
- if (sats >= 1e4 && days >= 30) return "bronze";
825
- return "none";
826
- }
827
- function computeScore(sats, days, config) {
828
- switch (config.algorithm) {
829
- case "v0":
830
- return scoreV0(sats, days);
831
- case "tier":
832
- return scoreTier(sats, days);
833
- case "none":
834
- 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]);
835
1069
  }
1070
+ return kept;
836
1071
  }
837
- function computeAllScores(sats, days) {
838
- 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
+ }
839
1080
  }
840
1081
 
841
- // src/verify.ts
842
- var log4 = createLogger("ocp/verify");
843
- function looksLikeHex(s) {
844
- return /^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0;
845
- }
846
- function toBase64Maybe(sig) {
847
- if (looksLikeHex(sig)) {
848
- 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;
849
1087
  }
850
- return sig;
1088
+ return !!window.nostr;
851
1089
  }
852
- function detectSignatureScheme(addr, sig) {
853
- const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
854
- if (isSegWit) {
855
- return "bip322";
1090
+ async function getNostrPublicKey() {
1091
+ if (!isNip07Available()) {
1092
+ log3.warn("NIP-07 extension not available");
1093
+ return null;
856
1094
  }
857
- const sigBase64 = toBase64Maybe(sig);
858
- const looksLikeLegacy = sigBase64.length === 88 && /^[IHG]/.test(sigBase64);
859
- if (looksLikeLegacy) {
860
- 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;
861
1103
  }
862
- return "bip322";
863
1104
  }
864
- async function verifySignature({
865
- addr,
866
- msg,
867
- sig,
868
- scheme
869
- }) {
870
- const detectedScheme = scheme || detectSignatureScheme(addr, sig);
871
- log4.debug(
872
- { addr, msgLength: msg.length, sigLength: sig.length, scheme: detectedScheme },
873
- "Verifying signature"
874
- );
875
- if (detectedScheme === "bip322") {
876
- const sigBase64 = toBase64Maybe(sig);
877
- log4.debug(
878
- { sigBase64: sigBase64.substring(0, 50) + "..." },
879
- "Signature converted to base64"
880
- );
881
- log4.debug(
882
- {
883
- msgFull: msg,
884
- msgLines: msg.split("\n").length,
885
- msgFirstLine: msg.split("\n")[0],
886
- msgLastLine: msg.split("\n")[msg.split("\n").length - 1]
887
- },
888
- "Message being verified"
889
- );
890
- const ok = verifySignature$1(addr, msg, sigBase64);
891
- log4.info({ addr, ok }, "BIP-322 verification result");
892
- if (!ok) {
893
- const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
894
- if (isSegWit && sigBase64.length === 88) {
895
- log4.warn(
896
- { addr, wallet: "possibly Electrum", sigLength: sigBase64.length },
897
- "BIP-322 verification failed - likely Electrum wallet (does not support BIP-322 for SegWit)"
898
- );
899
- } else {
900
- log4.error(
901
- {
902
- addr,
903
- sigLength: sigBase64.length,
904
- msgLength: msg.length,
905
- msgPreview: msg.substring(0, 200)
906
- },
907
- "BIP-322 verification failed - signature does not match message"
908
- );
909
- }
910
- }
911
- return ok ? "sig_ok_bip322" : "sig_invalid";
912
- }
913
- if (detectedScheme === "legacy") {
914
- const sigBase64 = toBase64Maybe(sig);
915
- if (!addr.startsWith("1") && !addr.startsWith("m") && !addr.startsWith("n")) {
916
- log4.warn({ addr }, "Legacy signature attempted on non-P2PKH address");
917
- return "sig_unsupported_script";
918
- }
919
- log4.debug(
920
- {
921
- addr,
922
- sigBase64: sigBase64.substring(0, 50) + "...",
923
- msgPreview: msg.substring(0, 100) + "...",
924
- msgLength: msg.length
925
- },
926
- "Verifying legacy signature"
927
- );
928
- const ok = verifySignature$1(addr, msg, sigBase64);
929
- log4.info({ addr, ok, sigBase64Length: sigBase64.length }, "Legacy verification result");
930
- if (!ok) {
931
- log4.error(
932
- {
933
- addr,
934
- msgFirstLine: msg.split("\n")[0],
935
- sigFormat: sigBase64.substring(0, 10)
936
- },
937
- "Legacy signature verification failed - check message format and signature"
938
- );
939
- }
940
- 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;
941
1109
  }
942
- return "invalid_scheme";
943
- }
944
- function parseCanonicalMessage(msg) {
945
- const lines = msg.split("\n").map((l) => l.trim());
946
- const getVal = (prefix) => {
947
- const needle = prefix + ":";
948
- const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
949
- if (!hit) return void 0;
950
- const idx = hit.indexOf(":");
951
- return hit.slice(idx + 1).trim();
952
- };
953
- const address = getVal("address") || "";
954
- const identitiesStr = getVal("identities") || "";
955
- let identities = [];
956
1110
  try {
957
- 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;
958
1115
  } catch (err) {
959
- log4.warn({ identitiesStr, error: err }, "Failed to parse identities field");
1116
+ log3.error({ error: err }, "Failed to sign Nostr event");
1117
+ return null;
960
1118
  }
961
- const coreKeys = /* @__PURE__ */ new Set([
962
- "orangecheck",
963
- "identities",
964
- "address",
965
- "purpose",
966
- "nonce",
967
- "issued_at",
968
- "ack"
969
- ]);
970
- const extensions = {};
971
- for (const line of lines) {
972
- const idx = line.indexOf(":");
973
- if (idx === -1) continue;
974
- const key = line.slice(0, idx).trim().toLowerCase();
975
- const val = line.slice(idx + 1).trim();
976
- if (!coreKeys.has(key) && val) extensions[key] = val;
1119
+ }
1120
+ function getNip07Info() {
1121
+ if (!isNip07Available()) {
1122
+ return { available: false };
977
1123
  }
978
- const network = extensions["network"] || "mainnet";
979
- const bondStr = extensions["bond"];
980
- const bond = bondStr ? parseInt(bondStr, 10) : void 0;
1124
+ const nostr = window.nostr;
981
1125
  return {
982
- core: { address, identities },
983
- network,
984
- extensions,
985
- bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
1126
+ available: true,
1127
+ name: nostr._metadata?.name,
1128
+ version: nostr._metadata?.version
986
1129
  };
987
1130
  }
988
- async function getAddressUtxos(address, network, opts) {
989
- const endpoints = network === "signet" ? [
990
- opts?.esploraSignetBase || "https://mempool.space/signet/api",
991
- "https://blockstream.info/signet/api"
992
- ] : network === "testnet" ? [
993
- opts?.esploraTestnetBase || "https://mempool.space/testnet/api",
994
- "https://blockstream.info/testnet/api"
995
- ] : [
996
- opts?.esploraMainnetBase || "https://mempool.space/api",
997
- "https://blockstream.info/api"
998
- ];
999
- let lastError = null;
1000
- for (const base of endpoints) {
1001
- try {
1002
- const url = `${base}/address/${address}/utxo`;
1003
- log4.debug({ url, network }, "Fetching UTXOs");
1004
- const resp = await fetch(url, {
1005
- method: "GET",
1006
- headers: { Accept: "application/json" },
1007
- signal: AbortSignal.timeout(5e3)
1008
- });
1009
- if (!resp.ok) {
1010
- throw new Error(`HTTP ${resp.status}`);
1011
- }
1012
- const data = await resp.json();
1013
- if (!Array.isArray(data)) {
1014
- throw new Error("Invalid response: expected array");
1015
- }
1016
- for (const utxo of data) {
1017
- if (typeof utxo.value !== "number" || typeof utxo.status !== "object" || typeof utxo.status.confirmed !== "boolean" || typeof utxo.txid !== "string" || typeof utxo.vout !== "number") {
1018
- throw new Error("Invalid UTXO structure");
1019
- }
1020
- }
1021
- log4.info({ base, count: data.length, network }, "UTXOs fetched successfully");
1022
- return data;
1023
- } catch (err) {
1024
- lastError = err instanceof Error ? err : new Error(String(err));
1025
- log4.warn(
1026
- { base, error: lastError.message, network },
1027
- "Esplora endpoint failed, trying next"
1028
- );
1029
- }
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
+ );
1030
1158
  }
1031
- log4.error({ endpoints, error: lastError?.message, network }, "All Esplora endpoints failed");
1032
- throw new Error(`esplora_all_failed: ${lastError?.message || "unknown"}`);
1159
+ log4.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
1160
+ return envelope;
1033
1161
  }
1034
- function computeMetrics(utxos, bondAmount, coverSats) {
1035
- const confirmedUtxos = utxos.filter((u) => u.status.confirmed);
1036
- if (confirmedUtxos.length === 0) {
1037
- return { sats_bonded: 0, days_unspent: 0, confirmed_balance: 0 };
1038
- }
1039
- const confirmed_balance = confirmedUtxos.reduce((sum, u) => sum + u.value, 0);
1040
- if (bondAmount !== void 0 && bondAmount > 0) {
1041
- const sorted = [...confirmedUtxos].sort((a, b) => {
1042
- const heightA = a.status.block_height ?? Infinity;
1043
- const heightB = b.status.block_height ?? Infinity;
1044
- if (heightA !== heightB) return heightA - heightB;
1045
- if (a.txid !== b.txid) return a.txid.localeCompare(b.txid);
1046
- return a.vout - b.vout;
1047
- });
1048
- let sum = 0;
1049
- let youngestTime = 0;
1050
- for (const utxo of sorted) {
1051
- sum += utxo.value;
1052
- youngestTime = Math.max(youngestTime, utxo.status.block_time || 0);
1053
- if (sum >= bondAmount) break;
1054
- }
1055
- const days_unspent2 = Math.max(
1056
- 0,
1057
- 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"
1058
1173
  );
1059
- return {
1060
- sats_bonded: bondAmount,
1061
- // Use exactly bond amount (ignore surplus)
1062
- days_unspent: days_unspent2,
1063
- confirmed_balance
1064
- };
1065
1174
  }
1066
- const sats_bonded = confirmed_balance;
1067
- if (coverSats !== void 0 && coverSats > 0) {
1068
- const sorted = [...confirmedUtxos].sort(
1069
- (a, b) => (a.status.block_height ?? Infinity) - (b.status.block_height ?? Infinity)
1070
- );
1071
- let sum = 0;
1072
- let youngestTime = 0;
1073
- for (const utxo of sorted) {
1074
- sum += utxo.value;
1075
- youngestTime = Math.max(youngestTime, utxo.status.block_time || Date.now() / 1e3);
1076
- if (sum >= coverSats) break;
1077
- }
1078
- const days_unspent2 = Math.max(
1079
- 0,
1080
- Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
1081
- );
1082
- 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");
1083
1179
  }
1084
- const oldestTime = Math.min(
1085
- ...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"
1086
1188
  );
1087
- const days_unspent = Math.max(0, Math.floor((Date.now() / 1e3 - oldestTime) / (24 * 60 * 60)));
1088
- return { sats_bonded, days_unspent, confirmed_balance };
1189
+ return result;
1089
1190
  }
1090
- async function verify(input, opts = {}) {
1091
- const codes = [];
1092
- 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) {
1093
1250
  try {
1094
- if (!input.msg || !input.addr || !input.sig) {
1095
- codes.push("bad_request");
1096
- return { ok: false, codes, network };
1097
- }
1098
- const parsed = parseCanonicalMessage(input.msg);
1099
- network = parsed.network || "mainnet";
1100
- const attestation_id = await generateAttestationId(input.msg);
1101
- if (parsed.core.address !== input.addr) {
1102
- codes.push("bad_request");
1103
- return { ok: false, codes, network, attestation_id };
1104
- }
1105
- if ((network === "testnet" || network === "signet") && !opts.testMode) {
1106
- codes.push("network_testmode");
1107
- return { ok: false, codes, network, attestation_id };
1108
- }
1109
- if (opts.expectedAud) {
1110
- const aud = parsed.extensions["aud"];
1111
- if (!aud || aud !== opts.expectedAud) {
1112
- codes.push("aud_mismatch");
1113
- return { ok: false, codes, network, attestation_id };
1114
- }
1115
- }
1116
- if (parsed.extensions["expires"]) {
1117
- const exp = Date.parse(parsed.extensions["expires"]);
1118
- if (!Number.isNaN(exp) && exp < Date.now()) {
1119
- codes.push("expired");
1120
- return { ok: false, codes, network, attestation_id };
1121
- }
1122
- }
1123
- const sigCode = await verifySignature({
1124
- addr: input.addr,
1125
- msg: input.msg,
1126
- sig: input.sig,
1127
- ...input.scheme && { scheme: input.scheme }
1128
- });
1129
- codes.push(sigCode);
1130
- const sigOk = sigCode === "sig_ok_bip322" || sigCode === "sig_ok_legacy";
1131
- if (!sigOk) {
1132
- return {
1133
- ok: false,
1134
- codes,
1135
- network,
1136
- attestation_id,
1137
- identities: parsed.core.identities
1138
- };
1139
- }
1140
- try {
1141
- const utxos = await getAddressUtxos(input.addr, network, {
1142
- esploraMainnetBase: opts.esploraMainnetBase,
1143
- esploraSignetBase: opts.esploraSignetBase
1144
- });
1145
- const { sats_bonded, days_unspent, confirmed_balance } = computeMetrics(
1146
- utxos,
1147
- parsed.bond,
1148
- opts.coverSats
1149
- );
1150
- if (parsed.bond !== void 0 && parsed.bond > 0) {
1151
- if (confirmed_balance < parsed.bond) {
1152
- codes.push("bond_insufficient");
1153
- log4.warn(
1154
- {
1155
- bond: parsed.bond,
1156
- confirmed_balance,
1157
- address: input.addr
1158
- },
1159
- "Bond extension: insufficient balance"
1160
- );
1161
- return {
1162
- ok: false,
1163
- codes,
1164
- network,
1165
- attestation_id,
1166
- identities: parsed.core.identities
1167
- };
1168
- }
1169
- }
1170
- if (sats_bonded <= 0) {
1171
- codes.push("bond_zero");
1172
- if (utxos.some((u) => !u.status.confirmed)) {
1173
- codes.push("bond_pending");
1174
- }
1175
- return {
1176
- ok: false,
1177
- codes,
1178
- network,
1179
- attestation_id,
1180
- identities: parsed.core.identities
1181
- };
1182
- } else {
1183
- codes.push("bond_confirmed");
1184
- }
1185
- const metrics = {
1186
- sats_bonded,
1187
- days_unspent,
1188
- score: computeScore(sats_bonded, days_unspent, { algorithm: "v0" })
1189
- };
1190
- return {
1191
- ok: true,
1192
- codes,
1193
- network,
1194
- attestation_id,
1195
- identities: parsed.core.identities,
1196
- metrics
1197
- };
1198
- } catch (utxoError) {
1199
- log4.error({ err: utxoError }, "UTXO fetch failed");
1200
- codes.push("bad_request");
1201
- return { ok: false, codes, network, attestation_id };
1202
- }
1203
- } catch (e) {
1204
- const msg = e instanceof Error ? e.message : String(e ?? "decode_error");
1205
- if (msg === "decode_error") {
1206
- codes.push("decode_error");
1207
- } else {
1208
- codes.push("bad_request");
1209
- }
1210
- 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;
1211
1259
  }
1212
1260
  }
1213
1261
 
1214
1262
  // src/check.ts
1263
+ var ISSUED_AHEAD_MS = 12e4;
1264
+ var MAX_CANDIDATES = 20;
1215
1265
  async function check(params) {
1216
1266
  const {
1217
1267
  addr,
@@ -1233,14 +1283,17 @@ async function check(params) {
1233
1283
  } else if (identity) {
1234
1284
  found = await getAttestationsForIdentity(identity.protocol, identity.identifier, relays);
1235
1285
  }
1236
- 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);
1237
1288
  if (envelopes.length === 0) {
1238
1289
  return { ok: false, sats: 0, days: 0, score: 0, reasons: ["not_found"] };
1239
1290
  }
1240
- envelopes.sort((a, b) => new Date(b.issued_at).getTime() - new Date(a.issued_at).getTime());
1241
- const env = envelopes[0];
1242
- if (!env) {
1243
- 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
+ }
1244
1297
  }
1245
1298
  const shared = identity ? await backsOtherIdentity(env, identity, relays) : false;
1246
1299
  const outcome = await verify(
@@ -1268,7 +1321,7 @@ async function check(params) {
1268
1321
  score,
1269
1322
  attestation_id: env.attestation_id,
1270
1323
  address: env.address,
1271
- identities: env.identities,
1324
+ identities: signedIdentities(env),
1272
1325
  network: outcome.network,
1273
1326
  ...reasons.length ? { reasons } : {}
1274
1327
  };
@@ -1304,16 +1357,19 @@ function signedMatch(env, q) {
1304
1357
  }
1305
1358
  return false;
1306
1359
  }
1360
+ function signedBy(e) {
1361
+ return signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme });
1362
+ }
1307
1363
  async function backsOtherIdentity(env, identity, relays) {
1308
1364
  const want = identityKey(identity);
1309
1365
  const protocol = identity.protocol.trim().toLowerCase();
1310
1366
  const siblings = await getAttestationsForAddress(env.address, relays);
1311
1367
  for (const e of [env, ...siblings]) {
1312
1368
  if (!signedMatch(e, { addr: env.address })) continue;
1313
- for (const b of signedIdentities(e)) {
1314
- if (b.protocol.trim().toLowerCase() !== protocol) continue;
1315
- if (identityKey(b) !== want) return true;
1316
- }
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;
1317
1373
  }
1318
1374
  return false;
1319
1375
  }
@@ -2418,6 +2474,6 @@ var STATUS_META = {
2418
2474
  }
2419
2475
  };
2420
2476
 
2421
- 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 };
2422
2478
  //# sourceMappingURL=index.mjs.map
2423
2479
  //# sourceMappingURL=index.mjs.map