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