Skip to content

Cold wallet: review the call a multisig execute dispatches - #623

Closed
n13 wants to merge 8 commits into
quantus/msig-execute-inner-callfrom
quantus/msig-execute-cold-wallet
Closed

Cold wallet: review the call a multisig execute dispatches#623
n13 wants to merge 8 commits into
quantus/msig-execute-inner-callfrom
quantus/msig-execute-cold-wallet

Conversation

@n13

@n13 n13 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #622. Review that one first. This PR is the cold wallet display, the reference parser, and a QR test corpus.

Showing the executed call

With chain#675, multisig.execute carries the call it dispatches, and the chain only dispatches it if it matches the stored proposal. So the cold wallet can show that call instead of a proposal id under a note saying its contents are not signed.

CallDecoder now describes execute the way it already describes approve: a nested "You are executing" call that also supplies the summary, so the screen headlines SEND with the amount and recipient. cancel and remove_expired keep the old note, which is still true for them.

Reference parser

rust-transaction-parser had drifted from the chain in two ways.

approve was already broken. The chain added the bound call bytes to approve some time ago, and this parser still decoded the old two-field shape. It could not parse a current approval at all. It now decodes and shows the approved call.

execute gains the inner call. Because it is inline rather than length prefixed, SCALE decoding recurses before the parser's own depth check runs. About 200 nested executes fit in the payload cap. Decoding now uses decode_with_depth_limit, so that fails on a counter instead of the stack. The three duplicated depth checks are now one helper.

Both parsers also cap nested calls at 10 KiB, the chain's own MaxCallSize, and raise the payload cap to 12 KiB so a chain-maximum proposal can arrive.

QR test corpus

New: cold-wallet-app/test/fixtures/qr/reduced/. Nine signing requests, one per call the Keystone firmware parses today.

Case Payload QR frames
transfer_allow_death 118 B 2
transfer_keep_alive 119 B 2
schedule_transfer 128 B 2
schedule_transfer_with_delay 137 B 2
create_multisig, 2 of 3 188 B 3
propose wrapping a transfer 158 B 3
approve wrapping a transfer 158 B 3
execute wrapping a transfer 157 B 2
execute wrapping a reversible transfer 166 B 3

Each case has the payload hex, the envelope JSON, the UR parts, one SVG per QR frame, and an index.html that animates them. There is a top-level index.html to pick a case. Open it in a browser and show it to a device camera or the simulator, which scans by screenshotting the screen.

A test walks the manifest and parses every case. It checks each decodes to the call it claims, including the nested call for propose, approve and execute. It also checks the manifest matches the folders on disk, and that the corpus targets the runtime the app bundles. So regenerating metadata and forgetting the corpus fails the build instead of going unnoticed.

I also ran all nine through the firmware Rust parser. All nine decode there too.

The corpus is generated, not hand written. See the quantus-cli generate_qr_fixtures example, added in quantus-cli#145. It encodes calls through the CLI's bundled chain metadata, so the bytes match what the chain accepts. The README next to the fixtures has the command.

Why this is the reduced set

The payload is hex inside JSON, so QR data is about twice the payload size, and UR splits it at 200 bytes. Even a 118 byte transfer needs 2 frames. A chain-maximum 10 KiB call would be roughly 100 frames, which is not practical to scan. A fuller set can be added later, but big calls should be treated as a deliberate stress case, not a normal one.

set_code is deliberately absent. It is being replaced by approving a hash for runtime upgrades.

Tests

quantus_sdk 264, cold-wallet-app 60, rust-transaction-parser 24.

`multisig.execute` now carries the proposal's inner call (chain PR #675), and
the chain dispatches it only when it re-encodes to the stored bytes. The cold
wallet can therefore review the executed call instead of showing a proposal id
and a note saying the contents are not part of what is signed.

- `CallDecoder` describes execute like approve: a nested "You are executing"
  call that also supplies the summary, so the headline reads SEND rather than
  naming the wrapper. `cancel` and `remove_expired` keep the reference note,
  which is still true for them.
- The debug payload menu gains a `Msig execute` entry.
- The standalone reference parser catches up on both calls: `approve` gained
  its bound call bytes on chain some time ago and was still decoding the old
  two-field shape, so it could not parse a current approval at all. Execute's
  inline nesting recurses inside the codec before `MAX_CALL_DEPTH` is reached,
  so decoding is bounded with `decode_with_depth_limit`.
n13 added 7 commits August 30, 2026 08:59
Mirrors the firmware limit: a batch_all of 32 transfers is 1667 bytes at the
worst-case encoding, 1707 inside a multisig wrapper, so 2 KiB leaves headroom.
Checked on the top-level call and on each nested call's bytes; execute's inline
inner call is bounded by the top-level check that contains it.
Parity with the firmware: propose, approve and execute all refuse to render an
inner call larger than 2 KiB, so an oversized proposal fails the same way on
every cold signer instead of producing a screen nobody can review.
…ghter limit

A client limit below the chain's refuses proposals the chain accepts. Use the
chain's number (10 KiB), applied to nested call bytes only — the outer extrinsic
call carries a wrapper that puts a chain-valid propose past it, and stays bounded
by MAX_PAYLOAD_BYTES, which rises to 12 KiB so such a proposal can arrive.
Nine signing requests, one per call the Keystone firmware parses today. Each
case carries the payload, the envelope, the UR parts, one SVG per QR frame and a
viewer page that animates them, so the same bytes can be unit tested and shown to
a real camera.

The test walks the manifest and checks every case decodes to the call it claims,
including the nested call for propose, approve and execute. It also checks the
manifest and the folders agree, and that the corpus targets the runtime the app
bundles, so a metadata regeneration cannot leave the corpus stale unnoticed.

Frame counts are the reason this is the reduced set: the payload is hex inside
JSON, so QR data is roughly twice the payload, and UR splits it at 200 bytes.
Even a 118 byte transfer needs 2 frames.

Generated by the quantus-cli `generate_qr_fixtures` example.
@n13

n13 commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded. This branch is stacked on #622, which is based on main before the 1.5.12+129 snapshot (78a698f) — that snapshot rewrote call_decoder.dart, added call_policy.dart, regenerated part of the bindings and deleted rust-transaction-parser/, so both branches conflict heavily. Redoing the work as a single PR on current main. Branch deleted; restorable from this page if the diff is needed as reference.

@n13 n13 closed this Aug 30, 2026
@n13
n13 deleted the quantus/msig-execute-cold-wallet branch August 30, 2026 12:13
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.

1 participant