diff --git a/rs/bitcoin/ckbtc/minter/BUILD.bazel b/rs/bitcoin/ckbtc/minter/BUILD.bazel index 9592d7d5822f..3789641a8c40 100644 --- a/rs/bitcoin/ckbtc/minter/BUILD.bazel +++ b/rs/bitcoin/ckbtc/minter/BUILD.bazel @@ -277,8 +277,10 @@ genrule( $(location :ckbtc_minter_dump_stable_memory) $(location @ckbtc_mainnet_events.gz//file) $@ """, # For some unknown reason this target can take well over an hour to run on MacOS x86_64 so we exclude that platform: + # Note: `@platforms//cpu:arm` is 32-bit ARM. Apple Silicon is `aarch64`, + # so use that here — otherwise this target is incompatible on every Mac. target_compatible_with = select({ - "@platforms//os:osx": ["@platforms//cpu:arm"], + "@platforms//os:osx": ["@platforms//cpu:aarch64"], "//conditions:default": [], }), visibility = ["//visibility:private"], diff --git a/rs/bitcoin/ckbtc/minter/canbench/results.yml b/rs/bitcoin/ckbtc/minter/canbench/results.yml index 90b219fadb6e..e4dbf561171b 100644 --- a/rs/bitcoin/ckbtc/minter/canbench/results.yml +++ b/rs/bitcoin/ckbtc/minter/canbench/results.yml @@ -2,13 +2,13 @@ benches: build_estimate_retrieve_btc_fee_1_50k_sats: total: calls: 1 - instructions: 419639 + instructions: 419644 heap_increase: 0 stable_memory_increase: 0 scopes: build_unsigned_transaction_from_inputs: calls: 1 - instructions: 3441 + instructions: 3446 heap_increase: 0 stable_memory_increase: 0 greedy: @@ -24,18 +24,18 @@ benches: build_unsigned_transaction_1_50k_sats: total: calls: 1 - instructions: 421753 + instructions: 421760 heap_increase: 0 stable_memory_increase: 0 scopes: build_unsigned_transaction: calls: 1 - instructions: 419172 + instructions: 419177 heap_increase: 0 stable_memory_increase: 0 build_unsigned_transaction_from_inputs: calls: 1 - instructions: 3441 + instructions: 3446 heap_increase: 0 stable_memory_increase: 0 greedy: @@ -51,18 +51,18 @@ benches: build_unsigned_transaction_2_100k_sats: total: calls: 1 - instructions: 421799 + instructions: 421806 heap_increase: 0 stable_memory_increase: 0 scopes: build_unsigned_transaction: calls: 1 - instructions: 419218 + instructions: 419223 heap_increase: 0 stable_memory_increase: 0 build_unsigned_transaction_from_inputs: calls: 1 - instructions: 3441 + instructions: 3446 heap_increase: 0 stable_memory_increase: 0 greedy: @@ -78,18 +78,18 @@ benches: build_unsigned_transaction_3_1m_sats: total: calls: 1 - instructions: 421702 + instructions: 421709 heap_increase: 0 stable_memory_increase: 0 scopes: build_unsigned_transaction: calls: 1 - instructions: 419121 + instructions: 419126 heap_increase: 0 stable_memory_increase: 0 build_unsigned_transaction_from_inputs: calls: 1 - instructions: 3441 + instructions: 3446 heap_increase: 0 stable_memory_increase: 0 greedy: @@ -105,18 +105,18 @@ benches: build_unsigned_transaction_4_10m_sats: total: calls: 1 - instructions: 422877 + instructions: 422884 heap_increase: 0 stable_memory_increase: 0 scopes: build_unsigned_transaction: calls: 1 - instructions: 420296 + instructions: 420301 heap_increase: 0 stable_memory_increase: 0 build_unsigned_transaction_from_inputs: calls: 1 - instructions: 3441 + instructions: 3446 heap_increase: 0 stable_memory_increase: 0 greedy: @@ -132,18 +132,18 @@ benches: build_unsigned_transaction_5_1_btc: total: calls: 1 - instructions: 422877 + instructions: 422884 heap_increase: 0 stable_memory_increase: 0 scopes: build_unsigned_transaction: calls: 1 - instructions: 420296 + instructions: 420301 heap_increase: 0 stable_memory_increase: 0 build_unsigned_transaction_from_inputs: calls: 1 - instructions: 3441 + instructions: 3446 heap_increase: 0 stable_memory_increase: 0 greedy: @@ -159,18 +159,18 @@ benches: build_unsigned_transaction_6_10_btc: total: calls: 1 - instructions: 429549 + instructions: 429556 heap_increase: 0 stable_memory_increase: 0 scopes: build_unsigned_transaction: calls: 1 - instructions: 426968 + instructions: 426973 heap_increase: 0 stable_memory_increase: 0 build_unsigned_transaction_from_inputs: calls: 1 - instructions: 3908 + instructions: 3913 heap_increase: 0 stable_memory_increase: 0 greedy: diff --git a/rs/bitcoin/ckbtc/minter/ckbtc_minter.did b/rs/bitcoin/ckbtc/minter/ckbtc_minter.did index 385b47dbb72e..5643f401ce37 100644 --- a/rs/bitcoin/ckbtc/minter/ckbtc_minter.did +++ b/rs/bitcoin/ckbtc/minter/ckbtc_minter.did @@ -611,7 +611,70 @@ type DecodeLedgerMemoResult = variant { // An error in case the minter was not able to decode the provided memo. This field is `opt`, so that other error // types can be added in the future. Err : opt DecodeLedgerMemoError; -} +}; + +// ICRC-10 supported standards record. +type Icrc10StandardRecord = record { + name : text; + url : text; +}; + +// ICRC-21 Canister Call Consent Message types. +type Icrc21ConsentMessageMetadata = record { + language : text; + utc_offset_minutes : opt int16; +}; + +type Icrc21DeviceSpec = variant { + GenericDisplay; + FieldsDisplay; +}; + +type Icrc21ConsentMessageSpec = record { + metadata : Icrc21ConsentMessageMetadata; + device_spec : opt Icrc21DeviceSpec; +}; + +type Icrc21ConsentMessageRequest = record { + method : text; + arg : blob; + user_preferences : Icrc21ConsentMessageSpec; +}; + +type Icrc21Value = variant { + TokenAmount : record { + decimals : nat8; + amount : nat64; + symbol : text; + }; + TimestampSeconds : record { amount : nat64 }; + DurationSeconds : record { amount : nat64 }; + Text : record { content : text }; +}; + +type Icrc21FieldsDisplay = record { + intent : text; + fields : vec record { text; Icrc21Value }; +}; + +type Icrc21ConsentMessage = variant { + GenericDisplayMessage : text; + FieldsDisplayMessage : Icrc21FieldsDisplay; +}; + +type Icrc21ConsentInfo = record { + consent_message : Icrc21ConsentMessage; + metadata : Icrc21ConsentMessageMetadata; +}; + +type Icrc21ErrorInfo = record { description : text }; + +type Icrc21Error = variant { + UnsupportedCanisterCall : Icrc21ErrorInfo; + ConsentMessageUnavailable : Icrc21ErrorInfo; + InsufficientPayment : Icrc21ErrorInfo; + GenericError : record { error_code : nat; description : text }; +}; service : (minter_arg : MinterArg) -> { // Section "Convert BTC to ckBTC" {{{ @@ -728,4 +791,16 @@ service : (minter_arg : MinterArg) -> { // Returns information related to minter transactions. decode_ledger_memo : (DecodeLedgerMemoArgs) -> (DecodeLedgerMemoResult) query; // }}} + + // Section "ICRC-10 / ICRC-21" {{{ + + // Returns the list of supported ICRC standards. + icrc10_supported_standards : () -> (vec Icrc10StandardRecord) query; + + // Returns a human-readable consent message describing the requested + // canister call. See the ICRC-21 standard for details: + // https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md + icrc21_canister_call_consent_message : (Icrc21ConsentMessageRequest) -> (variant { Ok : Icrc21ConsentInfo; Err : Icrc21Error }); + + // }}} Section "ICRC-10 / ICRC-21" } diff --git a/rs/bitcoin/ckbtc/minter/src/main.rs b/rs/bitcoin/ckbtc/minter/src/main.rs index d692d0866cc2..f4d390340f0d 100644 --- a/rs/bitcoin/ckbtc/minter/src/main.rs +++ b/rs/bitcoin/ckbtc/minter/src/main.rs @@ -30,6 +30,9 @@ use ic_ckbtc_minter::{ }; use ic_http_types::{HttpRequest, HttpResponse}; use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc21::errors::Icrc21Error; +use icrc_ledger_types::icrc21::requests::ConsentMessageRequest; +use icrc_ledger_types::icrc21::responses::ConsentInfo; #[init] fn init(args: MinterArg) { @@ -265,6 +268,18 @@ fn decode_ledger_memo(arg: DecodeLedgerMemoArgs) -> DecodeLedgerMemoResult { ic_ckbtc_minter::queries::decode_ledger_memo(arg) } +#[update] +fn icrc21_canister_call_consent_message( + consent_msg_request: ConsentMessageRequest, +) -> Result { + updates::icrc21::icrc21_canister_call_consent_message(consent_msg_request) +} + +#[query] +fn icrc10_supported_standards() -> Vec { + updates::icrc21::icrc10_supported_standards() +} + #[query(hidden = true)] fn http_request(req: HttpRequest) -> HttpResponse { if ic_cdk::api::in_replicated_execution() { diff --git a/rs/bitcoin/ckbtc/minter/src/updates.rs b/rs/bitcoin/ckbtc/minter/src/updates.rs index 06de24cc38b3..9a2ac30780b5 100644 --- a/rs/bitcoin/ckbtc/minter/src/updates.rs +++ b/rs/bitcoin/ckbtc/minter/src/updates.rs @@ -3,6 +3,7 @@ mod tests; pub mod get_btc_address; pub mod get_withdrawal_account; +pub mod icrc21; pub mod retrieve_btc; pub mod update_balance; diff --git a/rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs b/rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs new file mode 100644 index 000000000000..5a594ed426fd --- /dev/null +++ b/rs/bitcoin/ckbtc/minter/src/updates/icrc21.rs @@ -0,0 +1,222 @@ +//! Implementation of the [ICRC-21](https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md) +//! Canister Call Consent Message standard for the ckBTC minter. + +use crate::Network; +use crate::address::BitcoinAddress; +use crate::state::read_state; +use crate::updates::retrieve_btc::RetrieveBtcWithApprovalArgs; +use candid::{CandidType, Decode, Deserialize}; +use icrc_ledger_types::icrc21::errors::{ErrorInfo, Icrc21Error}; +use icrc_ledger_types::icrc21::lib::MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES; +use icrc_ledger_types::icrc21::requests::{ + ConsentMessageMetadata, ConsentMessageRequest, DisplayMessageType, +}; +use icrc_ledger_types::icrc21::responses::{ConsentInfo, ConsentMessage, FieldsDisplay, Value}; + +/// The number of decimals used to display token amounts. +/// Both ckBTC and BTC use 8 decimals (1 BTC = 10^8 satoshis). +pub(super) const DECIMALS: u8 = 8; + +/// Token symbols used in consent messages. They depend on the configured +/// Bitcoin network so that test deployments use the test-token names. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub(super) struct TokenSymbols { + /// The ledger token, e.g. "ckBTC" on mainnet, "ckTESTBTC" otherwise. + pub(super) ckbtc: &'static str, + /// The native Bitcoin token, e.g. "BTC" on mainnet, "TESTBTC" otherwise. + pub(super) btc: &'static str, +} + +impl TokenSymbols { + pub(super) fn for_network(network: Network) -> Self { + match network { + Network::Mainnet => Self { + ckbtc: "ckBTC", + btc: "BTC", + }, + Network::Testnet | Network::Regtest => Self { + ckbtc: "ckTESTBTC", + btc: "TESTBTC", + }, + } + } +} + +/// An entry of the ICRC-10 supported standards list. +#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] +pub struct StandardRecord { + pub name: String, + pub url: String, +} + +pub fn icrc10_supported_standards() -> Vec { + vec![ + StandardRecord { + name: "ICRC-10".to_string(), + url: "https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-10/ICRC-10.md".to_string(), + }, + StandardRecord { + name: "ICRC-21".to_string(), + url: "https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md".to_string(), + }, + ] +} + +pub fn icrc21_canister_call_consent_message( + consent_msg_request: ConsentMessageRequest, +) -> Result { + let network = read_state(|s| s.btc_network); + build_consent_info(consent_msg_request, network) +} + +pub(super) fn build_consent_info( + consent_msg_request: ConsentMessageRequest, + network: Network, +) -> Result { + if consent_msg_request.arg.len() > MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES as usize { + return Err(Icrc21Error::UnsupportedCanisterCall(ErrorInfo { + description: format!( + "The argument size is too large. The maximum allowed size is \ + {MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES} bytes." + ), + })); + } + + let display_type = consent_msg_request + .user_preferences + .device_spec + .clone() + .unwrap_or(DisplayMessageType::GenericDisplay); + + let symbols = TokenSymbols::for_network(network); + + let consent_message = match consent_msg_request.method.as_str() { + "retrieve_btc_with_approval" => { + let args = Decode!( + consent_msg_request.arg.as_slice(), + RetrieveBtcWithApprovalArgs + ) + .map_err(|e| { + Icrc21Error::UnsupportedCanisterCall(ErrorInfo { + description: format!("Failed to decode RetrieveBtcWithApprovalArgs: {e}"), + }) + })?; + validate_address(&args.address, network)?; + build_retrieve_btc_with_approval_message(&args, &display_type, symbols) + } + method => { + return Err(Icrc21Error::UnsupportedCanisterCall(ErrorInfo { + description: format!( + "The method '{method}' is not supported by the ckBTC minter ICRC-21 endpoint." + ), + })); + } + }; + + // Respond in English regardless of what the client requested for now. + let metadata = ConsentMessageMetadata { + language: "en".to_string(), + utc_offset_minutes: consent_msg_request + .user_preferences + .metadata + .utc_offset_minutes, + }; + + Ok(ConsentInfo { + metadata, + consent_message, + }) +} + +fn build_retrieve_btc_with_approval_message( + args: &RetrieveBtcWithApprovalArgs, + display_type: &DisplayMessageType, + symbols: TokenSymbols, +) -> ConsentMessage { + let TokenSymbols { ckbtc, btc } = symbols; + let amount = format_amount(args.amount, DECIMALS); + match display_type { + DisplayMessageType::GenericDisplay => { + let mut message = format!( + "# Convert {ckbtc} to {btc}\n\n\ + Authorize the {ckbtc} minter to burn {ckbtc} from your account and \ + send the equivalent amount in {btc} (minus network and minter fees) to \ + the Bitcoin address below.\n\n\ + **Amount to convert:** `{amount} {ckbtc}`\n\n\ + **Bitcoin destination address:**\n`{address}`", + address = args.address, + ); + if let Some(subaccount) = args.from_subaccount { + message.push_str(&format!( + "\n\n**{ckbtc} source subaccount:**\n`{}`", + hex::encode(subaccount) + )); + } + ConsentMessage::GenericDisplayMessage(message) + } + DisplayMessageType::FieldsDisplay => { + // Long values (Bitcoin addresses, subaccount hex) are sent as a + // single `Value::Text` per the ICRC-21 spec — wallets are + // responsible for paginating them across screens. See e.g. the + // Ledger ICP app, which calls `handle_ui_message` to chunk the + // value into device-sized pages. + let mut fields = vec![ + ( + "Amount".to_string(), + Value::TokenAmount { + decimals: DECIMALS, + amount: args.amount, + symbol: ckbtc.to_string(), + }, + ), + ( + format!("{btc} address"), + Value::Text { + content: args.address.clone(), + }, + ), + ]; + if let Some(subaccount) = args.from_subaccount { + fields.push(( + "From subaccount".to_string(), + Value::Text { + content: hex::encode(subaccount), + }, + )); + } + ConsentMessage::FieldsDisplayMessage(FieldsDisplay { + intent: format!("{ckbtc} to {btc}"), + fields, + }) + } + } +} + +/// Verifies that `address` parses as a valid Bitcoin address on the configured +/// network before it gets interpolated into a consent message. This both +/// guarantees the user is shown a meaningful (parseable) destination and rules +/// out Markdown-injection vectors in the GenericDisplay output (e.g. an +/// "address" that contains newlines or backticks crafted to fake additional +/// fields). Uses the same parser as `retrieve_btc_with_approval`, so any +/// address the consent endpoint accepts is also accepted by the actual call. +fn validate_address(address: &str, network: Network) -> Result<(), Icrc21Error> { + BitcoinAddress::parse(address, network).map_err(|e| { + Icrc21Error::UnsupportedCanisterCall(ErrorInfo { + description: format!("Invalid Bitcoin destination address: {e}"), + }) + })?; + Ok(()) +} + +pub(super) fn format_amount(amount: u64, decimals: u8) -> String { + let divisor = 10_u64.pow(decimals as u32); + let whole = amount / divisor; + let frac = amount % divisor; + if frac == 0 { + format!("{whole}") + } else { + let frac_str = format!("{frac:0width$}", width = decimals as usize); + let trimmed = frac_str.trim_end_matches('0'); + format!("{whole}.{trimmed}") + } +} diff --git a/rs/bitcoin/ckbtc/minter/src/updates/tests.rs b/rs/bitcoin/ckbtc/minter/src/updates/tests.rs index 4fa4b1f2ca89..951d781fe3f5 100644 --- a/rs/bitcoin/ckbtc/minter/src/updates/tests.rs +++ b/rs/bitcoin/ckbtc/minter/src/updates/tests.rs @@ -1164,3 +1164,315 @@ mod update_balance { CkBtcEventLogger.events_iter() } } + +mod icrc21 { + use crate::Network; + use crate::updates::icrc21::{DECIMALS, TokenSymbols, build_consent_info, format_amount}; + use crate::updates::retrieve_btc::RetrieveBtcWithApprovalArgs; + use candid::Encode; + use icrc_ledger_types::icrc21::errors::Icrc21Error; + use icrc_ledger_types::icrc21::lib::MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES; + use icrc_ledger_types::icrc21::requests::{ + ConsentMessageMetadata, ConsentMessageRequest, ConsentMessageSpec, DisplayMessageType, + }; + use icrc_ledger_types::icrc21::responses::{ConsentMessage, Value}; + + fn make_request( + method: &str, + arg: Vec, + device_spec: Option, + ) -> ConsentMessageRequest { + ConsentMessageRequest { + method: method.to_string(), + arg, + user_preferences: ConsentMessageSpec { + metadata: ConsentMessageMetadata { + language: "en".to_string(), + utc_offset_minutes: None, + }, + device_spec, + }, + } + } + + #[test] + fn test_format_amount() { + assert_eq!(format_amount(0, 8), "0"); + assert_eq!(format_amount(1, 8), "0.00000001"); + assert_eq!(format_amount(100_000_000, 8), "1"); + assert_eq!(format_amount(150_000_000, 8), "1.5"); + assert_eq!(format_amount(123_456_789, 8), "1.23456789"); + } + + #[test] + fn test_unsupported_method() { + // Includes `retrieve_btc` because the minter intentionally only + // supports ICRC-21 for the approval-based flow — wallets calling + // `retrieve_btc` should not get a consent message rendered for them. + for method in ["update_balance", "retrieve_btc", "get_btc_address", ""] { + let req = make_request(method, vec![], None); + let err = build_consent_info(req, Network::Mainnet).unwrap_err(); + assert!( + matches!(err, Icrc21Error::UnsupportedCanisterCall(_)), + "method {method:?} should be unsupported, got {err:?}" + ); + } + } + + #[test] + fn test_argument_too_large() { + let req = make_request( + "retrieve_btc_with_approval", + vec![0; MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES as usize + 1], + None, + ); + let err = build_consent_info(req, Network::Mainnet).unwrap_err(); + match err { + Icrc21Error::UnsupportedCanisterCall(info) => { + assert!(info.description.contains("argument size is too large")); + } + _ => panic!("expected UnsupportedCanisterCall, got {err:?}"), + } + } + + #[test] + fn test_retrieve_btc_with_approval_generic_display() { + let args = RetrieveBtcWithApprovalArgs { + amount: 150_000, + address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::GenericDisplay), + ); + let info = build_consent_info(req, Network::Mainnet).unwrap(); + assert_eq!(info.metadata.language, "en"); + let message = match info.consent_message { + ConsentMessage::GenericDisplayMessage(m) => m, + other => panic!("expected GenericDisplayMessage, got {other:?}"), + }; + assert!(message.starts_with("# Convert ckBTC to BTC")); + assert!(message.contains("0.0015 ckBTC")); + assert!(message.contains("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")); + // No subaccount section if from_subaccount is None. + assert!(!message.contains("source subaccount")); + } + + #[test] + fn test_retrieve_btc_with_approval_generic_display_with_subaccount() { + let mut subaccount = [0_u8; 32]; + subaccount[31] = 0x42; + let args = RetrieveBtcWithApprovalArgs { + amount: 100_000_000, + address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(), + from_subaccount: Some(subaccount), + }; + let req = make_request("retrieve_btc_with_approval", Encode!(&args).unwrap(), None); + let info = build_consent_info(req, Network::Mainnet).unwrap(); + let message = match info.consent_message { + ConsentMessage::GenericDisplayMessage(m) => m, + other => panic!("expected GenericDisplayMessage, got {other:?}"), + }; + assert!(message.contains("1 ckBTC")); + assert!(message.contains(&hex::encode(subaccount))); + } + + #[test] + fn test_retrieve_btc_with_approval_fields_display() { + // Long values (Bitcoin address, subaccount hex) are emitted as a + // single Value::Text — wallets paginate them across screens. The + // number of fields is therefore independent of the value length. + let mut subaccount = [0_u8; 32]; + subaccount[0] = 0xab; + subaccount[31] = 0xcd; + let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(); + let args = RetrieveBtcWithApprovalArgs { + amount: 250_000, + address: address.clone(), + from_subaccount: Some(subaccount), + }; + let req = make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::FieldsDisplay), + ); + let info = build_consent_info(req, Network::Mainnet).unwrap(); + let fields_display = match info.consent_message { + ConsentMessage::FieldsDisplayMessage(f) => f, + other => panic!("expected FieldsDisplayMessage, got {other:?}"), + }; + assert_eq!(fields_display.intent, "ckBTC to BTC"); + assert_eq!( + fields_display.fields, + vec![ + ( + "Amount".to_string(), + Value::TokenAmount { + decimals: DECIMALS, + amount: 250_000, + symbol: "ckBTC".to_string(), + } + ), + ("BTC address".to_string(), Value::Text { content: address }), + ( + "From subaccount".to_string(), + Value::Text { + content: hex::encode(subaccount) + } + ), + ] + ); + } + + #[test] + fn test_retrieve_btc_with_approval_fields_display_no_subaccount() { + let args = RetrieveBtcWithApprovalArgs { + amount: 250_000, + address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::FieldsDisplay), + ); + let info = build_consent_info(req, Network::Mainnet).unwrap(); + let fields_display = match info.consent_message { + ConsentMessage::FieldsDisplayMessage(f) => f, + other => panic!("expected FieldsDisplayMessage, got {other:?}"), + }; + assert_eq!(fields_display.fields.len(), 2); + // No subaccount field when from_subaccount is None. + assert!( + !fields_display + .fields + .iter() + .any(|(label, _)| label == "From subaccount") + ); + } + + #[test] + fn test_token_symbols_for_network() { + assert_eq!( + TokenSymbols::for_network(Network::Mainnet), + TokenSymbols { + ckbtc: "ckBTC", + btc: "BTC" + } + ); + assert_eq!( + TokenSymbols::for_network(Network::Testnet), + TokenSymbols { + ckbtc: "ckTESTBTC", + btc: "TESTBTC" + } + ); + assert_eq!( + TokenSymbols::for_network(Network::Regtest), + TokenSymbols { + ckbtc: "ckTESTBTC", + btc: "TESTBTC" + } + ); + } + + #[test] + fn test_retrieve_btc_with_approval_uses_testnet_symbols() { + let args = RetrieveBtcWithApprovalArgs { + amount: 250_000, + address: "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx".to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::FieldsDisplay), + ); + let info = build_consent_info(req, Network::Testnet).unwrap(); + let fields_display = match info.consent_message { + ConsentMessage::FieldsDisplayMessage(f) => f, + other => panic!("expected FieldsDisplayMessage, got {other:?}"), + }; + assert_eq!(fields_display.intent, "ckTESTBTC to TESTBTC"); + match &fields_display.fields[0].1 { + Value::TokenAmount { symbol, .. } => assert_eq!(symbol, "ckTESTBTC"), + other => panic!("expected TokenAmount, got {other:?}"), + } + // FieldsDisplay address label uses the testnet native symbol too. + assert_eq!(fields_display.fields[1].0, "TESTBTC address"); + } + + #[test] + fn test_retrieve_btc_with_approval_generic_uses_testnet_symbols() { + let args = RetrieveBtcWithApprovalArgs { + amount: 100_000_000, + address: "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx".to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::GenericDisplay), + ); + let info = build_consent_info(req, Network::Testnet).unwrap(); + let message = match info.consent_message { + ConsentMessage::GenericDisplayMessage(m) => m, + other => panic!("expected GenericDisplayMessage, got {other:?}"), + }; + assert!(message.starts_with("# Convert ckTESTBTC to TESTBTC")); + assert!(message.contains("1 ckTESTBTC")); + assert!(message.contains("ckTESTBTC minter")); + assert!(message.contains("equivalent amount in TESTBTC")); + } + + #[test] + fn test_invalid_args() { + let req = make_request("retrieve_btc_with_approval", vec![1, 2, 3], None); + let err = build_consent_info(req, Network::Mainnet).unwrap_err(); + match err { + Icrc21Error::UnsupportedCanisterCall(info) => { + assert!(info.description.contains("Failed to decode")); + } + _ => panic!("expected UnsupportedCanisterCall, got {err:?}"), + } + } + + #[test] + fn test_malformed_address_is_rejected() { + // The minter must not interpolate an unparseable address into the + // Markdown consent message — that would be a Markdown-injection vector + // (e.g. an "address" containing newlines, backticks, or '#' that fakes + // additional fields). + for bad_address in [ + "not-a-real-address", + "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq\n# You will receive 100 BTC", + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa`\n\n**Amount:** 100 BTC\n`", + "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", // valid testnet but on Mainnet + ] { + let args = RetrieveBtcWithApprovalArgs { + amount: 50_000, + address: bad_address.to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::GenericDisplay), + ); + let err = build_consent_info(req, Network::Mainnet).unwrap_err(); + match err { + Icrc21Error::UnsupportedCanisterCall(info) => { + assert!( + info.description + .contains("Invalid Bitcoin destination address"), + "unexpected error description: {}", + info.description + ); + } + other => panic!("expected UnsupportedCanisterCall, got {other:?}"), + } + } + } +} diff --git a/rs/bitcoin/ckbtc/minter/tests/tests.rs b/rs/bitcoin/ckbtc/minter/tests/tests.rs index 355bdb6f36b1..df96e9b07632 100644 --- a/rs/bitcoin/ckbtc/minter/tests/tests.rs +++ b/rs/bitcoin/ckbtc/minter/tests/tests.rs @@ -203,6 +203,166 @@ fn test_install_ckbtc_minter_canister() { install_minter(&env, ledger_id); } +/// Smoke-tests the ICRC-21 / ICRC-10 endpoints exposed by the minter: +/// +/// * `icrc10_supported_standards` advertises both standards with the canonical +/// `dfinity/ICRC` URLs. +/// * `icrc21_canister_call_consent_message` produces both a GenericDisplay +/// markdown message and a FieldsDisplay structured message for +/// `retrieve_btc_with_approval`, with the configured network's token symbols +/// threaded through. +/// * Unsupported methods return `Icrc21Error::UnsupportedCanisterCall`. +#[test] +fn test_icrc21_endpoints_smoke() { + use ic_ckbtc_minter::updates::icrc21::StandardRecord; + use icrc_ledger_types::icrc21::errors::Icrc21Error; + use icrc_ledger_types::icrc21::requests::{ + ConsentMessageMetadata, ConsentMessageRequest, ConsentMessageSpec, DisplayMessageType, + }; + use icrc_ledger_types::icrc21::responses::{ConsentInfo, ConsentMessage, Value}; + + let setup = CkBtcSetup::new(); // mainnet minter; "ckBTC" / "BTC" symbols. + + let consent_message = |req: &ConsentMessageRequest| -> Result { + Decode!( + &assert_reply( + setup + .env + .execute_ingress_as( + setup.caller, + setup.minter_id, + "icrc21_canister_call_consent_message", + Encode!(req).unwrap(), + ) + .expect("icrc21_canister_call_consent_message ingress failed") + ), + Result + ) + .unwrap() + }; + let make_request = |method: &str, + arg: Vec, + device_spec: Option| + -> ConsentMessageRequest { + ConsentMessageRequest { + method: method.to_string(), + arg, + user_preferences: ConsentMessageSpec { + metadata: ConsentMessageMetadata { + language: "en".to_string(), + utc_offset_minutes: None, + }, + device_spec, + }, + } + }; + + // 1. icrc10_supported_standards advertises ICRC-10 and ICRC-21. + let standards = Decode!( + &assert_reply( + setup + .env + .query( + setup.minter_id, + "icrc10_supported_standards", + Encode!().unwrap() + ) + .expect("icrc10_supported_standards query failed") + ), + Vec + ) + .unwrap(); + let names: Vec<_> = standards.iter().map(|s| s.name.as_str()).collect(); + assert!( + names.contains(&"ICRC-10") && names.contains(&"ICRC-21"), + "expected ICRC-10 and ICRC-21 in supported standards, got {names:?}" + ); + let icrc21 = standards + .iter() + .find(|s| s.name == "ICRC-21") + .expect("ICRC-21 entry missing"); + assert_eq!( + icrc21.url, + "https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md" + ); + + // 2a. retrieve_btc_with_approval / GenericDisplay renders the expected + // Markdown sections, with the configured (mainnet) token symbols. The + // assertion is a full-string equality so any wording change has to be + // updated here consciously. + let args = RetrieveBtcWithApprovalArgs { + amount: 250_000, + address: WITHDRAWAL_ADDRESS.to_string(), + from_subaccount: None, + }; + let info = consent_message(&make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::GenericDisplay), + )) + .expect("consent message should be produced for retrieve_btc_with_approval"); + let message = match info.consent_message { + ConsentMessage::GenericDisplayMessage(m) => m, + other => panic!("expected GenericDisplayMessage, got {other:?}"), + }; + assert_eq!( + message, + format!( + "# Convert ckBTC to BTC\n\n\ + Authorize the ckBTC minter to burn ckBTC from your account and \ + send the equivalent amount in BTC (minus network and minter fees) to \ + the Bitcoin address below.\n\n\ + **Amount to convert:** `0.0025 ckBTC`\n\n\ + **Bitcoin destination address:**\n`{WITHDRAWAL_ADDRESS}`" + ) + ); + + // 2b. retrieve_btc_with_approval / FieldsDisplay renders the structured + // intent + (Amount, BTC address) pair. + let info = consent_message(&make_request( + "retrieve_btc_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::FieldsDisplay), + )) + .expect("consent message should be produced for retrieve_btc_with_approval"); + let fields_display = match info.consent_message { + ConsentMessage::FieldsDisplayMessage(f) => f, + other => panic!("expected FieldsDisplayMessage, got {other:?}"), + }; + assert_eq!(fields_display.intent, "ckBTC to BTC"); + assert_eq!( + fields_display.fields, + vec![ + ( + "Amount".to_string(), + Value::TokenAmount { + decimals: 8, + amount: 250_000, + symbol: "ckBTC".to_string(), + } + ), + ( + "BTC address".to_string(), + Value::Text { + content: WITHDRAWAL_ADDRESS.to_string(), + } + ), + ] + ); + + // 3. Unsupported methods return UnsupportedCanisterCall. `retrieve_btc` + // is intentionally listed here — the minter only renders consent for + // the approval-based flow. + for method in ["retrieve_btc", "update_balance", "get_btc_address"] { + let err = consent_message(&make_request(method, vec![], None)) + .expect_err("expected UnsupportedCanisterCall"); + assert!( + matches!(err, Icrc21Error::UnsupportedCanisterCall(_)), + "method {method:?} should be rejected as unsupported, got {err:?}" + ); + } +} + #[test] fn test_wrong_upgrade_parameter() { let env = new_state_machine();