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