Skip to content

fix(ui): encode oversized QR payloads instead of rendering nothing - #3656

Open
MattDHill wants to merge 4 commits into
masterfrom
fix/qr-modal-density
Open

fix(ui): encode oversized QR payloads instead of rendering nothing#3656
MattDHill wants to merge 4 commits into
masterfrom
fix/qr-modal-density

Conversation

@MattDHill

@MattDHill MattDHill commented Aug 10, 2026

Copy link
Copy Markdown
Member

Action-result QR codes are encoded at correction level M, which runs out of versions well before the payloads services actually hand it. When that happens qrcode throws The amount of data is too big to be stored in a QR Code, and the dialog opens empty — no code, no message, just a console error.

Level L carries more, so oversized payloads fall back to it.

Picking the level

The threshold is byte-mode capacity, not character count. Anything containing a lowercase letter — a URL, a base64 key, a PEM certificate — encodes in byte mode, where level M tops out at 2331 bytes and L at 2953. Keying off the version-40 alphanumeric capacity (3391) left exactly those payloads on M, still throwing.

Measured against qrcode 1.5.4, the version ng-qrcode wraps: across every payload length to 4400 in byte, alphanumeric and PEM content, the rule renders 1289 lengths that previously rendered nothing and regresses none. The largest payload that draws goes 2331 → 2953 bytes for mixed-case content and 2495 → 3162 for a PEM body; alphanumeric is unchanged at 4296.

Changes

  • New app-qr component owns the level rule. The three portal call sites — the QR dialog, action-success single and member — each hard-coded size="350" and the default level; they now share one component. Rendering is unchanged: same 350px canvas as before.
  • A big-qr mock action on bitcoind returns a 3618-character payload, long enough to throw at M and short enough to encode at L. runAction ignored actionId and always returned the same fixture, so it now dispatches. npm run start:ui → Bitcoin Core → Actions → Show Large QR is a before/after in one click.

Not in scope

An earlier revision of this branch also drew the canvas oversized and let CSS size it, to get more pixels per module. That is reverted — the class never reached the canvas through ng-qrcode's styleClass, so it rendered a 1024px code clipped to the dialog.

It would not have helped the case that motivated it in any event: a Blockstream Jade scans at 320×240, so the symbol must resolve within 240 pixels — roughly a version-13 code at three pixels per module, version 23 even at the two-pixel floor. A certificate-bearing payload is version 28 or higher no matter how large it is drawn, because the limit is how much sensor the symbol gets once it fills the frame.

🤖 Generated with Claude Code

dr-bonez
dr-bonez previously approved these changes Aug 11, 2026
@dr-bonez

Copy link
Copy Markdown
Member

@helix-nine rebase and test

MattDHill and others added 2 commits August 11, 2026 00:15
Action-result QR codes were drawn on a fixed 350px canvas at correction
level M. Past 3391 alphanumeric characters level M has no version left,
so the encoder threw and the modal rendered nothing; below that a dense
code drew modules under 3px, too fine for a hardware wallet's camera.

Draw the canvas oversized and let CSS pick the physical size, and drop
to level L for payloads M cannot hold. All three call sites now share
one component rather than repeating the fixed size.
…er count

The level-`L` fallback keyed off `value.length > 3391`, which is the
version-40 *alphanumeric* capacity. Anything containing a lowercase letter
— a URL, a base64 key, a PEM certificate — encodes in byte mode, where
level `M` tops out at 2331 bytes and `L` at 2953. A 2.4 kB mixed-case
payload therefore stayed on `M`, the encoder still threw, and the modal
still came up empty: the case this is meant to fix.

Measured against qrcode 1.5.4, the version ng-qrcode wraps. Over every
payload length to 4400 across byte, alphanumeric and PEM content the new
rule renders 1289 lengths that previously rendered nothing and regresses
none. The largest payload that draws goes 2331 -> 2953 bytes for
mixed-case content and 2495 -> 3162 for a PEM body; alphanumeric is
unchanged at 4296, where the old constant was already right.
@helix-nine

Copy link
Copy Markdown
Contributor

Rebased onto 909a6f0 and tested. One conflict, in projects/start-os/CHANGELOG.md — master's "Image upgrades verify their checksum again" entry landed at the same spot under ### Fixed; kept both.

npx prettier --check, npm run check:ui and npm run build:ui are all clean, and the Prettier gate passes on CI.

Testing turned up a real problem, so there is a second commit on the branch.

The 3391 threshold is the wrong capacity

3391 is the version-40 alphanumeric capacity. Anything with a lowercase letter in it — a URL, a base64 key, a PEM certificate — encodes in byte mode, where version 40 holds 2331 bytes at M and 2953 at L. Measured against qrcode 1.5.4, the version ng-qrcode 22 wraps:

level L level M
alphanumeric 4296 3391
byte 2953 2331

So a payload between 2332 and 3391 bytes of mixed-case content stayed on M, the encoder threw, and the modal still came up empty:

chars   old rule        new rule
2331    M -> v40        M -> v40
2332    M -> nothing    L -> v36
2500    M -> nothing    L -> v37
2953    M -> nothing    L -> v40
2954    M -> nothing    L -> nothing

A JSON blob of a URL, a key and a 1.6 kB certificate lands squarely in that band, which is the case the PR is named after.

The table in the description reproduces exactly on alphanumeric input (1660 → v24/v28, 1812 → v25/v29, 3484 → v36/throws), which is where the constant came from. The same payload lengths in byte mode are v30/v34, v31/v36, and throws at both levels.

Fix

Key the fallback on UTF-8 byte length against the byte-mode capacity instead:

readonly level = computed(() =>
  utf8.encode(this.value()).length > 2331 ? 'L' : 'M',
)

Byte mode is the worst case — qrcode's segment optimiser never does worse than encoding everything as bytes — so "≤ 2331 bytes fits at M" holds whatever the content is. Checked against 2331-byte strings built from ASCII, base64, PEM, URL, punctuation and 2/3/4-byte UTF-8 alphabets, plus 400 random mixed payloads. A mostly-alphanumeric payload in the 2332–3391 range now drops to L where M would have held it, which lowers the version and gives the modules more room, so it costs nothing here.

Swept every payload length up to 4400 across byte, alphanumeric and PEM content: 1289 lengths that rendered nothing now render, and none regress. The largest payload that draws goes 2331 → 2953 bytes for mixed-case content and 2495 → 3162 for a PEM body; alphanumeric stays at 4296, where the old constant was already right.

The changelog said "past 3391 characters"; it now says "past about 2.3 kB".

Still open

Above 2953 bytes the encoder still throws and the modal still renders nothing with no message — the ceiling moves, the silent-failure mode doesn't. Left alone since a user-facing string means five locale dictionaries and this is your PR, but it is the one part of "a modal that silently renders nothing is a bug" that isn't closed.

I also tried to settle whether image-rendering: pixelated helps or hurts, by rasterising codes and decoding them back through jsQR under nearest-neighbour vs box downsampling. The results didn't separate the two cleanly and a software decoder on a synthetic raster isn't a camera, so I have nothing to report either way. What did come through consistently is the part the PR is built on: the same code that fails to decode at 350px decodes at 480px.

@dr-bonez the force-push dismissed your approval — dismiss_stale_reviews_on_push is on for master, and the rebase you asked for needed one.

@MattDHill
MattDHill force-pushed the fix/qr-modal-density branch from 65665af to 03eff13 Compare August 11, 2026 01:04
@helix-nine

Copy link
Copy Markdown
Contributor

Re-tested the rewritten branch (03eff13). prettier --check, npm run check:ui and npm run build:ui are clean, and the mock action renders the code it's meant to.

Two things worth a look before this goes in.

3391 holds for the mock, but not for the other action results

ABCDEFGHJKLMNPQRSTUVWXYZ0123456789 is the QR alphanumeric charset, and so are the :, - and / in the UR:JADE-UPDPS/ prefix — so ActionResBigQr encodes in alphanumeric mode, where 3391/4296 are exactly right. That's a fair fixture for UR specifically: BC-UR uppercases precisely to stay in this mode. But qrcode picks the mode from the content, and a payload with one lowercase letter in it goes to byte mode, where version 40 holds 2331 at M and 2953 at L.

Against qrcode 1.5.4:

payload len rule picks result
ActionResBigQr (the mock) 3618 L v37 ✅
same UR, lowercased 3618 L blank — 3618 B > L's 2953
{url, key, PEM cert} 2529 M blank — but v36 at L

That last row is the payload the first description opened with, and it's the one that stings: 2529 characters is under 3391, so the rule leaves it on M, the encoder throws, and the dialog is still empty — while L would have drawn it fine. Passwords, onion URLs, PEM blocks and JSON results are all byte mode.

Gating on byte length against the byte-mode number fixes it:

readonly level = computed(() =>
  utf8.encode(this.value()).length > 2331 ? 'L' : 'M',
)

Byte mode is the worst case — qrcode's segment optimiser never does worse than encoding everything as bytes — so "≤ 2331 bytes fits at M" holds for any content, multibyte UTF-8 included. Sweeping every length to 4400 across byte, alphanumeric and PEM content, it renders 1289 lengths that currently come up blank and regresses none; the mock keeps encoding at L exactly as it does now.

A second fixture with the same shape but lowercase would pin the difference — though note that at 3618 characters lowercase is past L's byte ceiling too, so a realistic one wants to be ~2.5 kB.

The rebase came off

03eff13 is based on 6a1f75d again, six commits behind master, and GitHub has the PR as DIRTYprojects/start-os/CHANGELOG.md conflicts with the "Image upgrades verify their checksum again" entry that landed in the same ### Fixed block. Same conflict as before; keeping both entries resolves it.

Happy to push either change, or leave the branch to you — say which. Not pushing unasked, since that's how the last commit got crossed.

The canvas was drawn at 1024px and sized by a .g-qr utility passed
through ng-qrcode's styleClass. The class never reached the canvas, so
the dialog rendered a 1024px code clipped to its bounds instead of a
scaled one. Reverts to the 350px canvas, keeping the correction-level
fallback that fixes the blank modal.

The density it was chasing does not help the case that motivated it
either: a Jade scans at 320x240, so a symbol has to resolve within 240
pixels — about a version-13 code — and a certificate-bearing payload is
version 28 or higher regardless of how large it is drawn.
The QR modal's failure mode only shows up past correction level M's
3391-character capacity, which nothing in the mock data reached. Adds a
'Show Large QR' action on bitcoind returning a 3618-character payload —
long enough to throw at M, short enough to encode at L.

runAction ignored actionId and always returned the same fixture; it now
dispatches so the new action can return its own.
@MattDHill
MattDHill force-pushed the fix/qr-modal-density branch from 03eff13 to 2647578 Compare August 11, 2026 01:09
@MattDHill MattDHill changed the title fix(ui): render dense QR codes instead of failing on them fix(ui): encode oversized QR payloads instead of rendering nothing Aug 11, 2026
@helix-nine

Copy link
Copy Markdown
Contributor

Tested 2647578. Green: prettier --check, npm run check:ui, npm run build:ui, and a --noUnusedLocals pass over the touched files finds nothing orphaned by dropping the oversized canvas — .g-qr and the styleClass/size bindings are gone cleanly, styles.scss is back to master. Rebased on 909a6f0 with the changelog conflict resolved.

Behaviour at the tip, against qrcode 1.5.4:

payload len level result
ActionResBigQr 3618 L v37 ✅
{url, key, PEM cert} 2529 L v36 ✅ (was blank)

1289 payload lengths that used to come up blank now render, none regress.

Nothing further from me — @dr-bonez this needs your re-approval, the earlier one was dismissed by the force-pushes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants