did-beta-bch 1.3.0 → 2.0.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/verify.js CHANGED
@@ -1,16 +1,29 @@
1
1
  import { verifyEvent, nip05 } from "nostr-tools";
2
2
  import { secp256k1, hash256, encodeCashAddress, hash160 } from "@bitauth/libauth";
3
- import { canonicalize, sha256hex, bchSigningPayload, BINDING_KIND, PROTOCOL_ID, } from "./binding.js";
3
+ import { canonicalize, sha256hex, bchSigningPayload, BINDING_KIND, PROTOCOL_ID, PROTOCOL_VERSION, } from "./binding.js";
4
4
  import { queryRelays, DEFAULT_RELAYS } from "./relay.js";
5
5
  import { CashletDidError } from "./errors.js";
6
6
  import { nip19 } from "nostr-tools";
7
- /** Encode n as a Bitcoin-style varint (compact size). */
7
+ import { BCH_PROOF_FORMAT, normalizeBchMessageSignature, } from "./signing.js";
8
+ /** Encode n as a Bitcoin CompactSize integer. */
8
9
  function varint(n) {
9
10
  if (n < 0xfd)
10
11
  return new Uint8Array([n]);
12
+ const value = BigInt(n);
11
13
  if (n <= 0xffff)
12
- return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]);
13
- return new Uint8Array([0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff]);
14
+ return new Uint8Array([0xfd, Number(value & 0xffn), Number((value >> 8n) & 0xffn)]);
15
+ if (n <= 0xffffffff)
16
+ return new Uint8Array([
17
+ 0xfe,
18
+ Number(value & 0xffn),
19
+ Number((value >> 8n) & 0xffn),
20
+ Number((value >> 16n) & 0xffn),
21
+ Number((value >> 24n) & 0xffn),
22
+ ]);
23
+ return new Uint8Array([
24
+ 0xff,
25
+ ...Array.from({ length: 8 }, (_, i) => Number((value >> BigInt(i * 8)) & 0xffn)),
26
+ ]);
14
27
  }
15
28
  /**
16
29
  * Compute the double-SHA256 hash used by Bitcoin signed messages.
@@ -23,16 +36,28 @@ function bitcoinMsgHash(message) {
23
36
  const prefix = enc.encode("\x18Bitcoin Signed Message:\n");
24
37
  return hash256(new Uint8Array([...prefix, ...varint(msgBytes.length), ...msgBytes]));
25
38
  }
26
- function recoverBchAddress(sigHex, msgHash, expectedAddress) {
27
- const sig = Uint8Array.from(sigHex.match(/.{2}/g).map(b => parseInt(b, 16)));
28
- const addrPrefix = (expectedAddress?.split(":")[0] ?? "bitcoincash");
29
- const tryRecover = (compact, id) => {
39
+ function recoverBchAddress(signatureBase64, msgHash, expectedAddress) {
40
+ try {
41
+ normalizeBchMessageSignature(signatureBase64);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ const bytes = Uint8Array.from(atob(signatureBase64), c => c.charCodeAt(0));
47
+ const recoveryId = (bytes[0] - 31);
48
+ const compact = bytes.slice(1);
49
+ const prefixes = expectedAddress
50
+ ? [expectedAddress.split(":")[0]]
51
+ : ["bitcoincash", "bchtest"];
52
+ const tryRecover = (prefix) => {
30
53
  try {
31
- const pub = secp256k1.recoverPublicKeyCompressed(compact, id, msgHash);
54
+ if (prefix !== "bitcoincash" && prefix !== "bchtest")
55
+ return null;
56
+ const pub = secp256k1.recoverPublicKeyCompressed(compact, recoveryId, msgHash);
32
57
  if (!(pub instanceof Uint8Array))
33
58
  return null;
34
59
  for (const type of ["p2pkh", "p2pkhWithTokens"]) {
35
- const r = encodeCashAddress({ prefix: addrPrefix, type, payload: hash160(pub) });
60
+ const r = encodeCashAddress({ prefix: prefix, type, payload: hash160(pub) });
36
61
  const addr = r?.address ?? (typeof r === "string" ? r : null);
37
62
  if (addr && (!expectedAddress || addr === expectedAddress))
38
63
  return addr;
@@ -41,110 +66,232 @@ function recoverBchAddress(sigHex, msgHash, expectedAddress) {
41
66
  catch { /* try next */ }
42
67
  return null;
43
68
  };
44
- // 65-byte: recovery ID is encoded in byte 0 — use it directly, no brute-force
45
- if (sig.length === 65) {
46
- return tryRecover(sig.slice(1), (sig[0] & 0x03));
47
- }
48
- // 64-byte raw compact: try all recovery IDs
49
- for (const id of [0, 1, 2, 3]) {
50
- const addr = tryRecover(sig, id);
69
+ for (const prefix of prefixes) {
70
+ const addr = tryRecover(prefix);
51
71
  if (addr)
52
72
  return addr;
53
73
  }
54
74
  return null;
55
75
  }
76
+ function decodeNpub(npub) {
77
+ if (typeof npub !== "string")
78
+ return null;
79
+ try {
80
+ const decoded = nip19.decode(npub);
81
+ if (decoded.type !== "npub" || typeof decoded.data !== "string" || !/^[0-9a-f]{64}$/.test(decoded.data))
82
+ return null;
83
+ return decoded.data;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
56
89
  /** Resolve a BCH address from an npub via Nostr relays */
57
90
  export async function resolveIdentity(npub, relays = DEFAULT_RELAYS) {
58
- const decoded = nip19.decode(npub);
59
- if (decoded.type !== "npub")
91
+ const pubkey = decodeNpub(npub);
92
+ if (!pubkey)
60
93
  return null;
61
- const pubkey = decoded.data;
62
94
  const [events, revocations] = await Promise.all([
63
95
  queryRelays(relays, { kinds: [BINDING_KIND], authors: [pubkey], "#t": [PROTOCOL_ID], limit: 10 }),
64
96
  queryRelays(relays, { kinds: [5], authors: [pubkey], limit: 50 }),
65
97
  ]);
66
98
  if (!events.length)
67
99
  return null;
68
- const revokedIds = new Set(revocations.flatMap(e => e.tags.filter(t => t[0] === "e").map(t => t[1])));
100
+ const revokedIds = new Set(revocations.flatMap(e => Array.isArray(e.tags)
101
+ ? e.tags.filter(t => Array.isArray(t) && t[0] === "e" && typeof t[1] === "string").map(t => t[1])
102
+ : []));
69
103
  // Pick latest non-revoked event
70
- const event = events
104
+ const resolvedEvents = events
105
+ .filter(e => e && e.pubkey === pubkey && Array.isArray(e.tags) && typeof e.content === "string")
71
106
  .filter(e => !revokedIds.has(e.id))
72
- .sort((a, b) => b.created_at - a.created_at)[0];
73
- if (!event)
74
- return null;
75
- const bchTag = event.tags.find(t => t[0] === "bch");
76
- const dTag = event.tags.find(t => t[0] === "d");
77
- const bchProofTag = event.tags.find(t => t[0] === "bch-proof");
78
- if (!bchTag || !dTag || !bchProofTag)
79
- return null;
80
- let createdAt = event.created_at;
81
- try {
82
- createdAt = JSON.parse(event.content).createdAt ?? createdAt;
83
- }
84
- catch { /* use event ts */ }
85
- const anchorTag = event.tags.find(t => t[0] === "bch-anchor");
86
- const anchoredAt = anchorTag?.[2] ? Number(anchorTag[2]) : undefined;
87
- return {
88
- npub,
89
- pubkey,
90
- bchAddress: bchTag[1],
91
- network: bchTag[2] ?? "mainnet",
92
- bindingId: dTag[1],
93
- createdAt,
94
- bchProofTag,
95
- rawEvent: event,
96
- ...(anchoredAt !== undefined ? { anchoredAt } : {}),
97
- };
107
+ .map(event => resolvedIdentityFromEvent(event))
108
+ .filter((identity) => identity !== null)
109
+ .sort((a, b) => b.rawEvent.created_at - a.rawEvent.created_at);
110
+ return resolvedEvents[0] ? { ...resolvedEvents[0], npub } : null;
98
111
  }
99
112
  /** Verify the BCH + Nostr signatures on a resolved identity */
100
113
  export async function verifyIdentity(resolved) {
114
+ let signatureBase64;
115
+ if (!resolved ||
116
+ !Array.isArray(resolved.bchProofTag) ||
117
+ resolved.bchProofTag.length !== 4 ||
118
+ resolved.bchProofTag[0] !== "bch-proof" ||
119
+ resolved.bchProofTag[1] !== BCH_PROOF_FORMAT ||
120
+ typeof resolved.bchProofTag[2] !== "string" ||
121
+ typeof resolved.bchProofTag[3] !== "string" ||
122
+ !resolved.rawEvent ||
123
+ !Array.isArray(resolved.rawEvent.tags) ||
124
+ typeof resolved.rawEvent.content !== "string") {
125
+ return { status: "malformed", bchValid: false, nostrValid: false, details: "Malformed identity proof" };
126
+ }
127
+ try {
128
+ signatureBase64 = normalizeBchMessageSignature(resolved.bchProofTag[3]);
129
+ }
130
+ catch {
131
+ return { status: "malformed", bchValid: false, nostrValid: false, details: "Malformed BCH compact signature" };
132
+ }
101
133
  const { bchProofTag, rawEvent, bchAddress, bindingId, pubkey } = resolved;
102
- const [, message, sigHex] = bchProofTag;
134
+ const [, , message] = bchProofTag;
103
135
  // 1. Nostr event signature
104
136
  let nostrValid = false;
137
+ const tagsAreStrings = rawEvent.tags.every(tag => Array.isArray(tag) && tag.every(value => typeof value === "string"));
105
138
  try {
106
- nostrValid = verifyEvent(rawEvent);
107
- const dTag = rawEvent.tags.find(t => t[0] === "d");
108
- const bchTag = rawEvent.tags.find(t => t[0] === "bch");
109
- if (dTag?.[1] !== bindingId || bchTag?.[1] !== bchAddress)
110
- nostrValid = false;
111
- if (rawEvent.pubkey !== pubkey)
112
- nostrValid = false;
139
+ nostrValid = tagsAreStrings && verifyEvent(rawEvent);
113
140
  }
114
141
  catch {
115
142
  nostrValid = false;
116
143
  }
117
- // 2. BCH signature recovery
144
+ let bindingMatches = false;
145
+ try {
146
+ const parsed = JSON.parse(rawEvent.content);
147
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
148
+ throw new Error("Binding content must be an object");
149
+ const content = parsed;
150
+ const bch = content.bch;
151
+ const getTags = (name) => rawEvent.tags.filter(tag => tag[0] === name);
152
+ const exactlyOne = (name) => {
153
+ const matches = getTags(name);
154
+ return matches.length === 1 ? matches[0] : undefined;
155
+ };
156
+ const optionalTag = (name) => {
157
+ const matches = getTags(name);
158
+ return matches.length <= 1 ? matches[0] : undefined;
159
+ };
160
+ const dTag = exactlyOne("d");
161
+ const bchTag = exactlyOne("bch");
162
+ const protocolTag = exactlyOne("protocol");
163
+ const markerTag = exactlyOne("t");
164
+ const proofTag = exactlyOne("bch-proof");
165
+ const paymentCodeTag = optionalTag("bch-payment-code");
166
+ const supersedesTag = optionalTag("supersedes");
167
+ const anchorTag = optionalTag("bch-anchor");
168
+ const previousBindingId = content.previousBindingId;
169
+ const contentKeys = Object.keys(content).sort().join(",");
170
+ const allowedContentKeys = ["bch", "bindingId", "createdAt", "nonce", "previousBindingId", "protocol", "version"]
171
+ .filter(key => content[key] !== undefined).sort().join(",");
172
+ const bchKeys = bch && typeof bch === "object" && !Array.isArray(bch)
173
+ ? Object.keys(bch).sort().join(",")
174
+ : "";
175
+ const expectedBchKeys = bch?.paymentCode === undefined ? "address,network" : "address,network,paymentCode";
176
+ const expectedPrefix = bch?.network === "mainnet" ? "bitcoincash" : "bchtest";
177
+ if (rawEvent.kind !== BINDING_KIND ||
178
+ rawEvent.created_at !== content.createdAt ||
179
+ contentKeys !== allowedContentKeys ||
180
+ rawEvent.content !== canonicalize(content) ||
181
+ content.protocol !== PROTOCOL_ID ||
182
+ content.version !== PROTOCOL_VERSION ||
183
+ !/^[0-9a-f]{64}$/.test(content.bindingId) ||
184
+ typeof bch?.address !== "string" ||
185
+ !["mainnet", "testnet", "chipnet"].includes(bch?.network) ||
186
+ !bch.address.startsWith(`${expectedPrefix}:`) ||
187
+ bchKeys !== expectedBchKeys ||
188
+ (bch.paymentCode !== undefined && (typeof bch.paymentCode !== "string" || bch.paymentCode.length === 0)) ||
189
+ !/^[0-9a-f]{64}$/.test(content.nonce) ||
190
+ !Number.isSafeInteger(content.createdAt) ||
191
+ (previousBindingId !== undefined && !/^[0-9a-f]{64}$/.test(previousBindingId)) ||
192
+ !dTag || dTag.length !== 2 ||
193
+ !bchTag || bchTag.length !== 3 ||
194
+ !protocolTag || protocolTag.length !== 3 ||
195
+ !markerTag || markerTag.length !== 2 ||
196
+ !proofTag || proofTag.length !== 4 ||
197
+ (bch.paymentCode === undefined ? paymentCodeTag !== undefined : (!paymentCodeTag || paymentCodeTag.length !== 2 || paymentCodeTag[1] !== bch.paymentCode)) ||
198
+ getTags("bch-payment-code").length > 1 ||
199
+ (previousBindingId === undefined ? supersedesTag !== undefined : (!supersedesTag || supersedesTag.length !== 2 || supersedesTag[1] !== previousBindingId)) ||
200
+ getTags("supersedes").length > 1 ||
201
+ getTags("bch-anchor").length > 1 ||
202
+ (anchorTag !== undefined && ((anchorTag.length !== 2 && anchorTag.length !== 3) ||
203
+ !/^[0-9a-f]{64}$/.test(anchorTag[1]) ||
204
+ (anchorTag.length === 3 && !/^\d+$/.test(anchorTag[2])))) ||
205
+ dTag?.[1] !== content.bindingId ||
206
+ bchTag?.[1] !== bch.address ||
207
+ bchTag?.[2] !== bch.network ||
208
+ protocolTag?.[1] !== PROTOCOL_ID ||
209
+ protocolTag?.[2] !== PROTOCOL_VERSION ||
210
+ markerTag?.[1] !== PROTOCOL_ID ||
211
+ proofTag?.[0] !== "bch-proof" ||
212
+ proofTag?.[1] !== BCH_PROOF_FORMAT ||
213
+ proofTag?.[2] !== message ||
214
+ proofTag?.[2] !== bchProofTag[2] ||
215
+ proofTag?.[3] !== signatureBase64 ||
216
+ content.bindingId !== bindingId ||
217
+ bch.address !== bchAddress ||
218
+ bch.network !== resolved.network ||
219
+ resolved.createdAt !== content.createdAt ||
220
+ decodeNpub(resolved.npub) !== pubkey ||
221
+ rawEvent.pubkey !== pubkey) {
222
+ return { status: "id_mismatch", bchValid: false, nostrValid, details: "Binding record does not match the event and resolved identity" };
223
+ }
224
+ const partial = {
225
+ protocol: PROTOCOL_ID,
226
+ version: PROTOCOL_VERSION,
227
+ bch: {
228
+ address: bch.address,
229
+ network: bch.network,
230
+ ...(bch.paymentCode !== undefined ? { paymentCode: bch.paymentCode } : {}),
231
+ },
232
+ nostr: { pubkey: rawEvent.pubkey },
233
+ createdAt: content.createdAt,
234
+ nonce: content.nonce,
235
+ purpose: "identity-binding-v2",
236
+ ...(previousBindingId !== undefined ? { previousBindingId } : {}),
237
+ };
238
+ const expectedBindingId = await sha256hex(canonicalize(partial));
239
+ const expectedMessage = bchSigningPayload({ ...partial, bindingId: content.bindingId });
240
+ bindingMatches = expectedBindingId === content.bindingId && message === expectedMessage;
241
+ }
242
+ catch {
243
+ return { status: "malformed", bchValid: false, nostrValid, details: "Malformed binding event content" };
244
+ }
245
+ if (!bindingMatches)
246
+ return { status: "id_mismatch", bchValid: false, nostrValid, details: "BCH proof does not commit to this binding" };
247
+ // 3. BCH signature recovery
118
248
  const msgHash = bitcoinMsgHash(message);
119
- const recovered = recoverBchAddress(sigHex, msgHash, bchAddress);
249
+ const recovered = recoverBchAddress(signatureBase64, msgHash, bchAddress);
120
250
  const bchValid = recovered === bchAddress;
121
- // 3. Payload integrity
122
- if (!message.startsWith(`${PROTOCOL_ID}/bch-proof/`)) {
123
- return { status: "malformed", bchValid: false, nostrValid, details: "Unexpected proof payload format" };
124
- }
125
251
  if (bchValid && nostrValid)
126
- return { status: "verified", bchValid, nostrValid, details: "Both proofs valid", ...(resolved.anchoredAt !== undefined ? { anchoredAt: resolved.anchoredAt } : {}) };
252
+ return { status: "verified", bchValid, nostrValid, details: "Both proofs valid" };
127
253
  if (!bchValid)
128
254
  return { status: "invalid_bch_sig", bchValid, nostrValid, details: "BCH signature invalid" };
129
255
  return { status: "invalid_nostr_sig", bchValid, nostrValid, details: "Nostr signature invalid" };
130
256
  }
131
257
  const CHALLENGE_TTL_SECS = 300; // 5 minutes
258
+ /** Build the exact versioned challenge message that a BCH wallet must sign. */
259
+ export function bchChallengePayload(nonce, audience) {
260
+ if (typeof nonce !== "string" || nonce.length === 0 || /[\r\n]/.test(nonce))
261
+ throw new TypeError("Challenge nonce must be a non-empty single-line string");
262
+ if (audience !== undefined && (typeof audience !== "string" || audience.length === 0 || /[\r\n]/.test(audience)))
263
+ throw new TypeError("Challenge audience must be a non-empty single-line string");
264
+ return `${PROTOCOL_ID}/challenge/v${PROTOCOL_VERSION}\n${nonce}${audience === undefined ? "" : `\n${audience}`}`;
265
+ }
132
266
  /** Verify a challenge-response proof.
133
- * Payload must be: `cashlet-did/challenge\n<nonce>\n<audience>`
134
- * Matches the SIGN_CHALLENGE format used by the Cashlet extension and any conforming client.
267
+ * The wallet signs the exact text produced by `bchChallengePayload`.
135
268
  * @param issuedAt - unix timestamp when the challenge was issued; if provided, rejects proofs older than 5 minutes.
136
269
  */
137
270
  export function verifyChallengeProof(proof, nonce, audience, issuedAt) {
138
- if (issuedAt !== undefined && Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS)
271
+ if (!proof ||
272
+ typeof proof.signature !== "string" ||
273
+ typeof proof.payload !== "string" ||
274
+ typeof proof.address !== "string" ||
275
+ typeof nonce !== "string")
139
276
  return false;
140
- // Exact line match — substring check would allow crafted payloads to pass
141
- const lines = proof.payload.split("\n");
142
- if (lines[0] !== `${PROTOCOL_ID}/challenge`)
277
+ if (issuedAt !== undefined &&
278
+ (!Number.isSafeInteger(issuedAt) || Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
143
279
  return false;
144
- if (lines[1] !== nonce)
280
+ let expectedPayload;
281
+ try {
282
+ expectedPayload = bchChallengePayload(nonce, audience);
283
+ }
284
+ catch {
145
285
  return false;
146
- if (audience !== undefined && lines[2] !== audience)
286
+ }
287
+ if (proof.payload !== expectedPayload)
288
+ return false;
289
+ try {
290
+ normalizeBchMessageSignature(proof.signature);
291
+ }
292
+ catch {
147
293
  return false;
294
+ }
148
295
  const msgHash = bitcoinMsgHash(proof.payload);
149
296
  return recoverBchAddress(proof.signature, msgHash, proof.address) === proof.address;
150
297
  }
@@ -164,46 +311,127 @@ export async function resolveAddress(npub, relays = DEFAULT_RELAYS) {
164
311
  const result = await verifyIdentity(identity);
165
312
  return result.status === "verified" ? identity.bchAddress : null;
166
313
  }
167
- /** Returns full binding history for an npub, newest first, with cryptographic supersedes validation */
314
+ /** Returns verified binding events newest first, following and validating each supersedes reference. */
168
315
  export async function resolveIdentityChain(npub, relays = DEFAULT_RELAYS) {
169
- const decoded = nip19.decode(npub);
170
- if (decoded.type !== "npub")
316
+ const pubkey = decodeNpub(npub);
317
+ if (!pubkey)
171
318
  return [];
172
- const pubkey = decoded.data;
173
319
  const events = await queryRelays(relays, {
174
320
  kinds: [BINDING_KIND],
175
321
  authors: [pubkey],
176
322
  "#t": [PROTOCOL_ID],
177
323
  limit: 50,
178
324
  });
179
- const results = [];
180
- for (const event of events.sort((a, b) => b.created_at - a.created_at)) {
181
- const bchTag = event.tags.find(t => t[0] === "bch");
182
- const dTag = event.tags.find(t => t[0] === "d");
183
- const bchProofTag = event.tags.find(t => t[0] === "bch-proof");
184
- if (!bchTag || !dTag || !bchProofTag)
325
+ const validCandidates = [];
326
+ for (const event of events) {
327
+ const candidate = resolvedIdentityFromEvent(event);
328
+ if (!candidate)
329
+ continue;
330
+ const verified = await verifyIdentity(candidate);
331
+ if (verified.status !== "verified" || await isBindingRevoked(event, relays))
185
332
  continue;
186
- let createdAt = event.created_at;
333
+ validCandidates.push(candidate);
334
+ }
335
+ validCandidates.sort((a, b) => b.rawEvent.created_at - a.rawEvent.created_at);
336
+ if (!validCandidates.length)
337
+ return [];
338
+ const chain = [];
339
+ const visited = new Set();
340
+ let current = validCandidates[0];
341
+ while (current) {
342
+ if (visited.has(current.bindingId))
343
+ return [];
344
+ visited.add(current.bindingId);
345
+ chain.push(current);
346
+ const supersedes = current.rawEvent.tags.find(t => t[0] === "supersedes")?.[1];
347
+ if (!supersedes)
348
+ return chain;
349
+ const predecessors = await queryRelays(relays, {
350
+ kinds: [BINDING_KIND],
351
+ "#t": [PROTOCOL_ID],
352
+ "#d": [supersedes],
353
+ limit: 50,
354
+ });
355
+ let predecessor;
356
+ for (const event of predecessors) {
357
+ const candidate = resolvedIdentityFromEvent(event);
358
+ if (!candidate || candidate.bindingId !== supersedes)
359
+ continue;
360
+ const verified = await verifyIdentity(candidate);
361
+ if (verified.status !== "verified" || await isBindingRevoked(event, relays))
362
+ continue;
363
+ predecessor = candidate;
364
+ break;
365
+ }
366
+ if (!predecessor)
367
+ return [];
368
+ current = predecessor;
369
+ }
370
+ return chain;
371
+ }
372
+ function resolvedIdentityFromEvent(event) {
373
+ if (event.kind !== BINDING_KIND ||
374
+ !/^[0-9a-f]{64}$/.test(event.pubkey) ||
375
+ !Array.isArray(event.tags))
376
+ return null;
377
+ const bchTag = event.tags.find(t => Array.isArray(t) && t[0] === "bch");
378
+ const dTag = event.tags.find(t => Array.isArray(t) && t[0] === "d");
379
+ const protocolTags = event.tags.filter(t => Array.isArray(t) && t[0] === "protocol");
380
+ const proofTags = event.tags.filter(t => Array.isArray(t) && t[0] === "bch-proof");
381
+ const bchProofTag = proofTags[0];
382
+ if (!bchTag?.[1] || !dTag?.[1] ||
383
+ protocolTags.length !== 1 || protocolTags[0].length !== 3 ||
384
+ protocolTags[0][1] !== PROTOCOL_ID || protocolTags[0][2] !== PROTOCOL_VERSION ||
385
+ proofTags.length !== 1 || bchProofTag.length !== 4 ||
386
+ bchProofTag[1] !== BCH_PROOF_FORMAT ||
387
+ !bchProofTag[2] || !bchProofTag[3])
388
+ return null;
389
+ let content;
390
+ try {
391
+ const parsed = JSON.parse(event.content);
392
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
393
+ return null;
394
+ content = parsed;
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ const createdAt = typeof content.createdAt === "number" ? content.createdAt : event.created_at;
400
+ const anchorTag = event.tags.find(t => Array.isArray(t) && t[0] === "bch-anchor");
401
+ const claimedBlockHeight = anchorTag?.[2] && /^\d+$/.test(anchorTag[2]) ? Number(anchorTag[2]) : undefined;
402
+ return {
403
+ npub: nip19.npubEncode(event.pubkey),
404
+ pubkey: event.pubkey,
405
+ bchAddress: bchTag[1],
406
+ network: bchTag[2],
407
+ bindingId: dTag[1],
408
+ createdAt,
409
+ bchProofTag: bchProofTag,
410
+ rawEvent: event,
411
+ ...(anchorTag?.[1] ? {
412
+ anchorClaim: {
413
+ txid: anchorTag[1],
414
+ ...(claimedBlockHeight !== undefined ? { blockHeight: claimedBlockHeight } : {}),
415
+ },
416
+ } : {}),
417
+ };
418
+ }
419
+ async function isBindingRevoked(event, relays) {
420
+ const revocations = await queryRelays(relays, {
421
+ kinds: [5],
422
+ authors: [event.pubkey],
423
+ limit: 100,
424
+ });
425
+ return revocations.some(revocation => {
187
426
  try {
188
- createdAt = JSON.parse(event.content).createdAt ?? createdAt;
427
+ return verifyEvent(revocation) &&
428
+ revocation.pubkey === event.pubkey &&
429
+ revocation.tags.some(tag => tag[0] === "e" && tag[1] === event.id);
189
430
  }
190
- catch { /* use event ts */ }
191
- // Validate supersedes tag: skip entry if referenced event d-tag doesn't match
192
- const supersedesTag = event.tags.find(t => t[0] === "supersedes");
193
- if (supersedesTag) {
194
- const claimedId = supersedesTag[1];
195
- const [prev] = await queryRelays(relays, { "#d": [claimedId], kinds: [BINDING_KIND], limit: 1 });
196
- if (prev) {
197
- const prevDTag = prev.tags.find(t => t[0] === "d");
198
- if (prevDTag?.[1] !== claimedId)
199
- continue; // broken chain — skip, don't throw
200
- }
431
+ catch {
432
+ return false;
201
433
  }
202
- const anchorTag = event.tags.find(t => t[0] === "bch-anchor");
203
- const anchoredAt = anchorTag?.[2] ? Number(anchorTag[2]) : undefined;
204
- results.push({ npub, pubkey, bchAddress: bchTag[1], network: bchTag[2] ?? "mainnet", bindingId: dTag[1], createdAt, bchProofTag, rawEvent: event, ...(anchoredAt !== undefined ? { anchoredAt } : {}) });
205
- }
206
- return results;
434
+ });
207
435
  }
208
436
  /**
209
437
  * Resolve a BCH address from a NIP-05 handle (e.g. "alice@cashlet.app")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "did-beta-bch",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "description": "Decentralised identity protocol binding BCH addresses to Nostr public keys",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,8 @@
18
18
  "PROTOCOL.md"
19
19
  ],
20
20
  "scripts": {
21
- "build": "tsc",
21
+ "build": "tsc && npm run build:demo",
22
+ "build:demo": "esbuild demo/client.js --bundle --format=esm --platform=browser --target=es2022 --outfile=demo/bundle.js",
22
23
  "typecheck": "tsc --noEmit",
23
24
  "test": "node --test test/cashlet-did.test.mjs",
24
25
  "prepublishOnly": "npm run build"
@@ -45,6 +46,7 @@
45
46
  "nostr-tools": "^2.0.0"
46
47
  },
47
48
  "devDependencies": {
49
+ "esbuild": "^0.28.2",
48
50
  "typescript": "^5.4.0"
49
51
  }
50
52
  }