2.2.0 carries the single-pass Bech32/TLV codec and the 255-byte TLV fix (vitorpamplona/quartz#14). The test-only prototype that the benchmark compared it against is gone: quartz now is that codec, so the benchmark times quartz alone and checks that every entity round-trips. The plan records the shipped state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VN3PFcMEkCT9ZFXtoUw4Y2
9.9 KiB
NIP-19 codec: faster, lighter Bech32 + TLV
2026-10-23. Shipped: quartz 2.2.0 (vitorpamplona/quartz#14), and Amethyst bumped to it with the
call-site changes below. The benchmark is commons/src/jvmTest/.../prodbench/nip19/Nip19Benchmark.kt.
Status
- quartz 2.2.0: single-pass Bech32, in-place TLV, the 255-byte TLV fix and the audit fixes (lone surrogates, non-hex keys, the generator-table index), in vitorpamplona/quartz#14. The public API keeps every old signature and only adds new ones. The behaviour changes are listed under "API decisions" below.
- Amethyst:
quartzKmpis 2.2.0. The call sites that cite someone else's d-tag useAddress.toNIP19OrFallback/toNostrUriOrFallback. These give thenaddr, else the loaded event'snevent, else the raw address, soAddressableNote.toNAddr()andAntiSpamFilter(which runs on ingest) never throw on an attacker-chosen long d-tag.Note/User/channeltoNostrUri()use the one-buffercreateNostrUrihelpers. - The prototype codec that this plan measured (
FastNip19.kt) was deleted once quartz carried the real one.Nip19Benchmarknow times quartz alone.
Measured with quartz built from the merged change (-PquartzDir), 2.1.0 → 2.2.0:
| 2.1.0 | 2.2.0 | |
|---|---|---|
Note.toNostrUri() |
3,339 ns, 3,559 B | 1,653 ns, 1,314 B (relay-hint lookup is 696 ns of it) |
User.pubkeyNpub() / pubkeyDisplayHex() |
918 / 921 ns | 285 / 311 ns |
nostr:nevent encode |
2,344 ns, 2,915 B | 688 ns, 728 B |
npub → entity |
1,150 ns, 1,686 B | 352 ns, 558 B |
nevent → entity |
3,739 ns, 4,846 B | 1,359 ns, 1,177 B |
uriToRoute nevent |
6,175 ns | 3,511 ns (the regex is now most of it) |
API decisions
TlvBuilderthrowsIllegalArgumentExceptionfor a value over 255 bytes (TlvBuilder.MAX_VALUE_SIZE).NAddress.createthrows for a d-tag over 255 bytes. NewcreateOrNullandcreateNostrUriOrNullreturn null instead.- Relay hints over 255 bytes are dropped from
nevent/nprofile/naddr. Event.toNIP19/Event.toNostrUrifall back to the event'sneventwhen its d-tag can't fit annaddr.- New, additive:
Bech32.encodeBytes(…, prefix),encodeHex,decodeBytesOrNull;NPub/NEvent/NProfile.createNostrUri;Hex.encode(bytes, from, to). - Stricter inputs:
- An ASCII non-hex char in a key passed to
createnow throws. Before, it encoded garbage. (Odd lengths and non-ASCII chars already threw.) - In
decodeBytes(noChecksum = true), a char outside the alphabet now throws instead of decoding asl. NAddress.parse(String)checks that the hrp is exactlynaddr.
- An ASCII non-hex char in a key passed to
Why
Note.toNostrUri() is the callbackUri of almost every rendered note (RenderRegularTextNote,
Text, Video, Chat, RenderEncryptedFile, …). Most call sites compute it during composition
without remember, so it runs on each recomposition. CachedRichTextParser keys its cache on
it, so the call can't be skipped. User.pubkeyNpub() / NPub.create (≈120 call sites) back
every short display name. On the decode side, Nip19Parser.parseAll runs parseComponents
on every nostr: reference of every ingested note.
Use cases surveyed
| Entity | Encode (app) | Decode (app) |
|---|---|---|
npub |
NPub.create ×61, toNpub() ×59, pubkeyNpub/pubkeyDisplayHex in composition |
decodePublicKeyAsHexOrNull ×22, login, search, parseAll |
nsec |
toNsec() ×10 (backup, export) |
login |
note |
NNote.create ×8 |
parseAll (legacy refs) |
nevent |
Note.toNEvent/toNostrUri, NEvent.create ×27, tag toNEvent() |
parseAll, uriToRoute (navigation, clickable routes) |
nprofile |
User.toNProfile/toNostrUri (QR, share, mentions) |
parseAll, uriToRoute |
naddr |
AddressableNote.toNAddr, NAddress.create ×18, live activities |
parseAll, NAddress.parse |
nrelay, nembed |
none / NEmbed.create (gzip-dominated) |
parseAll |
Measured (JVM, commons/src/jvmTest/.../prodbench/nip19/Nip19Benchmark.kt)
Run with ./gradlew :commons:jvmTest --tests "*nip19.Nip19Benchmark*". The figures are best of 5
rounds over 1,024 varied inputs, on an unloaded cloud container. outputsMatchQuartz asserts that
both codecs produce identical strings and identical entities.
| Operation | quartz 2.1.0 | prototype | |
|---|---|---|---|
npub encode |
870 ns, 998 B | 206 ns, 198 B | 4.2× faster, 5.0× less alloc |
nevent encode (id, author, kind, relay) |
2,255 ns, 2,680 B | 630 ns, 516 B | 3.6×, 5.2× |
nprofile encode (0–3 relays) |
1,880 ns, 2,147 B | 505 ns, 439 B | 3.7×, 4.9× |
naddr encode |
2,264 ns, 2,568 B | 643 ns, 492 B | 3.5×, 5.2× |
nostr:nevent (NIP-21 uri) |
2,434 ns, 2,891 B | 668 ns, 528 B | 3.6×, 5.5× |
npub → entity |
1,141 ns, 1,686 B | 277 ns, 262 B | 4.1×, 6.4× |
nevent → entity |
3,684 ns, 4,856 B | 1,258 ns, 622 B | 2.9×, 7.8× |
naddr → entity |
3,341 ns, 4,553 B | 925 ns, 515 B | 3.6×, 8.8× |
Note.toNostrUri() end to end |
3,322 ns, 3,535 B | — | relayHintUrl() is 661 ns of it, the encode is the rest |
uriToRoute (regex find + decode) costs 2.2 µs for an npub, where the decode alone is 0.3 µs
with the prototype.
Where quartz spends it
- Boxed 5-bit values.
eight2fivereturnsArrayList<Byte>anddecodebuildsArray<Byte>. Both areObject[]ofjava.lang.Byte.addChecksumthen copies again forexpand(hrp) + data. - Several full copies per string.
CharArray→concatToString()→hrp + "1" + …, then"nostr:" + …at the caller. On top of that,encodeallocateshrp.lowercase()andhrp.uppercase()on every call just to check the case. - Branchy polymod. Five conditional xors per step, instead of one lookup in a 32-entry table.
- TLV building.
hexToByteArray()per key,encodeToByteArray()per string, then a growableByteArrayOutputStreamand a finaltoByteArray()copy. - TLV parsing.
Tlv.parsebuilds aMap<Byte, MutableList<ByteArray>>holding a copy of every value, before the entity reads two or three of them.decodealsofilters the input,takes and lowercases the hrp, andcopyOfRanges the data.
The proposal
Prototype: commons/src/jvmTest/.../prodbench/nip19/FastNip19.kt. It is pure common Kotlin, so it
can go into quartz's commonMain as it stands.
- Single-pass encode. Size the output exactly. Regroup 8→5 bits and fold each group into the
checksum as it is written, with no intermediate arrays. Write the result into one ASCII
ByteArray, thendecodeToString(). On the JVM that was clearly faster than aCharArray(npub 441 → 206 ns), because an ASCII byte array copies straight into a Latin-1 compact string. - Encode hex without decoding it first.
npub/nsec/noteregroup the hex nibbles directly (encodeHex). - Optional
prefixparameter."nostr:"is written into the same buffer, sotoNostrUri()stops making a second copy. - Table polymod and a precomputable hrp seed. The checksum state after the hrp depends only on the prefix.
- Exact-size TLV writer. Compute the UTF-8 length first, decode hex into place, and copy ASCII strings char by char.
- Single-pass decode. Ignore chars outside
33..126as quartz does, split at the last1, and run the checksum and the 5→8 regrouping together into an exact-size array. Astart, endrange letsNip19Parserdecode straight from the scanned content, without thesubstring+type + keyconcatenation it does today. - Read TLV in place. One walk over the records: hex-encode and
decodeToString(from, to)each slice, keeping only the records the entity uses.
Keep Bech32.encode/decode/eight2five/five2eight as the public API (other modules, and the
BOLT12 code in this repo, call Bech32.encode). Implement them on top of the new core, and move
ByteArray.toNpub(), NPub.create, NEvent.create, NProfile.create, NAddress.create and the
parse(bytes) functions onto the fast paths. Add toNostrUri-style overloads that take a
prefix. Then point Note.toNostrUri(), User.toNostrUri(), PublicChatChannel.toNostrUri() and
LiveActivitiesChannel.toNostrUri() at them.
Bugs found on the way (fix in the same quartz PR)
- A TLV value over 255 bytes produces an
naddrno one can decode.TlvBuilder.addwritesbyteArray.size.toByte(). Checked:NAddress.create(30023, pk, "a".repeat(300), [])gives a 560-charnaddrthat quartz's ownNip19Parser.uriToRoutedecodes tonull. Long d-tags exist (slugs, URLs used as d-tags). Relay hints over 255 bytes should be dropped. A d-tag that long has no valid encoding, socreateshould fail loudly (or return null) rather than emit a corrupt string. NAddress.parse(String)logs$this(the companion object) instead of the input.to32BitByteArraymasks with0xFFFF. That is harmless becausetoByte()truncates anyway, but the mask is misleading.
App-side follow-ups (this repo)
Note.relayHintUrl()costs 661 ns per call.author.outboxRelays()re-parses the author's kind:10002 tags and builds aSeton every call.User.relayHints()does the same work (488 ns). Caching the parsed outbox list perAdvertisedRelayListEvent(on theUser, invalidated whennip65RelayListNote.eventchanges) removes most of it.- Don't memoize the URI string on each
Note. It would hold an extra string for every note ever rendered, which works against the memory goal. With the encode at ~0.6 µs, it's not needed. uriToRoutestill uses the ICU-backed regex thatparseAllmoved away from. It runs 38 times in the app (navigation, clickable routes). It could reuse the hand-written scanner.
Not verified
- Only measured on the JVM. ART's
String(byte[], UTF_8)andString(char[])are native, so theByteArrayvsCharArraychoice should be re-checked on a device (thebenchmarkmodule). Kotlin/Native was not measured.