@qntx/nostr 0.3.1 → 0.4.1

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.
@@ -6,36 +6,49 @@ export type ContentBlock = {
6
6
  text: string;
7
7
  } | {
8
8
  type: "reference";
9
- pointer: ProfilePointer | AddressPointer | EventPointer;
9
+ pointer: ProfilePointer | EventPointer | AddressPointer;
10
+ /**
11
+ * `true` when the reference had no `nostr:` prefix (see
12
+ * {@link ParseContentOptions.legacyBech32}).
13
+ */
14
+ bare: boolean;
10
15
  } | {
11
16
  type: "url";
12
17
  url: string;
13
18
  } | {
14
- type: "relay";
15
- url: string;
16
- } | {
17
- type: "image";
19
+ type: "image" | "video" | "audio";
18
20
  url: string;
19
21
  } | {
20
- type: "video";
22
+ type: "relay";
21
23
  url: string;
22
24
  } | {
23
- type: "audio";
24
- url: string;
25
+ type: "hashtag";
26
+ value: string;
25
27
  } | {
26
28
  type: "emoji";
27
29
  shortcode: string;
28
30
  url: string;
29
31
  } | {
30
- type: "hashtag";
31
- value: string;
32
+ type: "invoice";
33
+ bolt11: string;
34
+ };
35
+ export type ParseContentOptions = {
36
+ /**
37
+ * Recognize bare `npub`, `nprofile`, `note`, `nevent`, `naddr` without the `nostr:` prefix.
38
+ * Default `false`.
39
+ */
40
+ legacyBech32?: boolean;
41
+ /**
42
+ * URL → MIME type (the `m` field of NIP-92 `imeta` tags); takes precedence over extension
43
+ * classification.
44
+ */
45
+ imeta?: ReadonlyMap<string, string>;
32
46
  };
33
47
  /**
34
- * Parse note content (or a full event, to pick up emoji tags) into blocks. Generator — consume with
35
- * `for…of` or `[...parseContent(s)]`.
48
+ * Parse note content (or a full event, to pick up `emoji` tags) into ordered blocks. One
49
+ * left-to-right scan; at every position the rules fire in the alternation order above. Adjacent
50
+ * text merges and no empty text blocks are emitted.
36
51
  */
37
- export declare function parseContent(content: string | Pick<Event, "content" | "tags">): Iterable<ContentBlock>;
38
- /** Collect all content blocks into an array. */
39
- export declare function parseContentBlocks(content: string | Pick<Event, "content" | "tags">): ContentBlock[];
52
+ export declare function parseContent(content: string | Pick<Event, "content" | "tags">, opts?: ParseContentOptions): ContentBlock[];
40
53
  //#endregion
41
54
  //# sourceMappingURL=nip27.d.mts.map
@@ -1,176 +1,209 @@
1
1
  import { decode } from "./nip19.mjs";
2
2
  //#region src/nips/nip27.ts
3
- const noCharacter = /\W/m;
4
- const noURLCharacter = /[^\w/] |[^\w/]$|$|,| /m;
5
- const MAX_HASHTAG_LENGTH = 42;
3
+ const BECH32_CHARS = "02-9ac-hj-np-z";
4
+ const NIP19_PREFIX = "(?:npub|nprofile|note|nevent|naddr|nsec)1";
5
+ const NIP19_BARE_PREFIX = "(?:npub|nprofile|note|nevent|naddr)1";
6
+ const URL_RUN = `[^\\s<>"\\u3000-\\u303f\\uff00-\\uffef]+`;
7
+ const SCAN_BODY = [
8
+ `(?<nostr>nostr:${NIP19_PREFIX}[${BECH32_CHARS}]+)`,
9
+ `(?<url>https?://${URL_RUN})`,
10
+ `(?<relay>wss?://${URL_RUN})`,
11
+ `(?<invoice>(?:lightning:)?ln(?:bcrt|tbs|bc|tb)(?:\\d+[munp]?)?1[${BECH32_CHARS}]+)`,
12
+ `(?<hashtag>#[\\p{L}\\p{M}\\p{N}_]{1,42})`,
13
+ `(?<emoji>:[A-Za-z0-9_]+:)`
14
+ ].join("|");
15
+ const SCAN = new RegExp(SCAN_BODY, "giu");
16
+ const SCAN_LEGACY = new RegExp(`${SCAN_BODY}|(?<bare>${NIP19_BARE_PREFIX}[${BECH32_CHARS}]+)`, "giu");
17
+ const TAG_CHAR = /[\p{L}\p{M}\p{N}_]/u;
18
+ const WORD_CHAR = /[\p{L}\p{N}]/u;
19
+ const TRAILING_URL_CHARS = /* @__PURE__ */ new Set([
20
+ ".",
21
+ ",",
22
+ ";",
23
+ ":",
24
+ "!",
25
+ "?",
26
+ "'"
27
+ ]);
28
+ const IMAGE_EXT = /* @__PURE__ */ new Set([
29
+ "jpg",
30
+ "jpeg",
31
+ "png",
32
+ "gif",
33
+ "webp",
34
+ "avif",
35
+ "svg"
36
+ ]);
37
+ const VIDEO_EXT = /* @__PURE__ */ new Set([
38
+ "mp4",
39
+ "webm",
40
+ "mov",
41
+ "m4v",
42
+ "m3u8"
43
+ ]);
44
+ const AUDIO_EXT = /* @__PURE__ */ new Set([
45
+ "mp3",
46
+ "m4a",
47
+ "ogg",
48
+ "oga",
49
+ "opus",
50
+ "wav",
51
+ "flac",
52
+ "aac"
53
+ ]);
54
+ function isChar(cp, re) {
55
+ return cp !== void 0 && re.test(String.fromCodePoint(cp));
56
+ }
57
+ /** Drop trailing sentence punctuation and closers that have no opener inside the URL. */
58
+ function trimUrlTail(raw) {
59
+ let url = raw;
60
+ for (;;) {
61
+ const last = url.at(-1);
62
+ if (last !== void 0 && TRAILING_URL_CHARS.has(last)) {
63
+ url = url.slice(0, -1);
64
+ continue;
65
+ }
66
+ if (last === ")" || last === "]") {
67
+ const open = last === ")" ? "(" : "[";
68
+ let opens = 0;
69
+ let closes = 0;
70
+ for (const c of url) if (c === open) opens++;
71
+ else if (c === last) closes++;
72
+ if (closes > opens) {
73
+ url = url.slice(0, -1);
74
+ continue;
75
+ }
76
+ }
77
+ return url;
78
+ }
79
+ }
80
+ function mimeMedia(mime) {
81
+ if (mime === void 0) return;
82
+ const slash = mime.indexOf("/");
83
+ const base = slash === -1 ? mime : mime.slice(0, slash);
84
+ return base === "image" || base === "video" || base === "audio" ? base : void 0;
85
+ }
86
+ function extMedia(pathname) {
87
+ const dot = pathname.lastIndexOf(".");
88
+ if (dot === -1) return;
89
+ const ext = pathname.slice(dot + 1).toLowerCase();
90
+ if (IMAGE_EXT.has(ext)) return "image";
91
+ if (VIDEO_EXT.has(ext)) return "video";
92
+ return AUDIO_EXT.has(ext) ? "audio" : void 0;
93
+ }
94
+ function urlBlock(raw, relay, imeta) {
95
+ const url = trimUrlTail(raw);
96
+ let parsed;
97
+ try {
98
+ parsed = new URL(url);
99
+ } catch {
100
+ return;
101
+ }
102
+ if (!parsed.hostname.includes(".")) return;
103
+ if (relay) return {
104
+ type: "relay",
105
+ url
106
+ };
107
+ const media = mimeMedia(imeta?.get(url)) ?? extMedia(parsed.pathname);
108
+ return media === void 0 ? {
109
+ type: "url",
110
+ url
111
+ } : {
112
+ type: media,
113
+ url
114
+ };
115
+ }
116
+ function referenceFor(code, bare) {
117
+ let pointer;
118
+ try {
119
+ const decoded = decode(code);
120
+ switch (decoded.type) {
121
+ case "npub":
122
+ pointer = { pubkey: decoded.data };
123
+ break;
124
+ case "note":
125
+ pointer = { id: decoded.data };
126
+ break;
127
+ case "nprofile":
128
+ case "nevent":
129
+ case "naddr":
130
+ pointer = decoded.data;
131
+ break;
132
+ case "nsec": return;
133
+ }
134
+ } catch {
135
+ return;
136
+ }
137
+ return {
138
+ type: "reference",
139
+ pointer,
140
+ bare
141
+ };
142
+ }
6
143
  /**
7
- * Parse note content (or a full event, to pick up emoji tags) into blocks. Generator — consume with
8
- * `for…of` or `[...parseContent(s)]`.
144
+ * Parse note content (or a full event, to pick up `emoji` tags) into ordered blocks. One
145
+ * left-to-right scan; at every position the rules fire in the alternation order above. Adjacent
146
+ * text merges and no empty text blocks are emitted.
9
147
  */
10
- function* parseContent(content) {
11
- const emojis = [];
148
+ function parseContent(content, opts) {
149
+ const emojis = /* @__PURE__ */ new Map();
12
150
  let text;
13
151
  if (typeof content === "string") text = content;
14
152
  else {
15
- for (const tag of content.tags) {
16
- const shortcode = tag.at(1);
17
- const url = tag.at(2);
18
- if (tag[0] === "emoji" && shortcode !== void 0 && shortcode !== "" && url !== void 0 && url !== "") emojis.push({
153
+ for (const tag of content.tags) if (tag[0] === "emoji" && tag[1] !== void 0 && tag[1] !== "" && tag[2] !== void 0 && tag[2] !== "") emojis.set(tag[1], tag[2]);
154
+ text = content.content;
155
+ }
156
+ const scan = opts?.legacyBech32 === true ? SCAN_LEGACY : SCAN;
157
+ scan.lastIndex = 0;
158
+ const blocks = [];
159
+ let textStart = 0;
160
+ const flushText = (end) => {
161
+ if (end > textStart) blocks.push({
162
+ type: "text",
163
+ text: text.slice(textStart, end)
164
+ });
165
+ };
166
+ for (let m = scan.exec(text); m !== null; m = scan.exec(text)) {
167
+ const start = m.index;
168
+ const [token] = m;
169
+ const end = start + token.length;
170
+ const g = m.groups;
171
+ let block;
172
+ if (g?.["nostr"] !== void 0) block = referenceFor(token.slice(6), false);
173
+ else if (g?.["url"] !== void 0) block = urlBlock(token, false, opts?.imeta);
174
+ else if (g?.["relay"] !== void 0) block = urlBlock(token, true, void 0);
175
+ else if (g?.["invoice"] !== void 0) {
176
+ if (!isChar(start === 0 ? void 0 : text.codePointAt(start - 1), WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) block = {
177
+ type: "invoice",
178
+ bolt11: (token.toLowerCase().startsWith("lightning:") ? token.slice(10) : token).toLowerCase()
179
+ };
180
+ } else if (g?.["hashtag"] !== void 0) {
181
+ if (!isChar(start === 0 ? void 0 : text.codePointAt(start - 1), TAG_CHAR)) block = {
182
+ type: "hashtag",
183
+ value: token.slice(1)
184
+ };
185
+ } else if (g?.["emoji"] !== void 0) {
186
+ const url = emojis.get(token.slice(1, -1));
187
+ if (url !== void 0) block = {
19
188
  type: "emoji",
20
- shortcode,
189
+ shortcode: token.slice(1, -1),
21
190
  url
22
- });
191
+ };
192
+ } else if (g?.["bare"] !== void 0) {
193
+ if (!isChar(start === 0 ? void 0 : text.codePointAt(start - 1), WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) block = referenceFor(token, true);
23
194
  }
24
- text = content.content;
25
- }
26
- const max = text.length;
27
- let prevIndex = 0;
28
- let index = 0;
29
- mainloop: while (index < max) {
30
- const u = text.indexOf(":", index);
31
- const h = text.indexOf("#", index);
32
- if (u === -1 && h === -1) break;
33
- if (u === -1 || h !== -1 && h < u) {
34
- if (h === 0 || h > 0 && noCharacter.exec(text.at(h - 1) ?? "") !== null) {
35
- const m = noCharacter.exec(text.slice(h + 1, h + MAX_HASHTAG_LENGTH));
36
- const end = m ? h + 1 + (m.index ?? 0) : max;
37
- if (prevIndex !== h) yield {
38
- type: "text",
39
- text: text.slice(prevIndex, h)
40
- };
41
- yield {
42
- type: "hashtag",
43
- value: text.slice(h + 1, end)
44
- };
45
- index = end;
46
- prevIndex = index;
47
- continue;
48
- }
49
- index = h + 1;
195
+ if (block === void 0) {
196
+ scan.lastIndex = start + 1;
50
197
  continue;
51
198
  }
52
- if (u >= 5 && text.slice(u - 5, u) === "nostr") {
53
- const m = noCharacter.exec(text.slice(u + 60));
54
- const end = m ? u + 60 + (m.index ?? 0) : max;
55
- try {
56
- const { data, type } = decode(text.slice(u + 1, end));
57
- let pointer;
58
- switch (type) {
59
- case "npub":
60
- pointer = { pubkey: data };
61
- break;
62
- case "note":
63
- pointer = { id: data };
64
- break;
65
- case "nprofile":
66
- case "nevent":
67
- case "naddr":
68
- pointer = data;
69
- break;
70
- case "nsec":
71
- index = end + 1;
72
- continue mainloop;
73
- }
74
- if (prevIndex !== u - 5) yield {
75
- type: "text",
76
- text: text.slice(prevIndex, u - 5)
77
- };
78
- yield {
79
- type: "reference",
80
- pointer
81
- };
82
- index = end;
83
- prevIndex = index;
84
- continue;
85
- } catch {
86
- index = u + 1;
87
- continue;
88
- }
89
- }
90
- if (u >= 5 && text.slice(u - 5, u) === "https" || u >= 4 && text.slice(u - 4, u) === "http") {
91
- const m = noURLCharacter.exec(text.slice(u + 4));
92
- const end = m ? u + 4 + (m.index ?? 0) : max;
93
- const prefixLen = text[u - 1] === "s" ? 5 : 4;
94
- try {
95
- const url = new URL(text.slice(u - prefixLen, end));
96
- if (!url.hostname.includes(".")) throw new Error("invalid url");
97
- if (prevIndex !== u - prefixLen) yield {
98
- type: "text",
99
- text: text.slice(prevIndex, u - prefixLen)
100
- };
101
- const path = url.pathname;
102
- if (/\.(png|jpe?g|gif|webp|heic|svg)$/i.test(path)) yield {
103
- type: "image",
104
- url: url.toString()
105
- };
106
- else if (/\.(mp4|avi|webm|mkv|mov)$/i.test(path)) yield {
107
- type: "video",
108
- url: url.toString()
109
- };
110
- else if (/\.(mp3|aac|ogg|opus|wav|flac)$/i.test(path)) yield {
111
- type: "audio",
112
- url: url.toString()
113
- };
114
- else yield {
115
- type: "url",
116
- url: url.toString()
117
- };
118
- index = end;
119
- prevIndex = index;
120
- continue;
121
- } catch {
122
- index = end + 1;
123
- continue;
124
- }
125
- }
126
- if (u >= 3 && text.slice(u - 3, u) === "wss" || u >= 2 && text.slice(u - 2, u) === "ws") {
127
- const m = noURLCharacter.exec(text.slice(u + 4));
128
- const end = m ? u + 4 + (m.index ?? 0) : max;
129
- const prefixLen = text[u - 1] === "s" ? 3 : 2;
130
- try {
131
- const url = new URL(text.slice(u - prefixLen, end));
132
- if (!url.hostname.includes(".")) throw new Error("invalid ws url");
133
- if (prevIndex !== u - prefixLen) yield {
134
- type: "text",
135
- text: text.slice(prevIndex, u - prefixLen)
136
- };
137
- yield {
138
- type: "relay",
139
- url: url.toString()
140
- };
141
- index = end;
142
- prevIndex = index;
143
- continue;
144
- } catch {
145
- index = end + 1;
146
- continue;
147
- }
148
- }
149
- for (const emoji of emojis) {
150
- const endColon = u + emoji.shortcode.length + 1;
151
- if (text[endColon] === ":" && text.slice(u + 1, endColon) === emoji.shortcode) {
152
- if (prevIndex !== u) yield {
153
- type: "text",
154
- text: text.slice(prevIndex, u)
155
- };
156
- yield emoji;
157
- index = endColon + 1;
158
- prevIndex = index;
159
- continue mainloop;
160
- }
161
- }
162
- index = u + 1;
199
+ flushText(start);
200
+ blocks.push(block);
201
+ textStart = block.type === "url" || block.type === "relay" || block.type === "image" || block.type === "video" || block.type === "audio" ? start + block.url.length : end;
163
202
  }
164
- if (prevIndex !== max) yield {
165
- type: "text",
166
- text: text.slice(prevIndex)
167
- };
168
- }
169
- /** Collect all content blocks into an array. */
170
- function parseContentBlocks(content) {
171
- return [...parseContent(content)];
203
+ flushText(text.length);
204
+ return blocks;
172
205
  }
173
206
  //#endregion
174
- export { parseContent, parseContentBlocks };
207
+ export { parseContent };
175
208
 
176
209
  //# sourceMappingURL=nip27.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"nip27.mjs","names":[],"sources":["../../src/nips/nip27.ts"],"sourcesContent":["/**\n * NIP-27: Text Note References Tokenizes note content into text, nostr: references, URLs, hashtags,\n * media, relays, emoji.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/27.md\n */\nimport type { Event } from \"../core/event.ts\";\nimport { decode } from \"./nip19.ts\";\nimport type { AddressPointer, EventPointer, ProfilePointer } from \"./nip19.ts\";\n\nexport type ContentBlock =\n | { type: \"text\"; text: string }\n | { type: \"reference\"; pointer: ProfilePointer | AddressPointer | EventPointer }\n | { type: \"url\"; url: string }\n | { type: \"relay\"; url: string }\n | { type: \"image\"; url: string }\n | { type: \"video\"; url: string }\n | { type: \"audio\"; url: string }\n | { type: \"emoji\"; shortcode: string; url: string }\n | { type: \"hashtag\"; value: string };\n\nconst noCharacter = /\\W/m;\nconst noURLCharacter = /[^\\w/] |[^\\w/]$|$|,| /m;\nconst MAX_HASHTAG_LENGTH = 42;\n\n/**\n * Parse note content (or a full event, to pick up emoji tags) into blocks. Generator — consume with\n * `for…of` or `[...parseContent(s)]`.\n */\nexport function* parseContent(\n content: string | Pick<Event, \"content\" | \"tags\">,\n): Iterable<ContentBlock> {\n const emojis: Array<{ type: \"emoji\"; shortcode: string; url: string }> = [];\n let text: string;\n\n if (typeof content === \"string\") {\n text = content;\n } else {\n for (const tag of content.tags) {\n const shortcode = tag.at(1);\n const url = tag.at(2);\n if (\n tag[0] === \"emoji\" &&\n shortcode !== undefined &&\n shortcode !== \"\" &&\n url !== undefined &&\n url !== \"\"\n ) {\n emojis.push({ type: \"emoji\", shortcode, url });\n }\n }\n text = content.content;\n }\n\n const max = text.length;\n let prevIndex = 0;\n let index = 0;\n\n mainloop: while (index < max) {\n const u = text.indexOf(\":\", index);\n const h = text.indexOf(\"#\", index);\n if (u === -1 && h === -1) {\n break;\n }\n\n if (u === -1 || (h !== -1 && h < u)) {\n // hashtag\n if (h === 0 || (h > 0 && noCharacter.exec(text.at(h - 1) ?? \"\") !== null)) {\n const m = noCharacter.exec(text.slice(h + 1, h + MAX_HASHTAG_LENGTH));\n const end = m ? h + 1 + (m.index ?? 0) : max;\n if (prevIndex !== h) {\n yield { type: \"text\", text: text.slice(prevIndex, h) };\n }\n yield { type: \"hashtag\", value: text.slice(h + 1, end) };\n index = end;\n prevIndex = index;\n continue;\n }\n index = h + 1;\n continue;\n }\n\n // nostr: references\n if (u >= 5 && text.slice(u - 5, u) === \"nostr\") {\n const m = noCharacter.exec(text.slice(u + 60));\n const end = m ? u + 60 + (m.index ?? 0) : max;\n try {\n const { data, type } = decode(text.slice(u + 1, end));\n let pointer: ProfilePointer | AddressPointer | EventPointer | undefined;\n switch (type) {\n case \"npub\":\n pointer = { pubkey: data };\n break;\n case \"note\":\n pointer = { id: data };\n break;\n case \"nprofile\":\n case \"nevent\":\n case \"naddr\":\n pointer = data;\n break;\n case \"nsec\":\n index = end + 1;\n continue mainloop;\n }\n if (prevIndex !== u - 5) {\n yield { type: \"text\", text: text.slice(prevIndex, u - 5) };\n }\n yield { type: \"reference\", pointer };\n index = end;\n prevIndex = index;\n continue;\n } catch {\n index = u + 1;\n continue;\n }\n }\n\n // http(s) URLs\n if (\n (u >= 5 && text.slice(u - 5, u) === \"https\") ||\n (u >= 4 && text.slice(u - 4, u) === \"http\")\n ) {\n const m = noURLCharacter.exec(text.slice(u + 4));\n const end = m ? u + 4 + (m.index ?? 0) : max;\n const prefixLen = text[u - 1] === \"s\" ? 5 : 4;\n try {\n const url = new URL(text.slice(u - prefixLen, end));\n if (!url.hostname.includes(\".\")) {\n throw new Error(\"invalid url\");\n }\n if (prevIndex !== u - prefixLen) {\n yield { type: \"text\", text: text.slice(prevIndex, u - prefixLen) };\n }\n const path = url.pathname;\n if (/\\.(png|jpe?g|gif|webp|heic|svg)$/i.test(path)) {\n yield { type: \"image\", url: url.toString() };\n } else if (/\\.(mp4|avi|webm|mkv|mov)$/i.test(path)) {\n yield { type: \"video\", url: url.toString() };\n } else if (/\\.(mp3|aac|ogg|opus|wav|flac)$/i.test(path)) {\n yield { type: \"audio\", url: url.toString() };\n } else {\n yield { type: \"url\", url: url.toString() };\n }\n index = end;\n prevIndex = index;\n continue;\n } catch {\n index = end + 1;\n continue;\n }\n }\n\n // ws(s) relays\n if ((u >= 3 && text.slice(u - 3, u) === \"wss\") || (u >= 2 && text.slice(u - 2, u) === \"ws\")) {\n const m = noURLCharacter.exec(text.slice(u + 4));\n const end = m ? u + 4 + (m.index ?? 0) : max;\n const prefixLen = text[u - 1] === \"s\" ? 3 : 2;\n try {\n const url = new URL(text.slice(u - prefixLen, end));\n if (!url.hostname.includes(\".\")) {\n throw new Error(\"invalid ws url\");\n }\n if (prevIndex !== u - prefixLen) {\n yield { type: \"text\", text: text.slice(prevIndex, u - prefixLen) };\n }\n yield { type: \"relay\", url: url.toString() };\n index = end;\n prevIndex = index;\n continue;\n } catch {\n index = end + 1;\n continue;\n }\n }\n\n // custom emoji shortcodes :code:\n for (const emoji of emojis) {\n const endColon = u + emoji.shortcode.length + 1;\n if (text[endColon] === \":\" && text.slice(u + 1, endColon) === emoji.shortcode) {\n if (prevIndex !== u) {\n yield { type: \"text\", text: text.slice(prevIndex, u) };\n }\n yield emoji;\n index = endColon + 1;\n prevIndex = index;\n continue mainloop;\n }\n }\n\n index = u + 1;\n }\n\n if (prevIndex !== max) {\n yield { type: \"text\", text: text.slice(prevIndex) };\n }\n}\n\n/** Collect all content blocks into an array. */\nexport function parseContentBlocks(\n content: string | Pick<Event, \"content\" | \"tags\">,\n): ContentBlock[] {\n return [...parseContent(content)];\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"nip27.mjs","names":[],"sources":["../../src/nips/nip27.ts"],"sourcesContent":["/**\n * NIP-27: Text Note References. Tokenizes note content into text, `nostr:` references, URLs, media,\n * relays, hashtags, custom emoji, and Lightning invoices.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/27.md\n */\nimport type { Event } from \"../core/event.ts\";\nimport { decode } from \"./nip19.ts\";\nimport type { AddressPointer, EventPointer, ProfilePointer } from \"./nip19.ts\";\n\nexport type ContentBlock =\n | { type: \"text\"; text: string }\n | {\n type: \"reference\";\n pointer: ProfilePointer | EventPointer | AddressPointer;\n /**\n * `true` when the reference had no `nostr:` prefix (see\n * {@link ParseContentOptions.legacyBech32}).\n */\n bare: boolean;\n }\n | { type: \"url\"; url: string }\n | { type: \"image\" | \"video\" | \"audio\"; url: string }\n | { type: \"relay\"; url: string }\n | { type: \"hashtag\"; value: string }\n | { type: \"emoji\"; shortcode: string; url: string }\n | { type: \"invoice\"; bolt11: string };\n\nexport type ParseContentOptions = {\n /**\n * Recognize bare `npub`, `nprofile`, `note`, `nevent`, `naddr` without the `nostr:` prefix.\n * Default `false`.\n */\n legacyBech32?: boolean;\n /**\n * URL → MIME type (the `m` field of NIP-92 `imeta` tags); takes precedence over extension\n * classification.\n */\n imeta?: ReadonlyMap<string, string>;\n};\n\n// Bech32 data charset ([02-9ac-hj-np-z], case-insensitive) and the NIP-19 entity prefixes.\nconst BECH32_CHARS = \"02-9ac-hj-np-z\";\nconst NIP19_PREFIX = \"(?:npub|nprofile|note|nevent|naddr|nsec)1\";\nconst NIP19_BARE_PREFIX = \"(?:npub|nprofile|note|nevent|naddr)1\";\n\n// A URL run ends at whitespace, angle brackets, double quotes, CJK punctuation\n// (U+3000–U+303F), or full-width forms (U+FF00–U+FFEF).\nconst URL_RUN = `[^\\\\s<>\"\\\\u3000-\\\\u303f\\\\uff00-\\\\uffef]+`;\n\n// Rule order matters: at every position the first matching alternative wins.\nconst SCAN_BODY = [\n // 1. nostr: URIs carry a NIP-19 entity.\n `(?<nostr>nostr:${NIP19_PREFIX}[${BECH32_CHARS}]+)`,\n // 2./3. http(s) and ws(s) URLs share the same boundary rules.\n `(?<url>https?://${URL_RUN})`,\n `(?<relay>wss?://${URL_RUN})`,\n // 4. BOLT11 invoices, with an optional lightning: URI prefix. The HRP may carry an amount\n // (`lnbc10u1…` = ln + currency + amount + multiplier + the `1` separator).\n `(?<invoice>(?:lightning:)?ln(?:bcrt|tbs|bc|tb)(?:\\\\d+[munp]?)?1[${BECH32_CHARS}]+)`,\n // 5. Hashtags: Unicode letters/marks/numbers and underscore, at most 42 code points.\n `(?<hashtag>#[\\\\p{L}\\\\p{M}\\\\p{N}_]{1,42})`,\n // 6. Custom emoji shortcodes (validated against the event's emoji tags).\n `(?<emoji>:[A-Za-z0-9_]+:)`,\n].join(\"|\");\n\nconst SCAN = new RegExp(SCAN_BODY, \"giu\");\n// 7. Bare NIP-19 entities — legacy rendering support, opt-in only.\nconst SCAN_LEGACY = new RegExp(\n `${SCAN_BODY}|(?<bare>${NIP19_BARE_PREFIX}[${BECH32_CHARS}]+)`,\n \"giu\",\n);\n\n// The character before `#` must be absent or a non-tag character.\nconst TAG_CHAR = /[\\p{L}\\p{M}\\p{N}_]/u;\n// Invoice and bare-bech32 tokens must not be glued to a letter or digit.\nconst WORD_CHAR = /[\\p{L}\\p{N}]/u;\n\nconst TRAILING_URL_CHARS = new Set([\".\", \",\", \";\", \":\", \"!\", \"?\", \"'\"]);\n\nconst IMAGE_EXT = new Set([\"jpg\", \"jpeg\", \"png\", \"gif\", \"webp\", \"avif\", \"svg\"]);\nconst VIDEO_EXT = new Set([\"mp4\", \"webm\", \"mov\", \"m4v\", \"m3u8\"]);\nconst AUDIO_EXT = new Set([\"mp3\", \"m4a\", \"ogg\", \"oga\", \"opus\", \"wav\", \"flac\", \"aac\"]);\n\nfunction isChar(cp: number | undefined, re: RegExp): boolean {\n return cp !== undefined && re.test(String.fromCodePoint(cp));\n}\n\n/** Drop trailing sentence punctuation and closers that have no opener inside the URL. */\nfunction trimUrlTail(raw: string): string {\n let url = raw;\n for (;;) {\n const last = url.at(-1);\n if (last !== undefined && TRAILING_URL_CHARS.has(last)) {\n url = url.slice(0, -1);\n continue;\n }\n if (last === \")\" || last === \"]\") {\n const open = last === \")\" ? \"(\" : \"[\";\n let opens = 0;\n let closes = 0;\n for (const c of url) {\n if (c === open) {\n opens++;\n } else if (c === last) {\n closes++;\n }\n }\n if (closes > opens) {\n url = url.slice(0, -1);\n continue;\n }\n }\n return url;\n }\n}\n\ntype MediaType = \"image\" | \"video\" | \"audio\";\n\nfunction mimeMedia(mime: string | undefined): MediaType | undefined {\n if (mime === undefined) {\n return undefined;\n }\n const slash = mime.indexOf(\"/\");\n const base = slash === -1 ? mime : mime.slice(0, slash);\n return base === \"image\" || base === \"video\" || base === \"audio\" ? base : undefined;\n}\n\nfunction extMedia(pathname: string): MediaType | undefined {\n const dot = pathname.lastIndexOf(\".\");\n if (dot === -1) {\n return undefined;\n }\n const ext = pathname.slice(dot + 1).toLowerCase();\n if (IMAGE_EXT.has(ext)) {\n return \"image\";\n }\n if (VIDEO_EXT.has(ext)) {\n return \"video\";\n }\n return AUDIO_EXT.has(ext) ? \"audio\" : undefined;\n}\n\nfunction urlBlock(\n raw: string,\n relay: boolean,\n imeta: ReadonlyMap<string, string> | undefined,\n): ContentBlock | undefined {\n const url = trimUrlTail(raw);\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n return undefined;\n }\n if (!parsed.hostname.includes(\".\")) {\n return undefined;\n }\n if (relay) {\n return { type: \"relay\", url };\n }\n const media = mimeMedia(imeta?.get(url)) ?? extMedia(parsed.pathname);\n return media === undefined ? { type: \"url\", url } : { type: media, url };\n}\n\nfunction referenceFor(code: string, bare: boolean): ContentBlock | undefined {\n let pointer: ProfilePointer | EventPointer | AddressPointer;\n try {\n const decoded = decode(code);\n switch (decoded.type) {\n case \"npub\":\n pointer = { pubkey: decoded.data };\n break;\n case \"note\":\n pointer = { id: decoded.data };\n break;\n case \"nprofile\":\n case \"nevent\":\n case \"naddr\":\n pointer = decoded.data;\n break;\n case \"nsec\":\n return undefined;\n }\n } catch {\n return undefined;\n }\n return { type: \"reference\", pointer, bare };\n}\n\n/**\n * Parse note content (or a full event, to pick up `emoji` tags) into ordered blocks. One\n * left-to-right scan; at every position the rules fire in the alternation order above. Adjacent\n * text merges and no empty text blocks are emitted.\n */\nexport function parseContent(\n content: string | Pick<Event, \"content\" | \"tags\">,\n opts?: ParseContentOptions,\n): ContentBlock[] {\n const emojis = new Map<string, string>();\n let text: string;\n if (typeof content === \"string\") {\n text = content;\n } else {\n for (const tag of content.tags) {\n if (\n tag[0] === \"emoji\" &&\n tag[1] !== undefined &&\n tag[1] !== \"\" &&\n tag[2] !== undefined &&\n tag[2] !== \"\"\n ) {\n emojis.set(tag[1], tag[2]);\n }\n }\n text = content.content;\n }\n\n const scan = opts?.legacyBech32 === true ? SCAN_LEGACY : SCAN;\n scan.lastIndex = 0;\n\n const blocks: ContentBlock[] = [];\n let textStart = 0;\n const flushText = (end: number): void => {\n if (end > textStart) {\n blocks.push({ type: \"text\", text: text.slice(textStart, end) });\n }\n };\n\n for (let m = scan.exec(text); m !== null; m = scan.exec(text)) {\n const start = m.index;\n const [token] = m;\n const end = start + token.length;\n const g = m.groups;\n let block: ContentBlock | undefined;\n\n if (g?.[\"nostr\"] !== undefined) {\n block = referenceFor(token.slice(\"nostr:\".length), false);\n } else if (g?.[\"url\"] !== undefined) {\n block = urlBlock(token, false, opts?.imeta);\n } else if (g?.[\"relay\"] !== undefined) {\n block = urlBlock(token, true, undefined);\n } else if (g?.[\"invoice\"] !== undefined) {\n const prev = start === 0 ? undefined : text.codePointAt(start - 1);\n if (!isChar(prev, WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) {\n const invoice = token.toLowerCase().startsWith(\"lightning:\")\n ? token.slice(\"lightning:\".length)\n : token;\n block = { type: \"invoice\", bolt11: invoice.toLowerCase() };\n }\n } else if (g?.[\"hashtag\"] !== undefined) {\n const prev = start === 0 ? undefined : text.codePointAt(start - 1);\n if (!isChar(prev, TAG_CHAR)) {\n block = { type: \"hashtag\", value: token.slice(1) };\n }\n } else if (g?.[\"emoji\"] !== undefined) {\n const url = emojis.get(token.slice(1, -1));\n if (url !== undefined) {\n block = { type: \"emoji\", shortcode: token.slice(1, -1), url };\n }\n } else if (g?.[\"bare\"] !== undefined) {\n const prev = start === 0 ? undefined : text.codePointAt(start - 1);\n if (!isChar(prev, WORD_CHAR) && !isChar(text.codePointAt(end), WORD_CHAR)) {\n block = referenceFor(token, true);\n }\n }\n\n if (block === undefined) {\n // The candidate is plain text; resume scanning one code unit later so any token inside\n // its span can still match.\n scan.lastIndex = start + 1;\n continue;\n }\n flushText(start);\n blocks.push(block);\n // URL-family blocks may end before the regex run once trailing punctuation is trimmed.\n textStart =\n block.type === \"url\" ||\n block.type === \"relay\" ||\n block.type === \"image\" ||\n block.type === \"video\" ||\n block.type === \"audio\"\n ? start + block.url.length\n : end;\n }\n\n flushText(text.length);\n return blocks;\n}\n"],"mappings":"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"}
@@ -29,8 +29,14 @@ export type ZapReceiptValidation = {
29
29
  };
30
30
  export type Bolt11Fields = {
31
31
  amountMsats?: number;
32
+ /** The `d` tagged field, UTF-8 decoded; omitted on invalid UTF-8. */
33
+ description?: string;
32
34
  descriptionHash?: Uint8Array;
33
35
  paymentHash?: Uint8Array;
36
+ /** Invoice creation time (unix seconds). */
37
+ timestamp: number;
38
+ /** `x` tagged field — seconds after `timestamp` until expiry. Defaults to 3600 when absent. */
39
+ expiry: number;
34
40
  };
35
41
  /** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */
36
42
  export declare function parseBolt11(pr: string): Bolt11Fields | undefined;
@@ -103,13 +103,29 @@ const BOLT11_TIMESTAMP_WORDS = 7;
103
103
  const BOLT11_SIGNATURE_WORDS = 104;
104
104
  const BOLT11_HASH_WORDS = 52;
105
105
  const HASH_BYTES = 32;
106
+ const BOLT11_TAG_PAYMENT_HASH = 1;
107
+ const BOLT11_TAG_EXPIRY = 6;
108
+ const BOLT11_TAG_DESCRIPTION = 13;
109
+ const BOLT11_TAG_DESCRIPTION_HASH = 23;
110
+ const DEFAULT_EXPIRY_SECONDS = 3600;
111
+ const utf8DecoderFatal = new TextDecoder("utf-8", { fatal: true });
112
+ /** Big-endian 5-bit words as a JS number (safe below 2^53). */
113
+ function wordsToInt(words) {
114
+ let value = 0;
115
+ for (const word of words) value = value * 32 + word;
116
+ return value;
117
+ }
106
118
  /** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */
107
119
  function parseBolt11(pr) {
108
120
  try {
109
121
  const { prefix, words } = bech32.decode(pr.toLowerCase(), false);
110
122
  if (!prefix.startsWith("ln")) return;
111
123
  if (words.length < 111) return;
112
- const fields = {};
124
+ const fields = {
125
+ timestamp: wordsToInt(words.slice(0, BOLT11_TIMESTAMP_WORDS)),
126
+ expiry: DEFAULT_EXPIRY_SECONDS
127
+ };
128
+ let sawExpiry = false;
113
129
  const amountMsats = amountMsatsFromHrp(prefix);
114
130
  if (amountMsats !== void 0) fields.amountMsats = amountMsats;
115
131
  const tlvEnd = words.length - BOLT11_SIGNATURE_WORDS;
@@ -124,10 +140,24 @@ function parseBolt11(pr) {
124
140
  if (i + dataLen > tlvEnd) break;
125
141
  const data = words.slice(i, i + dataLen);
126
142
  i += dataLen;
127
- if (type !== 1 && type !== 23 || dataLen !== BOLT11_HASH_WORDS) continue;
143
+ if (type === BOLT11_TAG_EXPIRY) {
144
+ if (!sawExpiry) {
145
+ sawExpiry = true;
146
+ fields.expiry = wordsToInt(data);
147
+ }
148
+ continue;
149
+ }
150
+ if (type === BOLT11_TAG_DESCRIPTION) {
151
+ const bytes = bech32.fromWordsUnsafe(data);
152
+ if (bytes !== void 0) try {
153
+ fields.description ??= utf8DecoderFatal.decode(bytes);
154
+ } catch {}
155
+ continue;
156
+ }
157
+ if (type !== BOLT11_TAG_PAYMENT_HASH && type !== BOLT11_TAG_DESCRIPTION_HASH || dataLen !== BOLT11_HASH_WORDS) continue;
128
158
  const bytes = bech32.fromWordsUnsafe(data);
129
159
  if (!bytes || bytes.length !== HASH_BYTES) continue;
130
- if (type === 1) fields.paymentHash ??= bytes;
160
+ if (type === BOLT11_TAG_PAYMENT_HASH) fields.paymentHash ??= bytes;
131
161
  else fields.descriptionHash ??= bytes;
132
162
  }
133
163
  if (!fields.paymentHash) return;
@@ -1 +1 @@
1
- {"version":3,"file":"nip57.mjs","names":[],"sources":["../../src/nips/nip57.ts"],"sourcesContent":["/**\n * NIP-57 Lightning Zaps — zap request template (kind 9734) and receipt validation (kind 9735). Does\n * not fetch LNURL. Receipt checks never throw.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/57.md\n */\nimport { equalBytes } from \"@noble/ciphers/utils.js\";\nimport { sha256 } from \"@noble/hashes/sha2.js\";\nimport { bech32 } from \"@scure/base\";\n\nimport { EventValidationError } from \"../core/error.ts\";\nimport { validateSignedEvent } from \"../core/event.ts\";\nimport type { Event, EventTemplate } from \"../core/event.ts\";\nimport { isAddressableKind, Kind } from \"../core/kind.ts\";\nimport { eventAddress, getDTag, parseEventAddress, Tag } from \"../core/tag.ts\";\nimport { hexToBytes, utf8Encoder } from \"../core/util.ts\";\nimport { verifyEvent } from \"../core/verifier.ts\";\n\nexport type ProfileZapRequest = {\n pubkey: string;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport type EventZapRequest = {\n event: Event;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport function makeZapRequest(params: ProfileZapRequest | EventZapRequest): EventTemplate {\n if (params.relays.length === 0) {\n throw new EventValidationError(\"relays tag requires one or more URLs\");\n }\n\n const recipient = \"event\" in params ? params.event.pubkey : params.pubkey;\n const tags: Tag[] = [\n Tag.p(recipient),\n [\"amount\", params.amount.toString()],\n [\"relays\", ...params.relays],\n ];\n\n if (\"event\" in params) {\n const { event } = params;\n tags.push(Tag.e(event.id));\n if (isAddressableKind(event.kind)) {\n const d = getDTag(event.tags);\n if (d === undefined) {\n throw new EventValidationError(\"d tag not found\");\n }\n const addr = eventAddress(event);\n if (addr !== undefined) {\n tags.push([\"a\", addr]);\n }\n }\n tags.push([\"k\", event.kind.toString()]);\n }\n\n if (params.lnurl !== undefined && params.lnurl !== \"\") {\n tags.push([\"lnurl\", params.lnurl]);\n }\n\n return {\n kind: Kind.ZapRequest,\n created_at: Math.floor(Date.now() / 1000),\n content: params.comment ?? \"\",\n tags,\n };\n}\n\nexport type ZapReceiptContext = {\n nostrPubkey: string;\n lnurl?: string;\n};\n\nexport type ZapReceiptValidation = {\n valid: boolean;\n reason?: string;\n request?: Event;\n amountMsats?: number;\n};\n\nexport type Bolt11Fields = {\n amountMsats?: number;\n descriptionHash?: Uint8Array;\n paymentHash?: Uint8Array;\n};\n\nfunction firstTagValue(tags: ReadonlyArray<Tag>, name: string): string | undefined {\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined) {\n return tag[1];\n }\n }\n return undefined;\n}\n\nfunction fail(reason: string): ZapReceiptValidation {\n return { valid: false, reason };\n}\n\nconst MSATS_PER_BTC = 100_000_000_000;\nconst MSATS_PER_MILLI = 100_000_000;\nconst MSATS_PER_MICRO = 100_000;\nconst MSATS_PER_NANO = 100;\n\n/** Amount is digits after `ln` + currency letters, optional m/u/n/p. Non-integer pico is omitted. */\nfunction amountMsatsFromHrp(hrp: string): number | undefined {\n if (!hrp.startsWith(\"ln\")) {\n return undefined;\n }\n let i = 2;\n while (i < hrp.length) {\n const c = hrp.codePointAt(i);\n if (c === undefined || c < 97 || c > 122) {\n break;\n }\n i++;\n }\n if (i === hrp.length) {\n return undefined;\n }\n const rest = hrp.slice(i);\n const m = /^([0-9]+)([munp])?$/.exec(rest);\n if (!m) {\n return undefined;\n }\n const n = Number(m[1]);\n if (!Number.isSafeInteger(n)) {\n return undefined;\n }\n const mul = m.at(2);\n if (mul === undefined) {\n return n * MSATS_PER_BTC;\n }\n if (mul === \"m\") {\n return n * MSATS_PER_MILLI;\n }\n if (mul === \"u\") {\n return n * MSATS_PER_MICRO;\n }\n if (mul === \"n\") {\n return n * MSATS_PER_NANO;\n }\n // p: 0.1 msat; drop amounts that are not whole millisats\n if (n % 10 !== 0) {\n return undefined;\n }\n return n / 10;\n}\n\nfunction parseMsatsTag(value: string): number | undefined {\n if (!/^[0-9]+$/.test(value)) {\n return undefined;\n }\n const n = Number(value);\n return Number.isSafeInteger(n) ? n : undefined;\n}\n\nfunction countTags(tags: ReadonlyArray<Tag>, name: string): number {\n let n = 0;\n for (const tag of tags) {\n if (tag[0] === name) {\n n++;\n }\n }\n return n;\n}\n\nfunction hasHexTagValue(tags: ReadonlyArray<Tag>, name: string, value: string): boolean {\n const needle = value.toLowerCase();\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined && tag[1].toLowerCase() === needle) {\n return true;\n }\n }\n return false;\n}\n\nfunction hasAddressTag(tags: ReadonlyArray<Tag>, value: string): boolean {\n const want = parseEventAddress(value);\n if (!want) {\n return false;\n }\n for (const tag of tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n const got = parseEventAddress(tag[1]);\n if (\n got &&\n got.kind === want.kind &&\n got.pubkey === want.pubkey &&\n got.identifier === want.identifier\n ) {\n return true;\n }\n }\n return false;\n}\n\nconst BOLT11_TIMESTAMP_WORDS = 7;\nconst BOLT11_SIGNATURE_WORDS = 104;\nconst BOLT11_HASH_WORDS = 52;\nconst HASH_BYTES = 32;\n\n/** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */\nexport function parseBolt11(pr: string): Bolt11Fields | undefined {\n try {\n // Invoices exceed bech32's default 90-char limit.\n const { prefix, words } = bech32.decode(pr.toLowerCase(), false);\n if (!prefix.startsWith(\"ln\")) {\n return undefined;\n }\n // timestamp (7) + tagged fields + secp256k1 signature (104)\n if (words.length < BOLT11_TIMESTAMP_WORDS + BOLT11_SIGNATURE_WORDS) {\n return undefined;\n }\n const fields: Bolt11Fields = {};\n const amountMsats = amountMsatsFromHrp(prefix);\n if (amountMsats !== undefined) {\n fields.amountMsats = amountMsats;\n }\n const tlvEnd = words.length - BOLT11_SIGNATURE_WORDS;\n let i = BOLT11_TIMESTAMP_WORDS;\n while (i + 3 <= tlvEnd) {\n const type = words[i];\n const lenHigh = words[i + 1];\n const lenLow = words[i + 2];\n if (type === undefined || lenHigh === undefined || lenLow === undefined) {\n break;\n }\n const dataLen = lenHigh * 32 + lenLow;\n i += 3;\n if (i + dataLen > tlvEnd) {\n break;\n }\n const data = words.slice(i, i + dataLen);\n i += dataLen;\n if ((type !== 1 && type !== 23) || dataLen !== BOLT11_HASH_WORDS) {\n continue;\n }\n const bytes = bech32.fromWordsUnsafe(data);\n if (!bytes || bytes.length !== HASH_BYTES) {\n continue;\n }\n if (type === 1) {\n fields.paymentHash ??= bytes;\n } else {\n fields.descriptionHash ??= bytes;\n }\n }\n if (!fields.paymentHash) {\n return undefined;\n }\n return fields;\n } catch {\n return undefined;\n }\n}\n\n/** Parse the `description` tag of a kind 9735 receipt as a signed kind 9734. Never throws. */\nexport function parseZapRequestFromReceipt(receipt: Event): Event | undefined {\n try {\n const raw = firstTagValue(receipt.tags, \"description\");\n if (raw === undefined) {\n return undefined;\n }\n const parsed: unknown = JSON.parse(raw);\n if (!validateSignedEvent(parsed) || parsed.kind !== Kind.ZapRequest) {\n return undefined;\n }\n if (!verifyEvent(parsed)) {\n return undefined;\n }\n return parsed;\n } catch {\n return undefined;\n }\n}\n\n/** Validate a kind 9735 zap receipt. Never throws. */\nexport function validateZapReceipt(receipt: Event, ctx: ZapReceiptContext): ZapReceiptValidation {\n try {\n if (receipt.kind !== Kind.Zap || !verifyEvent(receipt)) {\n return fail(\"invalid receipt\");\n }\n if (receipt.pubkey.toLowerCase() !== ctx.nostrPubkey.toLowerCase()) {\n return fail(\"pubkey mismatch\");\n }\n\n const descriptionRaw = firstTagValue(receipt.tags, \"description\");\n const request = parseZapRequestFromReceipt(receipt);\n if (descriptionRaw === undefined || !request) {\n return fail(\"invalid zap request\");\n }\n if (countTags(request.tags, \"p\") !== 1) {\n return fail(\"invalid p count\");\n }\n if (countTags(request.tags, \"e\") > 1) {\n return fail(\"too many e tags\");\n }\n const relaysValue = firstTagValue(request.tags, \"relays\");\n if (relaysValue === undefined || relaysValue === \"\") {\n return fail(\"missing relays\");\n }\n\n for (const tag of request.tags) {\n if (tag[0] !== \"a\") {\n continue;\n }\n if (tag[1] === undefined) {\n return fail(\"invalid a\");\n }\n const parsed = parseEventAddress(tag[1]);\n if (!parsed || !isAddressableKind(parsed.kind)) {\n return fail(\"invalid a\");\n }\n }\n\n // Request P is the LNURL provider (receipt pubkey), not the zap sender.\n const requestPCount = countTags(request.tags, \"P\");\n if (requestPCount > 1) {\n return fail(\"too many P tags\");\n }\n if (requestPCount === 1) {\n const requestP = firstTagValue(request.tags, \"P\");\n if (requestP === undefined || requestP.toLowerCase() !== receipt.pubkey.toLowerCase()) {\n return fail(\"request P mismatch\");\n }\n }\n\n const bolt11Tag = firstTagValue(receipt.tags, \"bolt11\");\n if (bolt11Tag === undefined) {\n return fail(\"missing bolt11\");\n }\n const bolt11 = parseBolt11(bolt11Tag);\n if (!bolt11 || !bolt11.descriptionHash) {\n return fail(\"invalid bolt11\");\n }\n\n const requestAmount = firstTagValue(request.tags, \"amount\");\n if (requestAmount !== undefined) {\n const msats = parseMsatsTag(requestAmount);\n if (msats === undefined || bolt11.amountMsats !== msats) {\n return fail(\"amount mismatch\");\n }\n }\n\n // Hash the tag payload, not JSON.stringify(parsed) (key order may differ).\n const digest = sha256(utf8Encoder.encode(descriptionRaw));\n if (!equalBytes(digest, bolt11.descriptionHash)) {\n return fail(\"description hash mismatch\");\n }\n\n const requestLnurl = firstTagValue(request.tags, \"lnurl\");\n if (\n requestLnurl !== undefined &&\n ctx.lnurl !== undefined &&\n requestLnurl.toLowerCase() !== ctx.lnurl.toLowerCase()\n ) {\n return fail(\"lnurl mismatch\");\n }\n\n const preimageHex = firstTagValue(receipt.tags, \"preimage\");\n if (preimageHex !== undefined && bolt11.paymentHash) {\n let preimage: Uint8Array;\n try {\n preimage = hexToBytes(preimageHex);\n } catch {\n return fail(\"preimage mismatch\");\n }\n if (!equalBytes(sha256(preimage), bolt11.paymentHash)) {\n return fail(\"preimage mismatch\");\n }\n }\n\n const recipient = firstTagValue(request.tags, \"p\");\n if (recipient === undefined || !hasHexTagValue(receipt.tags, \"p\", recipient)) {\n return fail(\"missing p\");\n }\n const requestE = firstTagValue(request.tags, \"e\");\n if (requestE !== undefined && !hasHexTagValue(receipt.tags, \"e\", requestE)) {\n return fail(\"missing e\");\n }\n for (const tag of request.tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n if (!hasAddressTag(receipt.tags, tag[1])) {\n return fail(\"missing a\");\n }\n }\n\n // Receipt P is the zap sender (request pubkey). Do not copy request tag P.\n for (const tag of receipt.tags) {\n if (tag[0] !== \"P\") {\n continue;\n }\n if (tag[1] === undefined || tag[1].toLowerCase() !== request.pubkey.toLowerCase()) {\n return fail(\"receipt P mismatch\");\n }\n }\n\n const amountMsats =\n bolt11.amountMsats ??\n (requestAmount === undefined ? undefined : parseMsatsTag(requestAmount));\n return amountMsats === undefined\n ? { valid: true, request }\n : { valid: true, request, amountMsats };\n } catch {\n return fail(\"invalid receipt\");\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"nip57.mjs","names":[],"sources":["../../src/nips/nip57.ts"],"sourcesContent":["/**\n * NIP-57 Lightning Zaps — zap request template (kind 9734) and receipt validation (kind 9735). Does\n * not fetch LNURL. Receipt checks never throw.\n *\n * @see https://github.com/nostr-protocol/nips/blob/master/57.md\n */\nimport { equalBytes } from \"@noble/ciphers/utils.js\";\nimport { sha256 } from \"@noble/hashes/sha2.js\";\nimport { bech32 } from \"@scure/base\";\n\nimport { EventValidationError } from \"../core/error.ts\";\nimport { validateSignedEvent } from \"../core/event.ts\";\nimport type { Event, EventTemplate } from \"../core/event.ts\";\nimport { isAddressableKind, Kind } from \"../core/kind.ts\";\nimport { eventAddress, getDTag, parseEventAddress, Tag } from \"../core/tag.ts\";\nimport { hexToBytes, utf8Encoder } from \"../core/util.ts\";\nimport { verifyEvent } from \"../core/verifier.ts\";\n\nexport type ProfileZapRequest = {\n pubkey: string;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport type EventZapRequest = {\n event: Event;\n /** Amount in millisats. */\n amount: number;\n relays: ReadonlyArray<string>;\n comment?: string;\n lnurl?: string;\n};\n\nexport function makeZapRequest(params: ProfileZapRequest | EventZapRequest): EventTemplate {\n if (params.relays.length === 0) {\n throw new EventValidationError(\"relays tag requires one or more URLs\");\n }\n\n const recipient = \"event\" in params ? params.event.pubkey : params.pubkey;\n const tags: Tag[] = [\n Tag.p(recipient),\n [\"amount\", params.amount.toString()],\n [\"relays\", ...params.relays],\n ];\n\n if (\"event\" in params) {\n const { event } = params;\n tags.push(Tag.e(event.id));\n if (isAddressableKind(event.kind)) {\n const d = getDTag(event.tags);\n if (d === undefined) {\n throw new EventValidationError(\"d tag not found\");\n }\n const addr = eventAddress(event);\n if (addr !== undefined) {\n tags.push([\"a\", addr]);\n }\n }\n tags.push([\"k\", event.kind.toString()]);\n }\n\n if (params.lnurl !== undefined && params.lnurl !== \"\") {\n tags.push([\"lnurl\", params.lnurl]);\n }\n\n return {\n kind: Kind.ZapRequest,\n created_at: Math.floor(Date.now() / 1000),\n content: params.comment ?? \"\",\n tags,\n };\n}\n\nexport type ZapReceiptContext = {\n nostrPubkey: string;\n lnurl?: string;\n};\n\nexport type ZapReceiptValidation = {\n valid: boolean;\n reason?: string;\n request?: Event;\n amountMsats?: number;\n};\n\nexport type Bolt11Fields = {\n amountMsats?: number;\n /** The `d` tagged field, UTF-8 decoded; omitted on invalid UTF-8. */\n description?: string;\n descriptionHash?: Uint8Array;\n paymentHash?: Uint8Array;\n /** Invoice creation time (unix seconds). */\n timestamp: number;\n /** `x` tagged field — seconds after `timestamp` until expiry. Defaults to 3600 when absent. */\n expiry: number;\n};\n\nfunction firstTagValue(tags: ReadonlyArray<Tag>, name: string): string | undefined {\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined) {\n return tag[1];\n }\n }\n return undefined;\n}\n\nfunction fail(reason: string): ZapReceiptValidation {\n return { valid: false, reason };\n}\n\nconst MSATS_PER_BTC = 100_000_000_000;\nconst MSATS_PER_MILLI = 100_000_000;\nconst MSATS_PER_MICRO = 100_000;\nconst MSATS_PER_NANO = 100;\n\n/** Amount is digits after `ln` + currency letters, optional m/u/n/p. Non-integer pico is omitted. */\nfunction amountMsatsFromHrp(hrp: string): number | undefined {\n if (!hrp.startsWith(\"ln\")) {\n return undefined;\n }\n let i = 2;\n while (i < hrp.length) {\n const c = hrp.codePointAt(i);\n if (c === undefined || c < 97 || c > 122) {\n break;\n }\n i++;\n }\n if (i === hrp.length) {\n return undefined;\n }\n const rest = hrp.slice(i);\n const m = /^([0-9]+)([munp])?$/.exec(rest);\n if (!m) {\n return undefined;\n }\n const n = Number(m[1]);\n if (!Number.isSafeInteger(n)) {\n return undefined;\n }\n const mul = m.at(2);\n if (mul === undefined) {\n return n * MSATS_PER_BTC;\n }\n if (mul === \"m\") {\n return n * MSATS_PER_MILLI;\n }\n if (mul === \"u\") {\n return n * MSATS_PER_MICRO;\n }\n if (mul === \"n\") {\n return n * MSATS_PER_NANO;\n }\n // p: 0.1 msat; drop amounts that are not whole millisats\n if (n % 10 !== 0) {\n return undefined;\n }\n return n / 10;\n}\n\nfunction parseMsatsTag(value: string): number | undefined {\n if (!/^[0-9]+$/.test(value)) {\n return undefined;\n }\n const n = Number(value);\n return Number.isSafeInteger(n) ? n : undefined;\n}\n\nfunction countTags(tags: ReadonlyArray<Tag>, name: string): number {\n let n = 0;\n for (const tag of tags) {\n if (tag[0] === name) {\n n++;\n }\n }\n return n;\n}\n\nfunction hasHexTagValue(tags: ReadonlyArray<Tag>, name: string, value: string): boolean {\n const needle = value.toLowerCase();\n for (const tag of tags) {\n if (tag[0] === name && tag[1] !== undefined && tag[1].toLowerCase() === needle) {\n return true;\n }\n }\n return false;\n}\n\nfunction hasAddressTag(tags: ReadonlyArray<Tag>, value: string): boolean {\n const want = parseEventAddress(value);\n if (!want) {\n return false;\n }\n for (const tag of tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n const got = parseEventAddress(tag[1]);\n if (\n got &&\n got.kind === want.kind &&\n got.pubkey === want.pubkey &&\n got.identifier === want.identifier\n ) {\n return true;\n }\n }\n return false;\n}\n\nconst BOLT11_TIMESTAMP_WORDS = 7;\nconst BOLT11_SIGNATURE_WORDS = 104;\nconst BOLT11_HASH_WORDS = 52;\nconst HASH_BYTES = 32;\nconst BOLT11_TAG_PAYMENT_HASH = 1;\nconst BOLT11_TAG_EXPIRY = 6;\nconst BOLT11_TAG_DESCRIPTION = 13;\nconst BOLT11_TAG_DESCRIPTION_HASH = 23;\nconst DEFAULT_EXPIRY_SECONDS = 3600;\n\nconst utf8DecoderFatal = new TextDecoder(\"utf-8\", { fatal: true });\n\n/** Big-endian 5-bit words as a JS number (safe below 2^53). */\nfunction wordsToInt(words: ReadonlyArray<number>): number {\n let value = 0;\n for (const word of words) {\n value = value * 32 + word;\n }\n return value;\n}\n\n/** Decode a BOLT11 invoice. Never throws. Requires a 32-byte payment hash (type 1). */\nexport function parseBolt11(pr: string): Bolt11Fields | undefined {\n try {\n // Invoices exceed bech32's default 90-char limit.\n const { prefix, words } = bech32.decode(pr.toLowerCase(), false);\n if (!prefix.startsWith(\"ln\")) {\n return undefined;\n }\n // timestamp (7) + tagged fields + secp256k1 signature (104)\n if (words.length < BOLT11_TIMESTAMP_WORDS + BOLT11_SIGNATURE_WORDS) {\n return undefined;\n }\n const fields: Bolt11Fields = {\n timestamp: wordsToInt(words.slice(0, BOLT11_TIMESTAMP_WORDS)),\n expiry: DEFAULT_EXPIRY_SECONDS,\n };\n let sawExpiry = false;\n const amountMsats = amountMsatsFromHrp(prefix);\n if (amountMsats !== undefined) {\n fields.amountMsats = amountMsats;\n }\n const tlvEnd = words.length - BOLT11_SIGNATURE_WORDS;\n let i = BOLT11_TIMESTAMP_WORDS;\n while (i + 3 <= tlvEnd) {\n const type = words[i];\n const lenHigh = words[i + 1];\n const lenLow = words[i + 2];\n if (type === undefined || lenHigh === undefined || lenLow === undefined) {\n break;\n }\n const dataLen = lenHigh * 32 + lenLow;\n i += 3;\n if (i + dataLen > tlvEnd) {\n break;\n }\n const data = words.slice(i, i + dataLen);\n i += dataLen;\n if (type === BOLT11_TAG_EXPIRY) {\n if (!sawExpiry) {\n sawExpiry = true;\n fields.expiry = wordsToInt(data);\n }\n continue;\n }\n if (type === BOLT11_TAG_DESCRIPTION) {\n const bytes = bech32.fromWordsUnsafe(data);\n if (bytes !== undefined) {\n try {\n fields.description ??= utf8DecoderFatal.decode(bytes);\n } catch {\n // Invalid UTF-8 — drop the field, not the invoice.\n }\n }\n continue;\n }\n if (\n (type !== BOLT11_TAG_PAYMENT_HASH && type !== BOLT11_TAG_DESCRIPTION_HASH) ||\n dataLen !== BOLT11_HASH_WORDS\n ) {\n continue;\n }\n const bytes = bech32.fromWordsUnsafe(data);\n if (!bytes || bytes.length !== HASH_BYTES) {\n continue;\n }\n if (type === BOLT11_TAG_PAYMENT_HASH) {\n fields.paymentHash ??= bytes;\n } else {\n fields.descriptionHash ??= bytes;\n }\n }\n if (!fields.paymentHash) {\n return undefined;\n }\n return fields;\n } catch {\n return undefined;\n }\n}\n\n/** Parse the `description` tag of a kind 9735 receipt as a signed kind 9734. Never throws. */\nexport function parseZapRequestFromReceipt(receipt: Event): Event | undefined {\n try {\n const raw = firstTagValue(receipt.tags, \"description\");\n if (raw === undefined) {\n return undefined;\n }\n const parsed: unknown = JSON.parse(raw);\n if (!validateSignedEvent(parsed) || parsed.kind !== Kind.ZapRequest) {\n return undefined;\n }\n if (!verifyEvent(parsed)) {\n return undefined;\n }\n return parsed;\n } catch {\n return undefined;\n }\n}\n\n/** Validate a kind 9735 zap receipt. Never throws. */\nexport function validateZapReceipt(receipt: Event, ctx: ZapReceiptContext): ZapReceiptValidation {\n try {\n if (receipt.kind !== Kind.Zap || !verifyEvent(receipt)) {\n return fail(\"invalid receipt\");\n }\n if (receipt.pubkey.toLowerCase() !== ctx.nostrPubkey.toLowerCase()) {\n return fail(\"pubkey mismatch\");\n }\n\n const descriptionRaw = firstTagValue(receipt.tags, \"description\");\n const request = parseZapRequestFromReceipt(receipt);\n if (descriptionRaw === undefined || !request) {\n return fail(\"invalid zap request\");\n }\n if (countTags(request.tags, \"p\") !== 1) {\n return fail(\"invalid p count\");\n }\n if (countTags(request.tags, \"e\") > 1) {\n return fail(\"too many e tags\");\n }\n const relaysValue = firstTagValue(request.tags, \"relays\");\n if (relaysValue === undefined || relaysValue === \"\") {\n return fail(\"missing relays\");\n }\n\n for (const tag of request.tags) {\n if (tag[0] !== \"a\") {\n continue;\n }\n if (tag[1] === undefined) {\n return fail(\"invalid a\");\n }\n const parsed = parseEventAddress(tag[1]);\n if (!parsed || !isAddressableKind(parsed.kind)) {\n return fail(\"invalid a\");\n }\n }\n\n // Request P is the LNURL provider (receipt pubkey), not the zap sender.\n const requestPCount = countTags(request.tags, \"P\");\n if (requestPCount > 1) {\n return fail(\"too many P tags\");\n }\n if (requestPCount === 1) {\n const requestP = firstTagValue(request.tags, \"P\");\n if (requestP === undefined || requestP.toLowerCase() !== receipt.pubkey.toLowerCase()) {\n return fail(\"request P mismatch\");\n }\n }\n\n const bolt11Tag = firstTagValue(receipt.tags, \"bolt11\");\n if (bolt11Tag === undefined) {\n return fail(\"missing bolt11\");\n }\n const bolt11 = parseBolt11(bolt11Tag);\n if (!bolt11 || !bolt11.descriptionHash) {\n return fail(\"invalid bolt11\");\n }\n\n const requestAmount = firstTagValue(request.tags, \"amount\");\n if (requestAmount !== undefined) {\n const msats = parseMsatsTag(requestAmount);\n if (msats === undefined || bolt11.amountMsats !== msats) {\n return fail(\"amount mismatch\");\n }\n }\n\n // Hash the tag payload, not JSON.stringify(parsed) (key order may differ).\n const digest = sha256(utf8Encoder.encode(descriptionRaw));\n if (!equalBytes(digest, bolt11.descriptionHash)) {\n return fail(\"description hash mismatch\");\n }\n\n const requestLnurl = firstTagValue(request.tags, \"lnurl\");\n if (\n requestLnurl !== undefined &&\n ctx.lnurl !== undefined &&\n requestLnurl.toLowerCase() !== ctx.lnurl.toLowerCase()\n ) {\n return fail(\"lnurl mismatch\");\n }\n\n const preimageHex = firstTagValue(receipt.tags, \"preimage\");\n if (preimageHex !== undefined && bolt11.paymentHash) {\n let preimage: Uint8Array;\n try {\n preimage = hexToBytes(preimageHex);\n } catch {\n return fail(\"preimage mismatch\");\n }\n if (!equalBytes(sha256(preimage), bolt11.paymentHash)) {\n return fail(\"preimage mismatch\");\n }\n }\n\n const recipient = firstTagValue(request.tags, \"p\");\n if (recipient === undefined || !hasHexTagValue(receipt.tags, \"p\", recipient)) {\n return fail(\"missing p\");\n }\n const requestE = firstTagValue(request.tags, \"e\");\n if (requestE !== undefined && !hasHexTagValue(receipt.tags, \"e\", requestE)) {\n return fail(\"missing e\");\n }\n for (const tag of request.tags) {\n if (tag[0] !== \"a\" || tag[1] === undefined) {\n continue;\n }\n if (!hasAddressTag(receipt.tags, tag[1])) {\n return fail(\"missing a\");\n }\n }\n\n // Receipt P is the zap sender (request pubkey). Do not copy request tag P.\n for (const tag of receipt.tags) {\n if (tag[0] !== \"P\") {\n continue;\n }\n if (tag[1] === undefined || tag[1].toLowerCase() !== request.pubkey.toLowerCase()) {\n return fail(\"receipt P mismatch\");\n }\n }\n\n const amountMsats =\n bolt11.amountMsats ??\n (requestAmount === undefined ? undefined : parseMsatsTag(requestAmount));\n return amountMsats === undefined\n ? { valid: true, request }\n : { valid: true, request, amountMsats };\n } catch {\n return fail(\"invalid receipt\");\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qntx/nostr",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Nostr protocol implementation and SDK in TypeScript",
5
5
  "keywords": [
6
6
  "decentralized",