Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b3cdc0b
fix generic message
maciejdfinity Jun 16, 2025
6094164
clippy
maciejdfinity Jun 17, 2025
cad65b7
quote memo
maciejdfinity Jun 17, 2025
779b70f
fix subaccount
maciejdfinity Jun 17, 2025
478433c
approve
maciejdfinity Jun 17, 2025
a459b31
add comment
maciejdfinity Jun 17, 2025
0e400c3
fix no expiration msg
maciejdfinity Jun 17, 2025
9c8ec95
test memo not included
maciejdfinity Jun 17, 2025
6247efa
add comments
maciejdfinity Jun 17, 2025
179d2cd
transfer from
maciejdfinity Jun 17, 2025
f4028a3
add comment
maciejdfinity Jun 17, 2025
5efab68
check no memo
maciejdfinity Jun 17, 2025
4083dbb
Merge branch 'master' into maciej-hw
maciejdfinity Jun 17, 2025
94c8ada
remove line display, add generation to the type, remove account alway…
maciejdfinity Jun 18, 2025
1e8f229
put token symbol back
maciejdfinity Jun 18, 2025
52819a8
refactor
maciejdfinity Jun 18, 2025
3a05d01
add fields display
maciejdfinity Jun 18, 2025
c756f4f
fix icp ledger did file
maciejdfinity Jun 18, 2025
42688c5
fix icrc ledger did file
maciejdfinity Jun 18, 2025
080771e
add fields display test
maciejdfinity Jun 19, 2025
c0249f3
clippy
maciejdfinity Jun 19, 2025
ce83d74
Merge branch 'master' into maciej-hw
maciejdfinity Jun 19, 2025
da6d2e3
clippy
maciejdfinity Jun 19, 2025
f320988
Merge branch 'master' into maciej-hw
maciejdfinity Jun 19, 2025
c1b506c
use token name in the title
maciejdfinity Jun 23, 2025
6ebd49a
fix token name in icp
maciejdfinity Jun 23, 2025
eaa0b08
list intents explicitly
maciejdfinity Jun 23, 2025
5cdf205
use Icrc21Function instead of Intent
maciejdfinity Jun 23, 2025
55eaaf6
clippy
maciejdfinity Jun 23, 2025
36737d8
Merge branch 'master' into maciej-hw
maciejdfinity Jul 1, 2025
8a65b67
Merge branch 'master' into maciej-hw
maciejdfinity Jul 9, 2025
fbafa85
Merge branch 'master' into maciej-hw
maciejdfinity Jul 9, 2025
06adb63
Merge branch 'master' into maciej-hw
maciejdfinity Jul 15, 2025
6214015
OMerge branch 'master' into maciej-hw
maciejdfinity Jul 21, 2025
eff5050
use new value type for fields display
Aug 5, 2025
d2d33d2
fix did files
Aug 5, 2025
c2905b0
Merge branch 'master' into maciej-hw
Aug 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 73 additions & 246 deletions packages/icrc-ledger-types/src/icrc21/lib.rs

Large diffs are not rendered by default.

5 changes: 1 addition & 4 deletions packages/icrc-ledger-types/src/icrc21/requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
309 changes: 304 additions & 5 deletions packages/icrc-ledger-types/src/icrc21/responses.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,319 @@
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<String>,
#[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<LineDisplayPage> },
FieldsDisplayMessage(FieldsDisplay),
}

#[derive(Debug, CandidType, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConsentInfo {
pub consent_message: ConsentMessage,
pub metadata: ConsentMessageMetadata,
}

impl ConsentMessage {
pub fn add_intent(&mut self, intent: Icrc21Function, token_name: Option<String>) {
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<Nat>,
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<Nat>,
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<Nat>,
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<u64>, utc_offset_minutes: Option<i16>) {
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<String, Icrc21Error> {
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<u64, Icrc21Error> {
tokens.0.to_u64().ok_or(Icrc21Error::GenericError {
error_code: Nat::from(500u64),
description: "Failed to convert tokens to u64".to_owned(),
})
}
33 changes: 24 additions & 9 deletions rs/ledger_suite/icp/ledger.did
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};

Expand All @@ -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 {
Expand Down
Loading
Loading