From e219f993d925f393a237b035e592956d0322a0d1 Mon Sep 17 00:00:00 2001 From: Nikolas Haimerl Date: Fri, 5 Jul 2024 07:06:35 +0000 Subject: [PATCH] chore(ICRC21): FI-1339: Icrc 21 markdown refinement --- Cargo.lock | 1 + packages/icrc-ledger-types/BUILD.bazel | 1 + packages/icrc-ledger-types/Cargo.toml | 4 +- packages/icrc-ledger-types/src/icrc21/lib.rs | 441 +++++++----- .../icrc-ledger-types/src/icrc21/requests.rs | 1 + rs/rosetta-api/icp_ledger/ledger.did | 1 + rs/rosetta-api/icrc1/ledger/ledger.did | 1 + .../icrc1/ledger/sm-tests/src/lib.rs | 645 +++++++++--------- 8 files changed, 582 insertions(+), 513 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 75b2d782826..c8169a97263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12788,6 +12788,7 @@ dependencies = [ "serde_bytes", "sha2 0.10.8", "strum 0.26.2", + "time", ] [[package]] diff --git a/packages/icrc-ledger-types/BUILD.bazel b/packages/icrc-ledger-types/BUILD.bazel index dc80a5147c7..117f3be49f1 100644 --- a/packages/icrc-ledger-types/BUILD.bazel +++ b/packages/icrc-ledger-types/BUILD.bazel @@ -25,6 +25,7 @@ rust_library( "@crate_index//:serde_bytes", "@crate_index//:sha2", "@crate_index//:strum", + "@crate_index//:time", ], ) diff --git a/packages/icrc-ledger-types/Cargo.toml b/packages/icrc-ledger-types/Cargo.toml index c64cf6b6b68..73b34e916ac 100644 --- a/packages/icrc-ledger-types/Cargo.toml +++ b/packages/icrc-ledger-types/Cargo.toml @@ -22,7 +22,9 @@ serde = { workspace = true } sha2 = "0.10" itertools ={ workspace = true } strum = {workspace = true} +time = { workspace = true } + [dev-dependencies] assert_matches = { workspace = true } hex = { workspace = true } -proptest = "1.0.0" +proptest = "1.0.0" \ No newline at end of file diff --git a/packages/icrc-ledger-types/src/icrc21/lib.rs b/packages/icrc-ledger-types/src/icrc21/lib.rs index cfa24c190ff..416bccf051f 100644 --- a/packages/icrc-ledger-types/src/icrc21/lib.rs +++ b/packages/icrc-ledger-types/src/icrc21/lib.rs @@ -15,10 +15,6 @@ use serde_bytes::ByteBuf; use strum; use strum::EnumString; -pub const ICRC1_TRANSFER_DISPLAY_MESSAGE: &str = "Transfers {AMOUNT} {TOKEN_SYMBOL} from {SENDER_ACCOUNT} to {RECEIVER_ACCOUNT}. Fee paid by {SENDER_ACCOUNT} is {LEDGER_FEE} {TOKEN_SYMBOL}."; -pub const ICRC2_APPROVE_DISPLAY_MESSAGE: &str = "Approves {AMOUNT} {TOKEN_SYMBOL} from {APPROVER_ACCOUNT} to be spent by {SPENDER_ACCOUNT}. Fee paid by {SENDER_ACCOUNT} is {LEDGER_FEE} {TOKEN_SYMBOL}."; -pub const ICRC2_TRANSFER_FROM_DISPLAY_MESSAGE: &str = "Transfers {AMOUNT} {TOKEN_SYMBOL} from {SENDER_ACCOUNT} to {RECEIVER_ACCOUNT}. The tokens are spent by {SPENDER_ACCOUNT}. Fee paid by {SENDER_ACCOUNT} is {LEDGER_FEE} {TOKEN_SYMBOL}."; - // Maximum number of bytes that an argument to an ICRC-1 ledger function can have when passed to the ICRC-21 endpoint. pub const MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES: u16 = 500; @@ -37,16 +33,15 @@ pub struct ConsentMessageBuilder { display_type: Option, approver: Option, spender: Option, - sender: Option, + from: Option, receiver: Option, amount: Option, token_symbol: Option, - fee_set: Option, ledger_fee: Option, memo: Option, - created_at_time: Option, expected_allowance: Option, expires_at: Option, + utc_offset_minutes: Option, } impl ConsentMessageBuilder { @@ -64,35 +59,34 @@ impl ConsentMessageBuilder { display_type: None, approver: None, spender: None, - sender: None, + from: None, receiver: None, amount: None, token_symbol: None, - fee_set: None, ledger_fee: None, + utc_offset_minutes: None, memo: None, - created_at_time: None, expected_allowance: None, expires_at: None, }) } - pub fn with_approver(mut self, approver: Account) -> Self { + pub fn with_approver_account(mut self, approver: Account) -> Self { self.approver = Some(approver); self } - pub fn with_spender(mut self, spender: Account) -> Self { + pub fn with_spender_account(mut self, spender: Account) -> Self { self.spender = Some(spender); self } - pub fn with_sender(mut self, sender: Account) -> Self { - self.sender = Some(sender); + pub fn with_from_account(mut self, from: Account) -> Self { + self.from = Some(from); self } - pub fn with_receiver(mut self, receiver: Account) -> Self { + pub fn with_receiver_account(mut self, receiver: Account) -> Self { self.receiver = Some(receiver); self } @@ -107,11 +101,6 @@ impl ConsentMessageBuilder { self } - pub fn with_fee_set(mut self, fee_set: Nat) -> Self { - self.fee_set = Some(fee_set); - self - } - pub fn with_ledger_fee(mut self, ledger_fee: Nat) -> Self { self.ledger_fee = Some(ledger_fee); self @@ -122,11 +111,6 @@ impl ConsentMessageBuilder { self } - pub fn with_created_at_time(mut self, created_at_time: u64) -> Self { - self.created_at_time = Some(created_at_time); - self - } - pub fn with_expected_allowance(mut self, expected_allowance: Nat) -> Self { self.expected_allowance = Some(expected_allowance); self @@ -142,143 +126,254 @@ impl ConsentMessageBuilder { self } + pub fn with_utc_offset_minutes(mut self, utc_offset_minutes: i16) -> Self { + self.utc_offset_minutes = Some(utc_offset_minutes); + self + } + pub fn build(self) -> Result { - let mut message = match self.function { - Icrc21Function::Transfer => ICRC1_TRANSFER_DISPLAY_MESSAGE - .replace( - "{SENDER_ACCOUNT}", - &self - .sender - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Sender Account has to be specified.".to_owned(), - })? - .to_string(), - ) - .replace( - "{RECEIVER_ACCOUNT}", - &self - .receiver - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Receiver Account has to be specified.".to_owned(), - })? - .to_string(), - ), - Icrc21Function::Approve => ICRC2_APPROVE_DISPLAY_MESSAGE - .replace( - "{APPROVER_ACCOUNT}", - &self - .approver - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Approver Account has to be specified.".to_owned(), - })? - .to_string(), - ) - .replace( - "{SPENDER_ACCOUNT}", - &self - .spender - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Spender Account has to be specified.".to_owned(), - })? - .to_string(), - ), - Icrc21Function::TransferFrom => ICRC2_TRANSFER_FROM_DISPLAY_MESSAGE - .replace( - "{SENDER_ACCOUNT}", - &self - .sender - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Sender Account has to be specified.".to_owned(), - })? - .to_string(), - ) - .replace( - "{RECEIVER_ACCOUNT}", - &self - .receiver - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Receiver Account has to be specified.".to_owned(), - })? - .to_string(), - ) - .replace( - "{SPENDER_ACCOUNT}", - &self - .spender - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Spender Account has to be specified.".to_owned(), - })? - .to_string(), - ), - } - .replace( - "{AMOUNT}", - &self - .amount - .ok_or(Icrc21Error::GenericError { + let mut message = "".to_string(); + match self.function { + Icrc21Function::Transfer => { + message.push_str("# Approve the transfer of funds"); + let from_account = self.from.ok_or(Icrc21Error::GenericError { error_code: Nat::from(500u64), - description: "Amount has to be specified.".to_owned(), - })? - .to_string(), - ) - .replace( - "{TOKEN_SYMBOL}", - &self.token_symbol.ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Token Symbol must be specified.".to_owned(), - })?, - ) - .replace( - "{LEDGER_FEE}", - &self - .ledger_fee - .ok_or(Icrc21Error::GenericError { + description: "From Account has to be specified.".to_owned(), + })?; + let receiver_account = self.receiver.ok_or(Icrc21Error::GenericError { error_code: Nat::from(500u64), - description: "Ledger Fee must be specified.".to_owned(), - })? - .to_string(), - ); - - match self.display_type { - Some(DisplayMessageType::GenericDisplay) | None => { - message.push_str("\n---\n Request Details\n"); - if let Some(memo) = self.memo { + description: "Receiver Account has to be specified.".to_owned(), + })?; + let fee = self + .ledger_fee + .ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Ledger Fee must be specified.".to_owned(), + })? + .to_string() + .replace('_', "'"); + let token_symbol = self.token_symbol.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Token Symbol must be specified.".to_owned(), + })?; + let amount = self + .amount + .ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Amount has to be specified.".to_owned(), + })? + .to_string() + .replace('_', "'"); + + message.push_str(&format!("\n\n**Amount:**\n{} {}", amount, token_symbol)); + if from_account.owner == Principal::anonymous() { message.push_str(&format!( - "\n*Transaction Memo is: {}", - String::from_utf8_lossy(&memo) + "\n\n**From Subaccount:**\n{}", + from_account.to_string().split('.').last().ok_or( + Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Sender Subaccount has an unexpected format." + .to_owned(), + } + )? )); + } else { + message.push_str(&format!("\n\n**From:**\n{}", from_account)); } - if let Some(created_at_time) = self.created_at_time { + message.push_str(&format!("\n\n**To:**\n{}", receiver_account)); + message.push_str(&format!("\n\n**Fee:**\n{} {}", fee, token_symbol)); + } + Icrc21Function::Approve => { + message.push_str("# Authorize another address to withdraw from your account"); + let approver_account = self.approver.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Approver Account has to be specified.".to_owned(), + })?; + let spender_account = self.spender.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Spender Account has to be specified.".to_owned(), + })?; + let fee = self + .ledger_fee + .ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Ledger Fee must be specified.".to_owned(), + })? + .to_string() + .replace('_', "'"); + let token_symbol = self.token_symbol.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Token Symbol must be specified.".to_owned(), + })?; + let amount = self + .amount + .ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Amount has to be specified.".to_owned(), + })? + .to_string() + .replace('_', "'"); + let expires_at = self + .expires_at + .map(|ts| { + let seconds = (ts as i64) / 10_i64.pow(9); + let nanos = ((ts as i64) % 10_i64.pow(9)) as u32; + + let utc_dt = match (match time::OffsetDateTime::from_unix_timestamp(seconds) + { + Ok(dt) => dt, + Err(_) => return format!("Invalid timestamp: {}", ts), + }) + .replace_nanosecond(nanos) + { + Ok(dt) => dt, + Err(_) => return format!("Invalid nanosecond: {}", nanos), + }; + + // Apply the offset minutes + let offset = time::UtcOffset::from_whole_seconds( + (self.utc_offset_minutes.unwrap_or(0) * 60).into(), + ) + .expect("Invalid offset"); + let offset_dt = utc_dt.to_offset(offset); + + // Format as a string including the offset + match offset_dt.format(&time::format_description::well_known::Rfc2822) { + Ok(formatted) => formatted, + Err(_) => format!("Invalid timestamp: {}", ts), + } + }) + .unwrap_or("No expiration.".to_owned()); + + message.push_str(&format!( + "\n\n**The following address is allowed to withdraw from your account:**\n{}", + spender_account + )); + if approver_account.owner == Principal::anonymous() { message.push_str(&format!( - "\n*Transaction was created by the user at: {}", - created_at_time + "\n\n**Your Subaccount:**\n{}", + approver_account.to_string().split('.').last().ok_or( + Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Approver Subaccount has an unexpected format." + .to_owned(), + } + )? )); + } else { + message.push_str(&format!("\n\n**Your account:**\n{}", approver_account)); } - if let Some(expected_allowance) = self.expected_allowance { + message.push_str(&format!( + "\n\n**Requested withdrawal allowance:**\n{} {}", + amount, token_symbol + )); + message.push_str(&self.expected_allowance.map( + |expected_allowance| format!("\n\n**Current withdrawal allowance:**\n{} {}", expected_allowance.to_string().replace('_', "'"),token_symbol)) + .unwrap_or_else(|| format!("\u{26A0} The allowance will be set to {} {} independently of any previous allowance. Until this transaction has been executed the spender can still exercise the previous allowance (if any) to it's full amount.",amount,token_symbol))); + message.push_str(&format!("\n\n**Expiration date:**\n{}", expires_at)); + message.push_str(&format!("\n\n**Approval fee:**\n{} {}", fee, token_symbol)); + if approver_account.owner == Principal::anonymous() { + message.push_str(&format!( + "\n\n**Transaction fees to be paid by your subaccount:**\n{}", + approver_account.to_string().split('.').last().ok_or( + Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Approver Subaccount has an unexpected format." + .to_owned(), + } + )? + )); + } else { message.push_str(&format!( - "\n*The expected allowance before approving the requested amount is: {}", - expected_allowance + "\n\n**Transaction fees to be paid by:**\n{}", + approver_account )); } - if let Some(expires_at) = self.expires_at { - message.push_str(&format!("\n*The approval expires at: {}", expires_at)); + } + Icrc21Function::TransferFrom => { + message.push_str("# Transfer from a withdrawal account"); + let from_account = self.from.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "From Account has to be specified.".to_owned(), + })?; + let receiver_account = self.receiver.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Receiver Account has to be specified.".to_owned(), + })?; + let spender_account = self.spender.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Spender Account has to be specified.".to_owned(), + })?; + let fee = self + .ledger_fee + .ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Ledger Fee must be specified.".to_owned(), + })? + .to_string() + .replace('_', "'"); + let token_symbol = self.token_symbol.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Token Symbol must be specified.".to_owned(), + })?; + let amount = self + .amount + .ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Amount has to be specified.".to_owned(), + })? + .to_string() + .replace('_', "'"); + + message.push_str(&format!("\n\n**Withdrawal Account:**\n{}", from_account)); + if spender_account.owner == Principal::anonymous() { + message.push_str(&format!( + "\n\n**Subaccount sending the transfer request:**\n{}", + spender_account.to_string().split('.').last().ok_or( + Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Spender Subaccount has an unexpected format." + .to_owned(), + } + )? + )); + } else { + message.push_str(&format!( + "\n\n**Account sending the transfer request:**\n{}", + spender_account + )); + } + message.push_str(&format!( + "\n\n**Amount to withdraw:**\n{} {}", + amount, token_symbol + )); + message.push_str(&format!("\n\n**To:**\n{}", receiver_account)); + message.push_str(&format!( + "\n\n**Fee paid by withdrawal account:**\n{} {}", + fee, token_symbol + )); + } + }; + + if let Some(memo) = self.memo { + message.push_str(&format!( + "\n\n**Memo:**\n{}", + // Check if the memo is a valid UTF-8 string and display it as such if it is. + &match std::str::from_utf8(memo.as_slice()) { + Ok(valid_str) => valid_str.to_string(), + Err(_) => hex::encode(memo.as_slice()), } + )); + } + + match self.display_type { + Some(DisplayMessageType::GenericDisplay) | None => { Ok(ConsentMessage::GenericDisplayMessage(message)) } Some(DisplayMessageType::LineDisplay { lines_per_page, characters_per_line, }) => { - if let Some(memo) = self.memo { - message.push_str(&format!("\nMemo is {}", String::from_utf8_lossy(&memo))); - } let pages = consent_msg_text_pages(&message, characters_per_line, lines_per_page); Ok(ConsentMessage::LineDisplayMessage { pages }) } @@ -353,11 +448,24 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( // for now, respond in English regardless of what the client requested let metadata = ConsentMessageMetadata { language: "en".to_string(), + utc_offset_minutes: consent_msg_request + .user_preferences + .metadata + .utc_offset_minutes, }; let mut display_message_builder = ConsentMessageBuilder::new(&consent_msg_request.method)? .with_ledger_fee(ledger_fee) .with_token_symbol(token_symbol); + + if let Some(offset) = consent_msg_request + .user_preferences + .metadata + .utc_offset_minutes + { + display_message_builder = display_message_builder.with_utc_offset_minutes(offset); + } + if let Some(display_type) = consent_msg_request.user_preferences.device_spec { if let DisplayMessageType::LineDisplay { lines_per_page, @@ -378,10 +486,10 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( let TransferArg { memo, amount, - fee, from_subaccount, to, - created_at_time, + fee: _, + created_at_time: _, } = Decode!(&consent_msg_request.arg, TransferArg).map_err(|e| { Icrc21Error::UnsupportedCanisterCall(ErrorInfo { description: format!("Failed to decode TransferArg: {}", e), @@ -393,30 +501,23 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( }; display_message_builder = display_message_builder .with_amount(amount) - .with_receiver(to) - .with_sender(sender); + .with_receiver_account(to) + .with_from_account(sender); if let Some(memo) = memo { display_message_builder = display_message_builder.with_memo(memo.0); } - if let Some(created_at_time) = created_at_time { - display_message_builder = - display_message_builder.with_created_at_time(created_at_time); - } - if let Some(fee) = fee { - display_message_builder = display_message_builder.with_fee_set(fee); - } display_message_builder.build() } "icrc2_transfer_from" => { let TransferFromArgs { memo, amount, - fee, from, to, spender_subaccount, - created_at_time, + fee: _, + created_at_time: _, } = Decode!(&consent_msg_request.arg, TransferFromArgs).map_err(|e| { Icrc21Error::UnsupportedCanisterCall(ErrorInfo { description: format!("Failed to decode TransferFromArgs: {}", e), @@ -428,32 +529,25 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( }; display_message_builder = display_message_builder .with_amount(amount) - .with_receiver(to) - .with_sender(from) - .with_spender(spender); + .with_receiver_account(to) + .with_from_account(from) + .with_spender_account(spender); if let Some(memo) = memo { display_message_builder = display_message_builder.with_memo(memo.0); } - if let Some(created_at_time) = created_at_time { - display_message_builder = - display_message_builder.with_created_at_time(created_at_time); - } - if let Some(fee) = fee { - display_message_builder = display_message_builder.with_fee_set(fee); - } display_message_builder.build() } "icrc2_approve" => { let ApproveArgs { memo, amount, - fee, from_subaccount, spender, - created_at_time, expires_at, expected_allowance, + fee: _, + created_at_time: _, } = Decode!(&consent_msg_request.arg, ApproveArgs).map_err(|e| { Icrc21Error::UnsupportedCanisterCall(ErrorInfo { description: format!("Failed to decode ApproveArgs: {}", e), @@ -469,16 +563,12 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( }; display_message_builder = display_message_builder .with_amount(amount) - .with_approver(approver) - .with_spender(spender); + .with_approver_account(approver) + .with_spender_account(spender); if let Some(memo) = memo { display_message_builder = display_message_builder.with_memo(memo.0); } - if let Some(created_at_time) = created_at_time { - display_message_builder = - display_message_builder.with_created_at_time(created_at_time); - } if let Some(expires_at) = expires_at { display_message_builder = display_message_builder.with_expires_at(expires_at); } @@ -486,9 +576,6 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( display_message_builder = display_message_builder.with_expected_allowance(expected_allowance); } - if let Some(fee) = fee { - display_message_builder = display_message_builder.with_fee_set(fee); - } display_message_builder.build() } method => { diff --git a/packages/icrc-ledger-types/src/icrc21/requests.rs b/packages/icrc-ledger-types/src/icrc21/requests.rs index b832fe4adcb..0d4ded56749 100644 --- a/packages/icrc-ledger-types/src/icrc21/requests.rs +++ b/packages/icrc-ledger-types/src/icrc21/requests.rs @@ -4,6 +4,7 @@ use serde::Serialize; #[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ConsentMessageMetadata { pub language: String, + pub utc_offset_minutes: Option, } #[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/rs/rosetta-api/icp_ledger/ledger.did b/rs/rosetta-api/icp_ledger/ledger.did index a7d4e633b7c..62725e3e49d 100644 --- a/rs/rosetta-api/icp_ledger/ledger.did +++ b/rs/rosetta-api/icp_ledger/ledger.did @@ -427,6 +427,7 @@ type TransferFromError = variant { type icrc21_consent_message_metadata = record { language: text; + utc_offset_minutes: opt int16; }; type icrc21_consent_message_spec = record { diff --git a/rs/rosetta-api/icrc1/ledger/ledger.did b/rs/rosetta-api/icrc1/ledger/ledger.did index cedd7f53d87..53f49e45d10 100644 --- a/rs/rosetta-api/icrc1/ledger/ledger.did +++ b/rs/rosetta-api/icrc1/ledger/ledger.did @@ -427,6 +427,7 @@ type ICRC3DataCertificate = record { type icrc21_consent_message_metadata = record { language: text; + utc_offset_minutes: opt int16; }; type icrc21_consent_message_spec = record { diff --git a/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs b/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs index 3c9779183a9..f85dfde257e 100644 --- a/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs +++ b/rs/rosetta-api/icrc1/ledger/sm-tests/src/lib.rs @@ -3331,400 +3331,375 @@ pub fn test_icrc1_test_suite( } } -pub fn test_icrc21_standard(ledger_wasm: Vec, encode_init_args: fn(InitArgs) -> T) -where - T: CandidType, -{ - fn check_consent_message( - sender: Option, - receiver: Option, - spender: Option, - memo: Option, - created_at_time: Option, - amount: Option, - fee_payed: Option, - fee_set: Option, - token_symbol: Option<&str>, - consent_message: ConsentMessage, - expires_at: Option, - expected_allowance: Option, - ) { - let message = match consent_message { - ConsentMessage::GenericDisplayMessage(message) => message, - ConsentMessage::LineDisplayMessage { pages } => pages - .iter() - .map(|page| page.lines.join("")) - .collect::>() - .join(""), - }; - - if let Some(sender) = sender { - assert!( - message.contains(&sender.to_string()), - "Message: {}", - message - ); - } - if let Some(spender) = spender { - assert!( - message.contains(&spender.to_string()), - "Message: {}", - message - ); - } - if let Some(receiver) = receiver { - assert!( - message.contains(&receiver.to_string()), - "Message: {}", - message - ); - } - if let Some(memo) = memo { - assert!( - message.contains(&format!("{}", String::from_utf8_lossy(&memo.0))), - "Message: {}", - message - ); - } - if let Some(created_at_time) = created_at_time { - assert!( - message.contains(&format!("{}", created_at_time)), - "Message: {} ", - message - ); - } - if let Some(amount) = amount { - assert!( - message.contains(&amount.to_string()), - "Message: {}", - message - ); - } - if let Some(fee_payed) = fee_payed { - assert!( - message.contains(&fee_payed.to_string()), - "Message: {}", - message - ); - } - if let Some(fee_set) = fee_set { - assert!( - message.contains(&format!("{}", fee_set)), - "Message: {}", - message - ); - } - if let Some(token_symbol) = token_symbol { - assert!(message.contains(token_symbol), "Message: {}", message); - } - if let Some(expires_at) = expires_at { - assert!( - message.contains(&format!("{}", expires_at)), - "Message: {}", - message - ); - } - if let Some(expected_allowance) = expected_allowance { - assert!( - message.contains(&expected_allowance.to_string()), - "Message: {}", - message - ); - } - } - - let (env, canister_id) = setup(ledger_wasm, encode_init_args, vec![]); - let caller = PrincipalId::new_user_test_id(0); - let receiver = Account { - owner: PrincipalId::new_user_test_id(1).0, - subaccount: Some([2; 32]), - }; - let sender = Account { - owner: caller.0, - subaccount: Some([1; 32]), - }; - let spender = Account { - owner: PrincipalId::new_user_test_id(2).0, - subaccount: Some([3; 32]), - }; - let fee = Nat::from(10_000u64); - let now = system_time_to_nanos(env.time()); - +fn test_icrc21_transfer_message( + env: &StateMachine, + canister_id: CanisterId, + from_account: Account, + receiver_account: Account, +) { let transfer_args = TransferArg { - from_subaccount: sender.subaccount, - to: receiver, - fee: Some(fee), + from_subaccount: from_account.subaccount, + to: receiver_account, + fee: None, amount: Nat::from(1_000_000u32), - created_at_time: Some(now), + created_at_time: Some(system_time_to_nanos(env.time())), memo: Some(Memo::from(b"test_bytes".to_vec())), }; // We check that the GenericDisplay message is created correctly. - let args = ConsentMessageRequest { + let mut args = ConsentMessageRequest { method: "icrc1_transfer".to_owned(), arg: Encode!(&transfer_args).unwrap(), user_preferences: ConsentMessageSpec { metadata: ConsentMessageMetadata { language: "en".to_string(), + utc_offset_minutes: Some(60), }, device_spec: Some(DisplayMessageType::GenericDisplay), }, }; - let consent_info = icrc21_consent_message(&env, canister_id, caller.0, args).unwrap(); + let expected_transfer_message = "# Approve the transfer of funds + +**Amount:** +1'000'000 XTST + +**From:** +d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 + +**To:** +6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202 + +**Fee:** +10'000 XTST + +**Memo:** +test_bytes"; + + let consent_info = + icrc21_consent_message(env, canister_id, from_account.owner, args.clone()).unwrap(); assert_eq!(consent_info.metadata.language, "en"); assert!(matches!( consent_info.consent_message, ConsentMessage::GenericDisplayMessage { .. } )); - check_consent_message( - Some(sender), - Some(receiver), - None, - transfer_args.memo.clone(), - transfer_args.created_at_time, - Some(transfer_args.amount.clone()), - Some(Nat::from(FEE)), - transfer_args.fee.clone(), - Some(TOKEN_SYMBOL), - consent_info.consent_message, - None, - None, + let message = extract_icrc21_message_string(&consent_info.consent_message); + assert_eq!( + message, expected_transfer_message, + "Expected: {}, got: {}", + expected_transfer_message, message + ); + // Make sure the accounts are formatted correctly. + assert_eq!(from_account.to_string(), "d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101"); + assert_eq!(receiver_account.to_string(), "6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202"); + // If we do not set the Memo we expect it to not be included in the resulting message. + args.arg = Encode!(&TransferArg { + memo: None, + ..transfer_args.clone() + }) + .unwrap(); + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, from_account.owner, args.clone()) + .unwrap() + .consent_message, + ); + let expected_message = expected_transfer_message.replace("\n\n**Memo:**\ntest_bytes", ""); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message ); - // We also check the LineDisplay type and confirm the constraints are met. - let args = ConsentMessageRequest { - method: "icrc1_transfer".to_owned(), - arg: Encode!(&transfer_args).unwrap(), - user_preferences: ConsentMessageSpec { - metadata: ConsentMessageMetadata { - language: "en".to_string(), - }, - device_spec: Some(DisplayMessageType::LineDisplay { - characters_per_line: 10, - lines_per_page: 3, - }), - }, - }; + // If the memo is not a valid UTF string, it should be hex encoded. + args.arg = Encode!(&TransferArg { + memo: Some(vec![0, 159, 146, 150].into()), + ..transfer_args.clone() + }) + .unwrap(); + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, from_account.owner, args.clone()) + .unwrap() + .consent_message, + ); + let expected_message = + expected_transfer_message.replace("test_bytes", &hex::encode(vec![0, 159, 146, 150])); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message + ); - let consent_info = icrc21_consent_message(&env, canister_id, caller.0, args).unwrap(); - assert_eq!(consent_info.metadata.language, "en"); - match consent_info.consent_message.clone() { - ConsentMessage::LineDisplayMessage { pages } => { - for page in pages.into_iter() { - assert!(page.lines.len() <= 3); - assert!(!page.lines.is_empty()); - for line in page.lines.into_iter() { - assert!(line.len() <= 10); - assert!(!line.is_empty()); - } - } - } - _ => panic!("Expected LineDisplayMessage"), - } - check_consent_message( - Some(sender), - Some(receiver), - None, - transfer_args.memo.clone(), - None, - Some(transfer_args.amount.clone()), - Some(Nat::from(FEE)), - None, - Some(TOKEN_SYMBOL), - consent_info.consent_message, - None, - None, + // If the from account is anonymous, the message should not include the from account but only the from subaccount. + args.arg = Encode!(&transfer_args.clone()).unwrap(); + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, Principal::anonymous(), args.clone()) + .unwrap() + .consent_message, ); + let expected_message = expected_transfer_message.replace("\n\n**From:**\nd2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101","\n\n**From Subaccount:**\n101010101010101010101010101010101010101010101010101010101010101" ); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message + ); +} - // Check that for 0 length LineDisplay, the message is not created. - let args = ConsentMessageRequest { - method: "icrc1_transfer".to_owned(), - arg: Encode!(&transfer_args).unwrap(), - user_preferences: ConsentMessageSpec { - metadata: ConsentMessageMetadata { - language: "en".to_string(), - }, - device_spec: Some(DisplayMessageType::LineDisplay { - characters_per_line: 0, - lines_per_page: 0, - }), - }, +fn test_icrc21_approve_message( + env: &StateMachine, + canister_id: CanisterId, + from_account: Account, + spender_account: Account, +) { + // Test the message for icrc2 approve + let approve_args = ApproveArgs { + spender: spender_account, + amount: Nat::from(1_000_000u32), + from_subaccount: from_account.subaccount, + expires_at: Some( + system_time_to_nanos(env.time()) + Duration::from_secs(3600).as_nanos() as u64, + ), + expected_allowance: Some(Nat::from(1_000_000u32)), + created_at_time: Some(system_time_to_nanos(env.time())), + fee: Some(Nat::from(FEE)), + memo: Some(Memo::from(b"test_bytes".to_vec())), }; + assert_eq!(spender_account.to_string(), "djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303"); + let expected_approve_message = "# Authorize another address to withdraw from your account - assert!(icrc21_consent_message(&env, canister_id, caller.0, args).is_err()); +**The following address is allowed to withdraw from your account:** +djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303 - let args = ConsentMessageRequest { - method: "wrong_method".to_owned(), - arg: Encode!(&transfer_args).unwrap(), - user_preferences: ConsentMessageSpec { - metadata: ConsentMessageMetadata { - language: "en".to_string(), - }, - device_spec: None, - }, - }; +**Your account:** +d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 - assert!(icrc21_consent_message(&env, canister_id, caller.0, args).is_err()); +**Requested withdrawal allowance:** +1'000'000 XTST - // If the display type is not set it should default to GenericDisplay - let args = ConsentMessageRequest { - method: "icrc1_transfer".to_owned(), - arg: Encode!(&transfer_args).unwrap(), - user_preferences: ConsentMessageSpec { - metadata: ConsentMessageMetadata { - language: "en".to_string(), - }, - device_spec: None, - }, - }; +**Current withdrawal allowance:** +1'000'000 XTST - let consent_info = icrc21_consent_message(&env, canister_id, caller.0, args).unwrap(); - assert!(matches!( - consent_info.consent_message, - ConsentMessage::GenericDisplayMessage { .. } - )); - check_consent_message( - Some(sender), - Some(receiver), - None, - None, - None, - Some(transfer_args.amount), - Some(Nat::from(FEE)), - None, - Some(TOKEN_SYMBOL), - consent_info.consent_message, - None, - None, - ); +**Expiration date:** +Thu, 06 May 2021 20:17:10 +0000 - let approve_args = ApproveArgs { - spender, - amount: Nat::from(1_000_000u32), - expires_at: Some(now + 1), - from_subaccount: sender.subaccount, - expected_allowance: Some(Nat::from(1_000u32)), - created_at_time: Some(now), - fee: Some(Nat::from(10u32)), - memo: Some(Memo::from(b"test_bytes".to_vec())), - }; - let args = ConsentMessageRequest { +**Approval fee:** +10'000 XTST + +**Transaction fees to be paid by:** +d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 + +**Memo:** +test_bytes"; + + let mut args = ConsentMessageRequest { method: "icrc2_approve".to_owned(), - arg: Encode!(&approve_args.clone()).unwrap(), + arg: Encode!(&approve_args).unwrap(), user_preferences: ConsentMessageSpec { metadata: ConsentMessageMetadata { language: "en".to_string(), + utc_offset_minutes: None, }, device_spec: Some(DisplayMessageType::GenericDisplay), }, }; - let consent_info = icrc21_consent_message(&env, canister_id, caller.0, args).unwrap(); - check_consent_message( - Some(sender), - None, - Some(approve_args.spender), - approve_args.memo.clone(), - approve_args.created_at_time, - Some(approve_args.amount.clone()), - Some(Nat::from(FEE)), - approve_args.fee.clone(), - Some(TOKEN_SYMBOL), - consent_info.consent_message, - approve_args.expires_at, - approve_args.expected_allowance.clone(), + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, from_account.owner, args.clone()) + .unwrap() + .consent_message, + ); + assert_eq!( + message, expected_approve_message, + "Expected: {}, got: {}", + expected_approve_message, message + ); + args.arg = Encode!(&ApproveArgs { + expected_allowance: None, + ..approve_args.clone() + }) + .unwrap(); + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, from_account.owner, args.clone()) + .unwrap() + .consent_message, + ); + // When the expected allowance is not set, a warning should be displayed. + let expected_message = expected_approve_message.replace( + "\n\n**Current withdrawal allowance:**\n1'000'000 XTST", + "\u{26A0} The allowance will be set to 1'000'000 XTST independently of any previous allowance. Until this transaction has been executed the spender can still exercise the previous allowance (if any) to it's full amount.", +); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message ); - let args = ConsentMessageRequest { - method: "icrc2_approve".to_owned(), - arg: Encode!(&approve_args).unwrap(), - user_preferences: ConsentMessageSpec { - metadata: ConsentMessageMetadata { - language: "en".to_string(), - }, - device_spec: Some(DisplayMessageType::LineDisplay { - characters_per_line: 10, - lines_per_page: 3, - }), - }, - }; - let consent_info = icrc21_consent_message(&env, canister_id, caller.0, args).unwrap(); - check_consent_message( - Some(sender), - None, - Some(approve_args.spender), - approve_args.memo, - None, - Some(approve_args.amount.clone()), - Some(Nat::from(FEE)), - None, - Some(TOKEN_SYMBOL), - consent_info.consent_message, - None, - None, + args.arg = Encode!(&ApproveArgs { + expires_at: None, + ..approve_args.clone() + }) + .unwrap(); + + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, from_account.owner, args.clone()) + .unwrap() + .consent_message, + ); + let expected_message = + expected_approve_message.replace("Thu, 06 May 2021 20:17:10 +0000", "No expiration."); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message + ); + + // If the approver is anonymous, the message should not include the approver account but only the approver subaccount. + args.arg = Encode!(&approve_args.clone()).unwrap(); + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, Principal::anonymous(), args.clone()) + .unwrap() + .consent_message, + ); + let expected_message = expected_approve_message +.replace("\n\n**Transaction fees to be paid by:**\nd2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101","\n\n**Transaction fees to be paid by your subaccount:**\n101010101010101010101010101010101010101010101010101010101010101" ) +.replace("\n\n**Your account:**\nd2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101","\n\n**Your Subaccount:**\n101010101010101010101010101010101010101010101010101010101010101"); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message + ); + + // If we set the offset to 1 hour the expiration date should be 1 hour ahead. + args.user_preferences.metadata.utc_offset_minutes = Some(60); + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, from_account.owner, args.clone()) + .unwrap() + .consent_message, + ); + let expected_message = expected_approve_message.replace( + "Thu, 06 May 2021 20:17:10 +0000", + "Thu, 06 May 2021 21:17:10 +0100", + ); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message ); +} +fn test_icrc21_transfer_from_message( + env: &StateMachine, + canister_id: CanisterId, + from_account: Account, + spender_account: Account, + receiver_account: Account, +) { + // Test the message for icrc2 transfer_from let transfer_from_args = TransferFromArgs { - from: sender, - to: receiver, + from: from_account, + spender_subaccount: spender_account.subaccount, + to: receiver_account, amount: Nat::from(1_000_000u32), - spender_subaccount: spender.subaccount, - created_at_time: Some(now), - fee: Some(Nat::from(10u32)), + fee: None, + created_at_time: None, memo: Some(Memo::from(b"test_bytes".to_vec())), }; - let args = ConsentMessageRequest { + + let mut args = ConsentMessageRequest { method: "icrc2_transfer_from".to_owned(), - arg: Encode!(&transfer_from_args.clone()).unwrap(), + arg: Encode!(&transfer_from_args).unwrap(), user_preferences: ConsentMessageSpec { metadata: ConsentMessageMetadata { language: "en".to_string(), + utc_offset_minutes: None, }, device_spec: Some(DisplayMessageType::GenericDisplay), }, }; - let consent_info = icrc21_consent_message(&env, canister_id, spender.owner, args).unwrap(); - check_consent_message( - Some(transfer_from_args.from), - Some(transfer_from_args.to), - Some(spender), - transfer_from_args.memo.clone(), - transfer_from_args.created_at_time, - Some(transfer_from_args.amount.clone()), - Some(Nat::from(FEE)), - transfer_from_args.fee.clone(), - Some(TOKEN_SYMBOL), - consent_info.consent_message, - None, - None, + + let expected_transfer_from_message = "# Transfer from a withdrawal account + +**Withdrawal Account:** +d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 + +**Account sending the transfer request:** +djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303 + +**Amount to withdraw:** +1'000'000 XTST + +**To:** +6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202 + +**Fee paid by withdrawal account:** +10'000 XTST + +**Memo:** +test_bytes"; + + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, spender_account.owner, args.clone()) + .unwrap() + .consent_message, + ); + assert_eq!( + message, expected_transfer_from_message, + "Expected: {}, got: {}", + expected_transfer_from_message, message ); - let args = ConsentMessageRequest { - method: "icrc2_transfer_from".to_owned(), - arg: Encode!(&transfer_from_args).unwrap(), - user_preferences: ConsentMessageSpec { - metadata: ConsentMessageMetadata { - language: "en".to_string(), - }, - device_spec: Some(DisplayMessageType::LineDisplay { - characters_per_line: 10, - lines_per_page: 3, - }), - }, + // If the spender is anonymous, the message should not include the spender account but only the spender subaccount. + args.arg = Encode!(&transfer_from_args.clone()).unwrap(); + let message = extract_icrc21_message_string( + &icrc21_consent_message(env, canister_id, Principal::anonymous(), args.clone()) + .unwrap() + .consent_message, + ); + let expected_message = expected_transfer_from_message.replace( + "\n\n**Account sending the transfer request:**\ndjduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303", + "\n\n**Subaccount sending the transfer request:**\n303030303030303030303030303030303030303030303030303030303030303", +); + assert_eq!( + message, expected_message, + "Expected: {}, got: {}", + expected_message, message + ); +} + +fn extract_icrc21_message_string(consent_message: &ConsentMessage) -> String { + match consent_message { + ConsentMessage::GenericDisplayMessage(message) => message.to_string(), + ConsentMessage::LineDisplayMessage { pages } => pages + .iter() + .map(|page| page.lines.join("")) + .collect::>() + .join(""), + } +} + +pub fn test_icrc21_standard(ledger_wasm: Vec, encode_init_args: fn(InitArgs) -> T) +where + T: CandidType, +{ + let (env, canister_id) = setup(ledger_wasm, encode_init_args, vec![]); + let receiver_account = Account { + owner: PrincipalId::new_user_test_id(1).0, + subaccount: Some([2; 32]), }; - let consent_info = icrc21_consent_message(&env, canister_id, spender.owner, args).unwrap(); - check_consent_message( - Some(transfer_from_args.from), - Some(transfer_from_args.to), - Some(spender), - transfer_from_args.memo, - None, - Some(transfer_from_args.amount.clone()), - Some(Nat::from(FEE)), - None, - Some(TOKEN_SYMBOL), - consent_info.consent_message, - None, - None, + let from_account = Account { + owner: PrincipalId::new_user_test_id(0).0, + subaccount: Some([1; 32]), + }; + let spender_account = Account { + owner: PrincipalId::new_user_test_id(2).0, + subaccount: Some([3; 32]), + }; + + test_icrc21_transfer_message(&env, canister_id, from_account, receiver_account); + test_icrc21_approve_message(&env, canister_id, from_account, spender_account); + test_icrc21_transfer_from_message( + &env, + canister_id, + from_account, + spender_account, + receiver_account, ); }