Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save StellarStoic/c835d9247c1701deca5036526f62d8c9 to your computer and use it in GitHub Desktop.
Save StellarStoic/c835d9247c1701deca5036526f62d8c9 to your computer and use it in GitHub Desktop.
Nostr Nip-19 note1 and nevent playground in JavaScript. I needed a note1 & nevent1 decoder like the fiatjaf's NAK tool. There's not many how-to's about encoding, decoding bech32-encoded entities
"use strict";
const ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const ALPHABET_MAP = {};
// Map each character in the alphabet to its index
for (let i = 0; i < 32; i++) {
ALPHABET_MAP[ALPHABET.charAt(i)] = i;
}
// Polymod step (used for checksum calculations)
function polymodStep(pre) {
const b = pre >> 25;
return (
((pre & 0x1ffffff) << 5) ^
(-((b >> 0) & 1) & 0x3b6a57b2) ^
(-((b >> 1) & 1) & 0x26508e6d) ^
(-((b >> 2) & 1) & 0x1ea119fa) ^
(-((b >> 3) & 1) & 0x3d4233dd) ^
(-((b >> 4) & 1) & 0x2a1462b3)
);
}
// Compute the prefix checksum
function prefixChk(prefix) {
let chk = 1;
for (let i = 0; i < prefix.length; i++) {
const charCode = prefix.charCodeAt(i);
if (charCode < 33 || charCode > 126) {
return `Invalid prefix (${prefix})`;
}
chk = polymodStep(chk) ^ (charCode >> 5);
}
chk = polymodStep(chk);
for (let i = 0; i < prefix.length; i++) {
chk = polymodStep(chk) ^ (prefix.charCodeAt(i) & 0x1f);
}
return chk;
}
// Convert data between different bit sizes (5-bit, 8-bit)
function convert(data, inBits, outBits, pad) {
let value = 0;
let bits = 0;
const maxV = (1 << outBits) - 1;
const result = [];
for (let i = 0; i < data.length; i++) {
value = (value << inBits) | data[i];
bits += inBits;
while (bits >= outBits) {
bits -= outBits;
result.push((value >> bits) & maxV);
}
}
if (pad) {
if (bits > 0) {
result.push((value << (outBits - bits)) & maxV);
}
} else {
if (bits >= inBits) return "Excess padding";
if ((value << (outBits - bits)) & maxV) return "Non-zero padding";
}
return result;
}
// Convert bytes to 5-bit words
function toWords(bytes) {
return convert(bytes, 8, 5, true);
}
// Convert 5-bit words back to bytes (unsafe version)
function fromWordsUnsafe(words) {
return convert(words, 5, 8, false);
}
// Convert 5-bit words back to bytes (safe version with error handling)
function fromWords(words) {
const res = fromWordsUnsafe(words);
if (Array.isArray(res)) return res;
throw new Error(res);
}
// Get the appropriate library (bech32 or bech32m)
function getLibraryFromEncoding(encoding) {
const encodingConst = encoding === "bech32" ? 1 : 0x2bc830a3;
function encode(prefix, words, limit) {
if (!limit) limit = 90;
if (prefix.length + 7 + words.length > limit) {
throw new TypeError("Exceeds length limit");
}
let chk = prefixChk(prefix);
if (typeof chk === "string") {
throw new Error(chk);
}
let result = prefix + "1";
for (let i = 0; i < words.length; i++) {
if (words[i] >> 5 !== 0) {
throw new Error("Non 5-bit word");
}
chk = polymodStep(chk) ^ words[i];
result += ALPHABET.charAt(words[i]);
}
for (let i = 0; i < 6; i++) {
chk = polymodStep(chk);
}
chk ^= encodingConst;
for (let i = 0; i < 6; i++) {
result += ALPHABET.charAt((chk >> (5 * (5 - i))) & 31);
}
return result;
}
function decode(str, limit) {
if (!limit) limit = 90;
if (str.length < 8) return str + " too short";
if (str.length > limit) return "Exceeds length limit";
const lower = str.toLowerCase();
const upper = str.toUpperCase();
if (str !== lower && str !== upper) {
return "Mixed-case string " + str;
}
str = lower;
const pos = str.lastIndexOf("1");
if (pos === -1) return "No separator character";
if (pos === 0) return "Missing prefix";
const prefix = str.slice(0, pos);
const wordChars = str.slice(pos + 1);
if (wordChars.length < 6) return "Data too short";
let chk = prefixChk(prefix);
if (typeof chk === "string") return chk;
const words = [];
for (let i = 0; i < wordChars.length; i++) {
const char = wordChars.charAt(i);
const val = ALPHABET_MAP[char];
if (val === undefined) return "Unknown character " + char;
chk = polymodStep(chk) ^ val;
if (i + 6 >= wordChars.length) continue;
words.push(val);
}
if (chk !== encodingConst) return "Invalid checksum";
return { prefix, words };
}
return {
encode,
decode,
toWords,
fromWords,
fromWordsUnsafe
};
}
const bech32 = getLibraryFromEncoding("bech32");
const bech32m = getLibraryFromEncoding("bech32m");
// exports.bech32 = bech32;
// exports.bech32m = bech32m;
<!DOCTYPE html>
<html>
<head>
<title>Nostr Converter (HEX, Note1 & Extended Nevent – Lenient bech32m)</title>
<script src="/bech32.js"></script>
<script>
// --- Assume bech32.js exposes globals: bech32 and bech32m ---
// --- Utility Functions ---
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function hexToBytes(hex) {
if (hex.length % 2 !== 0) throw new Error("Invalid hex length");
let bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
function bytesToHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
function utf8Encode(str) {
return new TextEncoder().encode(str);
}
// ASCII is a subset of UTF-8 so decoding with UTF-8 is acceptable.
function utf8Decode(bytes) {
if (!(bytes instanceof Uint8Array)) {
bytes = new Uint8Array(bytes);
}
return new TextDecoder("utf-8").decode(bytes);
}
function numberToUint32(num) {
let arr = new Uint8Array(4);
arr[0] = (num >> 24) & 0xff;
arr[1] = (num >> 16) & 0xff;
arr[2] = (num >> 8) & 0xff;
arr[3] = num & 0xff;
return arr;
}
function uint32ToNumber(bytes) {
if (bytes.length !== 4) throw new Error("Invalid uint32 length");
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
}
// --- TLV Encoding/Decoding ---
function decodeTLV(bytes) {
let items = [];
let i = 0;
while (i < bytes.length) {
if (i + 2 > bytes.length) break; // need at least type and length
let type = bytes[i++];
let len = bytes[i++];
if (i + len > bytes.length) break;
let value = bytes.slice(i, i + len);
items.push({ type: type, value: value });
i += len;
}
return items;
}
function encodeTLV(items) {
let total = 0;
for (let item of items) {
total += 2 + item.value.length;
}
let out = new Uint8Array(total);
let offset = 0;
for (let item of items) {
out[offset++] = item.type;
out[offset++] = item.value.length;
out.set(item.value, offset);
offset += item.value.length;
}
return out;
}
// --- NOTE1 Conversion Functions (using standard bech32) ---
function hexToNote1(hex) {
console.log("Input HEX for note1:", hex);
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
throw new Error("Invalid HEX: Must be 64 hex characters");
}
const bytes = hexToBytes(hex);
const words = bech32.toWords(bytes);
const encoded = bech32.encode('note', words);
console.log("Encoded note1:", encoded);
return encoded;
}
function note1ToHex(note1) {
const clean = note1.replace(/^nostr:/i, '').toLowerCase();
console.log("Clean note1 input:", clean);
const decoded = bech32.decode(clean);
console.log("Decoded note1:", decoded);
if (typeof decoded !== "object" || !decoded.words) {
throw new Error(typeof decoded === "object" ? "Decoded result missing words" : decoded);
}
if (decoded.prefix !== 'note') {
throw new Error('Invalid prefix for note1: ' + decoded.prefix);
}
const bytes = bech32.fromWords(decoded.words);
console.log("Converted bytes from note1:", bytes);
if (bytes.length !== 32) {
throw new Error('Invalid payload length for note1: ' + bytes.length);
}
return bytesToHex(bytes);
}
// --- Lenient bech32m Decoder ---
// This function decodes a bech32m string without verifying the checksum.
// It simply removes the last 6 digits (checksum) from the data part.
function lenientDecode(bechString, limit) {
if (!limit) limit = 1000;
bechString = bechString.toLowerCase();
var pos = bechString.lastIndexOf('1');
if (pos < 1 || pos + 7 > bechString.length) {
throw new Error("Invalid bech32 string");
}
var hrp = bechString.substring(0, pos);
var data = [];
for (var i = pos + 1; i < bechString.length; i++) {
var d = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".indexOf(bechString.charAt(i));
if (d === -1) {
throw new Error("Unknown character: " + bechString.charAt(i));
}
data.push(d);
}
// Remove the last 6 digits (checksum)
return { hrp: hrp, data: data.slice(0, data.length - 6) };
}
// --- Extended NEVENT Conversion Functions (using bech32m with TLV) ---
// Encode an event to a nevent string.
// Parameters:
// eventIdHex: 64-character hex string (32 bytes)
// relays: array of relay URIs (strings)
// pubKeyHex: 64-character hex string (or null)
// kind: a number (or null)
function eventToNevent(eventIdHex, relays, pubKeyHex, kind) {
if (!/^[0-9a-fA-F]{64}$/.test(eventIdHex)) {
throw new Error("Invalid eventId HEX");
}
let items = [];
items.push({ type: 0, value: hexToBytes(eventIdHex) });
if (relays && relays.length > 0) {
for (let r of relays) {
items.push({ type: 1, value: utf8Encode(r) });
}
}
if (pubKeyHex && /^[0-9a-fA-F]{64}$/.test(pubKeyHex)) {
items.push({ type: 2, value: hexToBytes(pubKeyHex) });
}
if (typeof kind === 'number') {
items.push({ type: 3, value: numberToUint32(kind) });
}
const tlv = encodeTLV(items);
const encoded = bech32m.encode('nevent', bech32m.toWords(tlv), 1000);
console.log("Encoded nevent:", encoded);
return encoded;
}
// Decode a nevent string into its TLV fields.
// Returns an object: { eventId, relays, pubKey, kind, unknown }
// This version uses our lenientDecode to bypass strict checksum verification.
function neventToEvent(neventStr) {
const clean = neventStr.replace(/^nostr:/i, '').toLowerCase();
console.log("Clean nevent input:", clean);
const decoded = lenientDecode(clean, 1000);
console.log("Leniently decoded nevent:", decoded);
if (decoded.hrp !== 'nevent') {
throw new Error("Invalid prefix for nevent: " + decoded.hrp);
}
// Wrap the data array in a Uint8Array
const payload = new Uint8Array(bech32m.fromWords(decoded.data));
console.log("Decoded TLV payload for nevent:", payload);
const tlvs = decodeTLV(payload);
console.log("TLV items:", tlvs);
let eventId = null;
let relays = [];
let pubKey = null;
let kind = null;
let unknown = [];
for (let item of tlvs) {
switch(item.type) {
case 0:
if (item.value.length === 32) {
eventId = bytesToHex(item.value);
} else {
unknown.push(item);
}
break;
case 1:
relays.push(utf8Decode(item.value));
break;
case 2:
if (item.value.length === 32) {
pubKey = bytesToHex(item.value);
} else {
unknown.push(item);
}
break;
case 3:
if (item.value.length === 4) {
kind = uint32ToNumber(item.value);
} else {
unknown.push(item);
}
break;
default:
unknown.push(item);
}
}
if (!eventId) {
throw new Error("Missing event ID in TLV");
}
return { eventId, relays, pubKey, kind, unknown };
}
// --- Combined Conversion Function ---
// Depending on input (HEX, note1, or nevent), derive:
// - HEX (32-byte event id as hex)
// - Normalized note1 (using standard bech32)
// - Extended nevent (using bech32m with TLV)
// For nevent input, also display decoded TLV fields.
const debouncedConvert = debounce(function() {
const input = document.getElementById('input').value.trim();
console.log("User input:", input);
let output;
if (/^[0-9a-fA-F]{64}$/.test(input)) {
let hex = input.toLowerCase();
let note1 = hexToNote1(hex);
let nevent = eventToNevent(hex, [], null, null);
output = `HEX: ${hex}\nnote1: ${note1}\nnevent: ${nevent}`;
} else if (/^(nostr:)?note1/.test(input)) {
let hex = note1ToHex(input);
let note1 = hexToNote1(hex);
let nevent = eventToNevent(hex, [], null, null);
output = `HEX: ${hex}\nnote1: ${note1}\nnevent: ${nevent}`;
} else if (/^(nostr:)?nevent/.test(input)) {
let eventObj = neventToEvent(input);
let hex = eventObj.eventId;
let note1 = hexToNote1(hex);
let nevent = eventToNevent(hex, eventObj.relays, eventObj.pubKey, eventObj.kind);
output = `HEX: ${hex}\nnote1: ${note1}\nnevent: ${nevent}\n\nDecoded nevent fields:\nEvent ID: ${hex}\nRelays: ${JSON.stringify(eventObj.relays)}\nPubKey: ${eventObj.pubKey}\nKind: ${eventObj.kind}\nUnknown TLVs: ${JSON.stringify(eventObj.unknown)}`;
} else {
throw new Error("Invalid input format");
}
console.log("Final output:", output);
document.getElementById('result').textContent = output;
}, 300);
window.addEventListener("DOMContentLoaded", function() {
document.getElementById('input').addEventListener('input', debouncedConvert);
});
</script>
</head>
<body>
<input type="text" id="input" placeholder="Enter HEX, note1, or nevent..." style="width: 500px">
<pre id="result"></pre>
</body>
</html>
Workflow Notes
1. Objective
Goal:
Build a converter that can transform Nostr event identifiers between three formats:
HEX: A 64‑character hexadecimal string (32 bytes).
note1: A human‑friendly display format using standard bech32 encoding.
nevent: A format using bech32m that includes TLV (type–length–value) metadata (such as relay URIs, public key, and event kind).
2. Challenges and Struggles
Different Encoding Schemes:
bech32 vs. bech32m:
Standard bech32 (used for note1) and bech32m (used for nevent) use different checksum constants and have different constraints.
Length Limits:
The reference bech32 library often enforces a maximum length of 90 characters, which is too short for nevent strings that contain extra TLV data. We had to adjust the limit (to 1000) for nevent strings.
Strict Checksum Verification:
Strict checksum verification was causing valid nevent strings (with extra TLV data) to be rejected. This led us to implement a lenient decode mode where we remove the last 6 characters (the checksum) without verifying them.
Data Conversions:
Converting between 8‑bit bytes and 5‑bit words (required for bech32/bech32m) had to be handled carefully.
Ensuring that the output from conversion functions is a proper Uint8Array so that functions like TextDecoder.decode work correctly.
TLV Parsing:
TLV data can be of variable length and may contain extra fields that we need to ignore or display as “unknown.”
Handling Extra TLV Data:
The final 6 characters in a bech32(m) string represent the checksum. For nevent strings, this checksum is not part of the meaningful TLV data and must be removed to access the actual encoded metadata.
3. Building Workflow
Step 1: Utility Functions
Hex and Byte Conversions:
We built functions like hexToBytes and bytesToHex to convert between HEX strings and byte arrays.
Text Encoding/Decoding:
Functions utf8Encode and utf8Decode handle conversion between ASCII/UTF‑8 strings and bytes.
Number Conversion:
numberToUint32 and uint32ToNumber manage conversion between numbers and 4‑byte big‑endian representations.
Step 2: TLV Encoding/Decoding
Encoding:
encodeTLV loops over an array of TLV items (each with a type and a byte array value) and concatenates them into one Uint8Array.
Decoding:
decodeTLV iterates over a byte array to extract TLV records, stopping if there aren’t enough bytes for a complete record.
Step 3: note1 Conversion
HEX ↔ note1:
hexToNote1 converts a valid 64‑character HEX into a 32‑byte array, then into 5‑bit words, and finally encodes it using bech32 with the "note" prefix.
note1ToHex reverses this process: it removes any “nostr:” prefix, forces lowercase to ensure consistent checksum verification, decodes the bech32 string, and converts the resulting words back to a HEX string.
Step 4: nevent Conversion (Extended with TLV)
Encoding (eventToNevent):
We build a TLV payload that always includes Type 0 (the 32‑byte event ID).
Optionally, additional TLV items are added:
Type 1: Relay URI(s) (encoded as ASCII/UTF‑8).
Type 2: A 32‑byte public key.
Type 3: A 4‑byte event kind (big‑endian).
The TLV payload is then converted to 5‑bit words and encoded with bech32m under the "nevent" prefix.
A higher length limit (1000 characters) is used to support longer strings.
Decoding (neventToEvent):
Checksum Issue:
Because the last 6 characters of a bech32m string are the checksum and may not match under strict verification, we implemented a lenient decoder.
Lenient Decode:
The lenientDecode function removes the last 6 characters (the checksum) without verifying them, allowing us to access the actual TLV data.
TLV Extraction:
The payload is processed to extract TLV items. Known types (0, 1, 2, 3) are converted to human‑readable values (HEX for event ID and pubkey, UTF‑8 for relays, and a number for kind). Any unknown TLVs are collected for debugging.
Step 5: Combined Conversion Function
Input Handling:
The converter checks whether the input is a HEX string, a note1 string, or a nevent string.
Normalization:
In every case, the code converts the input to a normalized HEX, then re‑encodes both note1 and nevent formats.
Display:
For nevent inputs, it also displays the decoded TLV fields (event ID, relays, pubkey, kind, and any unknown TLVs).
Final Notes
Irrelevant Last 6 Characters:
In bech32 and bech32m, the final 6 characters of the encoded string are the checksum. In our lenient decoding of nevent strings, we remove these characters (instead of verifying them strictly) to extract the raw TLV payload. This is necessary because extra TLV data can alter the expected checksum, even though the meaningful data is still present.
Why So Much Trouble?
The difficulties stem from the fact that NIP‑19 uses two related but distinct encoding schemes (bech32 and bech32m), each with its own checksum rules, along with a variable-length TLV payload for nevent. Different clients might generate nevent strings with varying extra data, and a strict checksum might fail if the encoding isn’t exactly as expected. Thus, we needed to:
Adjust length limits.
Implement a lenient decoding mode.
Robustly convert between bytes and 5‑bit words.
Parse TLV records even when extra data is present.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment