diff --git a/rs/dogecoin/ckdoge/minter/BUILD.bazel b/rs/dogecoin/ckdoge/minter/BUILD.bazel index 04b2064f7223..fba9c20a08ff 100644 --- a/rs/dogecoin/ckdoge/minter/BUILD.bazel +++ b/rs/dogecoin/ckdoge/minter/BUILD.bazel @@ -84,6 +84,7 @@ rust_test( # Keep sorted. ":ckdoge_minter_lib", "//packages/ic-http-types", + "//packages/icrc-ledger-types:icrc_ledger_types_storable", "//rs/bitcoin/ckbtc/minter:ckbtc_minter_lib", "@crate_index//:candid", "@crate_index//:ic-cdk", diff --git a/rs/dogecoin/ckdoge/minter/ckdoge_minter.did b/rs/dogecoin/ckdoge/minter/ckdoge_minter.did index 67e197c373f1..a7c94f126c2f 100644 --- a/rs/dogecoin/ckdoge/minter/ckdoge_minter.did +++ b/rs/dogecoin/ckdoge/minter/ckdoge_minter.did @@ -383,6 +383,69 @@ type InvalidTransactionError = variant { }; }; +// 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 }; +}; + type EventType = variant { init : InitArgs; upgrade : UpgradeArgs; @@ -507,4 +570,12 @@ service : (minter_arg : MinterArg) -> { // // NOTE: this method exists for debugging purposes and backwards-compatibility is **not** guaranteed. get_events : (record { start: nat64; length : nat64 }) -> (vec Event) query; + + // 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 }); } diff --git a/rs/dogecoin/ckdoge/minter/src/main.rs b/rs/dogecoin/ckdoge/minter/src/main.rs index 09c256820ee4..1b39fc39b69e 100644 --- a/rs/dogecoin/ckdoge/minter/src/main.rs +++ b/rs/dogecoin/ckdoge/minter/src/main.rs @@ -18,6 +18,9 @@ use ic_ckdoge_minter::{ updates, }; use ic_http_types::{HttpRequest, HttpResponse}; +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) { @@ -230,6 +233,18 @@ fn get_events(args: GetEventsArg) -> Vec { .collect() } +#[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/dogecoin/ckdoge/minter/src/updates/icrc21.rs b/rs/dogecoin/ckdoge/minter/src/updates/icrc21.rs new file mode 100644 index 000000000000..066ef1c7694a --- /dev/null +++ b/rs/dogecoin/ckdoge/minter/src/updates/icrc21.rs @@ -0,0 +1,223 @@ +//! 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 ckDOGE minter. + +use crate::address::DogecoinAddress; +use crate::candid_api::RetrieveDogeWithApprovalArgs; +use crate::lifecycle::init::Network; +use candid::{CandidType, Decode, Deserialize}; +use ic_ckbtc_minter::state::read_state; +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 ckDOGE and DOGE use 8 decimals (1 DOGE = 10^8 koinus). +pub(super) const DECIMALS: u8 = 8; + +/// Token symbols used in consent messages. They depend on the configured +/// Dogecoin 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. "ckDOGE" on mainnet, "ckTESTDOGE" otherwise. + pub(super) ckdoge: &'static str, + /// The native Dogecoin token, e.g. "DOGE" on mainnet, "TESTDOGE" otherwise. + pub(super) doge: &'static str, +} + +impl TokenSymbols { + pub(super) fn for_network(network: Network) -> Self { + match network { + Network::Mainnet => Self { + ckdoge: "ckDOGE", + doge: "DOGE", + }, + Network::Regtest => Self { + ckdoge: "ckTESTDOGE", + doge: "TESTDOGE", + }, + } + } +} + +/// 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| Network::try_from(s.btc_network).unwrap_or_else(|err| ic_cdk::trap(err))); + 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_doge_with_approval" => { + let args = Decode!( + consent_msg_request.arg.as_slice(), + RetrieveDogeWithApprovalArgs + ) + .map_err(|e| { + Icrc21Error::UnsupportedCanisterCall(ErrorInfo { + description: format!("Failed to decode RetrieveDogeWithApprovalArgs: {e}"), + }) + })?; + validate_address(&args.address, network)?; + build_retrieve_doge_with_approval_message(&args, &display_type, symbols) + } + method => { + return Err(Icrc21Error::UnsupportedCanisterCall(ErrorInfo { + description: format!( + "The method '{method}' is not supported by the ckDOGE 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_doge_with_approval_message( + args: &RetrieveDogeWithApprovalArgs, + display_type: &DisplayMessageType, + symbols: TokenSymbols, +) -> ConsentMessage { + let TokenSymbols { ckdoge, doge } = symbols; + let amount = format_amount(args.amount, DECIMALS); + match display_type { + DisplayMessageType::GenericDisplay => { + let mut message = format!( + "# Convert {ckdoge} to {doge}\n\n\ + Authorize the {ckdoge} minter to burn {ckdoge} from your account and \ + send the equivalent amount in {doge} (minus network and minter fees) to \ + the Dogecoin address below.\n\n\ + **Amount to convert:** `{amount} {ckdoge}`\n\n\ + **Dogecoin destination address:**\n`{address}`", + address = args.address, + ); + if let Some(subaccount) = args.from_subaccount { + message.push_str(&format!( + "\n\n**{ckdoge} source subaccount:**\n`{}`", + hex::encode(subaccount) + )); + } + ConsentMessage::GenericDisplayMessage(message) + } + DisplayMessageType::FieldsDisplay => { + // Long values (Dogecoin 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: ckdoge.to_string(), + }, + ), + ( + format!("{doge} 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!("{ckdoge} to {doge}"), + fields, + }) + } + } +} + +/// Verifies that `address` parses as a valid Dogecoin 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_doge_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> { + DogecoinAddress::parse(address, &network).map_err(|e| { + Icrc21Error::UnsupportedCanisterCall(ErrorInfo { + description: format!("Invalid Dogecoin 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/dogecoin/ckdoge/minter/src/updates/mod.rs b/rs/dogecoin/ckdoge/minter/src/updates/mod.rs index c4309cf9ab0c..956aaf3a46b1 100644 --- a/rs/dogecoin/ckdoge/minter/src/updates/mod.rs +++ b/rs/dogecoin/ckdoge/minter/src/updates/mod.rs @@ -2,6 +2,7 @@ mod tests; pub mod get_doge_address; +pub mod icrc21; pub use get_doge_address::{ account_to_p2pkh_address, account_to_p2pkh_address_from_state, get_doge_address, diff --git a/rs/dogecoin/ckdoge/minter/src/updates/tests.rs b/rs/dogecoin/ckdoge/minter/src/updates/tests.rs index 8b43d0e21ef4..c4420eb9665c 100644 --- a/rs/dogecoin/ckdoge/minter/src/updates/tests.rs +++ b/rs/dogecoin/ckdoge/minter/src/updates/tests.rs @@ -1,3 +1,313 @@ +mod icrc21 { + use crate::candid_api::RetrieveDogeWithApprovalArgs; + use crate::lifecycle::init::Network; + use crate::updates::icrc21::{DECIMALS, TokenSymbols, build_consent_info, format_amount}; + 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}; + + // A valid Dogecoin mainnet P2PKH address. + const MAINNET_ADDRESS: &str = "DJfU2p6woQ9GiBdiXsWZWJnJ9uDdZfSSNC"; + // A valid Dogecoin regtest P2PKH address. + const REGTEST_ADDRESS: &str = "n31RjZEthBbNcW5F6ioj98HpeHkuJsPBJm"; + + 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_doge` because the minter intentionally only + // supports ICRC-21 for the approval-based flow — wallets calling + // `retrieve_doge` should not get a consent message rendered for them. + for method in ["update_balance", "retrieve_doge", "get_doge_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_doge_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_doge_with_approval_generic_display() { + let args = RetrieveDogeWithApprovalArgs { + amount: 150_000, + address: MAINNET_ADDRESS.to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_doge_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 ckDOGE to DOGE")); + assert!(message.contains("0.0015 ckDOGE")); + assert!(message.contains(MAINNET_ADDRESS)); + // No subaccount section if from_subaccount is None. + assert!(!message.contains("source subaccount")); + } + + #[test] + fn test_retrieve_doge_with_approval_generic_display_with_subaccount() { + let mut subaccount = [0_u8; 32]; + subaccount[31] = 0x42; + let args = RetrieveDogeWithApprovalArgs { + amount: 100_000_000, + address: MAINNET_ADDRESS.to_string(), + from_subaccount: Some(subaccount), + }; + let req = make_request("retrieve_doge_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 ckDOGE")); + assert!(message.contains(&hex::encode(subaccount))); + } + + #[test] + fn test_retrieve_doge_with_approval_fields_display() { + // Long values (Dogecoin 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 = MAINNET_ADDRESS.to_string(); + let args = RetrieveDogeWithApprovalArgs { + amount: 250_000, + address: address.clone(), + from_subaccount: Some(subaccount), + }; + let req = make_request( + "retrieve_doge_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, "ckDOGE to DOGE"); + assert_eq!( + fields_display.fields, + vec![ + ( + "Amount".to_string(), + Value::TokenAmount { + decimals: DECIMALS, + amount: 250_000, + symbol: "ckDOGE".to_string(), + } + ), + ("DOGE address".to_string(), Value::Text { content: address }), + ( + "From subaccount".to_string(), + Value::Text { + content: hex::encode(subaccount) + } + ), + ] + ); + } + + #[test] + fn test_retrieve_doge_with_approval_fields_display_no_subaccount() { + let args = RetrieveDogeWithApprovalArgs { + amount: 250_000, + address: MAINNET_ADDRESS.to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_doge_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 { + ckdoge: "ckDOGE", + doge: "DOGE" + } + ); + assert_eq!( + TokenSymbols::for_network(Network::Regtest), + TokenSymbols { + ckdoge: "ckTESTDOGE", + doge: "TESTDOGE" + } + ); + } + + #[test] + fn test_retrieve_doge_with_approval_uses_regtest_symbols() { + let args = RetrieveDogeWithApprovalArgs { + amount: 250_000, + address: REGTEST_ADDRESS.to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_doge_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::FieldsDisplay), + ); + let info = build_consent_info(req, Network::Regtest).unwrap(); + let fields_display = match info.consent_message { + ConsentMessage::FieldsDisplayMessage(f) => f, + other => panic!("expected FieldsDisplayMessage, got {other:?}"), + }; + assert_eq!(fields_display.intent, "ckTESTDOGE to TESTDOGE"); + match &fields_display.fields[0].1 { + Value::TokenAmount { symbol, .. } => assert_eq!(symbol, "ckTESTDOGE"), + other => panic!("expected TokenAmount, got {other:?}"), + } + // FieldsDisplay address label uses the regtest native symbol too. + assert_eq!(fields_display.fields[1].0, "TESTDOGE address"); + } + + #[test] + fn test_retrieve_doge_with_approval_generic_uses_regtest_symbols() { + let args = RetrieveDogeWithApprovalArgs { + amount: 100_000_000, + address: REGTEST_ADDRESS.to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_doge_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::GenericDisplay), + ); + let info = build_consent_info(req, Network::Regtest).unwrap(); + let message = match info.consent_message { + ConsentMessage::GenericDisplayMessage(m) => m, + other => panic!("expected GenericDisplayMessage, got {other:?}"), + }; + assert!(message.starts_with("# Convert ckTESTDOGE to TESTDOGE")); + assert!(message.contains("1 ckTESTDOGE")); + assert!(message.contains("ckTESTDOGE minter")); + assert!(message.contains("equivalent amount in TESTDOGE")); + } + + #[test] + fn test_invalid_args() { + let req = make_request("retrieve_doge_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", + "DJfU2p6woQ9GiBdiXsWZWJnJ9uDdZfSSNC\n# You will receive 100 DOGE", + "DJfU2p6woQ9GiBdiXsWZWJnJ9uDdZfSSNC`\n\n**Amount:** 100 DOGE\n`", + REGTEST_ADDRESS, // valid regtest but on Mainnet + ] { + let args = RetrieveDogeWithApprovalArgs { + amount: 50_000, + address: bad_address.to_string(), + from_subaccount: None, + }; + let req = make_request( + "retrieve_doge_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 Dogecoin destination address"), + "unexpected error description: {}", + info.description + ); + } + other => panic!("expected UnsupportedCanisterCall, got {other:?}"), + } + } + } +} + mod derivation { use crate::address::DogecoinAddress; use crate::lifecycle::init::Network; diff --git a/rs/dogecoin/ckdoge/minter/tests/tests.rs b/rs/dogecoin/ckdoge/minter/tests/tests.rs index 92378c057c30..7fae5bb01b59 100644 --- a/rs/dogecoin/ckdoge/minter/tests/tests.rs +++ b/rs/dogecoin/ckdoge/minter/tests/tests.rs @@ -78,6 +78,155 @@ fn should_fail_withdrawal() { ); } +/// 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_doge_with_approval`, with the configured network's token symbols +/// threaded through. +/// * Unsupported methods return `Icrc21Error::UnsupportedCanisterCall`. +#[test] +fn test_icrc21_endpoints_smoke() { + use candid::Encode; + use ic_ckdoge_minter::candid_api::RetrieveDogeWithApprovalArgs; + use ic_ckdoge_minter::lifecycle::init::Network; + use ic_ckdoge_minter_test_utils::{Setup, USER_PRINCIPAL}; + use icrc_ledger_types::icrc21::errors::Icrc21Error; + use icrc_ledger_types::icrc21::requests::{ + ConsentMessageMetadata, ConsentMessageRequest, ConsentMessageSpec, DisplayMessageType, + }; + use icrc_ledger_types::icrc21::responses::{ConsentMessage, Value}; + + // Mainnet so we don't need a dogecoind daemon; "ckDOGE" / "DOGE" symbols. + let setup = Setup::new(Network::Mainnet); + let minter = setup.minter(); + // A valid Dogecoin mainnet P2PKH address. + const ADDRESS: &str = "DJfU2p6woQ9GiBdiXsWZWJnJ9uDdZfSSNC"; + + 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 = minter.icrc10_supported_standards(); + 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_doge_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 = RetrieveDogeWithApprovalArgs { + amount: 250_000, + address: ADDRESS.to_string(), + from_subaccount: None, + }; + let info = minter + .icrc21_canister_call_consent_message( + USER_PRINCIPAL, + &make_request( + "retrieve_doge_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::GenericDisplay), + ), + ) + .expect("consent message should be produced for retrieve_doge_with_approval"); + let message = match info.consent_message { + ConsentMessage::GenericDisplayMessage(m) => m, + other => panic!("expected GenericDisplayMessage, got {other:?}"), + }; + assert_eq!( + message, + format!( + "# Convert ckDOGE to DOGE\n\n\ + Authorize the ckDOGE minter to burn ckDOGE from your account and \ + send the equivalent amount in DOGE (minus network and minter fees) to \ + the Dogecoin address below.\n\n\ + **Amount to convert:** `0.0025 ckDOGE`\n\n\ + **Dogecoin destination address:**\n`{ADDRESS}`" + ) + ); + + // 2b. retrieve_doge_with_approval / FieldsDisplay renders the structured + // intent + (Amount, DOGE address) pair. + let info = minter + .icrc21_canister_call_consent_message( + USER_PRINCIPAL, + &make_request( + "retrieve_doge_with_approval", + Encode!(&args).unwrap(), + Some(DisplayMessageType::FieldsDisplay), + ), + ) + .expect("consent message should be produced for retrieve_doge_with_approval"); + let fields_display = match info.consent_message { + ConsentMessage::FieldsDisplayMessage(f) => f, + other => panic!("expected FieldsDisplayMessage, got {other:?}"), + }; + assert_eq!(fields_display.intent, "ckDOGE to DOGE"); + assert_eq!( + fields_display.fields, + vec![ + ( + "Amount".to_string(), + Value::TokenAmount { + decimals: 8, + amount: 250_000, + symbol: "ckDOGE".to_string(), + } + ), + ( + "DOGE address".to_string(), + Value::Text { + content: ADDRESS.to_string(), + } + ), + ] + ); + + // 3. Unsupported methods return UnsupportedCanisterCall. `retrieve_doge` + // is intentionally listed here — the minter only renders consent for + // the approval-based flow. + for method in ["retrieve_doge", "update_balance", "get_doge_address"] { + let err = minter + .icrc21_canister_call_consent_message( + USER_PRINCIPAL, + &make_request(method, vec![], None), + ) + .expect_err("expected UnsupportedCanisterCall"); + assert!( + matches!(err, Icrc21Error::UnsupportedCanisterCall(_)), + "method {method:?} should be rejected as unsupported, got {err:?}" + ); + } +} + mod get_doge_address { use candid::Principal; use ic_ckdoge_minter::candid_api::GetDogeAddressArgs; diff --git a/rs/dogecoin/ckdoge/test_utils/src/minter.rs b/rs/dogecoin/ckdoge/test_utils/src/minter.rs index 0119e563ed2c..7be9b6fc7153 100644 --- a/rs/dogecoin/ckdoge/test_utils/src/minter.rs +++ b/rs/dogecoin/ckdoge/test_utils/src/minter.rs @@ -11,10 +11,14 @@ use ic_ckdoge_minter::{ }, event::{CkDogeMinterEvent, CkDogeMinterEventType}, lifecycle::{MinterArg, upgrade::UpgradeArgs}, + updates::icrc21::StandardRecord, }; use ic_management_canister_types::{CanisterId, CanisterStatusResult}; use ic_metrics_assert::{MetricsAssert, PocketIcHttpQuery}; 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; use pocket_ic::common::rest::RawMessageId; use pocket_ic::{PocketIc, RejectResponse}; use std::sync::Arc; @@ -303,6 +307,36 @@ impl MinterCanister { Decode!(&call_result, Vec).unwrap() } + pub fn icrc10_supported_standards(&self) -> Vec { + let call_result = self + .env + .query_call( + self.id, + Principal::anonymous(), + "icrc10_supported_standards", + Encode!().unwrap(), + ) + .expect("BUG: failed to call icrc10_supported_standards"); + Decode!(&call_result, Vec).unwrap() + } + + pub fn icrc21_canister_call_consent_message( + &self, + sender: Principal, + request: &ConsentMessageRequest, + ) -> Result { + let call_result = self + .env + .update_call( + self.id, + sender, + "icrc21_canister_call_consent_message", + Encode!(request).unwrap(), + ) + .expect("BUG: failed to call icrc21_canister_call_consent_message"); + Decode!(&call_result, Result).unwrap() + } + pub fn id(&self) -> CanisterId { self.id }