diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff3bfe35c..d1f7b69bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ # UNRELEASED +### feat: `dfx cycles redeem-faucet-coupon` + +It is now possible to redeem faucet coupons to cycles ledger accounts. + ### fix(deps): content of wasm_hash_url can have extra fields than the hash It is natural to point `wasm_hash_url` to the `.sha256` file generated by `shasum` or `sha256sum` which consists of the hash and the file name. diff --git a/e2e/assets/faucet/main.mo b/e2e/assets/faucet/main.mo index 52e7a2d1eb..db60aa3e5d 100644 --- a/e2e/assets/faucet/main.mo +++ b/e2e/assets/faucet/main.mo @@ -2,42 +2,79 @@ import Cycles "mo:base/ExperimentalCycles"; import Error "mo:base/Error"; import Principal "mo:base/Principal"; import Text "mo:base/Text"; +import Debug "mo:base/Debug"; actor class Coupon() = self { type Management = actor { - deposit_cycles : ({canister_id : Principal}) -> async (); + deposit_cycles : ({ canister_id : Principal }) -> async (); + }; + type CyclesLedger = actor { + deposit : (DepositArgs) -> async (DepositResult); + }; + type Account = { + owner : Principal; + subaccount : ?Blob; + }; + type DepositArgs = { + to : Account; + memo : ?Blob; + }; + type DepositResult = { balance : Nat; block_index : Nat }; + type DepositToCyclesLedgerResult = { + cycles : Nat; + balance : Nat; + block_index : Nat; }; // Uploading wasm is hard. This is much easier to handle. - var wallet_to_hand_out: ?Principal = null; - public func set_wallet_to_hand_out(wallet: Principal) : async () { + var wallet_to_hand_out : ?Principal = null; + public func set_wallet_to_hand_out(wallet : Principal) : async () { wallet_to_hand_out := ?wallet; }; // Redeem coupon code to create a cycle wallet - public shared (args) func redeem(code: Text) : async Principal { + public shared (args) func redeem(code : Text) : async Principal { if (code == "invalid") { - throw(Error.reject("Code is expired or not redeemable")); + throw (Error.reject("Code is expired or not redeemable")); }; switch (wallet_to_hand_out) { case (?wallet) { return wallet; }; case (_) { - throw(Error.reject("Set wallet to return before calling this!")); + throw (Error.reject("Set wallet to return before calling this!")); }; }; }; // Redeem coupon code to top up an existing wallet - public func redeem_to_wallet(code: Text, wallet: Principal) : async Nat { + public func redeem_to_wallet(code : Text, wallet : Principal) : async Nat { if (code == "invalid") { - throw(Error.reject("Code is expired or not redeemable")); + throw (Error.reject("Code is expired or not redeemable")); }; - let IC0 : Management = actor("aaaaa-aa"); - var amount = 10000000000000; + let IC0 : Management = actor ("aaaaa-aa"); + var amount = 10000000000000; // 10T Cycles.add(amount); await IC0.deposit_cycles({ canister_id = wallet }); return amount; }; + + // Redeem coupon code to cycle ledger + public shared (args) func redeem_to_cycles_ledger(code : Text, account : Account) : async DepositToCyclesLedgerResult { + if (code == "invalid") { + throw (Error.reject("Code is expired or not redeemable")); + }; + let CyclesLedgerCanister : CyclesLedger = actor ("um5iw-rqaaa-aaaaq-qaaba-cai"); + var amount = 10000000000000; // 10T + Cycles.add(amount); + let result = await CyclesLedgerCanister.deposit({ + to = account; + memo = null; + }); + return { + cycles = amount; + balance = result.balance; + block_index = result.block_index; + }; + }; }; diff --git a/e2e/tests-dfx/cycles-ledger.bash b/e2e/tests-dfx/cycles-ledger.bash index 7f0fda70b4..e64193edd8 100644 --- a/e2e/tests-dfx/cycles-ledger.bash +++ b/e2e/tests-dfx/cycles-ledger.bash @@ -494,7 +494,7 @@ current_time_nanoseconds() { assert_eq "1599800000000 cycles." dfx canister stop e2e_project_backend dfx canister delete e2e_project_backend - + assert_command dfx deploy --with-cycles 1T assert_command dfx canister id e2e_project_backend assert_command dfx canister id e2e_project_frontend @@ -546,6 +546,37 @@ current_time_nanoseconds() { assert_eq "22.379 TC (trillion cycles)." } +@test "redeem-faucet-coupon redeems into the cycles ledger" { + assert_command deploy_cycles_ledger + dfx_new hello + install_asset faucet + dfx deploy + dfx ledger fabricate-cycles --canister faucet --t 1000 + + dfx identity new --storage-mode plaintext no_wallet_identity + dfx identity use no_wallet_identity + SUBACCOUNT="7C7B7A030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + assert_command dfx cycles balance --identity no_wallet_identity + assert_eq "0.000 TC (trillion cycles)." + assert_command dfx cycles balance --identity no_wallet_identity --subaccount "$SUBACCOUNT" + assert_eq "0.000 TC (trillion cycles)." + + assert_command dfx cycles redeem-faucet-coupon --faucet "$(dfx canister id faucet)" 'valid-coupon' + assert_match "Redeemed coupon 'valid-coupon'" + assert_command dfx cycles redeem-faucet-coupon --faucet "$(dfx canister id faucet)" 'another-valid-coupon' + assert_match "Redeemed coupon 'another-valid-coupon'" + + # with subaccount + assert_command dfx cycles redeem-faucet-coupon --faucet "$(dfx canister id faucet)" 'another-valid-coupon' --to-subaccount "$SUBACCOUNT" + assert_match "Redeemed coupon 'another-valid-coupon'" + + assert_command dfx cycles balance --identity no_wallet_identity + assert_eq "20.000 TC (trillion cycles)." + assert_command dfx cycles balance --identity no_wallet_identity --subaccount "$SUBACCOUNT" + assert_eq "10.000 TC (trillion cycles)." +} + @test "create canister on specific subnet" { skip "can't be properly tested with feature flag turned off (CYCLES_LEDGER_ENABLED). TODO(SDK-1331): re-enable this test" dfx_new temporary @@ -581,4 +612,4 @@ current_time_nanoseconds() { cd ../e2e_project assert_command_fail dfx canister create e2e_project_frontend --subnet-type custom_subnet_type assert_contains "Provided subnet type custom_subnet_type does not exist" -} \ No newline at end of file +} diff --git a/e2e/tests-dfx/wallet.bash b/e2e/tests-dfx/wallet.bash index 752a7e56b2..904c242b9e 100644 --- a/e2e/tests-dfx/wallet.bash +++ b/e2e/tests-dfx/wallet.bash @@ -226,7 +226,7 @@ teardown() { assert_command_fail dfx wallet balance assert_match "No wallet configured" - assert_command dfx wallet redeem-faucet-coupon --faucet "$(dfx canister id faucet)" 'valid-coupon' + assert_command dfx wallet redeem-faucet-coupon --faucet "$(dfx canister id faucet)" 'valid-coupon' --yes assert_match "Redeemed coupon valid-coupon for a new wallet" assert_match "New wallet set." @@ -238,7 +238,7 @@ teardown() { unset DFX_DISABLE_AUTO_WALLET - assert_command dfx wallet redeem-faucet-coupon --faucet "$(dfx canister id faucet)" 'another-valid-coupon' + assert_command dfx wallet redeem-faucet-coupon --faucet "$(dfx canister id faucet)" 'another-valid-coupon' --yes assert_match "Redeemed coupon code another-valid-coupon for 10.000 TC" assert_command dfx wallet balance diff --git a/src/dfx/src/commands/cycles/mod.rs b/src/dfx/src/commands/cycles/mod.rs index 8e1b14228a..7b2c530fb3 100644 --- a/src/dfx/src/commands/cycles/mod.rs +++ b/src/dfx/src/commands/cycles/mod.rs @@ -6,6 +6,7 @@ use clap::Parser; use tokio::runtime::Runtime; mod balance; +mod redeem_faucet_coupon; pub mod top_up; mod transfer; @@ -25,6 +26,7 @@ enum SubCommand { Balance(balance::CyclesBalanceOpts), TopUp(top_up::TopUpOpts), Transfer(transfer::TransferOpts), + RedeemFaucetCoupon(redeem_faucet_coupon::RedeemFaucetCouponOpts), } pub fn exec(env: &dyn Environment, opts: CyclesOpts) -> DfxResult { @@ -35,6 +37,7 @@ pub fn exec(env: &dyn Environment, opts: CyclesOpts) -> DfxResult { SubCommand::Balance(v) => balance::exec(&agent_env, v).await, SubCommand::TopUp(v) => top_up::exec(&agent_env, v).await, SubCommand::Transfer(v) => transfer::exec(&agent_env, v).await, + SubCommand::RedeemFaucetCoupon(v) => redeem_faucet_coupon::exec(&agent_env, v).await, } }) } diff --git a/src/dfx/src/commands/cycles/redeem_faucet_coupon.rs b/src/dfx/src/commands/cycles/redeem_faucet_coupon.rs new file mode 100644 index 0000000000..5f2637065e --- /dev/null +++ b/src/dfx/src/commands/cycles/redeem_faucet_coupon.rs @@ -0,0 +1,95 @@ +use crate::lib::environment::Environment; +use crate::lib::error::DfxResult; +use crate::lib::root_key::fetch_root_key_if_needed; +use crate::util::clap::parsers::icrc_subaccount_parser; +use crate::util::{format_as_trillions, pretty_thousand_separators}; +use anyhow::{anyhow, bail, Context}; +use candid::{encode_args, CandidType, Decode, Deserialize, Principal}; +use clap::Parser; +use icrc_ledger_types::icrc1::account::{Account, Subaccount}; +use slog::{info, warn}; + +pub const DEFAULT_FAUCET_PRINCIPAL: Principal = + Principal::from_slice(&[0, 0, 0, 0, 1, 112, 0, 196, 1, 1]); + +/// Redeem a code at the cycles faucet. +#[derive(Parser)] +pub struct RedeemFaucetCouponOpts { + /// The coupon code to redeem at the faucet. + coupon_code: String, + + /// Alternative faucet address. If not set, this uses the DFINITY faucet. + #[arg(long)] + faucet: Option, + + /// Subaccount to redeem the coupon to. + #[arg(long, value_parser = icrc_subaccount_parser)] + to_subaccount: Option, +} + +pub async fn exec(env: &dyn Environment, opts: RedeemFaucetCouponOpts) -> DfxResult { + let log = env.get_logger(); + + let faucet_principal = if let Some(alternative_faucet) = opts.faucet { + let canister_id_store = env.get_canister_id_store()?; + Principal::from_text(&alternative_faucet) + .or_else(|_| canister_id_store.get(&alternative_faucet))? + } else { + DEFAULT_FAUCET_PRINCIPAL + }; + let agent = env.get_agent(); + if fetch_root_key_if_needed(env).await.is_err() { + bail!("Failed to connect to the local replica. Did you forget to use '--network ic'?"); + } else if !env.get_network_descriptor().is_ic { + warn!(log, "Trying to redeem a wallet coupon on a local replica. Did you forget to use '--network ic'?"); + } + + info!(log, "Redeeming coupon. This may take up to 30 seconds..."); + let identity = env + .get_selected_identity_principal() + .with_context(|| anyhow!("No identity selected."))?; + let response = agent + .update(&faucet_principal, "redeem_to_cycles_ledger") + .with_arg( + encode_args(( + opts.coupon_code.clone(), + Account { + owner: identity, + subaccount: opts.to_subaccount, + }, + )) + .context("Failed to serialize 'redeem_to_cycles_ledger' arguments.")?, + ) + .call_and_wait() + .await + .context("Failed 'redeem_to_cycles_ledger' call.")?; + #[derive(CandidType, Deserialize)] + struct RedeemResponse { + balance: u128, + cycles: u128, + block_index: u128, + } + let result = Decode!(&response, RedeemResponse) + .context("Failed to decode 'redeem_to_cycles_ledger' response.")?; + info!( + log, + "Redeemed coupon '{}' to the cycles ledger for {} TC (trillions of cycles). New balance: {} TC.", + opts.coupon_code, + pretty_thousand_separators(format_as_trillions(result.cycles)), + pretty_thousand_separators(format_as_trillions(result.balance)), + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_faucet_canister_id() { + assert_eq!( + DEFAULT_FAUCET_PRINCIPAL, + Principal::from_text("fg7gi-vyaaa-aaaal-qadca-cai").unwrap() + ); + } +} diff --git a/src/dfx/src/commands/wallet/redeem_faucet_coupon.rs b/src/dfx/src/commands/wallet/redeem_faucet_coupon.rs index 3ba18bcfda..9b01458789 100644 --- a/src/dfx/src/commands/wallet/redeem_faucet_coupon.rs +++ b/src/dfx/src/commands/wallet/redeem_faucet_coupon.rs @@ -3,11 +3,13 @@ use crate::lib::diagnosis::DiagnosedError; use crate::lib::environment::Environment; use crate::lib::error::DfxResult; use crate::lib::identity::wallet::set_wallet_id; +use crate::lib::operations::cycles_ledger::CYCLES_LEDGER_ENABLED; use crate::lib::root_key::fetch_root_key_if_needed; use crate::util::{format_as_trillions, pretty_thousand_separators}; use anyhow::{anyhow, bail, Context}; use candid::{encode_args, Decode, Principal}; use clap::Parser; +use dfx_core::cli::ask_for_consent; use slog::{info, warn}; pub const DEFAULT_FAUCET_PRINCIPAL: Principal = @@ -22,6 +24,10 @@ pub struct RedeemFaucetCouponOpts { /// Alternative faucet address. If not set, this uses the DFINITY faucet. #[arg(long)] faucet: Option, + + /// Skips yes/no checks by answering 'yes'. Not recommended outside of CI. + #[arg(long, short)] + yes: bool, } pub async fn exec(env: &dyn Environment, opts: RedeemFaucetCouponOpts) -> DfxResult { @@ -69,6 +75,9 @@ pub async fn exec(env: &dyn Environment, opts: RedeemFaucetCouponOpts) -> DfxRes } // identity has no wallet yet - faucet will provide one _ => { + if CYCLES_LEDGER_ENABLED && !opts.yes { + ask_for_consent("`dfx cycles` is now recommended instead of `dfx wallet`. Are you sure you want to create a new cycles wallet anyways?")?; + } let identity = env .get_selected_identity() .with_context(|| anyhow!("No identity selected."))?;