diff --git a/packages/icrc-ledger-types/src/icrc21/lib.rs b/packages/icrc-ledger-types/src/icrc21/lib.rs index d656b8398428..b5bca185146e 100644 --- a/packages/icrc-ledger-types/src/icrc21/lib.rs +++ b/packages/icrc-ledger-types/src/icrc21/lib.rs @@ -1,7 +1,7 @@ use super::errors::ErrorInfo; use super::requests::ConsentMessageRequest; +use super::requests::DisplayMessageType; use super::responses::{ConsentInfo, ConsentMessage}; -use super::{requests::DisplayMessageType, responses::LineDisplayPage}; use crate::icrc1::account::Account; use crate::icrc1::transfer::TransferArg; use crate::icrc2::approve::ApproveArgs; @@ -10,8 +10,6 @@ use crate::icrc21::errors::Icrc21Error; use crate::icrc21::requests::ConsentMessageMetadata; use candid::Decode; use candid::{Nat, Principal}; -use itertools::Itertools; -use num_traits::{Pow, ToPrimitive}; use serde_bytes::ByteBuf; use strum::{self, IntoEnumIterator}; use strum_macros::{Display, EnumIter, EnumString}; @@ -20,7 +18,7 @@ use strum_macros::{Display, EnumIter, EnumString}; pub const MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES: u16 = 500; #[derive(Debug, EnumString, EnumIter, Display)] -enum Icrc21Function { +pub enum Icrc21Function { #[strum(serialize = "icrc1_transfer")] Transfer, #[strum(serialize = "icrc2_approve")] @@ -38,6 +36,7 @@ pub struct ConsentMessageBuilder { receiver: Option, amount: Option, token_symbol: Option, + token_name: Option, ledger_fee: Option, memo: Option, expected_allowance: Option, @@ -63,6 +62,7 @@ impl ConsentMessageBuilder { receiver: None, amount: None, token_symbol: None, + token_name: None, ledger_fee: None, utc_offset_minutes: None, memo: None, @@ -102,6 +102,11 @@ impl ConsentMessageBuilder { self } + pub fn with_token_name(mut self, token_name: String) -> Self { + self.token_name = Some(token_name); + self + } + pub fn with_ledger_fee(mut self, ledger_fee: Nat) -> Self { self.ledger_fee = Some(ledger_fee); self @@ -133,24 +138,16 @@ impl ConsentMessageBuilder { } pub fn build(self) -> Result { - let mut message = "".to_string(); - let extract_subaccount = |account: Account| -> Result { - Ok(match account.subaccount { - None => hex::encode(account.effective_subaccount().as_slice()), - Some(_) => account - .to_string() - .split('.') - .next_back() - .ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Subaccount has an unexpected format.".to_owned(), - })? - .to_string(), - }) + let mut message = match self.display_type { + Some(DisplayMessageType::GenericDisplay) | None => { + ConsentMessage::GenericDisplayMessage(Default::default()) + } + Some(DisplayMessageType::FieldsDisplay) => { + ConsentMessage::FieldsDisplayMessage(Default::default()) + } }; 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: "From account has to be specified.".to_owned(), @@ -159,39 +156,30 @@ impl ConsentMessageBuilder { error_code: Nat::from(500u64), description: "Receiver account has to be specified.".to_owned(), })?; - let fee = convert_tokens_to_string_representation( - self.ledger_fee.ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Ledger fee must be specified.".to_owned(), - })?, - self.decimals, - )?; + 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 = convert_tokens_to_string_representation( - self.amount.ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Amount has to be specified.".to_owned(), - })?, - self.decimals, - )?; + let token_name = self.token_name.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Token Name must be specified.".to_owned(), + })?; - message.push_str(&format!("\n\n**Amount:**\n{} {}", amount, token_symbol)); - if from_account.owner == Principal::anonymous() { - message.push_str(&format!( - "\n\n**From subaccount:**\n{}", - extract_subaccount(from_account)? - )); - } else { - message.push_str(&format!("\n\n**From:**\n{}", from_account)); + message.add_intent(Icrc21Function::Transfer, Some(token_name)); + if from_account.owner != Principal::anonymous() { + message.add_account("From", &from_account); } - message.push_str(&format!("\n\n**To:**\n{}", receiver_account)); - message.push_str(&format!("\n\n**Fee:**\n{} {}", fee, token_symbol)); + message.add_amount(self.amount, self.decimals, &token_symbol)?; + message.add_account("To", &receiver_account); + message.add_fee( + Icrc21Function::Transfer, + self.ledger_fee, + self.decimals, + &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(), @@ -200,91 +188,36 @@ impl ConsentMessageBuilder { error_code: Nat::from(500u64), description: "Spender account has to be specified.".to_owned(), })?; - let fee = convert_tokens_to_string_representation( - self.ledger_fee.ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Ledger fee must be specified.".to_owned(), - })?, - self.decimals, - )?; 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 = convert_tokens_to_string_representation( - self.amount.ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Amount has to be specified.".to_owned(), - })?, + + message.add_intent(Icrc21Function::Approve, None); + if approver_account.owner != Principal::anonymous() { + message.add_account("From", &approver_account); + } + message.add_account("Approve to spender", &spender_account); + message.add_allowance(self.amount, self.decimals, &token_symbol)?; + if let Some(expected_allowance) = self.expected_allowance { + message.add_existing_allowance( + expected_allowance, + self.decimals, + &token_symbol, + )?; + } + message.add_expiration(self.expires_at, self.utc_offset_minutes); + message.add_fee( + Icrc21Function::Approve, + self.ledger_fee, self.decimals, + &token_symbol, )?; - 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\n**Your subaccount:**\n{}", - extract_subaccount(approver_account)? - )); - } else { - message.push_str(&format!("\n\n**Your account:**\n{}", approver_account)); - } - message.push_str(&format!( - "\n\n**Requested withdrawal allowance:**\n{} {}", - amount, token_symbol - )); - message.push_str(&match self.expected_allowance{ - Some(expected_allowance) => format!("\n\n**Current withdrawal allowance:**\n{} {}", convert_tokens_to_string_representation(expected_allowance,self.decimals)?,token_symbol), - None => format!("\n\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{}", - extract_subaccount(approver_account)? - )); - } else { - message.push_str(&format!( - "\n\n**Transaction fees to be paid by:**\n{}", - approver_account - )); + if approver_account.owner != Principal::anonymous() { + message.add_account("Fees paid by", &approver_account); } } 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(), @@ -297,130 +230,45 @@ impl ConsentMessageBuilder { error_code: Nat::from(500u64), description: "Spender account has to be specified.".to_owned(), })?; - let fee = convert_tokens_to_string_representation( - self.ledger_fee.ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Ledger fee must be specified.".to_owned(), - })?, - self.decimals, - )?; 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 = convert_tokens_to_string_representation( - self.amount.ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Amount has to be specified.".to_owned(), - })?, + let token_name = self.token_name.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Token Name must be specified.".to_owned(), + })?; + message.add_intent(Icrc21Function::TransferFrom, Some(token_name)); + message.add_account("From", &from_account); + message.add_amount(self.amount, self.decimals, &token_symbol)?; + if spender_account.owner != Principal::anonymous() { + message.add_account("Spender", &spender_account); + } + message.add_account("To", &receiver_account); + message.add_fee( + Icrc21Function::TransferFrom, + self.ledger_fee, self.decimals, + &token_symbol, )?; - - 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{}", - extract_subaccount(spender_account)? - )); - } 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()), - } - )); + message.add_memo(memo); } - match self.display_type { - Some(DisplayMessageType::GenericDisplay) | None => { - Ok(ConsentMessage::GenericDisplayMessage(message)) - } - Some(DisplayMessageType::LineDisplay { - lines_per_page, - characters_per_line, - }) => { - let pages = consent_msg_text_pages(&message, characters_per_line, lines_per_page); - Ok(ConsentMessage::LineDisplayMessage { pages }) - } - } + Ok(message) } } -/// This function was taken from the reference implementation: https://github.com/dfinity/wg-identity-authentication/blob/3ed140225b283c0a1cc88344d0cfb9912aec73cd/reference-implementations/ICRC-21/src/lib.rs#L73 -pub fn consent_msg_text_pages( - message: &str, - characters_per_line: u16, - lines_per_page: u16, -) -> Vec { - if characters_per_line == 0 || lines_per_page == 0 { - return vec![]; - } - - // Split text into word chunks that fit on a line (breaking long words) - let words = message.split_whitespace().flat_map(|word| { - word.chars() - .chunks(characters_per_line as usize) - .into_iter() - .map(|chunk| chunk.collect::()) - .collect::>() - }); - - // Add words to lines until the line is full - let mut lines = vec![]; - let mut current_line = "".to_string(); - for word in words { - if current_line.is_empty() { - // all words are guaranteed to fit on a line - current_line = word; - continue; - } - if current_line.len() + word.len() < characters_per_line as usize { - current_line.push(' '); - current_line.push_str(word.as_str()); - } else { - lines.push(current_line); - current_line = word; - } - } - lines.push(current_line); - - // Group lines into pages - lines - .into_iter() - .chunks(lines_per_page as usize) - .into_iter() - .map(|page| LineDisplayPage { - lines: page.collect(), - }) - .collect() -} - pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( consent_msg_request: ConsentMessageRequest, caller_principal: Principal, ledger_fee: Nat, token_symbol: String, + token_name: String, decimals: u8, ) -> Result { if consent_msg_request.arg.len() > MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES as usize { @@ -444,7 +292,8 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( let mut display_message_builder = ConsentMessageBuilder::new(&consent_msg_request.method, decimals)? .with_ledger_fee(ledger_fee) - .with_token_symbol(token_symbol); + .with_token_symbol(token_symbol) + .with_token_name(token_name); if let Some(offset) = consent_msg_request .user_preferences @@ -455,17 +304,6 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( } if let Some(display_type) = consent_msg_request.user_preferences.device_spec { - if let DisplayMessageType::LineDisplay { - lines_per_page, - characters_per_line, - } = display_type - { - if lines_per_page == 0 || characters_per_line == 0 { - return Err(Icrc21Error::UnsupportedCanisterCall(ErrorInfo { - description: "Invalid display type. Lines per page and characters per line must be greater than 0.".to_string() - })); - } - } display_message_builder = display_message_builder.with_display_type(display_type); } @@ -573,14 +411,3 @@ pub fn build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( consent_message, }) } - -fn convert_tokens_to_string_representation( - tokens: Nat, - decimals: u8, -) -> Result { - let tokens = tokens.0.to_f64().ok_or(Icrc21Error::GenericError { - error_code: Nat::from(500u64), - description: "Failed to convert tokens to u64".to_owned(), - })?; - Ok(format!("{}", tokens / 10_f64.pow(decimals))) -} diff --git a/packages/icrc-ledger-types/src/icrc21/requests.rs b/packages/icrc-ledger-types/src/icrc21/requests.rs index 0d4ded56749e..e95c27007f38 100644 --- a/packages/icrc-ledger-types/src/icrc21/requests.rs +++ b/packages/icrc-ledger-types/src/icrc21/requests.rs @@ -10,10 +10,7 @@ pub struct ConsentMessageMetadata { #[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum DisplayMessageType { GenericDisplay, - LineDisplay { - characters_per_line: u16, - lines_per_page: u16, - }, + FieldsDisplay, } #[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/packages/icrc-ledger-types/src/icrc21/responses.rs b/packages/icrc-ledger-types/src/icrc21/responses.rs index 6ffdc31777dd..fe5ceb38baef 100644 --- a/packages/icrc-ledger-types/src/icrc21/responses.rs +++ b/packages/icrc-ledger-types/src/icrc21/responses.rs @@ -1,16 +1,42 @@ +use crate::{ + icrc1::account::Account, + icrc21::{errors::Icrc21Error, lib::Icrc21Function}, +}; + use super::requests::ConsentMessageMetadata; -use candid::{CandidType, Deserialize}; +use candid::{CandidType, Deserialize, Nat}; +use num_traits::{Pow, ToPrimitive}; use serde::Serialize; +use serde_bytes::ByteBuf; -#[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct LineDisplayPage { - pub lines: Vec, +#[derive(CandidType, Deserialize, Eq, PartialEq, Debug, Serialize, Clone)] +pub enum Value { + TokenAmount { + decimals: u8, + amount: u64, + symbol: String, + }, + TimestampSeconds { + amount: u64, + }, + DurationSeconds { + amount: u64, + }, + Text { + content: String, + }, +} + +#[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct FieldsDisplay { + pub intent: String, + pub fields: Vec<(String, Value)>, } #[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ConsentMessage { GenericDisplayMessage(String), - LineDisplayMessage { pages: Vec }, + FieldsDisplayMessage(FieldsDisplay), } #[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -18,3 +44,276 @@ pub struct ConsentInfo { pub consent_message: ConsentMessage, pub metadata: ConsentMessageMetadata, } + +impl ConsentMessage { + pub fn add_intent(&mut self, intent: Icrc21Function, token_name: Option) { + match self { + ConsentMessage::GenericDisplayMessage(message) => match intent { + Icrc21Function::Transfer => { + assert!(token_name.is_some()); + message.push_str(&format!("# Send {}", token_name.unwrap())); + message + .push_str("\n\nYou are approving a transfer of funds from your account."); + } + Icrc21Function::Approve => { + message.push_str("# Approve spending"); + message.push_str( + "\n\nYou are authorizing another address to withdraw funds from your account.", + ); + } + Icrc21Function::TransferFrom => { + assert!(token_name.is_some()); + message.push_str(&format!("# Spend {}", token_name.unwrap())); + message.push_str( + "\n\nYou are approving a transfer of funds from a withdrawal account.", + ); + } + }, + ConsentMessage::FieldsDisplayMessage(fields_display) => match intent { + Icrc21Function::Transfer => { + assert!(token_name.is_some()); + fields_display.intent = format!("Send {}", token_name.unwrap()); + } + Icrc21Function::Approve => { + fields_display.intent = "Approve spending".to_string(); + } + Icrc21Function::TransferFrom => { + assert!(token_name.is_some()); + fields_display.intent = format!("Spend {}", token_name.unwrap()); + } + }, + } + } + + pub fn add_account(&mut self, name: &str, account: &Account) { + match self { + ConsentMessage::GenericDisplayMessage(message) => { + message.push_str(&format!("\n\n**{}:**\n`{}`", name, account)) + } + ConsentMessage::FieldsDisplayMessage(fields_display) => fields_display.fields.push(( + name.to_string(), + Value::Text { + content: account.to_string(), + }, + )), + } + } + + pub fn add_amount( + &mut self, + amount: Option, + decimals: u8, + token_symbol: &String, + ) -> Result<(), Icrc21Error> { + let amount = amount.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Amount has to be specified.".to_owned(), + })?; + match self { + ConsentMessage::GenericDisplayMessage(message) => { + let amount = convert_tokens_to_string_representation(amount, decimals)?; + message.push_str(&format!("\n\n**Amount:** `{} {}`", amount, token_symbol)); + } + ConsentMessage::FieldsDisplayMessage(fields_display) => fields_display.fields.push(( + "Amount".to_string(), + Value::TokenAmount { + decimals, + amount: nat_to_u64(amount)?, + symbol: token_symbol.to_string(), + }, + )), + } + Ok(()) + } + + pub fn add_fee( + &mut self, + intent: Icrc21Function, + amount: Option, + decimals: u8, + token_symbol: &String, + ) -> Result<(), Icrc21Error> { + let amount = amount.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Amount has to be specified.".to_owned(), + })?; + match self { + ConsentMessage::GenericDisplayMessage(message) => { + let fee = convert_tokens_to_string_representation(amount, decimals)?; + match intent { + Icrc21Function::Approve => message.push_str(&format!( + "\n\n**Approval fees:** `{} {}`\nCharged for processing the approval.", + fee, token_symbol + )), + Icrc21Function::Transfer | Icrc21Function::TransferFrom => { + message.push_str(&format!( + "\n\n**Fees:** `{} {}`\nCharged for processing the transfer.", + fee, token_symbol + )) + } + }; + } + ConsentMessage::FieldsDisplayMessage(fields_display) => { + let token_amount = Value::TokenAmount { + decimals, + amount: nat_to_u64(amount)?, + symbol: token_symbol.to_string(), + }; + match intent { + Icrc21Function::Approve => fields_display + .fields + .push(("Approval fees".to_string(), token_amount)), + Icrc21Function::Transfer | Icrc21Function::TransferFrom => fields_display + .fields + .push(("Fees".to_string(), token_amount)), + }; + } + } + Ok(()) + } + + pub fn add_allowance( + &mut self, + amount: Option, + decimals: u8, + token_symbol: &String, + ) -> Result<(), Icrc21Error> { + let amount = amount.ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Amount has to be specified.".to_owned(), + })?; + match self { + ConsentMessage::GenericDisplayMessage(message) => { + let amount = convert_tokens_to_string_representation(amount, decimals)?; + message.push_str(&format!( + "\n\n**Requested allowance:** `{} {}`\nThis is the withdrawal limit that will apply upon approval.", + amount, token_symbol + )); + } + ConsentMessage::FieldsDisplayMessage(fields_display) => fields_display.fields.push(( + "Requested allowance".to_string(), + Value::TokenAmount { + decimals, + amount: nat_to_u64(amount)?, + symbol: token_symbol.to_string(), + }, + )), + } + Ok(()) + } + + pub fn add_existing_allowance( + &mut self, + expected_allowance: Nat, + decimals: u8, + token_symbol: &String, + ) -> Result<(), Icrc21Error> { + match self { + ConsentMessage::GenericDisplayMessage(message) => { + let expected_allowance = + convert_tokens_to_string_representation(expected_allowance, decimals)?; + message.push_str(&format!("\n\n**Existing allowance:** `{} {}`\nUntil approval, this allowance remains in effect.", expected_allowance, token_symbol)); + } + ConsentMessage::FieldsDisplayMessage(fields_display) => fields_display.fields.push(( + "Existing allowance".to_string(), + Value::TokenAmount { + decimals, + amount: nat_to_u64(expected_allowance)?, + symbol: token_symbol.to_string(), + }, + )), + } + Ok(()) + } + + pub fn add_expiration(&mut self, expires_at: Option, utc_offset_minutes: Option) { + match self { + ConsentMessage::GenericDisplayMessage(message) => { + let expires_at = 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( + (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("This approval does not have an expiration.".to_owned()); + message.push_str(&format!("\n\n**Approval expiration:**\n{}", expires_at)); + } + ConsentMessage::FieldsDisplayMessage(fields_display) => { + match expires_at { + Some(expires_at) => { + let seconds = (expires_at as i64) / 10_i64.pow(9); + fields_display.fields.push(( + "Approval expiration".to_string(), + Value::TimestampSeconds { + amount: seconds as u64, + }, + )) + } + None => fields_display.fields.push(( + "Approval expiration".to_string(), + Value::Text { + content: "This approval does not have an expiration.".to_string(), + }, + )), + }; + } + } + } + + pub fn add_memo(&mut self, memo: ByteBuf) { + // Check if the memo is a valid UTF-8 string and display it as such if it is. + let memo_str = match std::str::from_utf8(memo.as_slice()) { + Ok(valid_str) => valid_str.to_string(), + Err(_) => hex::encode(memo.as_slice()), + }; + match self { + ConsentMessage::GenericDisplayMessage(message) => { + message.push_str(&format!("\n\n**Memo:**\n`{}`", memo_str)); + } + ConsentMessage::FieldsDisplayMessage(fields_display) => fields_display + .fields + .push(("Memo".to_string(), Value::Text { content: memo_str })), + } + } +} + +fn convert_tokens_to_string_representation( + tokens: Nat, + decimals: u8, +) -> Result { + let tokens = tokens.0.to_f64().ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Failed to convert tokens to u64".to_owned(), + })?; + Ok(format!("{}", tokens / 10_f64.pow(decimals))) +} + +fn nat_to_u64(tokens: Nat) -> Result { + tokens.0.to_u64().ok_or(Icrc21Error::GenericError { + error_code: Nat::from(500u64), + description: "Failed to convert tokens to u64".to_owned(), + }) +} diff --git a/rs/ledger_suite/icp/ledger.did b/rs/ledger_suite/icp/ledger.did index d57bf4dfcdb1..9693528a5f0e 100644 --- a/rs/ledger_suite/icp/ledger.did +++ b/rs/ledger_suite/icp/ledger.did @@ -431,10 +431,7 @@ type icrc21_consent_message_spec = record { metadata: icrc21_consent_message_metadata; device_spec: opt variant { GenericDisplay; - LineDisplay: record { - characters_per_line: nat16; - lines_per_page: nat16; - }; + FieldsDisplay; }; }; @@ -444,13 +441,31 @@ type icrc21_consent_message_request = record { user_preferences: icrc21_consent_message_spec; }; +type Icrc21Value = variant { + TokenAmount: record { + decimals: nat8; + amount: nat64; + symbol: text; + }; + TimestampSeconds: record { + amount: nat64; + }; + DurationSeconds: record { + amount: nat64; + }; + Text: record { + content: text; + }; +}; + +type FieldsDisplay = record { + intent: text; + fields: vec record { text; Icrc21Value }; +}; + type icrc21_consent_message = variant { GenericDisplayMessage: text; - LineDisplayMessage: record { - pages: vec record { - lines: vec text; - }; - }; + FieldsDisplayMessage: FieldsDisplay; }; type icrc21_consent_info = record { diff --git a/rs/ledger_suite/icp/ledger/src/main.rs b/rs/ledger_suite/icp/ledger/src/main.rs index bbffd00baa1f..0ff283a6ca77 100644 --- a/rs/ledger_suite/icp/ledger/src/main.rs +++ b/rs/ledger_suite/icp/ledger/src/main.rs @@ -1659,6 +1659,7 @@ fn icrc21_canister_call_consent_message( let caller_principal = caller(); let ledger_fee = Nat::from(LEDGER.read().unwrap().transfer_fee.get_e8s()); let token_symbol = LEDGER.read().unwrap().token_symbol.clone(); + let token_name = LEDGER.read().unwrap().token_name.clone(); let decimals = ic_ledger_core::tokens::DECIMAL_PLACES as u8; build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( @@ -1666,6 +1667,7 @@ fn icrc21_canister_call_consent_message( caller_principal, ledger_fee, token_symbol, + token_name, decimals, ) } diff --git a/rs/ledger_suite/icrc1/ledger/ledger.did b/rs/ledger_suite/icrc1/ledger/ledger.did index 7eaadf46a376..774d7f94a00e 100644 --- a/rs/ledger_suite/icrc1/ledger/ledger.did +++ b/rs/ledger_suite/icrc1/ledger/ledger.did @@ -432,10 +432,7 @@ type icrc21_consent_message_spec = record { metadata: icrc21_consent_message_metadata; device_spec: opt variant { GenericDisplay; - LineDisplay: record { - characters_per_line: nat16; - lines_per_page: nat16; - }; + FieldsDisplay; }; }; @@ -445,13 +442,31 @@ type icrc21_consent_message_request = record { user_preferences: icrc21_consent_message_spec; }; +type Icrc21Value = variant { + TokenAmount: record { + decimals: nat8; + amount: nat64; + symbol: text; + }; + TimestampSeconds: record { + amount: nat64; + }; + DurationSeconds: record { + amount: nat64; + }; + Text: record { + content: text; + }; +}; + +type FieldsDisplay = record { + intent: text; + fields: vec record { text; Icrc21Value }; +}; + type icrc21_consent_message = variant { GenericDisplayMessage: text; - LineDisplayMessage: record { - pages: vec record { - lines: vec text; - }; - }; + FieldsDisplayMessage: FieldsDisplay; }; type icrc21_consent_info = record { diff --git a/rs/ledger_suite/icrc1/ledger/src/main.rs b/rs/ledger_suite/icrc1/ledger/src/main.rs index 187e5f2a8204..e1852aeb8538 100644 --- a/rs/ledger_suite/icrc1/ledger/src/main.rs +++ b/rs/ledger_suite/icrc1/ledger/src/main.rs @@ -1437,6 +1437,7 @@ fn icrc21_canister_call_consent_message( let caller_principal = ic_cdk::api::caller(); let ledger_fee = icrc1_fee(); let token_symbol = icrc1_symbol(); + let token_name = icrc1_name(); let decimals = icrc1_decimals(); build_icrc21_consent_info_for_icrc1_and_icrc2_endpoints( @@ -1444,6 +1445,7 @@ fn icrc21_canister_call_consent_message( caller_principal, ledger_fee, token_symbol, + token_name, decimals, ) } diff --git a/rs/ledger_suite/tests/sm-tests/src/lib.rs b/rs/ledger_suite/tests/sm-tests/src/lib.rs index 5bd93c401d31..aaf61bbdea52 100644 --- a/rs/ledger_suite/tests/sm-tests/src/lib.rs +++ b/rs/ledger_suite/tests/sm-tests/src/lib.rs @@ -43,7 +43,9 @@ use icrc_ledger_types::icrc21::requests::ConsentMessageMetadata; use icrc_ledger_types::icrc21::requests::{ ConsentMessageRequest, ConsentMessageSpec, DisplayMessageType, }; -use icrc_ledger_types::icrc21::responses::{ConsentInfo, ConsentMessage}; +use icrc_ledger_types::icrc21::responses::{ + ConsentInfo, ConsentMessage, FieldsDisplay, Value as Icrc21Value, +}; use icrc_ledger_types::icrc3; use icrc_ledger_types::icrc3::archive::ArchiveInfo; use icrc_ledger_types::icrc3::blocks::{ @@ -4617,6 +4619,35 @@ pub fn test_icrc1_test_suite( } } +fn convert_to_fields_args(args: &ConsentMessageRequest) -> ConsentMessageRequest { + let mut fields_args = args.clone(); + fields_args.user_preferences.device_spec = Some(DisplayMessageType::FieldsDisplay); + fields_args +} + +fn modify_field( + fields_message: &FieldsDisplay, + field_name: String, + new_value: Option, +) -> FieldsDisplay { + let mut result = FieldsDisplay { + intent: fields_message.intent.clone(), + ..Default::default() + }; + for (f_name, f_value) in &fields_message.fields { + if *f_name == field_name { + if new_value.is_some() { + result + .fields + .push((f_name.to_string(), new_value.clone().unwrap().clone())); + } + } else { + result.fields.push((f_name.to_string(), f_value.clone())); + } + } + result +} + fn test_icrc21_transfer_message( env: &StateMachine, canister_id: CanisterId, @@ -4645,22 +4676,33 @@ fn test_icrc21_transfer_message( }, }; - let expected_transfer_message = "# Approve the transfer of funds + let expected_transfer_message = "# Send Test Token -**Amount:** -0.01 XTST +You are approving a transfer of funds from your account. **From:** -d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 +`d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101` + +**Amount:** `0.01 XTST` **To:** -6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202 +`6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202` -**Fee:** -0.0001 XTST +**Fees:** `0.0001 XTST` +Charged for processing the transfer. **Memo:** -test_bytes"; +`test_bytes`"; + + let expected_fields_message = FieldsDisplay { + intent: "Send Test Token".to_string(), + fields: vec![ + ("From".to_string(), Icrc21Value::Text{content: "d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101".to_string()}), + ("Amount".to_string(), Icrc21Value::TokenAmount {decimals: 8, amount: 1000000, symbol: "XTST".to_string()}), // "0.01 XTST".to_string()), + ("To".to_string(), Icrc21Value::Text{content: "6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202".to_string()}), + ("Fees".to_string(), Icrc21Value::TokenAmount {decimals: 8, amount: 10000, symbol: "XTST".to_string()}), + ("Memo".to_string(), Icrc21Value::Text{content: "test_bytes".to_string()})], + }; let consent_info = icrc21_consent_message(env, canister_id, from_account.owner, args.clone()).unwrap(); @@ -4675,6 +4717,20 @@ test_bytes"; "Expected: {}, got: {}", expected_transfer_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + assert_eq!( + fields_message, expected_fields_message, + "Expected: {:?}, got: {:?}", + expected_fields_message, fields_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"); @@ -4689,12 +4745,26 @@ test_bytes"; .unwrap() .consent_message, ); - let expected_message = expected_transfer_message.replace("\n\n**Memo:**\ntest_bytes", ""); + let expected_message = expected_transfer_message.replace("\n\n**Memo:**\n`test_bytes`", ""); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field(&expected_fields_message, "Memo".to_string(), None); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message + ); // If the memo is not a valid UTF string, it should be hex encoded. args.arg = Encode!(&TransferArg { @@ -4714,38 +4784,53 @@ test_bytes"; "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field( + &expected_fields_message, + "Memo".to_string(), + Some(Icrc21Value::Text { + content: hex::encode(vec![0, 159, 146, 150]), + }), + ); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message + ); - // If the from account is anonymous, the message should not include the from account but only the from subaccount. + // If the from account is anonymous, the message should not include the account information. 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" ); + let expected_message = expected_transfer_message.replace("\n\n**From:**\n`d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101`","" ); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); - - args.arg = Encode!(&TransferArg { - from_subaccount: None, - ..transfer_args.clone() - }) + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + Principal::anonymous(), + convert_to_fields_args(&args), + ) .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:**\n0000000000000000000000000000000000000000000000000000000000000000" ); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field(&expected_fields_message, "From".to_string(), None); assert_eq!( - message, expected_message, - "Expected: {}, got: {}", - expected_message, message + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message ); } @@ -4793,31 +4878,45 @@ fn test_icrc21_approve_message( 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 + let expected_approve_message = "# Approve spending -**The following address is allowed to withdraw from your account:** -djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303 +You are authorizing another address to withdraw funds from your account. -**Your account:** -d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 +**From:** +`d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101` + +**Approve to spender:** +`djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303` -**Requested withdrawal allowance:** -0.01 XTST +**Requested allowance:** `0.01 XTST` +This is the withdrawal limit that will apply upon approval. -**Current withdrawal allowance:** -0.01 XTST +**Existing allowance:** `0.01 XTST` +Until approval, this allowance remains in effect. -**Expiration date:** +**Approval expiration:** Thu, 06 May 2021 20:17:10 +0000 -**Approval fee:** -0.0001 XTST +**Approval fees:** `0.0001 XTST` +Charged for processing the approval. -**Transaction fees to be paid by:** -d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 +**Fees paid by:** +`d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101` **Memo:** -test_bytes"; +`test_bytes`"; + + let expected_fields_message = FieldsDisplay { + intent: "Approve spending".to_string(), + fields: vec![ + ("From".to_string(), Icrc21Value::Text{content: "d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101".to_string()}), + ("Approve to spender".to_string(), Icrc21Value::Text{content: "djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303".to_string()}), + ("Requested allowance".to_string(), Icrc21Value::TokenAmount {decimals: 8, amount: 1000000, symbol: "XTST".to_string()}), + ("Existing allowance".to_string(), Icrc21Value::TokenAmount {decimals: 8, amount: 1000000, symbol: "XTST".to_string()}), + ("Approval expiration".to_string(), Icrc21Value::TimestampSeconds { amount: 1620332230 }), + ("Approval fees".to_string(), Icrc21Value::TokenAmount {decimals: 8, amount: 10000, symbol: "XTST".to_string()}), + ("Fees paid by".to_string(), Icrc21Value::Text{content: "d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101".to_string()}), + ("Memo".to_string(), Icrc21Value::Text{content: "test_bytes".to_string()})]}; let mut args = ConsentMessageRequest { method: "icrc2_approve".to_owned(), @@ -4840,6 +4939,20 @@ test_bytes"; "Expected: {}, got: {}", expected_approve_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + assert_eq!( + fields_message, expected_fields_message, + "Expected: {:?}, got: {:?}", + expected_fields_message, fields_message + ); + args.arg = Encode!(&ApproveArgs { expected_allowance: None, ..approve_args.clone() @@ -4850,17 +4963,34 @@ test_bytes"; .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:**\n0.01 XTST", - "\n\u{26A0} The allowance will be set to 0.01 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.", - ); + // When the expected allowance is not set, it should be skipped. + let expected_message = + expected_approve_message.replace("\n\n**Existing allowance:** `0.01 XTST`\nUntil approval, this allowance remains in effect.", ""); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field( + &expected_fields_message, + "Existing allowance".to_string(), + None, + ); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message + ); + // Test approval without an expiration. args.arg = Encode!(&ApproveArgs { expires_at: None, ..approve_args.clone() @@ -4872,15 +5002,37 @@ test_bytes"; .unwrap() .consent_message, ); - let expected_message = - expected_approve_message.replace("Thu, 06 May 2021 20:17:10 +0000", "No expiration."); + let expected_message = expected_approve_message.replace( + "Thu, 06 May 2021 20:17:10 +0000", + "This approval does not have an expiration.", + ); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field( + &expected_fields_message, + "Approval expiration".to_string(), + Some(Icrc21Value::Text { + content: "This approval does not have an expiration.".to_string(), + }), + ); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message + ); - // If the approver is anonymous, the message should not include the approver account but only the approver subaccount. + // If the approver is anonymous, the message should not include the approver information. args.arg = Encode!(&approve_args.clone()).unwrap(); let message = extract_icrc21_message_string( &icrc21_consent_message(env, canister_id, Principal::anonymous(), args.clone()) @@ -4888,13 +5040,29 @@ test_bytes"; .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"); + .replace("\n\n**Fees paid by:**\n`d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101`","" ) + .replace("\n\n**From:**\n`d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101`",""); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + Principal::anonymous(), + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field(&expected_fields_message, "From".to_string(), None); + let new_exp_fields_message = + modify_field(&new_exp_fields_message, "Fees paid by".to_string(), None); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_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); @@ -4912,27 +5080,54 @@ test_bytes"; "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + assert_eq!( + fields_message, expected_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message + ); + args.user_preferences.metadata.utc_offset_minutes = None; + // If memo is not specified it should not be included. args.arg = Encode!(&ApproveArgs { - from_subaccount: None, + memo: None, ..approve_args.clone() }) .unwrap(); - args.user_preferences.metadata.utc_offset_minutes = None; let message = extract_icrc21_message_string( - &icrc21_consent_message(env, canister_id, Principal::anonymous(), args.clone()) + &icrc21_consent_message(env, canister_id, from_account.owner, args.clone()) .unwrap() .consent_message, ); - let expected_message = expected_approve_message.replace("\n\n**Your account:**\nd2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101","\n\n**Your subaccount:**\n0000000000000000000000000000000000000000000000000000000000000000" ) - .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:**\n0000000000000000000000000000000000000000000000000000000000000000" ); + let expected_message = expected_approve_message.replace("\n\n**Memo:**\n`test_bytes`", ""); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + from_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field(&expected_fields_message, "Memo".to_string(), None); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message + ); } fn test_icrc21_transfer_from_message( @@ -4965,25 +5160,36 @@ fn test_icrc21_transfer_from_message( }, }; - let expected_transfer_from_message = "# Transfer from a withdrawal account + let expected_transfer_from_message = "# Spend Test Token -**Withdrawal account:** -d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101 +You are approving a transfer of funds from a withdrawal account. -**Account sending the transfer request:** -djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303 +**From:** +`d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101` + +**Amount:** `0.01 XTST` -**Amount to withdraw:** -0.01 XTST +**Spender:** +`djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303` **To:** -6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202 +`6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202` -**Fee paid by withdrawal account:** -0.0001 XTST +**Fees:** `0.0001 XTST` +Charged for processing the transfer. **Memo:** -test_bytes"; +`test_bytes`"; + + let expected_fields_message = FieldsDisplay { + intent: "Spend Test Token".to_string(), + fields: vec![ + ("From".to_string(), Icrc21Value::Text{content: "d2zjj-uyaaa-aaaaa-aaaap-4ai-qmfzyha.101010101010101010101010101010101010101010101010101010101010101".to_string()}), + ("Amount".to_string(), Icrc21Value::TokenAmount {decimals: 8, amount: 1000000, symbol: "XTST".to_string()}), + ("Spender".to_string(), Icrc21Value::Text{content: "djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303".to_string()}), + ("To".to_string(), Icrc21Value::Text{content: "6fyp7-3ibaa-aaaaa-aaaap-4ai-v57emui.202020202020202020202020202020202020202020202020202020202020202".to_string()}), + ("Fees".to_string(), Icrc21Value::TokenAmount {decimals: 8, amount: 10000, symbol: "XTST".to_string()}), + ("Memo".to_string(), Icrc21Value::Text{content: "test_bytes".to_string()})]}; let message = extract_icrc21_message_string( &icrc21_consent_message(env, canister_id, spender_account.owner, args.clone()) @@ -4995,8 +5201,21 @@ test_bytes"; "Expected: {}, got: {}", expected_transfer_from_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + spender_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + assert_eq!( + fields_message, expected_fields_message, + "Expected: {:?}, got: {:?}", + expected_fields_message, fields_message + ); - // If the spender is anonymous, the message should not include the spender account but only the spender subaccount. + // If the spender is anonymous, the message should not include the spender account information. args.arg = Encode!(&transfer_from_args.clone()).unwrap(); let message = extract_icrc21_message_string( &icrc21_consent_message(env, canister_id, Principal::anonymous(), args.clone()) @@ -5004,45 +5223,77 @@ test_bytes"; .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", + "\n\n**Spender:**\n`djduj-3qcaa-aaaaa-aaaap-4ai-5r7aoqy.303030303030303030303030303030303030303030303030303030303030303`", + "", ); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + Principal::anonymous(), + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = + modify_field(&expected_fields_message, "Spender".to_string(), None); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_message + ); + // If memo is not specified it should not be included. args.arg = Encode!(&TransferFromArgs { - spender_subaccount: None, + memo: None, ..transfer_from_args.clone() }) .unwrap(); let message = extract_icrc21_message_string( - &icrc21_consent_message(env, canister_id, Principal::anonymous(), args.clone()) + &icrc21_consent_message(env, canister_id, spender_account.owner, 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:**\n0000000000000000000000000000000000000000000000000000000000000000" ); + let expected_message = + expected_transfer_from_message.replace("\n\n**Memo:**\n`test_bytes`", ""); assert_eq!( message, expected_message, "Expected: {}, got: {}", expected_message, message ); + let fields_consent_info = icrc21_consent_message( + env, + canister_id, + spender_account.owner, + convert_to_fields_args(&args), + ) + .unwrap(); + let fields_message = extract_icrc21_fields_message(&fields_consent_info.consent_message); + let new_exp_fields_message = modify_field(&expected_fields_message, "Memo".to_string(), None); + assert_eq!( + fields_message, new_exp_fields_message, + "Expected: {:?}, got: {:?}", + new_exp_fields_message, fields_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(""), + ConsentMessage::FieldsDisplayMessage(_) => panic!("cannot convert to string"), + } +} + +fn extract_icrc21_fields_message(consent_message: &ConsentMessage) -> FieldsDisplay { + match consent_message { + ConsentMessage::GenericDisplayMessage(_) => panic!("should not be a string"), + ConsentMessage::FieldsDisplayMessage(message) => message.clone(), } }