bolt12-utils 0.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.
Files changed (51) hide show
  1. package/dist/bech32.d.ts +31 -0
  2. package/dist/bech32.d.ts.map +1 -0
  3. package/dist/bech32.js +161 -0
  4. package/dist/bech32.js.map +1 -0
  5. package/dist/bigsize.d.ts +22 -0
  6. package/dist/bigsize.d.ts.map +1 -0
  7. package/dist/bigsize.js +87 -0
  8. package/dist/bigsize.js.map +1 -0
  9. package/dist/fields.d.ts +61 -0
  10. package/dist/fields.d.ts.map +1 -0
  11. package/dist/fields.js +99 -0
  12. package/dist/fields.js.map +1 -0
  13. package/dist/generated.d.ts +179 -0
  14. package/dist/generated.d.ts.map +1 -0
  15. package/dist/generated.js +565 -0
  16. package/dist/generated.js.map +1 -0
  17. package/dist/index.d.ts +47 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +125 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/merkle.d.ts +55 -0
  22. package/dist/merkle.d.ts.map +1 -0
  23. package/dist/merkle.js +144 -0
  24. package/dist/merkle.js.map +1 -0
  25. package/dist/offer.d.ts +45 -0
  26. package/dist/offer.d.ts.map +1 -0
  27. package/dist/offer.js +288 -0
  28. package/dist/offer.js.map +1 -0
  29. package/dist/payer_proof.d.ts +89 -0
  30. package/dist/payer_proof.d.ts.map +1 -0
  31. package/dist/payer_proof.js +576 -0
  32. package/dist/payer_proof.js.map +1 -0
  33. package/dist/tlv.d.ts +26 -0
  34. package/dist/tlv.d.ts.map +1 -0
  35. package/dist/tlv.js +65 -0
  36. package/dist/tlv.js.map +1 -0
  37. package/dist/utils.d.ts +12 -0
  38. package/dist/utils.d.ts.map +1 -0
  39. package/dist/utils.js +52 -0
  40. package/dist/utils.js.map +1 -0
  41. package/package.json +47 -0
  42. package/src/bech32.ts +187 -0
  43. package/src/bigsize.ts +97 -0
  44. package/src/fields.ts +147 -0
  45. package/src/generated.ts +697 -0
  46. package/src/index.ts +132 -0
  47. package/src/merkle.ts +163 -0
  48. package/src/offer.ts +328 -0
  49. package/src/payer_proof.ts +727 -0
  50. package/src/tlv.ts +75 -0
  51. package/src/utils.ts +49 -0
package/src/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * bolt12-decoder: Pure JS/TS BOLT12 implementation.
3
+ *
4
+ * Supports decoding and validating BOLT12 offers, invoice requests,
5
+ * and invoices using only pure JavaScript dependencies:
6
+ * - @noble/curves for secp256k1/schnorr
7
+ * - @noble/hashes for SHA256
8
+ */
9
+
10
+ export { decodeBolt12, encodeBolt12 } from './bech32.js';
11
+ export { readBigSize, writeBigSize } from './bigsize.js';
12
+ export { parseTlvStream, type TlvRecord } from './tlv.js';
13
+ export { computeMerkleRoot, taggedHash, verifySignature } from './merkle.js';
14
+ export { validateOffer } from './offer.js';
15
+ /**
16
+ * @deprecated Use `extractGeneratedOfferFields` and `GeneratedOfferFields` from
17
+ * the auto-generated module instead. These hand-written exports use non-spec
18
+ * field names (e.g. `description` instead of `offer_description`) and only
19
+ * cover offers — the generated module covers all BOLT12 message types.
20
+ */
21
+ export {
22
+ extractOfferFields,
23
+ type OfferFields,
24
+ type Chain,
25
+ } from './fields.js';
26
+ export {
27
+ parsePayerProof,
28
+ reconstructMerkleRoot,
29
+ verifyPayerProof,
30
+ createPayerProof,
31
+ type PayerProofFields,
32
+ type CreatePayerProofParams,
33
+ type CreatePayerProofResult,
34
+ } from './payer_proof.js';
35
+
36
+ // Auto-generated types and extractors from BOLT12 spec CSV
37
+ export {
38
+ // Offer
39
+ type OfferFields as GeneratedOfferFields,
40
+ extractOfferFields as extractGeneratedOfferFields,
41
+ KNOWN_OFFER_TYPES,
42
+ OFFER_TLV_NAMES,
43
+ // Invoice request
44
+ type InvoiceRequestFields,
45
+ extractInvoiceRequestFields,
46
+ KNOWN_INVOICE_REQUEST_TYPES,
47
+ INVOICE_REQUEST_TLV_NAMES,
48
+ // Invoice
49
+ type InvoiceFields,
50
+ extractInvoiceFields,
51
+ KNOWN_INVOICE_TYPES,
52
+ INVOICE_TLV_NAMES,
53
+ // Invoice error
54
+ type InvoiceErrorFields,
55
+ extractInvoiceErrorFields,
56
+ KNOWN_INVOICE_ERROR_TYPES,
57
+ INVOICE_ERROR_TLV_NAMES,
58
+ // Subtypes
59
+ type BlindedPayinfo,
60
+ type FallbackAddress,
61
+ // Constants (all)
62
+ OFFER_CHAINS, OFFER_METADATA, OFFER_CURRENCY, OFFER_AMOUNT,
63
+ OFFER_DESCRIPTION, OFFER_FEATURES, OFFER_ABSOLUTE_EXPIRY,
64
+ OFFER_PATHS, OFFER_ISSUER, OFFER_QUANTITY_MAX, OFFER_ISSUER_ID,
65
+ INVREQ_METADATA, INVREQ_CHAIN, INVREQ_AMOUNT, INVREQ_FEATURES,
66
+ INVREQ_QUANTITY, INVREQ_PAYER_ID, INVREQ_PAYER_NOTE, INVREQ_PATHS,
67
+ INVREQ_BIP_353_NAME, SIGNATURE,
68
+ INVOICE_PATHS, INVOICE_BLINDEDPAY, INVOICE_CREATED_AT,
69
+ INVOICE_RELATIVE_EXPIRY, INVOICE_PAYMENT_HASH, INVOICE_AMOUNT,
70
+ INVOICE_FALLBACKS, INVOICE_FEATURES, INVOICE_NODE_ID,
71
+ } from './generated.js';
72
+
73
+ import { decodeBolt12, type Bolt12HRP } from './bech32.js';
74
+ import { parseTlvStream, type TlvRecord } from './tlv.js';
75
+ import { computeMerkleRoot, verifySignature } from './merkle.js';
76
+ import { validateOffer } from './offer.js';
77
+ import { extractOfferFields, type OfferFields } from './fields.js';
78
+ import { parsePayerProof, createPayerProof, type PayerProofFields, type CreatePayerProofParams, type CreatePayerProofResult } from './payer_proof.js';
79
+
80
+ export interface DecodedOffer extends OfferFields {
81
+ hrp: Bolt12HRP;
82
+ offer_id: Uint8Array;
83
+ }
84
+
85
+ /**
86
+ * Decode and validate a BOLT12 offer string.
87
+ *
88
+ * Returns typed fields directly:
89
+ * const { description, amount, issuer_id, offer_id } = decodeOffer(str);
90
+ */
91
+ export function decodeOffer(bolt12String: string): DecodedOffer {
92
+ const { hrp, data } = decodeBolt12(bolt12String);
93
+
94
+ if (hrp !== 'lno') {
95
+ throw new Error(`Expected offer (lno), got ${hrp}`);
96
+ }
97
+
98
+ const records = parseTlvStream(data);
99
+
100
+ // Validate offer semantics
101
+ validateOffer(records);
102
+
103
+ // Extract typed fields
104
+ const fields = extractOfferFields(records);
105
+
106
+ // Compute offer_id (merkle root of all TLVs)
107
+ const offer_id = computeMerkleRoot(records);
108
+
109
+ return { ...fields, hrp, offer_id };
110
+ }
111
+
112
+ export interface DecodedPayerProof {
113
+ hrp: Bolt12HRP;
114
+ records: TlvRecord[];
115
+ proof: PayerProofFields;
116
+ }
117
+
118
+ /**
119
+ * Decode and validate a BOLT12 payer proof string (experimental, PR #1295).
120
+ */
121
+ export function decodePayerProof(bolt12String: string): DecodedPayerProof {
122
+ const { hrp, data } = decodeBolt12(bolt12String);
123
+
124
+ if (hrp !== 'lnp') {
125
+ throw new Error(`Expected payer proof (lnp), got ${hrp}`);
126
+ }
127
+
128
+ const records = parseTlvStream(data);
129
+ const proof = parsePayerProof(records);
130
+
131
+ return { hrp, records, proof };
132
+ }
package/src/merkle.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Merkle tree computation for BOLT12 signature verification.
3
+ *
4
+ * Each TLV record is paired with a nonce derived from the first TLV:
5
+ * leaf = H("LnLeaf", tlv_bytes)
6
+ * nonce = H(SHA256("LnNonce" || first_tlv_bytes), type_bytes)
7
+ * branch = H("LnBranch", sorted(leaf, nonce))
8
+ *
9
+ * These branches are then paired up in a binary tree:
10
+ * parent = H("LnBranch", sorted(left, right))
11
+ *
12
+ * The root of the tree is the merkle root (offer_id for offers).
13
+ *
14
+ * Signature verification uses:
15
+ * msg = H("lightning" || messagename || "signature", merkle_root)
16
+ */
17
+
18
+ import { sha256 } from '@noble/hashes/sha2';
19
+ import { concatBytes } from '@noble/hashes/utils';
20
+ import { schnorr } from '@noble/curves/secp256k1';
21
+ import type { TlvRecord } from './tlv.js';
22
+ import { writeBigSize } from './bigsize.js';
23
+ import { compareBytes } from './utils.js';
24
+
25
+ const encoder = new TextEncoder();
26
+
27
+ /**
28
+ * Tagged hash: H(tag, msg) = SHA256(SHA256(tag) || SHA256(tag) || msg)
29
+ */
30
+ export function taggedHash(tag: Uint8Array, msg: Uint8Array): Uint8Array {
31
+ const tagHash = sha256(tag);
32
+ return sha256(concatBytes(tagHash, tagHash, msg));
33
+ }
34
+
35
+ /**
36
+ * Tagged hash using a pre-computed tag hash.
37
+ */
38
+ function taggedHashWithHash(tagHash: Uint8Array, msg: Uint8Array): Uint8Array {
39
+ return sha256(concatBytes(tagHash, tagHash, msg));
40
+ }
41
+
42
+ /**
43
+ * Serialize a single TLV record to its wire format (type + length + value).
44
+ */
45
+ export function tlvToBytes(record: TlvRecord): Uint8Array {
46
+ const typeBytes = writeBigSize(record.type);
47
+ const lengthBytes = writeBigSize(record.length);
48
+ return concatBytes(typeBytes, lengthBytes, record.value);
49
+ }
50
+
51
+ /**
52
+ * Compute a branch from a pair of nodes, ordering them lexicographically.
53
+ */
54
+ export function branchHash(a: Uint8Array, b: Uint8Array): Uint8Array {
55
+ const branchTagHash = sha256(encoder.encode('LnBranch'));
56
+ const [smaller, larger] = compareBytes(a, b) < 0 ? [a, b] : [b, a];
57
+ return taggedHashWithHash(branchTagHash, concatBytes(smaller, larger));
58
+ }
59
+
60
+ /** Signature TLV type range (240-1000 inclusive). */
61
+ function isSignatureType(type: bigint): boolean {
62
+ return type >= 240n && type <= 1000n;
63
+ }
64
+
65
+ /**
66
+ * Compute per-TLV branch hashes (leaf+nonce combined).
67
+ * Returns the branch hash for each non-signature TLV, and the nonce tag hash.
68
+ */
69
+ export function computePerTlvBranches(records: TlvRecord[]): {
70
+ branches: Uint8Array[];
71
+ nonceTagHash: Uint8Array;
72
+ leafTagHash: Uint8Array;
73
+ branchTagHash: Uint8Array;
74
+ } {
75
+ const nonSig = records.filter(r => !isSignatureType(r.type));
76
+ if (nonSig.length === 0) {
77
+ throw new Error('Cannot compute merkle root of empty TLV set');
78
+ }
79
+
80
+ // Nonce tag: SHA256("LnNonce" || first_record_bytes)
81
+ const firstRecBytes = tlvToBytes(nonSig[0]);
82
+ const nonceTagHash = sha256(concatBytes(encoder.encode('LnNonce'), firstRecBytes));
83
+
84
+ const leafTagHash = sha256(encoder.encode('LnLeaf'));
85
+ const branchTagHash = sha256(encoder.encode('LnBranch'));
86
+
87
+ const branches: Uint8Array[] = nonSig.map((record) => {
88
+ const recBytes = tlvToBytes(record);
89
+ const typeBytes = writeBigSize(record.type);
90
+
91
+ const leaf = taggedHashWithHash(leafTagHash, recBytes);
92
+ const nonce = taggedHashWithHash(nonceTagHash, typeBytes);
93
+
94
+ // Combine leaf and nonce with lexicographic ordering
95
+ const [smaller, larger] = compareBytes(leaf, nonce) < 0 ? [leaf, nonce] : [nonce, leaf];
96
+ return taggedHashWithHash(branchTagHash, concatBytes(smaller, larger));
97
+ });
98
+
99
+ return { branches, nonceTagHash, leafTagHash, branchTagHash };
100
+ }
101
+
102
+ /**
103
+ * Build merkle tree from per-TLV branch hashes, bottom-up.
104
+ */
105
+ function buildMerkleTree(nodes: Uint8Array[]): Uint8Array {
106
+ const branchTagHash = sha256(encoder.encode('LnBranch'));
107
+
108
+ while (nodes.length > 1) {
109
+ const parents: Uint8Array[] = [];
110
+ let i = 0;
111
+ while (i < nodes.length) {
112
+ if (i + 1 < nodes.length) {
113
+ const [smaller, larger] = compareBytes(nodes[i], nodes[i + 1]) < 0
114
+ ? [nodes[i], nodes[i + 1]]
115
+ : [nodes[i + 1], nodes[i]];
116
+ parents.push(taggedHashWithHash(branchTagHash, concatBytes(smaller, larger)));
117
+ i += 2;
118
+ } else {
119
+ parents.push(nodes[i]);
120
+ i += 1;
121
+ }
122
+ }
123
+ nodes = parents;
124
+ }
125
+
126
+ return nodes[0];
127
+ }
128
+
129
+ /**
130
+ * Compute the merkle root from an array of TLV records.
131
+ *
132
+ * Excludes signature TLVs (types 240-1000) from the tree.
133
+ */
134
+ export function computeMerkleRoot(records: TlvRecord[]): Uint8Array {
135
+ const { branches } = computePerTlvBranches(records);
136
+ return buildMerkleTree([...branches]);
137
+ }
138
+
139
+ /**
140
+ * Compute the signature verification message.
141
+ * tag = "lightning" + messagename + "signature"
142
+ */
143
+ export function signatureTag(messageName: string): Uint8Array {
144
+ return encoder.encode(`lightning${messageName}signature`);
145
+ }
146
+
147
+ /**
148
+ * Verify a BIP340 Schnorr signature on a BOLT12 message.
149
+ */
150
+ export function verifySignature(
151
+ messageName: string,
152
+ merkleRoot: Uint8Array,
153
+ pubkey32: Uint8Array,
154
+ signature: Uint8Array,
155
+ ): boolean {
156
+ const tag = signatureTag(messageName);
157
+ const msg = taggedHash(tag, merkleRoot);
158
+ try {
159
+ return schnorr.verify(signature, msg, pubkey32);
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
package/src/offer.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * BOLT12 Offer validation.
3
+ *
4
+ * An offer is a TLV stream encoded with the "lno" prefix.
5
+ * This module validates the semantic rules for offers as specified
6
+ * in BOLT 12.
7
+ *
8
+ * Offer TLV types (from the spec):
9
+ * 2 - offer_chains (array of 32-byte chain_hashes)
10
+ * 4 - offer_metadata (arbitrary bytes)
11
+ * 6 - offer_currency (UTF-8 ISO 4217 code)
12
+ * 8 - offer_amount (tu64 msat or currency units)
13
+ * 10 - offer_description (UTF-8 string)
14
+ * 12 - offer_features (feature bits)
15
+ * 14 - offer_absolute_expiry (tu64 seconds since epoch)
16
+ * 16 - offer_paths (blinded_path array)
17
+ * 18 - offer_issuer (UTF-8 string)
18
+ * 20 - offer_quantity_max (tu64)
19
+ * 22 - offer_issuer_id (point, 33 bytes)
20
+ */
21
+
22
+ import { secp256k1 } from '@noble/curves/secp256k1';
23
+ import type { TlvRecord } from './tlv.js';
24
+ import { readTruncatedUint } from './utils.js';
25
+
26
+ // Offer TLV type numbers
27
+ export const OFFER_CHAINS = 2n;
28
+ export const OFFER_METADATA = 4n;
29
+ export const OFFER_CURRENCY = 6n;
30
+ export const OFFER_AMOUNT = 8n;
31
+ export const OFFER_DESCRIPTION = 10n;
32
+ export const OFFER_FEATURES = 12n;
33
+ export const OFFER_ABSOLUTE_EXPIRY = 14n;
34
+ export const OFFER_PATHS = 16n;
35
+ export const OFFER_ISSUER = 18n;
36
+ export const OFFER_QUANTITY_MAX = 20n;
37
+ export const OFFER_ISSUER_ID = 22n;
38
+
39
+ const KNOWN_OFFER_TYPES = new Set([
40
+ OFFER_CHAINS,
41
+ OFFER_METADATA,
42
+ OFFER_CURRENCY,
43
+ OFFER_AMOUNT,
44
+ OFFER_DESCRIPTION,
45
+ OFFER_FEATURES,
46
+ OFFER_ABSOLUTE_EXPIRY,
47
+ OFFER_PATHS,
48
+ OFFER_ISSUER,
49
+ OFFER_QUANTITY_MAX,
50
+ OFFER_ISSUER_ID,
51
+ ]);
52
+
53
+ /**
54
+ * Check if a type is in the valid offer range.
55
+ * Offers may contain types 1-79 and 1000000000-1999999999.
56
+ */
57
+ function isValidOfferType(type: bigint): boolean {
58
+ if (type >= 1n && type <= 79n) {
59
+ return true;
60
+ }
61
+ if (type >= 1000000000n && type <= 1999999999n) {
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Validate UTF-8 encoding of a byte array.
69
+ * Returns the decoded string or throws if invalid.
70
+ */
71
+ function validateUtf8(data: Uint8Array, fieldName: string): string {
72
+ const decoder = new TextDecoder('utf-8', { fatal: true });
73
+ try {
74
+ return decoder.decode(data);
75
+ } catch {
76
+ throw new Error(`Invalid UTF-8 in ${fieldName}`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Validate a compressed public key (33 bytes, valid point on secp256k1).
82
+ */
83
+ function validatePoint(data: Uint8Array, fieldName: string): void {
84
+ if (data.length !== 33) {
85
+ throw new Error(`Invalid ${fieldName}: expected 33 bytes, got ${data.length}`);
86
+ }
87
+ if (data[0] !== 0x02 && data[0] !== 0x03) {
88
+ throw new Error(`Invalid ${fieldName}: must start with 02 or 03`);
89
+ }
90
+ // Validate the point is actually on the secp256k1 curve
91
+ try {
92
+ secp256k1.ProjectivePoint.fromHex(data);
93
+ } catch {
94
+ throw new Error(`Invalid ${fieldName}: not a valid point on secp256k1`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate offer_chains field: must be a multiple of 32 bytes, and non-empty.
100
+ */
101
+ function validateChains(data: Uint8Array): void {
102
+ if (data.length === 0 || data.length % 32 !== 0) {
103
+ throw new Error('Invalid offer_chains: length must be a non-zero multiple of 32');
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Validate blinded paths (offer_paths field, type 16).
109
+ *
110
+ * Format:
111
+ * For each path:
112
+ * first_node_id: either 33-byte point OR 9-byte sciddir (if first byte 0x00 or 0x01)
113
+ * path_key: 33-byte point (compressed pubkey)
114
+ * num_hops: u8 (must be > 0)
115
+ * For each hop:
116
+ * blinded_node_id: 33-byte point
117
+ * enclen: u16
118
+ * encrypted_recipient_data: enclen bytes
119
+ */
120
+ function validateBlindedPaths(data: Uint8Array): void {
121
+ let offset = 0;
122
+
123
+ // We need to parse all paths in the single offer_paths TLV value
124
+ let pathCount = 0;
125
+ while (offset < data.length) {
126
+ pathCount++;
127
+
128
+ // first_node_id: check if it's a sciddir (starts with 0x00 or 0x01) or regular point
129
+ if (offset >= data.length) {
130
+ throw new Error('Truncated offer_paths: missing first_node_id');
131
+ }
132
+
133
+ const firstByte = data[offset];
134
+ let firstNodeIdLen: number;
135
+ if (firstByte === 0x00 || firstByte === 0x01) {
136
+ // sciddir: 1 byte direction + 8 byte short_channel_id = 9 bytes
137
+ firstNodeIdLen = 9;
138
+ } else if (firstByte === 0x02 || firstByte === 0x03) {
139
+ // Regular compressed point: 33 bytes
140
+ firstNodeIdLen = 33;
141
+ } else {
142
+ throw new Error('Invalid first_node_id in blinded path: bad prefix byte');
143
+ }
144
+
145
+ if (offset + firstNodeIdLen > data.length) {
146
+ throw new Error('Truncated offer_paths: first_node_id truncated');
147
+ }
148
+ offset += firstNodeIdLen;
149
+
150
+ // path_key: 33-byte compressed point
151
+ if (offset + 33 > data.length) {
152
+ throw new Error('Truncated offer_paths: missing path_key');
153
+ }
154
+ const pathKeyPrefix = data[offset];
155
+ if (pathKeyPrefix !== 0x02 && pathKeyPrefix !== 0x03) {
156
+ throw new Error('Invalid path_key in blinded path: must start with 02 or 03');
157
+ }
158
+ offset += 33;
159
+
160
+ // num_hops: u8
161
+ if (offset >= data.length) {
162
+ throw new Error('Truncated offer_paths: missing num_hops');
163
+ }
164
+ const numHops = data[offset];
165
+ offset += 1;
166
+
167
+ if (numHops === 0) {
168
+ throw new Error('Invalid blinded path: num_hops must be > 0');
169
+ }
170
+
171
+ // Parse each hop
172
+ for (let h = 0; h < numHops; h++) {
173
+ // blinded_node_id: 33-byte point
174
+ if (offset + 33 > data.length) {
175
+ throw new Error('Truncated onionmsg_hop: missing blinded_node_id');
176
+ }
177
+ const blindedPrefix = data[offset];
178
+ if (blindedPrefix !== 0x02 && blindedPrefix !== 0x03) {
179
+ throw new Error('Invalid blinded_node_id: must start with 02 or 03');
180
+ }
181
+ offset += 33;
182
+
183
+ // enclen: u16
184
+ if (offset + 2 > data.length) {
185
+ throw new Error('Truncated onionmsg_hop: missing enclen');
186
+ }
187
+ const enclen = (data[offset] << 8) | data[offset + 1];
188
+ offset += 2;
189
+
190
+ // encrypted_recipient_data
191
+ if (offset + enclen > data.length) {
192
+ throw new Error('Truncated onionmsg_hop: encrypted_data truncated');
193
+ }
194
+ offset += enclen;
195
+ }
196
+ }
197
+
198
+ // Must have at least one path
199
+ if (pathCount === 0) {
200
+ throw new Error('offer_paths must contain at least one path');
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Validate feature bits. Per the spec, unknown even feature bits must
206
+ * cause rejection. Odd bits are always safe to ignore.
207
+ */
208
+ function validateFeatures(data: Uint8Array): void {
209
+ for (let byteIdx = 0; byteIdx < data.length; byteIdx++) {
210
+ const byte = data[byteIdx];
211
+ if (byte === 0) {
212
+ continue;
213
+ }
214
+
215
+ // Position from the right (LSB of last byte = bit 0)
216
+ const bitOffset = (data.length - 1 - byteIdx) * 8;
217
+
218
+ for (let bit = 0; bit < 8; bit++) {
219
+ if (byte & (1 << bit)) {
220
+ const featureBit = bitOffset + bit;
221
+ if (featureBit % 2 === 0) {
222
+ throw new Error(`Unknown even feature bit ${featureBit}`);
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ export interface ValidatedOffer {
230
+ records: TlvRecord[];
231
+ hasDescription: boolean;
232
+ hasAmount: boolean;
233
+ hasCurrency: boolean;
234
+ hasIssuerId: boolean;
235
+ hasPaths: boolean;
236
+ }
237
+
238
+ /**
239
+ * Validate an offer's TLV records according to BOLT12 semantic rules.
240
+ */
241
+ export function validateOffer(records: TlvRecord[]): ValidatedOffer {
242
+ let hasDescription = false;
243
+ let hasAmount = false;
244
+ let hasCurrency = false;
245
+ let hasIssuerId = false;
246
+ let hasPaths = false;
247
+
248
+ for (const record of records) {
249
+ const type = record.type;
250
+
251
+ // Check type is in valid offer range
252
+ if (!isValidOfferType(type)) {
253
+ if (type % 2n === 0n) {
254
+ throw new Error(`Invalid: unknown even field type ${type} outside offer range`);
255
+ }
256
+ // This type is out of range but odd - still invalid for offers
257
+ throw new Error(`Invalid: field type ${type} outside valid offer range`);
258
+ }
259
+
260
+ // Unknown even types must be rejected
261
+ if (!KNOWN_OFFER_TYPES.has(type) && type % 2n === 0n) {
262
+ throw new Error(`Unknown even TLV type ${type}`);
263
+ }
264
+
265
+ // Validate specific fields
266
+ switch (type) {
267
+ case OFFER_CHAINS:
268
+ validateChains(record.value);
269
+ break;
270
+
271
+ case OFFER_CURRENCY:
272
+ validateUtf8(record.value, 'offer_currency');
273
+ hasCurrency = true;
274
+ break;
275
+
276
+ case OFFER_AMOUNT: {
277
+ const amount = readTruncatedUint(record.value);
278
+ if (amount === 0n) {
279
+ throw new Error('Invalid: zero offer_amount');
280
+ }
281
+ hasAmount = true;
282
+ break;
283
+ }
284
+
285
+ case OFFER_DESCRIPTION:
286
+ validateUtf8(record.value, 'offer_description');
287
+ hasDescription = true;
288
+ break;
289
+
290
+ case OFFER_FEATURES:
291
+ validateFeatures(record.value);
292
+ break;
293
+
294
+ case OFFER_PATHS:
295
+ validateBlindedPaths(record.value);
296
+ hasPaths = true;
297
+ break;
298
+
299
+ case OFFER_ISSUER:
300
+ validateUtf8(record.value, 'offer_issuer');
301
+ break;
302
+
303
+ case OFFER_ISSUER_ID:
304
+ validatePoint(record.value, 'offer_issuer_id');
305
+ hasIssuerId = true;
306
+ break;
307
+ }
308
+ }
309
+
310
+ // Semantic validation rules:
311
+
312
+ // An offer with amount but no description is invalid
313
+ if (hasAmount && !hasDescription) {
314
+ throw new Error('Missing offer_description with offer_amount');
315
+ }
316
+
317
+ // Currency requires amount
318
+ if (hasCurrency && !hasAmount) {
319
+ throw new Error('Missing offer_amount with offer_currency');
320
+ }
321
+
322
+ // Must have either issuer_id or paths (or both)
323
+ if (!hasIssuerId && !hasPaths) {
324
+ throw new Error('Missing offer_issuer_id and no offer_paths');
325
+ }
326
+
327
+ return { records, hasDescription, hasAmount, hasCurrency, hasIssuerId, hasPaths };
328
+ }