did-beta-bch 1.2.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,108 +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
- if (!proof.payload.startsWith(`${PROTOCOL_ID}/challenge\n`))
277
+ if (issuedAt !== undefined &&
278
+ (!Number.isSafeInteger(issuedAt) || Math.floor(Date.now() / 1000) - issuedAt > CHALLENGE_TTL_SECS))
141
279
  return false;
142
- if (!proof.payload.includes(nonce))
280
+ let expectedPayload;
281
+ try {
282
+ expectedPayload = bchChallengePayload(nonce, audience);
283
+ }
284
+ catch {
143
285
  return false;
144
- if (audience !== undefined && !proof.payload.includes(audience))
286
+ }
287
+ if (proof.payload !== expectedPayload)
288
+ return false;
289
+ try {
290
+ normalizeBchMessageSignature(proof.signature);
291
+ }
292
+ catch {
145
293
  return false;
294
+ }
146
295
  const msgHash = bitcoinMsgHash(proof.payload);
147
296
  return recoverBchAddress(proof.signature, msgHash, proof.address) === proof.address;
148
297
  }
@@ -162,47 +311,127 @@ export async function resolveAddress(npub, relays = DEFAULT_RELAYS) {
162
311
  const result = await verifyIdentity(identity);
163
312
  return result.status === "verified" ? identity.bchAddress : null;
164
313
  }
165
- /** 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. */
166
315
  export async function resolveIdentityChain(npub, relays = DEFAULT_RELAYS) {
167
- const decoded = nip19.decode(npub);
168
- if (decoded.type !== "npub")
316
+ const pubkey = decodeNpub(npub);
317
+ if (!pubkey)
169
318
  return [];
170
- const pubkey = decoded.data;
171
319
  const events = await queryRelays(relays, {
172
320
  kinds: [BINDING_KIND],
173
321
  authors: [pubkey],
174
322
  "#t": [PROTOCOL_ID],
175
323
  limit: 50,
176
324
  });
177
- const results = [];
178
- for (const event of events.sort((a, b) => b.created_at - a.created_at)) {
179
- const bchTag = event.tags.find(t => t[0] === "bch");
180
- const dTag = event.tags.find(t => t[0] === "d");
181
- const bchProofTag = event.tags.find(t => t[0] === "bch-proof");
182
- 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))
183
332
  continue;
184
- 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 => {
185
426
  try {
186
- 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);
187
430
  }
188
- catch { /* use event ts */ }
189
- // Validate supersedes tag: the referenced event must exist and its d-tag must match claimedId
190
- const supersedesTag = event.tags.find(t => t[0] === "supersedes");
191
- if (supersedesTag) {
192
- const claimedId = supersedesTag[1];
193
- const [prev] = await queryRelays(relays, { "#d": [claimedId], kinds: [BINDING_KIND], limit: 1 });
194
- if (prev) {
195
- const prevDTag = prev.tags.find(t => t[0] === "d");
196
- if (prevDTag?.[1] !== claimedId) {
197
- throw new CashletDidError("CHAIN_BROKEN", `supersedes tag ${claimedId} does not match referenced event d-tag`);
198
- }
199
- }
431
+ catch {
432
+ return false;
200
433
  }
201
- const anchorTag = event.tags.find(t => t[0] === "bch-anchor");
202
- const anchoredAt = anchorTag?.[2] ? Number(anchorTag[2]) : undefined;
203
- results.push({ npub, pubkey, bchAddress: bchTag[1], network: bchTag[2] ?? "mainnet", bindingId: dTag[1], createdAt, bchProofTag, rawEvent: event, ...(anchoredAt !== undefined ? { anchoredAt } : {}) });
204
- }
205
- return results;
434
+ });
206
435
  }
207
436
  /**
208
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.2.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
  }