did-beta-bch 2.0.1 → 3.1.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Electrum client helpers for broadcasting anchor transactions and fetching
3
+ * SPV proofs. Works in Node.js (TCP) and browsers (WebSocket) via a pluggable
4
+ * transport. Default transports are provided for each environment.
5
+ */
6
+ export const ELECTRUM_MAINNET_TCP = "electroncash.dk:50001";
7
+ export const ELECTRUM_MAINNET_WS = "wss://electroncash.dk:50004";
8
+ export const ELECTRUM_CHIPNET_TCP = "chipnet.imaginary.cash:50001";
9
+ export const ELECTRUM_CHIPNET_WS = "wss://chipnet.imaginary.cash:50004";
10
+ async function electrumCall(transport, requests, timeoutMs = 15000) {
11
+ const results = new Map();
12
+ return new Promise((resolve, reject) => {
13
+ const timer = setTimeout(() => { transport.close(); reject(new Error("Electrum request timed out")); }, timeoutMs);
14
+ transport.onError(e => { clearTimeout(timer); reject(e); });
15
+ transport.onLine(line => {
16
+ if (!line.trim())
17
+ return;
18
+ const msg = JSON.parse(line);
19
+ if (msg.error) {
20
+ clearTimeout(timer);
21
+ transport.close();
22
+ reject(new Error(msg.error.message));
23
+ return;
24
+ }
25
+ results.set(msg.id, msg.result);
26
+ if (results.size === requests.length) {
27
+ clearTimeout(timer);
28
+ transport.close();
29
+ resolve(results);
30
+ }
31
+ });
32
+ for (const req of requests)
33
+ transport.send(JSON.stringify(req) + "\n");
34
+ });
35
+ }
36
+ /** WebSocket transport — works in both browser and Node.js (Node 22+ has native WebSocket). */
37
+ export function wsTransport(url) {
38
+ const ws = new WebSocket(url, "binary");
39
+ let lineHandler = null;
40
+ let errHandler = null;
41
+ let buf = "";
42
+ ws.onmessage = (ev) => {
43
+ buf += typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);
44
+ const lines = buf.split("\n");
45
+ buf = lines.pop() ?? "";
46
+ for (const l of lines)
47
+ lineHandler?.(l);
48
+ };
49
+ ws.onerror = () => errHandler?.(new Error("WebSocket error"));
50
+ return {
51
+ send: (line) => ws.readyState === WebSocket.OPEN ? ws.send(line) : ws.addEventListener("open", () => ws.send(line)),
52
+ onLine: (h) => { lineHandler = h; },
53
+ onError: (h) => { errHandler = h; },
54
+ close: () => ws.close(),
55
+ };
56
+ }
57
+ /**
58
+ * Broadcast a signed anchor transaction hex to an Electrum server.
59
+ * Returns the txid on success.
60
+ */
61
+ export async function broadcastAnchorTx(txHex, transport) {
62
+ const results = await electrumCall(transport, [
63
+ { id: 1, method: "blockchain.transaction.broadcast", params: [txHex] },
64
+ ]);
65
+ const txid = results.get(1);
66
+ if (typeof txid !== "string" || !/^[0-9a-f]{64}$/.test(txid))
67
+ throw new Error(`Unexpected broadcast response: ${JSON.stringify(txid)}`);
68
+ return txid;
69
+ }
70
+ /**
71
+ * Fetch a BchAnchorProof for a confirmed transaction.
72
+ * checkpointHeight must match the checkpoint you intend to verify against —
73
+ * headers are fetched from checkpointHeight+1 through the confirmation window.
74
+ */
75
+ export async function fetchAnchorProof(txid, checkpointHeight, transport, minConfirmations = 6) {
76
+ if (!/^[0-9a-f]{64}$/.test(txid))
77
+ throw new Error("Invalid txid");
78
+ // Step 1: get Merkle proof to learn blockHeight and tx position
79
+ const merkleResults = await electrumCall(transport, [
80
+ { id: 1, method: "blockchain.transaction.get_merkle", params: [txid, 0] },
81
+ ]);
82
+ const merkle = merkleResults.get(1);
83
+ if (!merkle?.block_height)
84
+ throw new Error("Transaction not yet confirmed or not found");
85
+ const blockHeight = merkle.block_height;
86
+ const tipHeight = blockHeight + minConfirmations - 1;
87
+ const startHeight = checkpointHeight + 1;
88
+ const count = tipHeight - startHeight + 1;
89
+ if (count <= 0)
90
+ throw new Error("Block is at or below the checkpoint height");
91
+ // Step 2: fetch raw tx hex + header range
92
+ const batchResults = await electrumCall(transport, [
93
+ { id: 2, method: "blockchain.transaction.get", params: [txid, false] },
94
+ { id: 3, method: "blockchain.block.headers", params: [startHeight, count] },
95
+ ]);
96
+ const transactionHex = batchResults.get(2);
97
+ const headersResult = batchResults.get(3);
98
+ if (typeof transactionHex !== "string")
99
+ throw new Error("Failed to fetch transaction hex");
100
+ if (!headersResult?.hex)
101
+ throw new Error("Failed to fetch header chain");
102
+ // Split concatenated 80-byte headers (160 hex chars each)
103
+ const headers = [];
104
+ for (let i = 0; i < headersResult.count; i++)
105
+ headers.push(headersResult.hex.slice(i * 160, i * 160 + 160));
106
+ return { transactionHex, blockHeight, transactionIndex: merkle.pos, merkleBranch: merkle.merkle, headers };
107
+ }
@@ -0,0 +1,46 @@
1
+ export interface BchSpvCheckpoint {
2
+ height: number;
3
+ headerHex: string;
4
+ hash: string;
5
+ recentTimestamps: number[];
6
+ }
7
+ export interface BchAsertParameters {
8
+ anchorHeight: number;
9
+ anchorBits: number;
10
+ anchorPreviousBlockTime: number;
11
+ powLimitBits: number;
12
+ allowMinDifficultyBlocks?: boolean;
13
+ }
14
+ export interface BchAnchorProof {
15
+ transactionHex: string;
16
+ blockHeight: number;
17
+ transactionIndex: number;
18
+ merkleBranch: string[];
19
+ headers: string[];
20
+ }
21
+ export interface BchAnchorVerificationOptions {
22
+ checkpoint: BchSpvCheckpoint;
23
+ asert: BchAsertParameters;
24
+ minConfirmations?: number;
25
+ maxHeaders?: number;
26
+ now?: number;
27
+ }
28
+ export interface BchAnchorVerificationResult {
29
+ status: "confirmed" | "pending" | "invalid" | "unverifiable";
30
+ txid?: string;
31
+ blockHeight?: number;
32
+ tipHeight?: number;
33
+ confirmations?: number;
34
+ error?: string;
35
+ trustModel: "checkpointed-spv";
36
+ }
37
+ /**
38
+ * Verify a BCH transaction commitment, Merkle inclusion, and an ASERT header
39
+ * chain extending from an application-trusted checkpoint. The proof source is
40
+ * untrusted; checkpoint selection remains the caller's explicit trust root.
41
+ */
42
+ export declare function verifyBchAnchorProof(proof: BchAnchorProof, bindingId: string, options: BchAnchorVerificationOptions): BchAnchorVerificationResult;
43
+ export declare const BCH_MAINNET_ASERT_PARAMETERS: BchAsertParameters;
44
+ export declare const BCH_MAINNET_CHECKPOINT: BchSpvCheckpoint;
45
+ export declare const BCH_CHIPNET_ASERT_PARAMETERS: BchAsertParameters;
46
+ export declare const BCH_CHIPNET_CHECKPOINT: BchSpvCheckpoint;
@@ -0,0 +1,282 @@
1
+ import { decodeTransaction, hash256, hexToBin } from "@bitauth/libauth";
2
+ import { ANCHOR_PREFIX } from "./anchor.js";
3
+ const HEADER_BYTES = 80;
4
+ const MAX_PROOF_HEADERS = 2048;
5
+ const TARGET_SPACING_SECS = 600n;
6
+ const ASERT_HALF_LIFE_SECS = 172800n;
7
+ const ASERT_RADIX = 1n << 16n;
8
+ function bytesToHex(bytes) {
9
+ return Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
10
+ }
11
+ function reverse(bytes) {
12
+ return Uint8Array.from(bytes).reverse();
13
+ }
14
+ function parseHex(value, byteLength) {
15
+ if (!/^(?:[0-9a-f]{2})+$/i.test(value))
16
+ throw new Error("Expected even-length hexadecimal data");
17
+ const bytes = hexToBin(value);
18
+ if (typeof bytes === "string" || (byteLength !== undefined && bytes.length !== byteLength))
19
+ throw new Error("Invalid hexadecimal data length");
20
+ return bytes;
21
+ }
22
+ function parseHeader(headerHex) {
23
+ const raw = parseHex(headerHex, HEADER_BYTES);
24
+ const hash = hash256(raw);
25
+ const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
26
+ return {
27
+ raw,
28
+ hash,
29
+ hashHex: bytesToHex(reverse(hash)),
30
+ previousHash: raw.slice(4, 36),
31
+ merkleRoot: raw.slice(36, 68),
32
+ timestamp: view.getUint32(68, true),
33
+ bits: view.getUint32(72, true),
34
+ };
35
+ }
36
+ function compactToTarget(bits) {
37
+ const exponent = bits >>> 24;
38
+ const mantissa = bits & 0x007fffff;
39
+ const negative = (bits & 0x00800000) !== 0;
40
+ const overflow = mantissa !== 0 && (exponent > 34 ||
41
+ (mantissa > 0xff && exponent > 33) ||
42
+ (mantissa > 0xffff && exponent > 32));
43
+ if (negative || overflow || mantissa === 0)
44
+ throw new Error("Invalid compact difficulty target");
45
+ const target = exponent <= 3
46
+ ? BigInt(mantissa) >> (8n * BigInt(3 - exponent))
47
+ : BigInt(mantissa) << (8n * BigInt(exponent - 3));
48
+ if (target <= 0n || target >= (1n << 256n))
49
+ throw new Error("Difficulty target is out of range");
50
+ if (targetToCompact(target) !== bits)
51
+ throw new Error("Non-canonical compact difficulty target");
52
+ return target;
53
+ }
54
+ function targetToCompact(target) {
55
+ let size = Math.ceil(target.toString(16).length / 2);
56
+ let compact = size <= 3
57
+ ? Number(target << (8n * BigInt(3 - size)))
58
+ : Number(target >> (8n * BigInt(size - 3)));
59
+ if (compact & 0x00800000) {
60
+ compact >>>= 8;
61
+ size++;
62
+ }
63
+ return ((size << 24) | compact) >>> 0;
64
+ }
65
+ function asertTarget(parameters, previousHeight, previousTime) {
66
+ const anchorTarget = compactToTarget(parameters.anchorBits);
67
+ const timeDifference = BigInt(previousTime - parameters.anchorPreviousBlockTime);
68
+ const heightDifference = BigInt(previousHeight - parameters.anchorHeight);
69
+ if (heightDifference < 0n)
70
+ throw new Error("Checkpoint predates the ASERT anchor");
71
+ const exponent = ((timeDifference - TARGET_SPACING_SECS * heightDifference) * ASERT_RADIX) /
72
+ ASERT_HALF_LIFE_SECS;
73
+ const shifts = exponent >> 16n;
74
+ const fraction = exponent & 0xffffn;
75
+ const factor = ASERT_RADIX + ((195766423245049n * fraction +
76
+ 971821376n * fraction * fraction +
77
+ 5127n * fraction * fraction * fraction +
78
+ (1n << 47n)) >> 48n);
79
+ let target = anchorTarget * factor;
80
+ const shift = shifts - 16n;
81
+ target = shift <= 0n ? target >> -shift : target << shift;
82
+ const powLimit = compactToTarget(parameters.powLimitBits);
83
+ if (target <= 0n)
84
+ return 1n;
85
+ return target > powLimit ? powLimit : target;
86
+ }
87
+ function expectedBits(parameters, previousHeight, previous, candidateTime) {
88
+ if (parameters.allowMinDifficultyBlocks &&
89
+ candidateTime > previous.timestamp + Number(2n * TARGET_SPACING_SECS))
90
+ return parameters.powLimitBits;
91
+ return targetToCompact(asertTarget(parameters, previousHeight, previous.timestamp));
92
+ }
93
+ function assertProofOfWork(header, powLimit) {
94
+ const target = compactToTarget(header.bits);
95
+ if (target > powLimit)
96
+ throw new Error("Header target exceeds the network proof-of-work limit");
97
+ const hashValue = BigInt(`0x${header.hashHex}`);
98
+ if (hashValue > target)
99
+ throw new Error("Header does not satisfy proof of work");
100
+ return target;
101
+ }
102
+ function transactionId(transactionHex) {
103
+ const raw = parseHex(transactionHex);
104
+ const decoded = decodeTransaction(raw);
105
+ if (typeof decoded === "string")
106
+ throw new Error(`Invalid transaction: ${decoded}`);
107
+ return { txid: bytesToHex(reverse(hash256(raw))), outputs: decoded.outputs };
108
+ }
109
+ function hasBindingCommitment(outputs, bindingId) {
110
+ const data = new TextEncoder().encode(`${ANCHOR_PREFIX}${bindingId}`);
111
+ const expected = new Uint8Array([0x6a, 0x4c, data.length, ...data]);
112
+ return outputs.filter(output => output.valueSatoshis === 0n &&
113
+ output.lockingBytecode.length === expected.length &&
114
+ output.lockingBytecode.every((byte, index) => byte === expected[index])).length === 1;
115
+ }
116
+ function verifyMerkleBranch(txid, transactionIndex, branch, merkleRoot) {
117
+ if (!Number.isSafeInteger(transactionIndex) || transactionIndex < 0 || branch.length > 32)
118
+ return false;
119
+ let index = transactionIndex;
120
+ let hash = reverse(parseHex(txid, 32));
121
+ for (const siblingHex of branch) {
122
+ const sibling = reverse(parseHex(siblingHex, 32));
123
+ hash = index & 1
124
+ ? hash256(new Uint8Array([...sibling, ...hash]))
125
+ : hash256(new Uint8Array([...hash, ...sibling]));
126
+ index = Math.floor(index / 2);
127
+ }
128
+ return index === 0 && hash.every((byte, position) => byte === merkleRoot[position]);
129
+ }
130
+ function result(status, error, details = {}) {
131
+ return {
132
+ status,
133
+ trustModel: "checkpointed-spv",
134
+ ...(error ? { error } : {}),
135
+ ...details,
136
+ };
137
+ }
138
+ /**
139
+ * Verify a BCH transaction commitment, Merkle inclusion, and an ASERT header
140
+ * chain extending from an application-trusted checkpoint. The proof source is
141
+ * untrusted; checkpoint selection remains the caller's explicit trust root.
142
+ */
143
+ export function verifyBchAnchorProof(proof, bindingId, options) {
144
+ try {
145
+ if (!/^[0-9a-f]{64}$/.test(bindingId))
146
+ return result("invalid", "Invalid binding ID");
147
+ const minConfirmations = options.minConfirmations ?? 6;
148
+ const maxHeaders = options.maxHeaders ?? MAX_PROOF_HEADERS;
149
+ if (!Number.isSafeInteger(minConfirmations) || minConfirmations < 1)
150
+ return result("invalid", "Minimum confirmations must be a positive integer");
151
+ if (!Number.isSafeInteger(maxHeaders) || maxHeaders < 1)
152
+ return result("invalid", "Maximum header count must be a positive integer");
153
+ if (!proof || !Array.isArray(proof.headers) || proof.headers.length === 0)
154
+ return result("unverifiable", "Header chain proof is missing");
155
+ if (proof.headers.length > maxHeaders)
156
+ return result("unverifiable", "Header chain exceeds the configured verification limit");
157
+ const checkpoint = options.checkpoint;
158
+ const checkpointHeader = parseHeader(checkpoint.headerHex);
159
+ if (!Number.isSafeInteger(checkpoint.height) ||
160
+ checkpoint.height < options.asert.anchorHeight ||
161
+ !Number.isSafeInteger(options.asert.anchorHeight) ||
162
+ !Number.isSafeInteger(options.asert.anchorPreviousBlockTime) ||
163
+ !Number.isSafeInteger(options.asert.powLimitBits) ||
164
+ checkpointHeader.hashHex !== checkpoint.hash.toLowerCase() ||
165
+ !Array.isArray(checkpoint.recentTimestamps) ||
166
+ checkpoint.recentTimestamps.length !== 11 ||
167
+ checkpoint.recentTimestamps[checkpoint.recentTimestamps.length - 1] !== checkpointHeader.timestamp)
168
+ return result("unverifiable", "Invalid or unsupported trusted checkpoint");
169
+ if (!Number.isSafeInteger(proof.blockHeight) || proof.blockHeight <= checkpoint.height)
170
+ return result("unverifiable", "Anchor predates the configured trusted checkpoint");
171
+ if (!Number.isSafeInteger(proof.blockHeight) ||
172
+ !Number.isSafeInteger(proof.transactionIndex) ||
173
+ !Array.isArray(proof.merkleBranch))
174
+ return result("invalid", "Malformed Merkle proof");
175
+ const transaction = transactionId(proof.transactionHex);
176
+ if (!hasBindingCommitment(transaction.outputs, bindingId))
177
+ return result("invalid", "Transaction does not contain the exact binding commitment", { txid: transaction.txid });
178
+ let previous = checkpointHeader;
179
+ let previousHeight = checkpoint.height;
180
+ let timestamps = checkpoint.recentTimestamps.slice(-11);
181
+ let inclusionHeader;
182
+ const powLimit = compactToTarget(options.asert.powLimitBits);
183
+ const now = options.now ?? Math.floor(Date.now() / 1000);
184
+ for (let offset = 0; offset < proof.headers.length; offset++) {
185
+ const height = checkpoint.height + offset + 1;
186
+ const header = parseHeader(proof.headers[offset]);
187
+ if (!header.previousHash.every((byte, index) => byte === previous.hash[index]))
188
+ return result("invalid", `Header chain link failed at height ${height}`);
189
+ if (header.timestamp <= [...timestamps].sort((a, b) => a - b)[Math.floor(timestamps.length / 2)])
190
+ return result("invalid", `Header timestamp violates median-time-past at height ${height}`);
191
+ if (header.timestamp > now + 2 * 60 * 60)
192
+ return result("invalid", `Header timestamp is too far in the future at height ${height}`);
193
+ const requiredBits = expectedBits(options.asert, previousHeight, previous, header.timestamp);
194
+ if (header.bits !== requiredBits)
195
+ return result("invalid", `Unexpected difficulty target at height ${height}`);
196
+ assertProofOfWork(header, powLimit);
197
+ if (height === proof.blockHeight)
198
+ inclusionHeader = header;
199
+ timestamps = [...timestamps, header.timestamp].slice(-11);
200
+ previous = header;
201
+ previousHeight = height;
202
+ }
203
+ const tipHeight = checkpoint.height + proof.headers.length;
204
+ if (!inclusionHeader)
205
+ return result("invalid", "Inclusion block is not present in the supplied header chain", { txid: transaction.txid, tipHeight });
206
+ if (!verifyMerkleBranch(transaction.txid, proof.transactionIndex, proof.merkleBranch, inclusionHeader.merkleRoot))
207
+ return result("invalid", "Transaction Merkle proof does not match the inclusion block", {
208
+ txid: transaction.txid,
209
+ blockHeight: proof.blockHeight,
210
+ tipHeight,
211
+ });
212
+ const confirmations = tipHeight - proof.blockHeight + 1;
213
+ return result(confirmations >= minConfirmations ? "confirmed" : "pending", undefined, {
214
+ txid: transaction.txid,
215
+ blockHeight: proof.blockHeight,
216
+ tipHeight,
217
+ confirmations,
218
+ });
219
+ }
220
+ catch (error) {
221
+ return result("invalid", error instanceof Error ? error.message : String(error));
222
+ }
223
+ }
224
+ export const BCH_MAINNET_ASERT_PARAMETERS = {
225
+ anchorHeight: 661647,
226
+ anchorBits: 0x1804dafe,
227
+ anchorPreviousBlockTime: 1605447844,
228
+ powLimitBits: 0x1d00ffff,
229
+ };
230
+ // Fixed trust root, cross-checked against electroncash.dk and a second independent
231
+ // Electrum server. Refresh in a reviewed release as it ages; never fetch trust
232
+ // roots from the same untrusted endpoint that supplies a proof.
233
+ export const BCH_MAINNET_CHECKPOINT = {
234
+ height: 975752,
235
+ hash: "0000000000000000010a200fe779aa3c313c7fc1b355b13560ef105709d153fd",
236
+ headerHex: "000000248509713afafd1db2ec77ef48baf6522757a8b2cd8cb315000000000000000000d70ca4e92d1bc9485441fb6626766cc8a350e59cc44b6a15ddc36dae072f9b60eaa7e96a680f02181a62ef3c",
237
+ recentTimestamps: [
238
+ 1793688001,
239
+ 1793688081,
240
+ 1793688330,
241
+ 1793692665,
242
+ 1793693308,
243
+ 1793694029,
244
+ 1793694650,
245
+ 1793696313,
246
+ 1793696954,
247
+ 1793697100,
248
+ 1793697770,
249
+ ],
250
+ };
251
+ // Chipnet ASERT anchor: block 16844 (November 2020 upgrade).
252
+ // anchorPreviousBlockTime is the timestamp of block 16843.
253
+ // Cross-checked against chipnet.imaginary.cash and chipnet.bch.ninja.
254
+ export const BCH_CHIPNET_ASERT_PARAMETERS = {
255
+ anchorHeight: 16844,
256
+ anchorBits: 0x1d00ffff,
257
+ anchorPreviousBlockTime: 1605451779,
258
+ powLimitBits: 0x1d00ffff,
259
+ };
260
+ // Fixed chipnet trust root at height 330718.
261
+ // Cross-checked against chipnet.imaginary.cash and chipnet.bch.ninja.
262
+ // WARNING: chipnet is a test network that may be reset. This checkpoint is
263
+ // valid for the current chipnet instance only. After a reset, callers must
264
+ // supply a new checkpoint derived from the post-reset chain.
265
+ export const BCH_CHIPNET_CHECKPOINT = {
266
+ height: 330718,
267
+ hash: "00000000ad6fb02510bdccdf5eb9c2d4d0f16cfaa721a0450a6c95fef549ee16",
268
+ headerHex: "040000004c9554d6ad2ce73070ececb406327f3f4b843146056fc64d0f010000000000003cefeb99df9976c9abc99da3a773405e8e2330603729ad0ce149071ad7868ee164cde96affff001d9f3db255",
269
+ recentTimestamps: [
270
+ 1793701553,
271
+ 1793701753,
272
+ 1793702955,
273
+ 1793703300,
274
+ 1793703430,
275
+ 1793704246,
276
+ 1793704910,
277
+ 1793704951,
278
+ 1793705277,
279
+ 1793706160,
280
+ 1793707364,
281
+ ],
282
+ };
package/dist/binding.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type BchMessageProof } from "./signing.js";
2
2
  export declare const PROTOCOL_ID = "cashlet-did";
3
- export declare const PROTOCOL_VERSION = "2.0.0";
3
+ export declare const PROTOCOL_VERSION = "3.0.0";
4
4
  export declare const BINDING_KIND = 30078;
5
+ export declare const ROTATION_AUTH_KIND = 30080;
5
6
  export type Network = "mainnet" | "testnet" | "chipnet";
6
7
  export interface CanonicalBinding {
7
8
  protocol: typeof PROTOCOL_ID;
@@ -17,7 +18,7 @@ export interface CanonicalBinding {
17
18
  };
18
19
  createdAt: number;
19
20
  nonce: string;
20
- purpose: "identity-binding-v2";
21
+ purpose: "identity-binding-v3";
21
22
  previousBindingId?: string;
22
23
  anchor?: {
23
24
  txid: string;
@@ -30,6 +31,7 @@ export interface IdentityBinding {
30
31
  nostrProof: {
31
32
  event: NostrEvent;
32
33
  };
34
+ rotationAuthorization?: NostrEvent;
33
35
  publishedAt?: number;
34
36
  }
35
37
  export interface NostrEvent {
@@ -45,7 +47,14 @@ export declare function canonicalize(v: unknown): string;
45
47
  export declare function sha256hex(s: string): Promise<string>;
46
48
  export declare function buildBinding(bchAddress: string, network: Network, nostrPubkey: string, paymentCode?: string, previousBindingId?: string): Promise<CanonicalBinding>;
47
49
  export declare function bchSigningPayload(binding: CanonicalBinding): string;
48
- export declare function nostrEventTemplate(binding: CanonicalBinding, bchProof?: BchMessageProof): {
50
+ export declare function rotationAuthorizationTemplate(previous: CanonicalBinding, successor: CanonicalBinding): {
51
+ kind: number;
52
+ created_at: number;
53
+ tags: string[][];
54
+ content: string;
55
+ };
56
+ export declare function verifyRotationAuthorization(authorization: NostrEvent, previous: CanonicalBinding, successor: CanonicalBinding): boolean;
57
+ export declare function nostrEventTemplate(binding: CanonicalBinding, bchProof?: BchMessageProof, rotationAuthorization?: NostrEvent): {
49
58
  kind: number;
50
59
  created_at: number;
51
60
  tags: string[][];
package/dist/binding.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { BCH_PROOF_FORMAT, normalizeBchMessageSignature, } from "./signing.js";
2
+ import { verifyEvent } from "nostr-tools";
2
3
  export const PROTOCOL_ID = "cashlet-did";
3
- export const PROTOCOL_VERSION = "2.0.0";
4
+ export const PROTOCOL_VERSION = "3.0.0";
4
5
  export const BINDING_KIND = 30078;
6
+ export const ROTATION_AUTH_KIND = 30080;
5
7
  export function canonicalize(v) {
6
8
  if (v === null || typeof v !== "object")
7
9
  return JSON.stringify(v);
@@ -38,7 +40,7 @@ export async function buildBinding(bchAddress, network, nostrPubkey, paymentCode
38
40
  nostr: { pubkey: nostrPubkey },
39
41
  createdAt: Math.floor(Date.now() / 1000),
40
42
  nonce: randomHex(32),
41
- purpose: "identity-binding-v2",
43
+ purpose: "identity-binding-v3",
42
44
  ...(previousBindingId ? { previousBindingId } : {}),
43
45
  };
44
46
  const bindingId = await sha256hex(canonicalize(partial));
@@ -55,12 +57,56 @@ export function bchSigningPayload(binding) {
55
57
  createdAt: binding.createdAt,
56
58
  });
57
59
  }
58
- export function nostrEventTemplate(binding, bchProof) {
60
+ export function rotationAuthorizationTemplate(previous, successor) {
61
+ if (successor.previousBindingId !== previous.bindingId)
62
+ throw new Error("Successor must reference the previous binding");
63
+ if (successor.nostr.pubkey === previous.nostr.pubkey)
64
+ throw new Error("Rotation authorization is only required when the Nostr key changes");
65
+ return {
66
+ kind: ROTATION_AUTH_KIND,
67
+ created_at: successor.createdAt,
68
+ tags: [
69
+ ["d", successor.bindingId],
70
+ ["protocol", PROTOCOL_ID, PROTOCOL_VERSION],
71
+ ["t", `${PROTOCOL_ID}-rotation`],
72
+ ],
73
+ content: canonicalize({
74
+ protocol: PROTOCOL_ID,
75
+ version: PROTOCOL_VERSION,
76
+ purpose: "key-rotation-authorization-v3",
77
+ previousBindingId: previous.bindingId,
78
+ previousPubkey: previous.nostr.pubkey,
79
+ successorBindingId: successor.bindingId,
80
+ successorPubkey: successor.nostr.pubkey,
81
+ successorBch: successor.bch,
82
+ createdAt: successor.createdAt,
83
+ }),
84
+ };
85
+ }
86
+ export function verifyRotationAuthorization(authorization, previous, successor) {
87
+ if (!authorization ||
88
+ authorization.kind !== ROTATION_AUTH_KIND ||
89
+ authorization.pubkey !== previous.nostr.pubkey ||
90
+ authorization.created_at !== successor.createdAt ||
91
+ !Array.isArray(authorization.tags) ||
92
+ !verifyEvent(authorization))
93
+ return false;
94
+ const expected = rotationAuthorizationTemplate(previous, successor);
95
+ return authorization.content === expected.content &&
96
+ JSON.stringify(authorization.tags) === JSON.stringify(expected.tags);
97
+ }
98
+ export function nostrEventTemplate(binding, bchProof, rotationAuthorization) {
59
99
  if (bchProof) {
60
100
  if (bchProof.message !== bchSigningPayload(binding))
61
101
  throw new Error("BCH proof message does not match the binding");
62
102
  normalizeBchMessageSignature(bchProof.signatureBase64);
63
103
  }
104
+ if (!binding.previousBindingId && rotationAuthorization)
105
+ throw new Error("Unexpected rotation authorization without a predecessor");
106
+ if (rotationAuthorization && (rotationAuthorization.kind !== ROTATION_AUTH_KIND ||
107
+ !Array.isArray(rotationAuthorization.tags) ||
108
+ rotationAuthorization.tags.find(tag => tag[0] === "d")?.[1] !== binding.bindingId))
109
+ throw new Error("Rotation authorization does not match the successor binding");
64
110
  return {
65
111
  kind: BINDING_KIND,
66
112
  created_at: binding.createdAt,
@@ -71,6 +117,7 @@ export function nostrEventTemplate(binding, bchProof) {
71
117
  ["protocol", PROTOCOL_ID, PROTOCOL_VERSION],
72
118
  ["t", PROTOCOL_ID],
73
119
  ...(binding.previousBindingId ? [["supersedes", binding.previousBindingId]] : []),
120
+ ...(rotationAuthorization ? [["rotation-proof", rotationAuthorization.id, rotationAuthorization.pubkey]] : []),
74
121
  ...(binding.anchor ? [["bch-anchor", binding.anchor.txid, ...(binding.anchor.blockHeight ? [String(binding.anchor.blockHeight)] : [])]] : []),
75
122
  ...(bchProof ? [["bch-proof", BCH_PROOF_FORMAT, bchProof.message, bchProof.signatureBase64]] : []),
76
123
  ],
@@ -82,6 +129,7 @@ export function nostrEventTemplate(binding, bchProof) {
82
129
  nonce: binding.nonce,
83
130
  createdAt: binding.createdAt,
84
131
  ...(binding.previousBindingId ? { previousBindingId: binding.previousBindingId } : {}),
132
+ ...(rotationAuthorization ? { rotationAuthorization } : {}),
85
133
  }),
86
134
  };
87
135
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * BIP47 v1 reusable payment code helpers.
3
+ * Notification transactions and scanning are wallet-specific and out of scope.
4
+ */
5
+ import { deriveHdPrivateNodeFromBip39Mnemonic, deriveHdPath, deriveHdPublicNode, type HdPublicNodeValid, type HdPrivateNodeValid } from "@bitauth/libauth";
6
+ export interface PaymentCode {
7
+ /** Compressed public key (33 bytes) */
8
+ publicKey: Uint8Array;
9
+ /** Chain code (32 bytes) */
10
+ chainCode: Uint8Array;
11
+ }
12
+ export interface EncodedPaymentCode extends PaymentCode {
13
+ /** Base58Check encoded payment code string ("PM8T...") */
14
+ encoded: string;
15
+ }
16
+ /**
17
+ * Encode a BIP47 v1 payment code from a public key and chain code.
18
+ */
19
+ export declare function encodePaymentCode(publicKey: Uint8Array, chainCode: Uint8Array): Promise<string>;
20
+ /**
21
+ * Decode a BIP47 v1 Base58Check payment code string.
22
+ */
23
+ export declare function parsePaymentCode(paymentCode: string): PaymentCode;
24
+ /**
25
+ * Derive a BIP47 payment code from a mnemonic (m/47'/0'/<account>').
26
+ */
27
+ export declare function derivePaymentCode(mnemonic: string, account?: number): Promise<EncodedPaymentCode>;
28
+ /**
29
+ * Derive the nth P2PKH CashAddr payment address for sending to a recipient.
30
+ *
31
+ * @param senderScanPrivkey - sender's M/47'/0'/0'/0 private key (32 bytes)
32
+ * @param recipientNode - recipient's BIP47 HD public node (M/47'/0'/0')
33
+ * @param index - address index (0-based)
34
+ * @param prefix - CashAddr prefix, defaults to "bitcoincash"
35
+ */
36
+ export declare function derivePaymentAddress(senderScanPrivkey: Uint8Array, recipientNode: HdPublicNodeValid, index: number, prefix?: "bitcoincash" | "bchtest" | "bchreg"): Promise<string>;
37
+ /**
38
+ * Derive the nth payment private key for a receiver to spend funds sent via BIP47.
39
+ *
40
+ * Per BIP47 spec: s = SHA256(b[i] * A) where A = sender's scan pubkey (M/47'/0'/0'/0)
41
+ * b' = b[i] + s
42
+ *
43
+ * @param receiverAccountNode - receiver's M/47'/0'/0' private node
44
+ * @param senderScanPubkey - sender's M/47'/0'/0'/0 compressed public key (33 bytes)
45
+ * @param index - address index (0-based)
46
+ */
47
+ export declare function derivePaymentPrivkey(receiverAccountNode: HdPrivateNodeValid, senderScanPubkey: Uint8Array, index: number): Promise<Uint8Array>;
48
+ export { deriveHdPublicNode, deriveHdPath, deriveHdPrivateNodeFromBip39Mnemonic };
49
+ /** Construct a minimal HdPublicNodeValid from a BIP47 PaymentCode for use with derivePaymentAddress/derivePaymentPrivkey. */
50
+ export declare function hdPublicNodeFromPaymentCode(pc: PaymentCode): HdPublicNodeValid;