@orangecheck/sdk 1.14.1 → 1.16.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
@@ -1,6 +1,6 @@
1
1
  import { bech32 } from '@scure/base';
2
- import { Buffer } from 'buffer';
3
2
  import { verifySignature as verifySignature$1, decodeAddress } from '@orangecheck/bip322';
3
+ import { Buffer } from 'buffer';
4
4
  import { hmac } from '@noble/hashes/hmac.js';
5
5
  import { sha256 } from '@noble/hashes/sha2.js';
6
6
  import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
@@ -212,8 +212,345 @@ 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
+ function toBase64Maybe(sig) {
249
+ if (/^[0-9a-fA-F]+$/.test(sig) && sig.length % 2 === 0) {
250
+ return Buffer.from(sig, "hex").toString("base64");
251
+ }
252
+ return sig;
253
+ }
254
+ function signatureScheme(sigBase64) {
255
+ if (/^(ful|pof)/.test(sigBase64)) return null;
256
+ if (sigBase64.startsWith("smp")) return "bip322";
257
+ return Buffer.from(sigBase64, "base64").length === 65 ? "legacy" : "bip322";
258
+ }
259
+
260
+ // src/verify.ts
261
+ var log = createLogger("ocp/verify");
262
+ async function verifySignature({ addr, msg, sig }) {
263
+ const sigBase64 = toBase64Maybe(sig);
264
+ const scheme = signatureScheme(sigBase64);
265
+ if (!scheme) return "sig_unsupported_script";
266
+ const ok = verifySignature$1(addr, msg, sigBase64);
267
+ log.info({ addr, ok, scheme, msgLength: msg.length }, "Signature verification result");
268
+ if (!ok) return "sig_invalid";
269
+ return scheme === "legacy" ? "sig_ok_legacy" : "sig_ok_bip322";
270
+ }
271
+ async function signatureIsValid(input) {
272
+ try {
273
+ const code = await verifySignature(input);
274
+ return code === "sig_ok_bip322" || code === "sig_ok_legacy";
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+ function parseCanonicalMessage(msg) {
280
+ const lines = msg.split("\n").map((l) => l.trim());
281
+ const getVal = (prefix) => {
282
+ const needle = prefix + ":";
283
+ const hit = lines.find((l) => l.toLowerCase().startsWith(needle));
284
+ if (!hit) return void 0;
285
+ const idx = hit.indexOf(":");
286
+ return hit.slice(idx + 1).trim();
287
+ };
288
+ const address = getVal("address") || "";
289
+ const identitiesStr = getVal("identities") || "";
290
+ const issued_at = getVal("issued_at") || "";
291
+ const expires = getVal("expires");
292
+ let identities = [];
293
+ try {
294
+ identities = parseIdentities(identitiesStr);
295
+ } catch (err) {
296
+ log.warn({ identitiesStr, error: err }, "Failed to parse identities field");
297
+ }
298
+ const coreKeys = /* @__PURE__ */ new Set([
299
+ "orangecheck",
300
+ "identities",
301
+ "address",
302
+ "purpose",
303
+ "nonce",
304
+ "issued_at",
305
+ "ack"
306
+ ]);
307
+ const extensions = {};
308
+ for (const line of lines) {
309
+ const idx = line.indexOf(":");
310
+ if (idx === -1) continue;
311
+ const key = line.slice(0, idx).trim().toLowerCase();
312
+ const val = line.slice(idx + 1).trim();
313
+ if (!coreKeys.has(key) && val) extensions[key] = val;
314
+ }
315
+ const network = extensions["network"] || "mainnet";
316
+ const bondStr = extensions["bond"];
317
+ const bond = bondStr ? parseInt(bondStr, 10) : void 0;
318
+ return {
319
+ core: { address, identities, issued_at },
320
+ expires,
321
+ network,
322
+ extensions,
323
+ bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
324
+ };
325
+ }
326
+ async function getAddressUtxos(address, network, opts) {
327
+ const endpoints = network === "signet" ? [
328
+ opts?.esploraSignetBase || "https://mempool.space/signet/api",
329
+ "https://blockstream.info/signet/api"
330
+ ] : network === "testnet" ? [
331
+ opts?.esploraTestnetBase || "https://mempool.space/testnet/api",
332
+ "https://blockstream.info/testnet/api"
333
+ ] : [
334
+ opts?.esploraMainnetBase || "https://mempool.space/api",
335
+ "https://blockstream.info/api"
336
+ ];
337
+ let lastError = null;
338
+ for (const base of endpoints) {
339
+ try {
340
+ const url = `${base}/address/${address}/utxo`;
341
+ log.debug({ url, network }, "Fetching UTXOs");
342
+ const resp = await fetch(url, {
343
+ method: "GET",
344
+ headers: { Accept: "application/json" },
345
+ signal: AbortSignal.timeout(5e3)
346
+ });
347
+ if (!resp.ok) {
348
+ throw new Error(`HTTP ${resp.status}`);
349
+ }
350
+ const data = await resp.json();
351
+ if (!Array.isArray(data)) {
352
+ throw new Error("Invalid response: expected array");
353
+ }
354
+ for (const utxo of data) {
355
+ if (typeof utxo.value !== "number" || typeof utxo.status !== "object" || typeof utxo.status.confirmed !== "boolean" || typeof utxo.txid !== "string" || typeof utxo.vout !== "number") {
356
+ throw new Error("Invalid UTXO structure");
357
+ }
358
+ }
359
+ log.info({ base, count: data.length, network }, "UTXOs fetched successfully");
360
+ return data;
361
+ } catch (err) {
362
+ lastError = err instanceof Error ? err : new Error(String(err));
363
+ log.warn(
364
+ { base, error: lastError.message, network },
365
+ "Esplora endpoint failed, trying next"
366
+ );
367
+ }
368
+ }
369
+ log.error({ endpoints, error: lastError?.message, network }, "All Esplora endpoints failed");
370
+ throw new Error(`esplora_all_failed: ${lastError?.message || "unknown"}`);
371
+ }
372
+ function computeMetrics(utxos, bondAmount, coverSats) {
373
+ const confirmedUtxos = utxos.filter((u) => u.status.confirmed);
374
+ if (confirmedUtxos.length === 0) {
375
+ return { sats_bonded: 0, days_unspent: 0, confirmed_balance: 0 };
376
+ }
377
+ const confirmed_balance = confirmedUtxos.reduce((sum, u) => sum + u.value, 0);
378
+ if (bondAmount !== void 0 && bondAmount > 0) {
379
+ const sorted = [...confirmedUtxos].sort((a, b) => {
380
+ const heightA = a.status.block_height ?? Infinity;
381
+ const heightB = b.status.block_height ?? Infinity;
382
+ if (heightA !== heightB) return heightA - heightB;
383
+ if (a.txid !== b.txid) return a.txid.localeCompare(b.txid);
384
+ return a.vout - b.vout;
385
+ });
386
+ let sum = 0;
387
+ let youngestTime = 0;
388
+ for (const utxo of sorted) {
389
+ sum += utxo.value;
390
+ youngestTime = Math.max(youngestTime, utxo.status.block_time || 0);
391
+ if (sum >= bondAmount) break;
392
+ }
393
+ const days_unspent2 = Math.max(
394
+ 0,
395
+ Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
396
+ );
397
+ return {
398
+ sats_bonded: bondAmount,
399
+ // Use exactly bond amount (ignore surplus)
400
+ days_unspent: days_unspent2,
401
+ confirmed_balance
402
+ };
403
+ }
404
+ const sats_bonded = confirmed_balance;
405
+ if (coverSats !== void 0 && coverSats > 0) {
406
+ const sorted = [...confirmedUtxos].sort(
407
+ (a, b) => (a.status.block_height ?? Infinity) - (b.status.block_height ?? Infinity)
408
+ );
409
+ let sum = 0;
410
+ let youngestTime = 0;
411
+ for (const utxo of sorted) {
412
+ sum += utxo.value;
413
+ youngestTime = Math.max(youngestTime, utxo.status.block_time || Date.now() / 1e3);
414
+ if (sum >= coverSats) break;
415
+ }
416
+ const days_unspent2 = Math.max(
417
+ 0,
418
+ Math.floor((Date.now() / 1e3 - youngestTime) / (24 * 60 * 60))
419
+ );
420
+ return { sats_bonded, days_unspent: days_unspent2, confirmed_balance };
421
+ }
422
+ const oldestTime = Math.min(
423
+ ...confirmedUtxos.map((u) => u.status.block_time || Date.now() / 1e3)
424
+ );
425
+ const days_unspent = Math.max(0, Math.floor((Date.now() / 1e3 - oldestTime) / (24 * 60 * 60)));
426
+ return { sats_bonded, days_unspent, confirmed_balance };
427
+ }
428
+ async function verify(input, opts = {}) {
429
+ const codes = [];
430
+ let network = "mainnet";
431
+ try {
432
+ if (!input.msg || !input.addr || !input.sig) {
433
+ codes.push("bad_request");
434
+ return { ok: false, codes, network };
435
+ }
436
+ const parsed = parseCanonicalMessage(input.msg);
437
+ network = parsed.network || "mainnet";
438
+ const attestation_id = await generateAttestationId(input.msg);
439
+ if (parsed.core.address !== input.addr) {
440
+ codes.push("bad_request");
441
+ return { ok: false, codes, network, attestation_id };
442
+ }
443
+ if ((network === "testnet" || network === "signet") && !opts.testMode) {
444
+ codes.push("network_testmode");
445
+ return { ok: false, codes, network, attestation_id };
446
+ }
447
+ if (opts.expectedAud) {
448
+ const aud = parsed.extensions["aud"];
449
+ if (!aud || aud !== opts.expectedAud) {
450
+ codes.push("aud_mismatch");
451
+ return { ok: false, codes, network, attestation_id };
452
+ }
453
+ }
454
+ if (parsed.expires !== void 0) {
455
+ const exp = rfc3339Ms(parsed.expires);
456
+ if (exp === null || exp < Date.now()) {
457
+ codes.push("expired");
458
+ return { ok: false, codes, network, attestation_id };
459
+ }
460
+ }
461
+ const sigCode = await verifySignature({
462
+ addr: input.addr,
463
+ msg: input.msg,
464
+ sig: input.sig,
465
+ ...input.scheme && { scheme: input.scheme }
466
+ });
467
+ codes.push(sigCode);
468
+ const sigOk = sigCode === "sig_ok_bip322" || sigCode === "sig_ok_legacy";
469
+ if (!sigOk) {
470
+ return {
471
+ ok: false,
472
+ codes,
473
+ network,
474
+ attestation_id,
475
+ identities: parsed.core.identities
476
+ };
477
+ }
478
+ try {
479
+ const utxos = await getAddressUtxos(input.addr, network, {
480
+ esploraMainnetBase: opts.esploraMainnetBase,
481
+ esploraSignetBase: opts.esploraSignetBase
482
+ });
483
+ const { sats_bonded, days_unspent, confirmed_balance } = computeMetrics(
484
+ utxos,
485
+ parsed.bond,
486
+ opts.coverSats
487
+ );
488
+ if (parsed.bond !== void 0 && parsed.bond > 0) {
489
+ if (confirmed_balance < parsed.bond) {
490
+ codes.push("bond_insufficient");
491
+ log.warn(
492
+ {
493
+ bond: parsed.bond,
494
+ confirmed_balance,
495
+ address: input.addr
496
+ },
497
+ "Bond extension: insufficient balance"
498
+ );
499
+ return {
500
+ ok: false,
501
+ codes,
502
+ network,
503
+ attestation_id,
504
+ identities: parsed.core.identities
505
+ };
506
+ }
507
+ }
508
+ if (sats_bonded <= 0) {
509
+ codes.push("bond_zero");
510
+ if (utxos.some((u) => !u.status.confirmed)) {
511
+ codes.push("bond_pending");
512
+ }
513
+ return {
514
+ ok: false,
515
+ codes,
516
+ network,
517
+ attestation_id,
518
+ identities: parsed.core.identities
519
+ };
520
+ } else {
521
+ codes.push("bond_confirmed");
522
+ }
523
+ const metrics = {
524
+ sats_bonded,
525
+ days_unspent,
526
+ score: computeScore(sats_bonded, days_unspent, { algorithm: "v0" })
527
+ };
528
+ return {
529
+ ok: true,
530
+ codes,
531
+ network,
532
+ attestation_id,
533
+ identities: parsed.core.identities,
534
+ metrics
535
+ };
536
+ } catch (utxoError) {
537
+ log.error({ err: utxoError }, "UTXO fetch failed");
538
+ codes.push("bad_request");
539
+ return { ok: false, codes, network, attestation_id };
540
+ }
541
+ } catch (e) {
542
+ const msg = e instanceof Error ? e.message : String(e ?? "decode_error");
543
+ if (msg === "decode_error") {
544
+ codes.push("decode_error");
545
+ } else {
546
+ codes.push("bad_request");
547
+ }
548
+ return { ok: false, codes, network };
549
+ }
550
+ }
551
+
215
552
  // src/nostr.ts
216
- var log = createLogger("ocp/nostr");
553
+ var log2 = createLogger("ocp/nostr");
217
554
  var ATTESTATION_EVENT_KIND = 30078;
218
555
  var DEFAULT_RELAYS = [
219
556
  "wss://relay.damus.io",
@@ -266,7 +603,7 @@ function createAttestationEvent(envelope, pubkey) {
266
603
  async function publishToRelays(event, relays = DEFAULT_RELAYS) {
267
604
  const success = [];
268
605
  const failed = [];
269
- log.info({ eventId: event.id, relayCount: relays.length }, "Publishing event to relays");
606
+ log2.info({ eventId: event.id, relayCount: relays.length }, "Publishing event to relays");
270
607
  const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
271
608
  const publishPromises = relays.map(async (relayUrl) => {
272
609
  let ws = null;
@@ -305,7 +642,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
305
642
  cleanup();
306
643
  resolve();
307
644
  } else {
308
- log.warn(
645
+ log2.warn(
309
646
  { relay: relayUrl, reason: data[3] },
310
647
  "Relay rejected event"
311
648
  );
@@ -315,7 +652,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
315
652
  }
316
653
  }
317
654
  } catch (err) {
318
- log.error(
655
+ log2.error(
319
656
  { relay: relayUrl, error: err },
320
657
  "Failed to parse relay response"
321
658
  );
@@ -325,7 +662,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
325
662
  }
326
663
  };
327
664
  wsRef.onerror = (err) => {
328
- log.error({ relay: relayUrl, error: err }, "WebSocket error");
665
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error");
329
666
  recordFailure();
330
667
  cleanup();
331
668
  reject(err);
@@ -335,7 +672,7 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
335
672
  };
336
673
  });
337
674
  } catch (err) {
338
- log.error({ relay: relayUrl, error: err }, "Failed to publish to relay");
675
+ log2.error({ relay: relayUrl, error: err }, "Failed to publish to relay");
339
676
  recordFailure();
340
677
  } finally {
341
678
  if (ws && ws.readyState !== WebSocket.CLOSED) {
@@ -344,12 +681,12 @@ async function publishToRelays(event, relays = DEFAULT_RELAYS) {
344
681
  }
345
682
  });
346
683
  await Promise.allSettled(publishPromises);
347
- log.info({ successCount: success.length, failedCount: failed.length }, "Publishing complete");
684
+ log2.info({ successCount: success.length, failedCount: failed.length }, "Publishing complete");
348
685
  return { success, failed };
349
686
  }
350
687
  async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
351
688
  const events = [];
352
- log.info({ attestationId, relayCount: relays.length }, "Querying relays for attestation");
689
+ log2.info({ attestationId, relayCount: relays.length }, "Querying relays for attestation");
353
690
  const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
354
691
  const queryPromises = relays.map(async (relayUrl) => {
355
692
  let ws = null;
@@ -389,11 +726,11 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
389
726
  resolve();
390
727
  }
391
728
  } catch (err) {
392
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
729
+ log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
393
730
  }
394
731
  };
395
732
  wsRef.onerror = (err) => {
396
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
733
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
397
734
  cleanup();
398
735
  reject(err);
399
736
  };
@@ -402,7 +739,7 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
402
739
  };
403
740
  });
404
741
  } catch (err) {
405
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
742
+ log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
406
743
  } finally {
407
744
  if (ws && ws.readyState !== WebSocket.CLOSED) {
408
745
  ws.close();
@@ -410,12 +747,12 @@ async function queryByAttestationId(attestationId, relays = DEFAULT_RELAYS) {
410
747
  }
411
748
  });
412
749
  await Promise.allSettled(queryPromises);
413
- log.info({ eventCount: events.length }, "Query complete");
750
+ log2.info({ eventCount: events.length }, "Query complete");
414
751
  return events;
415
752
  }
416
753
  async function queryByAddress(address, relays = DEFAULT_RELAYS) {
417
754
  const events = [];
418
- log.info({ address, relayCount: relays.length }, "Querying relays for address");
755
+ log2.info({ address, relayCount: relays.length }, "Querying relays for address");
419
756
  const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
420
757
  const queryPromises = relays.map(async (relayUrl) => {
421
758
  try {
@@ -457,28 +794,28 @@ async function queryByAddress(address, relays = DEFAULT_RELAYS) {
457
794
  resolve();
458
795
  }
459
796
  } catch (err) {
460
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
797
+ log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
461
798
  }
462
799
  };
463
800
  ws.onerror = (err) => {
464
801
  clearTimeout(timeout);
465
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
802
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
466
803
  reject(err);
467
804
  };
468
805
  });
469
806
  } catch (err) {
470
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
807
+ log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
471
808
  }
472
809
  });
473
810
  await Promise.allSettled(queryPromises);
474
- log.info({ eventCount: events.length }, "Query complete");
811
+ log2.info({ eventCount: events.length }, "Query complete");
475
812
  return events;
476
813
  }
477
814
  async function queryByIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
478
815
  const events = [];
479
816
  const identityTag = `${protocol}:${identifier}`;
480
817
  const identityTags = protocol === "nostr" ? nostrIdentifierForms(identifier).map((f) => `nostr:${f}`) : [identityTag];
481
- log.info({ protocol, identifier, relayCount: relays.length }, "Querying relays for identity");
818
+ log2.info({ protocol, identifier, relayCount: relays.length }, "Querying relays for identity");
482
819
  const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
483
820
  const queryPromises = relays.map(async (relayUrl) => {
484
821
  try {
@@ -513,26 +850,26 @@ async function queryByIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
513
850
  resolve();
514
851
  }
515
852
  } catch (err) {
516
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
853
+ log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
517
854
  }
518
855
  };
519
856
  ws.onerror = (err) => {
520
857
  clearTimeout(timeout);
521
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
858
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
522
859
  reject(err);
523
860
  };
524
861
  });
525
862
  } catch (err) {
526
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
863
+ log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
527
864
  }
528
865
  });
529
866
  await Promise.allSettled(queryPromises);
530
- log.info({ eventCount: events.length }, "Query complete");
867
+ log2.info({ eventCount: events.length }, "Query complete");
531
868
  return events;
532
869
  }
533
870
  async function queryRecent(relays = DEFAULT_RELAYS, limit = 200) {
534
871
  const events = [];
535
- log.info({ relayCount: relays.length, limit }, "Querying relays for recent attestations");
872
+ log2.info({ relayCount: relays.length, limit }, "Querying relays for recent attestations");
536
873
  const deadlineAt = Date.now() + FANOUT_DEADLINE_MS;
537
874
  const queryPromises = relays.map(async (relayUrl) => {
538
875
  try {
@@ -568,30 +905,22 @@ async function queryRecent(relays = DEFAULT_RELAYS, limit = 200) {
568
905
  resolve();
569
906
  }
570
907
  } catch (err) {
571
- log.error({ relay: relayUrl, error: err }, "Failed to parse event");
908
+ log2.error({ relay: relayUrl, error: err }, "Failed to parse event");
572
909
  }
573
910
  };
574
911
  ws.onerror = (err) => {
575
912
  clearTimeout(timeout);
576
- log.error({ relay: relayUrl, error: err }, "WebSocket error during query");
913
+ log2.error({ relay: relayUrl, error: err }, "WebSocket error during query");
577
914
  reject(err);
578
915
  };
579
916
  });
580
917
  } catch (err) {
581
- log.error({ relay: relayUrl, error: err }, "Failed to query relay");
918
+ log2.error({ relay: relayUrl, error: err }, "Failed to query relay");
582
919
  }
583
920
  });
584
921
  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");
922
+ const envelopes = await collectSignedEnvelopes(events);
923
+ log2.info({ eventCount: events.length, unique: envelopes.length }, "Recent query complete");
595
924
  return envelopes;
596
925
  }
597
926
  async function verifyAttestationEnvelope(envelope) {
@@ -603,22 +932,74 @@ async function verifyAttestationEnvelope(envelope) {
603
932
  const computedId = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
604
933
  return computedId === envelope.attestation_id;
605
934
  } catch (err) {
606
- log.error({ error: err }, "Failed to verify attestation envelope");
935
+ log2.error({ error: err }, "Failed to verify attestation envelope");
607
936
  return false;
608
937
  }
609
938
  }
939
+ function base64Url(text) {
940
+ const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(text)));
941
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
942
+ }
943
+ function signedEnvelope(envelope) {
944
+ let parsed;
945
+ try {
946
+ parsed = parseCanonicalMessage(envelope.message);
947
+ } catch {
948
+ return null;
949
+ }
950
+ if (!parsed.core.address || rfc3339Ms(parsed.core.issued_at) === null) return null;
951
+ const { expires_at: _copy, ...rest } = envelope;
952
+ return {
953
+ ...rest,
954
+ address: parsed.core.address,
955
+ identities: parsed.core.identities,
956
+ issued_at: parsed.core.issued_at,
957
+ ...parsed.expires !== void 0 ? { expires_at: parsed.expires } : {},
958
+ message_b64url: base64Url(envelope.message)
959
+ };
960
+ }
961
+ async function collectSignedEnvelopes(events, accept = () => true) {
962
+ const copies = /* @__PURE__ */ new Map();
963
+ for (const event of events) {
964
+ const raw = parseAttestationFromEvent(event);
965
+ if (!raw || !await verifyAttestationEnvelope(raw)) continue;
966
+ const envelope = signedEnvelope(raw);
967
+ if (!envelope || !accept(event, envelope)) continue;
968
+ const same = copies.get(envelope.attestation_id) ?? [];
969
+ if (!same.some((e) => e.signature === envelope.signature && e.scheme === envelope.scheme)) {
970
+ same.push(envelope);
971
+ }
972
+ copies.set(envelope.attestation_id, same);
973
+ }
974
+ const kept = [];
975
+ for (const same of copies.values()) {
976
+ if (same.length === 1) {
977
+ kept.push(same[0]);
978
+ continue;
979
+ }
980
+ let valid;
981
+ for (const e of same) {
982
+ if (await signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme })) {
983
+ valid = e;
984
+ break;
985
+ }
986
+ }
987
+ kept.push(valid ?? same[0]);
988
+ }
989
+ return kept;
990
+ }
610
991
  function parseAttestationFromEvent(event) {
611
992
  try {
612
993
  const envelope = JSON.parse(event.content);
613
994
  return envelope;
614
995
  } catch (err) {
615
- log.error({ eventId: event.id, error: err }, "Failed to parse attestation from event");
996
+ log2.error({ eventId: event.id, error: err }, "Failed to parse attestation from event");
616
997
  return null;
617
998
  }
618
999
  }
619
1000
 
620
1001
  // src/nostr-crypto.ts
621
- var log2 = createLogger("ocp/nostr-crypto");
1002
+ var log3 = createLogger("ocp/nostr-crypto");
622
1003
  function isNip07Available() {
623
1004
  if (typeof window === "undefined") {
624
1005
  return false;
@@ -627,31 +1008,31 @@ function isNip07Available() {
627
1008
  }
628
1009
  async function getNostrPublicKey() {
629
1010
  if (!isNip07Available()) {
630
- log2.warn("NIP-07 extension not available");
1011
+ log3.warn("NIP-07 extension not available");
631
1012
  return null;
632
1013
  }
633
1014
  try {
634
1015
  const nostr = window.nostr;
635
1016
  const pubkey = await nostr.getPublicKey();
636
- log2.info({ pubkey }, "Got Nostr public key from NIP-07");
1017
+ log3.info({ pubkey }, "Got Nostr public key from NIP-07");
637
1018
  return pubkey;
638
1019
  } catch (err) {
639
- log2.error({ error: err }, "Failed to get Nostr public key");
1020
+ log3.error({ error: err }, "Failed to get Nostr public key");
640
1021
  return null;
641
1022
  }
642
1023
  }
643
1024
  async function signNostrEvent(event) {
644
1025
  if (!isNip07Available()) {
645
- log2.warn("NIP-07 extension not available");
1026
+ log3.warn("NIP-07 extension not available");
646
1027
  return null;
647
1028
  }
648
1029
  try {
649
1030
  const nostr = window.nostr;
650
1031
  const signedEvent = await nostr.signEvent(event);
651
- log2.info({ eventId: signedEvent.id }, "Signed Nostr event with NIP-07");
1032
+ log3.info({ eventId: signedEvent.id }, "Signed Nostr event with NIP-07");
652
1033
  return signedEvent;
653
1034
  } catch (err) {
654
- log2.error({ error: err }, "Failed to sign Nostr event");
1035
+ log3.error({ error: err }, "Failed to sign Nostr event");
655
1036
  return null;
656
1037
  }
657
1038
  }
@@ -668,7 +1049,7 @@ function getNip07Info() {
668
1049
  }
669
1050
 
670
1051
  // src/attestation.ts
671
- var log3 = createLogger("ocp/attestation");
1052
+ var log4 = createLogger("ocp/attestation");
672
1053
  var AttestationError = class extends Error {
673
1054
  constructor(message, code) {
674
1055
  super(message);
@@ -678,7 +1059,7 @@ var AttestationError = class extends Error {
678
1059
  };
679
1060
  async function createAttestation(options) {
680
1061
  const { message, signature, scheme, address, identities = [], extensions = {} } = options;
681
- log3.info({ address, identityCount: identities.length }, "Creating attestation");
1062
+ log4.info({ address, identityCount: identities.length }, "Creating attestation");
682
1063
  const envelope = await createAttestationEnvelope(
683
1064
  message,
684
1065
  signature,
@@ -689,529 +1070,117 @@ async function createAttestation(options) {
689
1070
  );
690
1071
  const isValid = await verifyAttestationEnvelope(envelope);
691
1072
  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(", ");
798
- }
799
- function getVerificationUrl(attestationId, baseUrl = "https://attest.ochk.io") {
800
- return `${baseUrl}/attest/${attestationId}`;
801
- }
802
- function extractAttestationIdFromUrl(url) {
803
- 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;
810
- } catch {
811
- return null;
812
- }
813
- }
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;
835
- }
836
- }
837
- function computeAllScores(sats, days) {
838
- return { v0: scoreV0(sats, days), tier: scoreTier(sats, days) };
839
- }
840
-
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");
849
- }
850
- return sig;
851
- }
852
- function detectSignatureScheme(addr, sig) {
853
- const isSegWit = addr.startsWith("bc1") || addr.startsWith("tb1");
854
- if (isSegWit) {
855
- return "bip322";
856
- }
857
- const sigBase64 = toBase64Maybe(sig);
858
- const looksLikeLegacy = sigBase64.length === 88 && /^[IHG]/.test(sigBase64);
859
- if (looksLikeLegacy) {
860
- return "legacy";
861
- }
862
- return "bip322";
863
- }
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";
941
- }
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
- try {
957
- identities = parseIdentities(identitiesStr);
958
- } catch (err) {
959
- log4.warn({ identitiesStr, error: err }, "Failed to parse identities field");
960
- }
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;
977
- }
978
- const network = extensions["network"] || "mainnet";
979
- const bondStr = extensions["bond"];
980
- const bond = bondStr ? parseInt(bondStr, 10) : void 0;
981
- return {
982
- core: { address, identities },
983
- network,
984
- extensions,
985
- bond: bond !== void 0 && !isNaN(bond) && bond > 0 ? bond : void 0
986
- };
987
- }
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
- }
1073
+ throw new AttestationError(
1074
+ "Attestation envelope integrity check failed",
1075
+ "INTEGRITY_CHECK_FAILED"
1076
+ );
1030
1077
  }
1031
- log4.error({ endpoints, error: lastError?.message, network }, "All Esplora endpoints failed");
1032
- throw new Error(`esplora_all_failed: ${lastError?.message || "unknown"}`);
1078
+ log4.info({ attestationId: envelope.attestation_id }, "Attestation created successfully");
1079
+ return envelope;
1033
1080
  }
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))
1081
+ async function publishAttestation(options) {
1082
+ const { envelope, npub, relays = DEFAULT_RELAYS } = options;
1083
+ log4.info(
1084
+ { attestationId: envelope.attestation_id, relayCount: relays.length },
1085
+ "Publishing attestation"
1086
+ );
1087
+ const unsignedEvent = createAttestationEvent(envelope, npub);
1088
+ if (!isNip07Available()) {
1089
+ log4.warn("NIP-07 unavailable \u2014 skipping Nostr publish");
1090
+ throw new Error(
1091
+ "nostr_signer_unavailable: install a NIP-07 extension (e.g. nos2x, Alby) to publish"
1058
1092
  );
1059
- return {
1060
- sats_bonded: bondAmount,
1061
- // Use exactly bond amount (ignore surplus)
1062
- days_unspent: days_unspent2,
1063
- confirmed_balance
1064
- };
1065
1093
  }
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 };
1094
+ log4.info("Using NIP-07 to sign event");
1095
+ const signedEvent = await signNostrEvent(unsignedEvent);
1096
+ if (!signedEvent) {
1097
+ throw new Error("nostr_signing_failed: NIP-07 extension rejected or errored");
1083
1098
  }
1084
- const oldestTime = Math.min(
1085
- ...confirmedUtxos.map((u) => u.status.block_time || Date.now() / 1e3)
1099
+ const result = await publishToRelays(signedEvent, relays);
1100
+ log4.info(
1101
+ {
1102
+ attestationId: envelope.attestation_id,
1103
+ successCount: result.success.length,
1104
+ failedCount: result.failed.length
1105
+ },
1106
+ "Publishing complete"
1086
1107
  );
1087
- const days_unspent = Math.max(0, Math.floor((Date.now() / 1e3 - oldestTime) / (24 * 60 * 60)));
1088
- return { sats_bonded, days_unspent, confirmed_balance };
1108
+ return result;
1089
1109
  }
1090
- async function verify(input, opts = {}) {
1091
- const codes = [];
1092
- let network = "mainnet";
1110
+ function eventIndexesEnvelope(event, envelope) {
1111
+ const d = event.tags.find((t) => t[0] === "d")?.[1];
1112
+ const id = envelope.attestation_id;
1113
+ if (d !== id && d !== `orangecheck:${id}`) return false;
1114
+ return event.tags.some((t) => t[0] === "t" && t[1] === envelope.address);
1115
+ }
1116
+ async function discoverAttestations(options) {
1117
+ const { attestationId, address, identity, relays = DEFAULT_RELAYS } = options;
1118
+ let events = [];
1119
+ if (attestationId) {
1120
+ log4.info({ attestationId }, "Discovering by attestation ID");
1121
+ events = await queryByAttestationId(attestationId, relays);
1122
+ } else if (address) {
1123
+ log4.info({ address }, "Discovering by Bitcoin address");
1124
+ events = await queryByAddress(address, relays);
1125
+ } else if (identity) {
1126
+ log4.info({ identity }, "Discovering by identity");
1127
+ events = await queryByIdentity(identity.protocol, identity.identifier, relays);
1128
+ } else {
1129
+ throw new Error("Must provide attestationId, address, or identity");
1130
+ }
1131
+ const envelopes = await collectSignedEnvelopes(events, (event, envelope) => {
1132
+ if (eventIndexesEnvelope(event, envelope)) return true;
1133
+ log4.warn({ eventId: event.id }, "Skipping event whose index tags disagree with its envelope");
1134
+ return false;
1135
+ });
1136
+ log4.info({ eventCount: events.length, validCount: envelopes.length }, "Discovery complete");
1137
+ return envelopes;
1138
+ }
1139
+ async function verifyAttestationById(attestationId, relays = DEFAULT_RELAYS) {
1140
+ log4.info({ attestationId }, "Verifying attestation by ID");
1141
+ const envelopes = await discoverAttestations({ attestationId, relays });
1142
+ if (envelopes.length === 0) {
1143
+ log4.warn({ attestationId }, "Attestation not found on any relay");
1144
+ return null;
1145
+ }
1146
+ if (envelopes.length > 1) {
1147
+ log4.warn(
1148
+ { attestationId, count: envelopes.length },
1149
+ "Multiple attestations found with same ID - using first"
1150
+ );
1151
+ }
1152
+ return envelopes[0] || null;
1153
+ }
1154
+ async function getAttestationsForAddress(address, relays = DEFAULT_RELAYS) {
1155
+ log4.info({ address }, "Getting all attestations for address");
1156
+ return discoverAttestations({ address, relays });
1157
+ }
1158
+ async function getAttestationsForIdentity(protocol, identifier, relays = DEFAULT_RELAYS) {
1159
+ log4.info({ protocol, identifier }, "Getting all attestations for identity");
1160
+ return discoverAttestations({ identity: { protocol, identifier }, relays });
1161
+ }
1162
+ function formatIdentitiesForDisplay(identities) {
1163
+ return identities.map((id) => `${id.protocol}:${id.identifier}`).join(", ");
1164
+ }
1165
+ function getVerificationUrl(attestationId, baseUrl = "https://attest.ochk.io") {
1166
+ return `${baseUrl}/attest/${attestationId}`;
1167
+ }
1168
+ function extractAttestationIdFromUrl(url) {
1093
1169
  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 };
1170
+ const u = new URL(url);
1171
+ const m = u.pathname.match(/^\/attest\/([0-9a-f]{64})\/?$/i);
1172
+ if (m) return m[1].toLowerCase();
1173
+ const q = u.searchParams.get("id");
1174
+ if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase();
1175
+ return null;
1176
+ } catch {
1177
+ return null;
1211
1178
  }
1212
1179
  }
1213
1180
 
1214
1181
  // src/check.ts
1182
+ var ISSUED_AHEAD_MS = 12e4;
1183
+ var MAX_CANDIDATES = 20;
1215
1184
  async function check(params) {
1216
1185
  const {
1217
1186
  addr,
@@ -1233,14 +1202,17 @@ async function check(params) {
1233
1202
  } else if (identity) {
1234
1203
  found = await getAttestationsForIdentity(identity.protocol, identity.identifier, relays);
1235
1204
  }
1236
- const envelopes = found.filter((e) => signedMatch(e, { addr, id, identity }));
1205
+ const latest = Date.now() + ISSUED_AHEAD_MS;
1206
+ 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
1207
  if (envelopes.length === 0) {
1238
1208
  return { ok: false, sats: 0, days: 0, score: 0, reasons: ["not_found"] };
1239
1209
  }
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"] };
1210
+ let env = envelopes[0];
1211
+ for (const candidate of envelopes.slice(0, MAX_CANDIDATES)) {
1212
+ if (await signedBy(candidate)) {
1213
+ env = candidate;
1214
+ break;
1215
+ }
1244
1216
  }
1245
1217
  const shared = identity ? await backsOtherIdentity(env, identity, relays) : false;
1246
1218
  const outcome = await verify(
@@ -1268,7 +1240,7 @@ async function check(params) {
1268
1240
  score,
1269
1241
  attestation_id: env.attestation_id,
1270
1242
  address: env.address,
1271
- identities: env.identities,
1243
+ identities: signedIdentities(env),
1272
1244
  network: outcome.network,
1273
1245
  ...reasons.length ? { reasons } : {}
1274
1246
  };
@@ -1304,16 +1276,19 @@ function signedMatch(env, q) {
1304
1276
  }
1305
1277
  return false;
1306
1278
  }
1279
+ function signedBy(e) {
1280
+ return signatureIsValid({ addr: e.address, msg: e.message, sig: e.signature, scheme: e.scheme });
1281
+ }
1307
1282
  async function backsOtherIdentity(env, identity, relays) {
1308
1283
  const want = identityKey(identity);
1309
1284
  const protocol = identity.protocol.trim().toLowerCase();
1310
1285
  const siblings = await getAttestationsForAddress(env.address, relays);
1311
1286
  for (const e of [env, ...siblings]) {
1312
1287
  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
- }
1288
+ const other = signedIdentities(e).some(
1289
+ (b) => b.protocol.trim().toLowerCase() === protocol && identityKey(b) !== want
1290
+ );
1291
+ if (other && (e === env || await signedBy(e))) return true;
1317
1292
  }
1318
1293
  return false;
1319
1294
  }
@@ -1430,24 +1405,17 @@ async function verifyChallenge(opts) {
1430
1405
  if (opts.expectedPurpose && purpose !== opts.expectedPurpose) {
1431
1406
  return { ok: false, reason: "purpose_mismatch" };
1432
1407
  }
1433
- const scheme = opts.scheme ?? "bip322";
1434
- let sigValid;
1435
- if (scheme === "bip322") {
1436
- const sigBase64 = /^[0-9a-fA-F]+$/.test(opts.signature) && opts.signature.length % 2 === 0 ? Buffer.from(opts.signature, "hex").toString("base64") : opts.signature;
1437
- sigValid = verifySignature$1(address, opts.message, sigBase64);
1438
- } else if (scheme === "legacy") {
1439
- if (!/^[1m][a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(address)) {
1440
- return { ok: false, reason: "sig_unsupported_scheme" };
1441
- }
1442
- sigValid = verifySignature$1(address, opts.message, opts.signature);
1443
- } else {
1444
- return { ok: false, reason: "sig_unsupported_scheme" };
1408
+ const sigBase64 = toBase64Maybe(opts.signature);
1409
+ const scheme = signatureScheme(sigBase64);
1410
+ if (!scheme) return { ok: false, reason: "sig_unsupported_scheme" };
1411
+ if (!verifySignature$1(address, opts.message, sigBase64)) {
1412
+ return { ok: false, reason: "sig_invalid" };
1445
1413
  }
1446
- if (!sigValid) return { ok: false, reason: "sig_invalid" };
1447
1414
  return {
1448
1415
  ok: true,
1449
1416
  reason: "ok",
1450
1417
  address,
1418
+ scheme,
1451
1419
  expiresAt: expiresAt.getTime(),
1452
1420
  nonce,
1453
1421
  ...audience !== void 0 ? { audience } : {},
@@ -2353,12 +2321,12 @@ var STATUS_META = {
2353
2321
  },
2354
2322
  sig_invalid: {
2355
2323
  label: "Signature verification failed",
2356
- detail: "The signature does not match the message and address. Common issues: (1) Message not copied exactly from Step 2, (2) Wrong wallet/address used, (3) Wallet does not support BIP-322 (Electrum does NOT support BIP-322 for SegWit addresses - use Sparrow Wallet instead). For testnet, ensure your wallet is in testnet mode.",
2324
+ detail: "The signature does not match the message and address. Common issues: (1) the message was not copied exactly, (2) a different address signed it, (3) the wallet signed on another network.",
2357
2325
  severity: "error"
2358
2326
  },
2359
2327
  sig_unsupported_script: {
2360
- label: "Wallet does not support BIP-322",
2361
- detail: 'Your wallet generated a legacy signature, but your SegWit address (bc1/tb1) requires BIP-322 message signing. Please use a wallet that supports BIP-322 (like Sparrow Wallet or Bitcoin Core), or switch to a P2PKH address (starting with "1" for mainnet or "m"/"n" for testnet) if your wallet only supports legacy signing.',
2328
+ label: "Signature form not supported",
2329
+ detail: "Your wallet made a BIP-322 full or proof-of-funds signature. Sign with a BIP-322 simple signature, or with legacy message signing, which every address type here accepts.",
2362
2330
  severity: "error"
2363
2331
  },
2364
2332
  bond_confirmed: {
@@ -2418,6 +2386,6 @@ var STATUS_META = {
2418
2386
  }
2419
2387
  };
2420
2388
 
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 };
2389
+ 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
2390
  //# sourceMappingURL=index.mjs.map
2423
2391
  //# sourceMappingURL=index.mjs.map