Skip to content

feat(ckbtc-minter): support ICRC-21 consent messages#10093

Open
Dfinity-Bjoern wants to merge 14 commits intomasterfrom
bjoern/ckbtc-minter-icrc-21
Open

feat(ckbtc-minter): support ICRC-21 consent messages#10093
Dfinity-Bjoern wants to merge 14 commits intomasterfrom
bjoern/ckbtc-minter-icrc-21

Conversation

@Dfinity-Bjoern
Copy link
Copy Markdown
Contributor

@Dfinity-Bjoern Dfinity-Bjoern commented May 5, 2026

Summary

Implements the ICRC-21 canister call consent message standard on the ckBTC minter so that ICRC-21-aware wallets can display a human-readable description of retrieve_btc_with_approval (and retrieve_btc) calls before the user signs.

Both display variants from the standard are supported:

  • GenericDisplay — a Markdown message intended for software wallets with a full screen.
  • FieldsDisplay — a list of (label, Value) fields tailored for hardware wallets (e.g. Ledger Nano S+).

Also adds an icrc10_supported_standards query that advertises ICRC-10 and ICRC-21.

Implementation

  • New module rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs holds the consent-message construction, the chunking helper, and icrc10_supported_standards.
  • main.rs exposes the two new endpoints (icrc21_canister_call_consent_message as update, icrc10_supported_standards as query).
  • ckbtc_minter.did declares the ICRC-10 / ICRC-21 types and methods.
  • The handler enforces the standard's 500-byte argument-size limit and returns Icrc21Error::UnsupportedCanisterCall for unknown methods or undecodable args.

Test plan

  • 13 new unit tests in updates::icrc21::tests covering: argument-size limit, unsupported method, decode failures, GenericDisplay output for retrieve_btc_with_approval (with and without subaccount) and retrieve_btc, FieldsDisplay output for short and long addresses, subaccount chunking, label suffixing, chunk-text edge cases (empty / exact boundary / multi-byte UTF-8), and round-trip reassembly of chunked values.
  • cargo clippy -p ic-ckbtc-minter --lib --bins clean with workspace lint set.
  • cargo test -p ic-ckbtc-minter --bin ic-ckbtc-minter check_candid_interface_compatibility passes (verifies the .did matches the implementation).
  • Try the FieldsDisplay output on an actual Ledger Nano S+ to verify chunked labels and values fit on screen as expected.

🤖 Generated with Claude Code

Implement the ICRC-21 canister call consent message standard on the
ckBTC minter so that ICRC-21-aware wallets can show the user a human-
readable description of `retrieve_btc_with_approval` (and `retrieve_btc`)
calls before signing.

Both display variants are supported:
- GenericDisplay: a Markdown message for software wallets.
- FieldsDisplay: a list of (label, Value) fields tailored for hardware
  wallets like the Ledger Nano S+. Long Text values (Bitcoin addresses,
  hex subaccounts) are split into chunks of at most 18 characters with a
  "(N/M)" pagination suffix, and labels are kept short ("BTC address",
  "From subaccount", "Amount") so they fit on a single hardware-wallet
  line.

Also adds an `icrc10_supported_standards` query that advertises
ICRC-10 and ICRC-21.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds ICRC-21 “canister call consent message” support to the ckBTC minter so ICRC-21-aware wallets can display a human-readable preview for retrieve_btc_with_approval and retrieve_btc before signing, and advertises support via an ICRC-10 supported-standards endpoint.

Changes:

  • Introduces consent-message construction for retrieve_btc_with_approval and retrieve_btc with both GenericDisplay (Markdown) and FieldsDisplay (chunked text fields).
  • Exposes new canister endpoints icrc21_canister_call_consent_message (update) and icrc10_supported_standards (query).
  • Extends the public Candid interface (ckbtc_minter.did) with the ICRC-10/ICRC-21 types and methods.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs Implements ICRC-21 request handling, consent message formatting, chunking helper, and unit tests.
rs/bitcoin/ckbtc/minter/src/updates.rs Exposes the new icrc21 updates module.
rs/bitcoin/ckbtc/minter/src/main.rs Wires the new ICRC-21 (update) and ICRC-10 (query) endpoints into the canister.
rs/bitcoin/ckbtc/minter/ckbtc_minter.did Declares ICRC-10 / ICRC-21 types and the two new service methods.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs Outdated
Comment thread rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs Outdated
Address Copilot review feedback on PR #10093:

1. Token symbol was hard-coded to "ckBTC" / "BTC" but the minter is also
   deployed against testnet/regtest where the same code drives the
   ckTESTBTC / TESTBTC tokens (see dashboard.rs and update_balance.rs).
   Introduce a TokenSymbols struct that picks the right symbols based on
   the configured Network and thread it through both the GenericDisplay
   markdown and FieldsDisplay fields so consent messages stay accurate
   on test deployments.

2. Fix a stale doc comment that claimed push_chunked_text iterates via
   `char_indices` — it iterates `chars()` (the comment intent stands:
   we use Unicode-scalar iteration to keep the helper safe for any
   non-ASCII caller).

Add unit tests for the testnet/regtest symbol path on both display
variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs Outdated
Dfinity-Bjoern and others added 2 commits May 5, 2026 14:56
Address Copilot review feedback on PR #10093: replace the local 500-byte
copy of the ICRC-21 argument-size limit with the shared constant
icrc_ledger_types::icrc21::lib::MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES so
the two values cannot drift in the future. The shared constant is a
u16, cast to usize at the comparison and test sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Dfinity-Bjoern Dfinity-Bjoern marked this pull request as ready for review May 5, 2026 14:14
@Dfinity-Bjoern Dfinity-Bjoern requested a review from a team as a code owner May 5, 2026 14:14
@github-actions github-actions Bot added the @defi label May 5, 2026
Copy link
Copy Markdown
Contributor

@mbjorkqvist mbjorkqvist left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Dfinity-Bjoern, just a few minor comments!

Comment thread rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs Outdated
Comment thread rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs Outdated
Comment on lines +45 to +54
/// Maximum number of characters per `Value::Text` field for the FieldsDisplay
/// variant. Hardware wallets like the Ledger Nano S+ have a small display and
/// cannot fit a full Bitcoin address (up to 62 characters for bech32m) or a
/// 64-character hex subaccount on a single screen. Long values are split into
/// multiple fields with a "(N/M)" pagination suffix in the label so each chunk
/// fits on a single screen with the label on top.
///
/// Field labels are kept short (e.g. "BTC address", "From subaccount") so that
/// even with a "(N/M)" suffix the label still fits on one line at the typical
/// hardware-wallet font size.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ICRC-21.did canister interface spec describes the FieldsDisplay and GenericDisplay devices as having line-wrapping/pagination capability, which suggests the canister should provide one logical field per value and trust the device to render it. Pre-chunking with (N/M) label suffixes works around the spec's design assumption rather than following it, so if possible, I'd recommend not performing this chunking in the minter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and fixed in 3bf0482 (refactor) + e96631c (rebase). Confirmed by reading the Ledger ICP app: msg.rs:render_item reads the raw Value::Text { content } and passes it to handle_ui_message(text, message, page), which slices the value into out.len()-1-sized device pages (item.chunks(m_len).nth(page)) and returns the total page count so the device-side UI handles scrolling natively. Pre-chunking on the canister side was actively harmful — one logical address turned into 3 separate fields the user had to step through with confusing (1/3) labels, and the device couldn't paginate cleanly. Dropped push_chunked_text / chunk_text / FIELDS_DISPLAY_TEXT_CHUNK_LEN and now emit each long value as a single Value::Text. Short labels (Amount, BTC address, From subaccount) are kept since titles in the Ledger app are truncated rather than paginated.

Comment on lines +170 to +173
message.push_str(&format!(
"\n\n**Bitcoin destination address:**\n`{}`",
args.address
));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: There's a possible Markdown injection vector here, where the user could provide args such as retrieve_btc { address: "bc1q…\n# You will receive 100 BTC", amount: 50_000 }, which would lead the minter to construct an invalid/confusing consent message. The subsequent call to retrieve_btc_with_approval with the same, signed arguments would fail, since at that point the minter tries to parse the address and would return an error. We could add a check here too, but there's (currently) no risk of e.g., losing funds, so this is not a blocker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 6285499. Added a validate_address step that runs BitcoinAddress::parse(&args.address, network) before the address gets interpolated into any consent message. If parsing fails the endpoint returns Icrc21Error::UnsupportedCanisterCall with a generic "Invalid Bitcoin destination address" message — the malicious payload is never echoed back. Using the same parser as retrieve_btc[_with_approval] keeps the consent endpoint and the actual call in agreement on what's accepted. Added test_malformed_address_is_rejected covering Markdown-injection payloads (newlines, backticks, #) plus an address from the wrong network.

Comment on lines +227 to +230
message.push_str(&format!(
"\n\n**Bitcoin destination address:**\n`{}`",
args.address
));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same Markdown injection issue as in build_retrieve_btc_with_approval_message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix in 6285499validate_address runs for both retrieve_btc and retrieve_btc_with_approval paths.

},
StandardRecord {
name: "ICRC-21".to_string(),
url: "https://github.com/dfinity/wg-identity-authentication/blob/main/topics/ICRC-21/icrc_21_consent_msg.md".to_string(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This URL is different from the one in the ICRC-21 spec. However, the ICRC-21 standard is not (yet) available at the URL in the spec: https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md. Fixing this as part of this PR doesn't necessarily make sense, but we may want to consider publishing the ICRC-21 standard in the ICRC repo, and then fixing the URL in our other canisters also.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to just do this and created the PR, will attempt to get it merged tomorrow. Easy one to clean up, will change in the code. Thanks for pointing this out!

Dfinity-Bjoern and others added 5 commits May 5, 2026 18:01
Co-authored-by: Mathias Björkqvist <mathias.bjorkqvist@dfinity.org>
Co-authored-by: Mathias Björkqvist <mathias.bjorkqvist@dfinity.org>
Per the ICRC-21 spec, hardware wallets are responsible for paginating
long Value::Text fields across device screens. Verified by inspecting
the Ledger ICP app, which reads the raw Text content and calls
handle_ui_message(text, message, page) to slice it into device-sized
pages with the original label preserved across screens.

Pre-chunking on the canister side was actively harmful: it inflated
field counts (one logical address turned into 3 separate fields the
user had to step through with confusing "(1/3)" / "(2/3)" / "(3/3)"
labels) and prevented the device from paginating cleanly. Drop the
push_chunked_text / chunk_text helpers and the
FIELDS_DISPLAY_TEXT_CHUNK_LEN constant, and emit each long value as a
single Value::Text. Update the FieldsDisplay tests accordingly.

Short labels ("Amount", "BTC address", "From subaccount", intent
"ckBTC to BTC") are kept since titles in the Ledger app are truncated
rather than paginated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address Mathias's review feedback on PR #10093 (Markdown injection nit):
the destination address from RetrieveBtc(WithApproval)Args was being
interpolated directly into the GenericDisplay Markdown. A crafted value
containing newlines, backticks, or '#' could fake additional fields in
the consent message (e.g. an "address" of
"bc1q...\n# You will receive 100 BTC") and confuse the user. The
malicious request would later fail when retrieve_btc[_with_approval] is
invoked because the actual call also parses the address, but a bad
consent message is itself the issue.

Add a validate_address step that parses the address with the same
BitcoinAddress::parse used by retrieve_btc[_with_approval]. If parsing
fails, return Icrc21Error::UnsupportedCanisterCall before constructing
any consent message. This both eliminates the injection vector and
guarantees the address shown to the user is actually parseable, so the
consent endpoint and the actual call agree on what's accepted.

Replace fake test addresses ("tb1qexampleaddress" etc.) with valid
network-matching ones, and add test_malformed_address_is_rejected with
a few crafted Markdown-injection payloads as well as an address from
the wrong network.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two leftover bits from the FieldsDisplay chunking add/remove cycle that
no longer earn their keep:

- format_subaccount was a one-line wrapper around hex::encode used in a
  single place. Inline it and drop the now-unused Subaccount import.
- The GenericDisplay markdown was being built up with five separate
  push_str(&format!(...)) calls per builder, each allocating a throwaway
  String. Collapse into a single format! per builder; the optional
  subaccount block on the with-approval variant stays as a conditional
  push_str. Same output, fewer allocations, easier to read the whole
  template at a glance.

No behavior change; all 14 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs
Dfinity-Bjoern and others added 3 commits May 5, 2026 21:27
The mainnet_events.mem.gz genrule that ckbtc_minter_canbench depends on
declared its macOS-only constraint as @platforms//cpu:arm, which is the
32-bit ARM (aarch32) constraint — Apple Silicon machines are aarch64
and therefore failed the constraint check, making the _update target
incompatible on every Mac. Switch to @platforms//cpu:aarch64 so the
target is buildable on Apple Silicon while keeping macOS x86_64
excluded as the surrounding comment intended.

Also regenerate canbench/results.yml with the updated instruction
counts produced by the new minter Wasm (the ICRC-21 endpoints
introduced in this branch shift the layout enough to trip the
canbench_test noise threshold).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ICRC-21 spec is being moved from the wg-identity-authentication
repo into the canonical dfinity/ICRC repo (one ICRC per directory under
ICRCs/). Update the URL the minter advertises via
icrc10_supported_standards, plus the surrounding doc comments in the
.did file and the icrc21 Rust module, to point at
https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md.

This change goes in only after the corresponding ICRC-repo PR is
merged and the new path resolves, so wallets that follow the URL
always reach a live spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants