Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
1 change: 1 addition & 0 deletions rs/dogecoin/ckdoge/minter/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ rust_test(
# Keep sorted.
":ckdoge_minter_lib",
"//packages/ic-http-types",
"//packages/icrc-ledger-types:icrc_ledger_types_storable",
Comment thread
Dfinity-Bjoern marked this conversation as resolved.
"//rs/bitcoin/ckbtc/minter:ckbtc_minter_lib",
"@crate_index//:candid",
"@crate_index//:ic-cdk",
Expand Down
71 changes: 71 additions & 0 deletions rs/dogecoin/ckdoge/minter/ckdoge_minter.did
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,69 @@ type InvalidTransactionError = variant {
};
};

// ICRC-10 supported standards record.
type Icrc10StandardRecord = record {
name : text;
url : text;
};

// ICRC-21 Canister Call Consent Message types.
type Icrc21ConsentMessageMetadata = record {
language : text;
utc_offset_minutes : opt int16;
};

type Icrc21DeviceSpec = variant {
GenericDisplay;
FieldsDisplay;
};

type Icrc21ConsentMessageSpec = record {
metadata : Icrc21ConsentMessageMetadata;
device_spec : opt Icrc21DeviceSpec;
};

type Icrc21ConsentMessageRequest = record {
method : text;
arg : blob;
user_preferences : Icrc21ConsentMessageSpec;
};

type Icrc21Value = variant {
TokenAmount : record {
decimals : nat8;
amount : nat64;
symbol : text;
};
TimestampSeconds : record { amount : nat64 };
DurationSeconds : record { amount : nat64 };
Text : record { content : text };
};

type Icrc21FieldsDisplay = record {
intent : text;
fields : vec record { text; Icrc21Value };
};

type Icrc21ConsentMessage = variant {
GenericDisplayMessage : text;
FieldsDisplayMessage : Icrc21FieldsDisplay;
};

type Icrc21ConsentInfo = record {
consent_message : Icrc21ConsentMessage;
metadata : Icrc21ConsentMessageMetadata;
};

type Icrc21ErrorInfo = record { description : text };

type Icrc21Error = variant {
UnsupportedCanisterCall : Icrc21ErrorInfo;
ConsentMessageUnavailable : Icrc21ErrorInfo;
InsufficientPayment : Icrc21ErrorInfo;
GenericError : record { error_code : nat; description : text };
};

type EventType = variant {
init : InitArgs;
upgrade : UpgradeArgs;
Expand Down Expand Up @@ -507,4 +570,12 @@ service : (minter_arg : MinterArg) -> {
//
// NOTE: this method exists for debugging purposes and backwards-compatibility is **not** guaranteed.
get_events : (record { start: nat64; length : nat64 }) -> (vec Event) query;

// Returns the list of supported ICRC standards.
icrc10_supported_standards : () -> (vec Icrc10StandardRecord) query;

// Returns a human-readable consent message describing the requested
// canister call. See the ICRC-21 standard for details:
// https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md
icrc21_canister_call_consent_message : (Icrc21ConsentMessageRequest) -> (variant { Ok : Icrc21ConsentInfo; Err : Icrc21Error });
Comment thread
Dfinity-Bjoern marked this conversation as resolved.
Outdated
}
15 changes: 15 additions & 0 deletions rs/dogecoin/ckdoge/minter/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ use ic_ckdoge_minter::{
updates,
};
use ic_http_types::{HttpRequest, HttpResponse};
use icrc_ledger_types::icrc21::errors::Icrc21Error;
use icrc_ledger_types::icrc21::requests::ConsentMessageRequest;
use icrc_ledger_types::icrc21::responses::ConsentInfo;

#[init]
fn init(args: MinterArg) {
Expand Down Expand Up @@ -230,6 +233,18 @@ fn get_events(args: GetEventsArg) -> Vec<CkDogeMinterEvent> {
.collect()
}

#[update]
fn icrc21_canister_call_consent_message(
consent_msg_request: ConsentMessageRequest,
) -> Result<ConsentInfo, Icrc21Error> {
updates::icrc21::icrc21_canister_call_consent_message(consent_msg_request)
}

#[query]
fn icrc10_supported_standards() -> Vec<updates::icrc21::StandardRecord> {
updates::icrc21::icrc10_supported_standards()
}

#[query(hidden = true)]
fn http_request(req: HttpRequest) -> HttpResponse {
if ic_cdk::api::in_replicated_execution() {
Expand Down
223 changes: 223 additions & 0 deletions rs/dogecoin/ckdoge/minter/src/updates/icrc21.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
//! Implementation of the [ICRC-21](https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md)
//! Canister Call Consent Message standard for the ckDOGE minter.

use crate::address::DogecoinAddress;
use crate::candid_api::RetrieveDogeWithApprovalArgs;
use crate::lifecycle::init::Network;
use candid::{CandidType, Decode, Deserialize};
use ic_ckbtc_minter::state::read_state;
use icrc_ledger_types::icrc21::errors::{ErrorInfo, Icrc21Error};
use icrc_ledger_types::icrc21::lib::MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES;
use icrc_ledger_types::icrc21::requests::{
ConsentMessageMetadata, ConsentMessageRequest, DisplayMessageType,
};
use icrc_ledger_types::icrc21::responses::{ConsentInfo, ConsentMessage, FieldsDisplay, Value};

/// The number of decimals used to display token amounts.
/// Both ckDOGE and DOGE use 8 decimals (1 DOGE = 10^8 koinus).
pub(super) const DECIMALS: u8 = 8;

/// Token symbols used in consent messages. They depend on the configured
/// Dogecoin network so that test deployments use the test-token names.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub(super) struct TokenSymbols {
/// The ledger token, e.g. "ckDOGE" on mainnet, "ckTESTDOGE" otherwise.
pub(super) ckdoge: &'static str,
/// The native Dogecoin token, e.g. "DOGE" on mainnet, "TESTDOGE" otherwise.
pub(super) doge: &'static str,
}

impl TokenSymbols {
pub(super) fn for_network(network: Network) -> Self {
match network {
Network::Mainnet => Self {
ckdoge: "ckDOGE",
doge: "DOGE",
},
Network::Regtest => Self {
ckdoge: "ckTESTDOGE",
doge: "TESTDOGE",
},
}
}
}

/// An entry of the ICRC-10 supported standards list.
#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)]
pub struct StandardRecord {
pub name: String,
pub url: String,
}

pub fn icrc10_supported_standards() -> Vec<StandardRecord> {
vec![
StandardRecord {
name: "ICRC-10".to_string(),
url: "https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-10/ICRC-10.md".to_string(),
},
StandardRecord {
name: "ICRC-21".to_string(),
url: "https://github.com/dfinity/ICRC/blob/main/ICRCs/ICRC-21/ICRC-21.md".to_string(),
},
]
}

pub fn icrc21_canister_call_consent_message(
consent_msg_request: ConsentMessageRequest,
) -> Result<ConsentInfo, Icrc21Error> {
let network =
read_state(|s| Network::try_from(s.btc_network).unwrap_or_else(|err| ic_cdk::trap(err)));
build_consent_info(consent_msg_request, network)
}

pub(super) fn build_consent_info(
consent_msg_request: ConsentMessageRequest,
network: Network,
) -> Result<ConsentInfo, Icrc21Error> {
if consent_msg_request.arg.len() > MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES as usize {
return Err(Icrc21Error::UnsupportedCanisterCall(ErrorInfo {
description: format!(
"The argument size is too large. The maximum allowed size is \
{MAX_CONSENT_MESSAGE_ARG_SIZE_BYTES} bytes."
),
}));
}

let display_type = consent_msg_request
.user_preferences
.device_spec
.clone()
.unwrap_or(DisplayMessageType::GenericDisplay);

let symbols = TokenSymbols::for_network(network);

let consent_message = match consent_msg_request.method.as_str() {
"retrieve_doge_with_approval" => {
let args = Decode!(
consent_msg_request.arg.as_slice(),
RetrieveDogeWithApprovalArgs
)
.map_err(|e| {
Icrc21Error::UnsupportedCanisterCall(ErrorInfo {
description: format!("Failed to decode RetrieveDogeWithApprovalArgs: {e}"),
})
})?;
validate_address(&args.address, network)?;
build_retrieve_doge_with_approval_message(&args, &display_type, symbols)
}
method => {
return Err(Icrc21Error::UnsupportedCanisterCall(ErrorInfo {
description: format!(
"The method '{method}' is not supported by the ckDOGE minter ICRC-21 endpoint."
),
}));
}
};

// Respond in English regardless of what the client requested for now.
let metadata = ConsentMessageMetadata {
language: "en".to_string(),
utc_offset_minutes: consent_msg_request
.user_preferences
.metadata
.utc_offset_minutes,
};

Ok(ConsentInfo {
metadata,
consent_message,
})
}

fn build_retrieve_doge_with_approval_message(
args: &RetrieveDogeWithApprovalArgs,
display_type: &DisplayMessageType,
symbols: TokenSymbols,
) -> ConsentMessage {
let TokenSymbols { ckdoge, doge } = symbols;
let amount = format_amount(args.amount, DECIMALS);
match display_type {
DisplayMessageType::GenericDisplay => {
let mut message = format!(
"# Convert {ckdoge} to {doge}\n\n\
Authorize the {ckdoge} minter to burn {ckdoge} from your account and \
send the equivalent amount in {doge} (minus network and minter fees) to \
the Dogecoin address below.\n\n\
**Amount to convert:** `{amount} {ckdoge}`\n\n\
**Dogecoin destination address:**\n`{address}`",
address = args.address,
);
if let Some(subaccount) = args.from_subaccount {
message.push_str(&format!(
"\n\n**{ckdoge} source subaccount:**\n`{}`",
hex::encode(subaccount)
));
}
ConsentMessage::GenericDisplayMessage(message)
}
DisplayMessageType::FieldsDisplay => {
// Long values (Dogecoin addresses, subaccount hex) are sent as a
// single `Value::Text` per the ICRC-21 spec — wallets are
// responsible for paginating them across screens. See e.g. the
// Ledger ICP app, which calls `handle_ui_message` to chunk the
// value into device-sized pages.
let mut fields = vec![
(
"Amount".to_string(),
Value::TokenAmount {
decimals: DECIMALS,
amount: args.amount,
symbol: ckdoge.to_string(),
},
),
(
format!("{doge} address"),
Value::Text {
content: args.address.clone(),
},
),
];
if let Some(subaccount) = args.from_subaccount {
fields.push((
"From subaccount".to_string(),
Value::Text {
content: hex::encode(subaccount),
},
));
}
ConsentMessage::FieldsDisplayMessage(FieldsDisplay {
intent: format!("{ckdoge} to {doge}"),
fields,
})
}
}
}

/// Verifies that `address` parses as a valid Dogecoin address on the configured
/// network before it gets interpolated into a consent message. This both
/// guarantees the user is shown a meaningful (parseable) destination and rules
/// out Markdown-injection vectors in the GenericDisplay output (e.g. an
/// "address" that contains newlines or backticks crafted to fake additional
/// fields). Uses the same parser as `retrieve_doge_with_approval`, so any
/// address the consent endpoint accepts is also accepted by the actual call.
fn validate_address(address: &str, network: Network) -> Result<(), Icrc21Error> {
DogecoinAddress::parse(address, &network).map_err(|e| {
Icrc21Error::UnsupportedCanisterCall(ErrorInfo {
description: format!("Invalid Dogecoin destination address: {e}"),
})
})?;
Ok(())
}

pub(super) fn format_amount(amount: u64, decimals: u8) -> String {
let divisor = 10_u64.pow(decimals as u32);
let whole = amount / divisor;
let frac = amount % divisor;
if frac == 0 {
format!("{whole}")
} else {
let frac_str = format!("{frac:0width$}", width = decimals as usize);
let trimmed = frac_str.trim_end_matches('0');
format!("{whole}.{trimmed}")
}
}
1 change: 1 addition & 0 deletions rs/dogecoin/ckdoge/minter/src/updates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
mod tests;

pub mod get_doge_address;
pub mod icrc21;

pub use get_doge_address::{
account_to_p2pkh_address, account_to_p2pkh_address_from_state, get_doge_address,
Expand Down
Loading
Loading