This tutorial will guide you through the process of working with ckETH, from generating a subaccount for ETH deposits to minting ckETH and verifying transactions on-chain. By the end of this tutorial, you will have a basic understanding of how to interact with the ckETH protocol using a Rust backend and a React frontend.
Slides on ckETH explanation can be found here
You can now easily mint ckSepoliaETH to your principal ID using this site - Link
Here are the steps:
Step 1: Get the Canister ID/Principal ID.
Step 2: Generate Byte32 Address from the Generate Byte32 Address function
Step 3: Connect Your Wallet via the Connect Wallet button
Step 4: Put the generated Byte32 address in the Deposit ckETH section and enter amount of ckETH youd like to deposit
Step 5: You can now check the balance of deposited ckETH by checking Check ckETH Balance function
Note: The "Get canister byte32 address" button generates the byte32 address of the backend canister.
Before we begin, ensure you have the following:
- You've installed necessary environment requirements
- MetaMask installed in your browser with Sepolia ETH (testnet) tokens
- Basic knowledge oF rust
The frontend logic for ckSepoliaETH
is located in the
_src
...
|
|_cketh_tutorial_frontend
|_components
|_Header
...
|_ckSepoliaETH
...
This function retrieves the Ledger and Minter canister IDs for ckSepoliaETH from the backend canister on the Internet Computer.
- Purpose: Fetches and sets the Ledger and Minter canister IDs for ckSepoliaETH.
- Called When: Automatically called when the component is mounted.
const ckSepoliaCanisterIDs = async () => {
const ledgerCanisterID = await cketh_tutorial_backend.ck_sepolia_eth_ledger_canister_id();
setkSepoliaETHLedgerid(ledgerCanisterID);
const minterCanisterID = await cketh_tutorial_backend.ck_sepolia_eth_minter_canister_id();
setkSepoliaETHMinterid(minterCanisterID);
};
- Async Operation: Retrieves canister IDs asynchronously.
- State Management: Updates
ckSepoliaETHLedgerid
andckSepoliaETHMinterid
states.
Fetches a unique deposit address from the backend canister on the Internet Computer, used for Ethereum deposits.
- Purpose: Retrieves and sets the deposit address in the
canisterDepositAddress
state. - Called When: Automatically called when the component is mounted.
const depositAddress = async () => {
const depositAddress = await cketh_tutorial_backend.canister_deposit_principal();
console.log("Deposit address: ", depositAddress);
setCanisterDepositAddress(depositAddress);
};
- Async Operation: Ensures that the deposit address is retrieved before updating the state.
- State Update: Updates the
canisterDepositAddress
state.
Interacts with the MinterHelper smart contract on Ethereum to deposit the specified amount of Ethereum to the deposit address.
- Purpose: Executes the deposit transaction and stores the transaction hash.
- Called When: The user clicks the "Deposit ckSepoliaETH" button.
const depositckETH = async () => {
if (!walletConnected) {
toast.error("Wallet not connected");
return;
}
setIsDepositLoading(true);
try {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const contract = new ethers.Contract(MinterHelper.SepoliaETHMinterHelper, abi, signer);
const tx = await contract.deposit(canisterDepositAddress, {
value: ethers.utils.parseEther(amount.toString())
});
toast.info("Sending ETH to the helper contract");
await tx.wait();
toast.success("Transaction successful");
// Store transaction hash
toast.info("Storing transaction hash...");
await cketh_tutorial_backend.store_ck_sepolia_eth_hash(tx.hash);
toast.success("Transaction hash stored");
// Fetch updated transaction hashes
fetchTransactionHashes();
} catch (error) {
toast.error("Failed to send ETH");
console.error(error);
} finally {
setIsDepositLoading(false);
}
};
- Wallet Check: Verifies if the wallet is connected.
- Transaction Execution: Sends ETH to the contract and stores the transaction hash on success.
- State Management: Manages loading state with
isDepositLoading
.
Fetches and displays the list of stored transaction hashes.
- Purpose: Retrieves stored transaction hashes for ckSepoliaETH.
- Called When: Automatically called when the component is mounted and after a successful deposit.
const fetchTransactionHashes = async () => {
try {
const hashes = await cketh_tutorial_backend.get_ck_sepolia_eth_hashes();
setTransactionHashes(hashes);
} catch (error) {
toast.error("Failed to fetch transaction hashes");
console.error(error);
}
};
- Async Operation: Fetches transaction hashes from the backend.
- State Update: Updates the
transactionHashes
state.
Retrieves the receipt of a specific Ethereum transaction using its hash.
- Purpose: Fetches and displays the transaction receipt.
- Called When: The user clicks on a transaction hash in the transaction list.
const getReceipt = async (hash) => {
setIsReceiptLoading(true);
try {
const receipt = await cketh_tutorial_backend.get_receipt(hash);
setSelectedReceipt(receipt);
} catch (error) {
toast.error("Failed to fetch transaction receipt");
console.error(error);
} finally {
setIsReceiptLoading(false);
}
};
- Receipt Fetching: Fetches the receipt from the backend canister.
- State Management: Updates
selectedReceipt
andisReceiptLoading
states.
Handles the change event for the amount input field, ensuring only valid numbers are accepted.
- Purpose: Updates the
amount
state. - Called When: The user inputs a value in the "Amount" field.
const changeAmountHandler = (e) => {
let amount = e.target.valueAsNumber;
if (Number.isNaN(amount) || amount < 0) amount = 0;
setAmount(amount);
};
- Input Validation: Ensures the input is a valid positive number.
- State Update: Updates the
amount
state.
Fetches and displays the ckSepoliaETH balance for a given Principal ID.
- Purpose: Checks the ckSepoliaETH balance on the Internet Computer.
- Called When: The user clicks the "Check Balance" button.
const checkCkEthBalance = async () => {
try {
setIsBalanceLoading(true);
const principal = Principal.fromText(balancePrincipalId);
const balance = await cketh_tutorial_backend.ck_sepolia_eth_balance(principal);
setCkEthBalance(balance.toString());
toast.success("Balance fetched successfully");
} catch (error) {
toast.error("Failed to fetch balance");
console.error(error);
} finally {
setIsBalanceLoading(false);
}
};
- Balance Retrieval: Fetches the balance using the Principal ID.
- State Management: Manages
isBalanceLoading
andckEthBalance
states.
Generates a Byte32 address from a given Principal ID.
- Purpose: Converts a Principal ID into a Byte32 address.
- Called When: The user clicks the "Generate Byte32 Address" button.
const generateByte32Address = async () => {
try {
setIsGenerateLoading(true);
const principal = Principal.fromText(generatePrincipalId);
const byte32Address = await cketh_tutorial_backend.convert_principal_to_byte32(principal);
setGeneratedByte32Address(byte32Address);
toast.success("Byte32 address generated successfully");
} catch (error) {
toast.error("Failed to generate byte32 address");
console.error(error);
} finally {
setIsGenerateLoading(false);
}
};
- Byte32 Generation: Converts Principal ID to Byte32.
- State Management: Manages
isGenerateLoading
andgeneratedByte32Address
states.
The first step is to create a function that converts a Principal ID into a subaccount. This subaccount is necessary for depositing ETH.
First of all you need to add the follwing dependency to your Cargo.toml
file inside the backend directory
b3_utils = { version = "0.11.0", features = ["ledger"] }
Then you can insert the rust function to your lib.rs
file
use b3_utils::{vec_to_hex_string_with_0x, Subaccount};
#[ic_cdk::query]
fn canister_deposit_principal() -> String {
let subaccount = Subaccount::from(ic_cdk::id());
let bytes32 = subaccount.to_bytes32().unwrap();
vec_to_hex_string_with_0x(bytes32)
}
This function generates a deposit address that you can use to mint ckETH to the new subaccount.
Import the following structs from the b3_utils::ledger
and b3_utils::api
dependencies:
use b3_utils::ledger::{ICRCAccount, ICRC1, ICRC1TransferArgs, ICRC1TransferResult};
use b3_utils::api::{InterCall, CallCycles};
Now define the LEDGER
canister ID that is responsible for the withdrawal and transfer of ckETH and the MINTER
canister ID that is responsible for minting and burning of ckETH tokens
const LEDGER: &str = "apia6-jaaaa-aaaar-qabma-cai";
const MINTER: &str = "jzenf-aiaaa-aaaar-qaa7q-cai";
Copy the following content and add them to a mod file, name the file minter.rs
:
use candid::{CandidType, Deserialize, Nat};
#[derive(CandidType, Deserialize)]
pub struct WithdrawalArg {
pub amount: Nat,
pub recipient: String,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
pub struct RetrieveEthRequest {
pub block_index: Nat,
}
#[derive(CandidType, Deserialize, Debug)]
pub enum WithdrawalError {
AmountTooLow { min_withdrawal_amount: Nat },
InsufficientFunds { balance: Nat },
InsufficientAllowance { allowance: Nat },
TemporarilyUnavailable(String),
}
pub type WithdrawalResult = Result<RetrieveEthRequest, WithdrawalError>;
Ensure you've imported the mod on your lib.rs
file:
mod minter;
To ensure that only authorized accounts call the withdraw functionality, ensure you've imported the function caller_is_controller
from b3_utils
:
use b3_utils::caller_is_controller;
Now add the following functions to your lib.rs
:
// Fetching canister's balance of ckETH
#[ic_cdk::update]
async fn balance() -> Nat {
let account = ICRCAccount::new(ic_cdk::id(), None);
ICRC1::from(LEDGER).balance_of(account).await.unwrap()
}
// Transfering a specified amount of ckETH to another account
#[ic_cdk::update]
async fn transfer(to: String, amount: Nat) -> ICRC1TransferResult {
let to = ICRCAccount::from_str(&to).unwrap();
let transfer_args = ICRC1TransferArgs {
to,
amount,
from_subaccount: None,
fee: None,
memo: None,
created_at_time: None,
};
ICRC1::from(LEDGER).transfer(transfer_args).await.unwrap()
}
// Withdrawing ckETH from the canister
#[ic_cdk::update(guard = "caller_is_controller")]
async fn withdraw(amount: Nat, recipient: String) -> minter::WithdrawalResult {
let withdraw = minter::WithdrawalArg{
amount,
recipient
};
InterCall::from(MINTER)
.call(
"withdraw_eth",
withdraw,
CallCycles::NoPay
)
.await
.unwrap()
}
This project is licensed under the MIT license, see LICENSE.md for details. See CONTRIBUTE.md for details about how to contribute to this project.