Skip to content
Open
Show file tree
Hide file tree
Changes from all 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",
"//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 });
}
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