diff --git a/evaluations/canister-calls.json b/evaluations/canister-calls.json new file mode 100644 index 0000000..504a72c --- /dev/null +++ b/evaluations/canister-calls.json @@ -0,0 +1,131 @@ +{ + "skill": "canister-calls", + "description": "Evaluation cases for the canister-calls skill. Tests whether agents can discover canister interfaces via Candid, use curated workflows for well-known canisters, avoid common pitfalls (wrong IDs, missing fees, incomplete flows), and fall back to generic Candid discovery for unknown canisters.", + + "output_evals": [ + { + "name": "Discover unknown canister API", + "prompt": "I found this canister on mainnet: rdmx6-jaaaa-aaaaa-aaadq-cai. I want to call it from my Rust canister but I have no idea what methods it exposes. How do I figure out its API?", + "expected_behaviors": [ + "Suggests fetching the Candid interface via icp canister metadata or equivalent", + "Explains how to read the returned .did file (method names, types, query vs update)", + "Does NOT hallucinate method names for this canister", + "Mentions generating typed Rust bindings from the .did (ic-cdk-bindgen)" + ] + }, + { + "name": "ICRC-1 token transfer", + "prompt": "I need my Motoko canister to send 1 ICP to another principal. Show me the code.", + "expected_behaviors": [ + "Uses the ICP ledger canister ID ryjl3-tyaaa-aaaaa-aaaba-cai", + "Uses icrc1_transfer (not the legacy transfer method)", + "Fee is 10000 e8s (not 10000 ICP)", + "Amount is in e8s (100_000_000 for 1 ICP)", + "Account format is { owner: Principal; subaccount: ?Blob }, not AccountIdentifier", + "Handles the TransferError variant (not just Ok)" + ] + }, + { + "name": "ckBTC deposit flow", + "prompt": "I'm building a Rust canister that accepts BTC deposits from users. Walk me through the full flow.", + "expected_behaviors": [ + "Uses the correct minter canister ID mqygn-kiaaa-aaaar-qaadq-cai (not the ledger ID)", + "Shows the complete flow: get_btc_address -> user sends BTC -> update_balance", + "Explicitly mentions that update_balance must be called (minter does not auto-detect deposits)", + "Derives per-user subaccounts from the caller's principal (32 bytes, padded)", + "Sets owner to the canister's own principal (not the user's principal)" + ] + }, + { + "name": "ckBTC withdrawal", + "prompt": "My canister holds ckBTC for users in subaccounts. A user wants to withdraw 0.001 BTC to their Bitcoin address. How do I implement this in Motoko?", + "expected_behaviors": [ + "Uses the two-step flow: icrc2_approve on ledger, then retrieve_btc_with_approval on minter", + "Approve amount includes the fee (amount + 10 satoshis)", + "Spender in the approve call is the minter canister", + "Mentions the minimum withdrawal amount (50,000 satoshis)", + "Handles error variants from both the approve and retrieve calls" + ] + }, + { + "name": "EVM RPC call with cycles", + "prompt": "I want to read the ETH balance of a wallet address from my Motoko canister using the EVM RPC canister. Show me how.", + "expected_behaviors": [ + "Uses the EVM RPC canister ID 7hfb6-caaaa-aaaar-qadga-cai", + "Attaches cycles using 'await (with cycles = ...)' syntax (not Cycles.add)", + "Handles both #Consistent and #Inconsistent result variants", + "Uses #EthMainnet variant for Ethereum L1", + "Does NOT forget the null config parameter" + ] + }, + { + "name": "Adversarial: wrong canister ID", + "prompt": "I want to check a user's ckBTC balance. I'll call icrc1_balance_of on mqygn-kiaaa-aaaar-qaadq-cai, right?", + "expected_behaviors": [ + "Corrects the canister ID — mqygn is the minter, not the ledger", + "Provides the correct ledger canister ID: mxzaz-hqaaa-aaaar-qaada-cai", + "Explains the difference between the minter and ledger canisters" + ] + }, + { + "name": "Adversarial: missing update_balance", + "prompt": "I set up ckBTC deposits. I call get_btc_address, the user sends BTC, and then I show their ckBTC balance. But it always shows 0. What's wrong?", + "expected_behaviors": [ + "Identifies the missing update_balance call as the root cause", + "Explains that the minter does not auto-detect BTC deposits", + "Shows how to call update_balance with the correct owner and subaccount" + ] + }, + { + "name": "ICRC-2 approve and transferFrom", + "prompt": "I'm building a marketplace canister in Rust. When a buyer purchases an item, my canister needs to transfer ICP from the buyer to the seller. How do I do this without the buyer calling my canister with the tokens directly?", + "expected_behaviors": [ + "Explains the ICRC-2 approve/transferFrom flow", + "Buyer calls icrc2_approve on the ledger to authorize the marketplace canister", + "Marketplace calls icrc2_transfer_from to move tokens from buyer to seller", + "Uses correct ICP ledger canister ID", + "Mentions that approve must happen before transferFrom", + "Handles InsufficientAllowance error variant" + ] + }, + { + "name": "Call canister from frontend", + "prompt": "I have a canister deployed on mainnet and I want to call its methods from my TypeScript frontend. How do I generate the bindings and set up the actor?", + "expected_behaviors": [ + "Recommends @icp-sdk/bindgen for generating TypeScript bindings", + "Mentions @icp-sdk/core for the runtime actor", + "Does NOT suggest dfx generate" + ] + } + ], + + "trigger_evals": { + "description": "Queries to test whether the skill activates correctly. 'should_trigger' queries should cause the skill to load; 'should_not_trigger' queries should NOT activate this skill.", + "should_trigger": [ + "How do I call a canister I found on the dashboard?", + "Send ICP tokens from my canister to another principal", + "I want to accept BTC deposits in my dapp using ckBTC", + "Read an ERC-20 balance from my IC canister", + "What's the Candid interface of this canister?", + "How do I do icrc2_approve and transferFrom?", + "My ckBTC balance shows 0 after sending BTC to the deposit address", + "Call the EVM RPC canister from Motoko", + "I need to interact with a canister but I don't know its API", + "Transfer ckETH from my canister", + "How do I generate TypeScript bindings for a canister?", + "Withdraw ckBTC back to a Bitcoin address" + ], + "should_not_trigger": [ + "Make an HTTP request to an external API from my canister", + "How do I deploy my canister to mainnet?", + "Add access control to my canister methods", + "How does stable memory work for canister upgrades?", + "Set up Internet Identity login for my frontend", + "How do I handle inter-canister call failures safely?", + "Configure my icp.yaml for a Rust canister", + "What's the best way to store large files on IC?", + "How do I set up a custom domain for my frontend?", + "Monitor my canister's cycle balance" + ] + } +} diff --git a/skills/canister-calls/SKILL.md b/skills/canister-calls/SKILL.md new file mode 100644 index 0000000..f9b6afb --- /dev/null +++ b/skills/canister-calls/SKILL.md @@ -0,0 +1,155 @@ +--- +name: canister-calls +description: "Discover and call any Internet Computer canister. Covers retrieving Candid interfaces from deployed canisters (both off-chain and via inter-canister calls), reading type signatures, generating typed client bindings, and constructing calls using any IC agent library. Includes curated workflows for well-known infrastructure canisters (ICRC ledgers, ckBTC minter, EVM RPC). Use when making any canister call, exploring an unfamiliar canister's API, integrating with IC infrastructure canisters, working with token transfers, ckBTC deposits/withdrawals, or Ethereum/EVM calls from IC." +license: Apache-2.0 +compatibility: "icp-cli >= 0.1.0" +metadata: + title: Canister Calls & Interface Discovery + category: Integration +--- + +# Canister Calls & Interface Discovery + +## What This Is + +Every canister on the Internet Computer exposes a Candid interface — a typed API description embedded in the WASM module. Candid is to canisters what `--help` is to CLI tools: the standard way to discover what a canister can do and how to call it. This skill teaches you how to retrieve, read, and use Candid interfaces to call any canister, plus curated workflows for well-known infrastructure canisters where raw Candid alone isn't enough. + +## Prerequisites + +- For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml +- For Rust: `ic-cdk >= 0.19`, `candid >= 0.10` +- For JavaScript/TypeScript: `@icp-sdk/core` (runtime), `@icp-sdk/bindgen` (codegen) +- For Rust bindings: `ic-cdk-bindgen` (build-time Candid-to-Rust codegen) + +## Discovering a Canister's Interface + +### From Outside IC (Off-Chain) + +Retrieve the Candid interface of any deployed canister: + +```bash +# Fetch the .did file from a deployed canister (local or mainnet) +icp canister metadata candid:service -e ic + +# Example: get the ICP ledger's interface +icp canister metadata ryjl3-tyaaa-aaaaa-aaaba-cai candid:service -e ic +``` + +This returns the full Candid service definition with all method signatures, types, and documentation comments (if the canister author included them). + +### From Inside a Canister (Inter-Canister) + +When your canister needs to call another canister dynamically, you can fetch its Candid interface at runtime using the management canister: + +```bash +# The management canister exposes canister metadata +icp canister call aaaaa-aa canister_metadata '(record { canister_id = principal ""; path = "candid:service" })' -e ic +``` + +### Reading Candid Interfaces + +A Candid interface describes: +- **Method names** and whether they are `query` (fast, read-only) or `update` (consensus-based, can mutate state) +- **Argument types** and **return types** — fully typed, including records, variants, optionals, vectors +- **Documentation comments** (if the canister author included them, prefixed with `///` in the .did file) + +Example Candid snippet: +```candid +service : { + icrc1_transfer : (TransferArg) -> (variant { Ok : nat; Err : TransferError }); + icrc1_balance_of : (Account) -> (nat) query; + icrc1_fee : () -> (nat) query; +} +``` + +This tells you: `icrc1_transfer` is an update call taking `TransferArg` and returning a result variant. `icrc1_balance_of` is a query call. The types (`TransferArg`, `Account`, `TransferError`) are defined elsewhere in the same .did file. + +### Generating Typed Client Bindings + +Each language has a dedicated tool for generating typed bindings from .did files: + +#### Rust + +Use `ic-cdk-bindgen` to generate typed Rust bindings from .did files at build time. Add it to your `build-dependencies` in `Cargo.toml` and configure it in `build.rs`. See https://crates.io/crates/ic-cdk-bindgen for setup. + +#### JavaScript / TypeScript + +Use `@icp-sdk/bindgen` to generate typed JS/TS bindings from .did files: + +```bash +npx @icp-sdk/bindgen --canister -e ic +``` + +See https://www.npmjs.com/package/@icp-sdk/bindgen for options. + +### Calling Any Canister via CLI + +Once you know the method signature from the Candid interface: + +```bash +# Call any method on any canister +icp canister call '()' -e ic + +# Query call (faster, read-only) +icp canister call '()' --query -e ic +``` + +### Calling Any Canister from Code + +#### Motoko — Dynamic Actor Reference + +```motoko +// Reference a remote canister by principal with a typed interface +transient let remote = actor ("aaaaa-bbbbb-ccccc-ddddd-cai") : actor { + some_method : shared (Nat) -> async Text; + some_query : shared query () -> async Nat; +}; + +// Call it +let result = await remote.some_method(42); +``` + +#### Rust — Using ic-cdk Call API + +```rust +use ic_cdk::call::Call; +use candid::Principal; + +let canister_id = Principal::from_text("aaaaa-bbbbb-ccccc-ddddd-cai").unwrap(); + +// Unbounded wait (guaranteed response) +let (result,): (String,) = Call::unbounded_wait(canister_id, "some_method") + .with_arg(42u64) + .await + .expect("Call failed") + .candid_tuple() + .expect("Decode failed"); +``` + +## What Candid Doesn't Tell You + +Candid gives you the shape of an API but not the workflow. For well-known infrastructure canisters, you need to know: +- **Which canisters to call and in what order** (e.g., ckBTC deposit is a multi-step flow across minter + ledger) +- **Cycle costs** (e.g., EVM RPC requires cycles attached to calls) +- **Fee amounts and units** (e.g., ICP fee is 10,000 e8s, not 10,000 ICP) +- **Pitfalls that cause silent failures** (e.g., forgetting `update_balance` after a BTC deposit) + +The reference files below contain this curated knowledge for each well-known canister. + +## Well-Known Canister Registry + +| Canister | ID (Mainnet) | What It Does | Reference | +|----------|-------------|-------------|-----------| +| ICP Ledger | `ryjl3-tyaaa-aaaaa-aaaba-cai` | ICP token transfers, balances, ICRC-1/2 | `references/icrc-ledger.md` | +| ckBTC Ledger | `mxzaz-hqaaa-aaaar-qaada-cai` | ckBTC token transfers | `references/icrc-ledger.md` | +| ckBTC Minter | `mqygn-kiaaa-aaaar-qaadq-cai` | BTC deposit/withdrawal via ckBTC | `references/ckbtc.md` | +| ckETH Ledger | `ss2fx-dyaaa-aaaar-qacoq-cai` | ckETH token transfers | `references/icrc-ledger.md` | +| EVM RPC | `7hfb6-caaaa-aaaar-qadga-cai` | Ethereum/EVM JSON-RPC proxy | `references/evm-rpc.md` | + +**For any canister not listed here**, use the Candid discovery flow above: fetch the .did, read the types, generate bindings, and call. + +### When to Read a Reference File + +- **Making token transfers (ICP, ckBTC, ckETH)** or working with **ICRC-1/ICRC-2 approve/transferFrom** -> Read `references/icrc-ledger.md` +- **Integrating Bitcoin** (BTC deposits, ckBTC minting, BTC withdrawals) -> Read `references/ckbtc.md` +- **Calling Ethereum/EVM chains** (ETH balances, ERC-20 reads, sending transactions) -> Read `references/evm-rpc.md` diff --git a/skills/canister-calls/references/ckbtc.md b/skills/canister-calls/references/ckbtc.md new file mode 100644 index 0000000..f713389 --- /dev/null +++ b/skills/canister-calls/references/ckbtc.md @@ -0,0 +1,755 @@ +# Chain-Key Bitcoin (ckBTC) Integration + +## What This Is + +ckBTC is a 1:1 BTC-backed token native to the Internet Computer. No bridges, no wrapping, no third-party custodians. The ckBTC minter canister holds real BTC and mints/burns ckBTC tokens. Transfers settle in 1-2 seconds with a 10 satoshi fee (versus minutes and thousands of satoshis on Bitcoin L1). + +## Prerequisites + +- For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml +- For Rust: `ic-cdk`, `icrc-ledger-types`, `candid`, `serde` + +## Canister IDs + +### Bitcoin Mainnet + +| Canister | ID | +|---|---| +| ckBTC Ledger | `mxzaz-hqaaa-aaaar-qaada-cai` | +| ckBTC Minter | `mqygn-kiaaa-aaaar-qaadq-cai` | +| ckBTC Index | `n5wcd-faaaa-aaaar-qaaea-cai` | +| ckBTC Checker | `oltsj-fqaaa-aaaar-qal5q-cai` | + +### Bitcoin Testnet4 + +| Canister | ID | +|---|---| +| ckBTC Ledger | `mc6ru-gyaaa-aaaar-qaaaq-cai` | +| ckBTC Minter | `ml52i-qqaaa-aaaar-qaaba-cai` | +| ckBTC Index | `mm444-5iaaa-aaaar-qaabq-cai` | + +## How It Works + +### Deposit Flow (BTC -> ckBTC) + +1. Call `get_btc_address` on the minter with the user's principal + subaccount. This returns a unique Bitcoin address controlled by the minter. +2. User sends BTC to that address using any Bitcoin wallet. +3. Wait for Bitcoin confirmations (the minter requires confirmations before minting). +4. Call `update_balance` on the minter with the same principal + subaccount. The minter checks for new UTXOs and mints equivalent ckBTC to the user's ICRC-1 account. + +### Transfer Flow (ckBTC -> ckBTC) + +Call `icrc1_transfer` on the ckBTC ledger. Fee is 10 satoshis. Settles in 1-2 seconds. + +### Withdrawal Flow (ckBTC -> BTC) + +1. Call `icrc2_approve` on the ckBTC ledger to grant the minter canister an allowance to spend from your account. +2. Call `retrieve_btc_with_approval` on the minter with `{ address, amount, from_subaccount: null }`. +3. The minter uses the approval to burn the ckBTC and submits a Bitcoin transaction. +4. The BTC arrives at the destination address after Bitcoin confirmations. + +### Subaccount Generation + +Each user gets a unique deposit address derived from their principal + an optional 32-byte subaccount. To give each user a distinct deposit address within your canister, derive subaccounts from a user-specific identifier (their principal or a sequential ID). + +## Mistakes That Break Your Build + +1. **Using the wrong minter canister ID.** The minter ID is `mqygn-kiaaa-aaaar-qaadq-cai`. Do not confuse it with the ledger (`mxzaz-...`) or index (`n5wcd-...`). + +2. **Forgetting the 10 satoshi transfer fee.** Every `icrc1_transfer` deducts 10 satoshis beyond the amount. If the user has exactly 1000 satoshis and you transfer 1000, it fails with `InsufficientFunds`. Transfer `balance - 10` instead. + +3. **Not calling `update_balance` after a BTC deposit.** Sending BTC to the deposit address does nothing until you call `update_balance`. The minter does not auto-detect deposits. Your app must call this. + +4. **Using Account Identifier instead of ICRC-1 Account.** ckBTC uses the ICRC-1 standard: `{ owner: Principal, subaccount: ?Blob }`. Do NOT use the legacy `AccountIdentifier` (hex string) from the ICP ledger. + +5. **Subaccount must be exactly 32 bytes or null.** Passing a subaccount shorter or longer than 32 bytes causes a trap. Pad with leading zeros if deriving from a shorter value. + +6. **Calling `retrieve_btc` with amount below the minimum.** The minter has a minimum withdrawal amount (currently 50,000 satoshis / 0.0005 BTC). Below this, you get `AmountTooLow`. + +7. **Not checking the `retrieve_btc` response for errors.** The response is a variant: `Ok` contains `{ block_index }`, `Err` contains specific errors like `MalformedAddress`, `InsufficientFunds`, `TemporarilyUnavailable`. Always match both arms. + +8. **Forgetting `owner` in `get_btc_address` args.** If you omit `owner`, Candid sub-typing assigns null, and the minter returns the deposit address of the caller (the canister) instead of the user. + +## Implementation + +### Motoko + +#### mops.toml + +```toml +[package] +name = "ckbtc-app" +version = "0.1.0" + +[dependencies] +core = "2.0.0" +icrc2-types = "1.1.0" +``` + +#### icp.yaml + +Your backend canister calls the ckBTC ledger and minter by principal directly — no local ckBTC canister deployment needed. + +```yaml +canisters: + - name: backend + recipe: + type: "@dfinity/motoko@v4.1.0" + configuration: + main: src/backend/main.mo +``` + +#### src/backend/main.mo + +```motoko +import Principal "mo:core/Principal"; +import Blob "mo:core/Blob"; +import Nat "mo:core/Nat"; +import Nat8 "mo:core/Nat8"; +import Nat64 "mo:core/Nat64"; +import Array "mo:core/Array"; +import Result "mo:core/Result"; +import Error "mo:core/Error"; +import Runtime "mo:core/Runtime"; + +persistent actor Self { + + // -- Types -- + + type Account = { + owner : Principal; + subaccount : ?Blob; + }; + + type TransferArgs = { + from_subaccount : ?Blob; + to : Account; + amount : Nat; + fee : ?Nat; + memo : ?Blob; + created_at_time : ?Nat64; + }; + + type TransferResult = { + #Ok : Nat; // block index + #Err : TransferError; + }; + + type TransferError = { + #BadFee : { expected_fee : Nat }; + #BadBurn : { min_burn_amount : Nat }; + #InsufficientFunds : { balance : Nat }; + #TooOld; + #CreatedInFuture : { ledger_time : Nat64 }; + #Duplicate : { duplicate_of : Nat }; + #TemporarilyUnavailable; + #GenericError : { error_code : Nat; message : Text }; + }; + + type UpdateBalanceResult = { + #Ok : [UtxoStatus]; + #Err : UpdateBalanceError; + }; + + type UtxoStatus = { + #ValueTooSmall : Utxo; + #Tainted : Utxo; + #Checked : Utxo; + #Minted : { block_index : Nat64; minted_amount : Nat64; utxo : Utxo }; + }; + + type Utxo = { + outpoint : { txid : Blob; vout : Nat32 }; + value : Nat64; + height : Nat32; + }; + + type UpdateBalanceError = { + #NoNewUtxos : { + required_confirmations : Nat32; + pending_utxos : ?[PendingUtxo]; + current_confirmations : ?Nat32; + }; + #AlreadyProcessing; + #TemporarilyUnavailable : Text; + #GenericError : { error_code : Nat64; error_message : Text }; + }; + + type PendingUtxo = { + outpoint : { txid : Blob; vout : Nat32 }; + value : Nat64; + confirmations : Nat32; + }; + + type ApproveArgs = { + from_subaccount : ?Blob; + spender : Account; + amount : Nat; + expected_allowance : ?Nat; + expires_at : ?Nat64; + fee : ?Nat; + memo : ?Blob; + created_at_time : ?Nat64; + }; + + type ApproveError = { + #BadFee : { expected_fee : Nat }; + #InsufficientFunds : { balance : Nat }; + #AllowanceChanged : { current_allowance : Nat }; + #Expired : { ledger_time : Nat64 }; + #TooOld; + #CreatedInFuture : { ledger_time : Nat64 }; + #Duplicate : { duplicate_of : Nat }; + #TemporarilyUnavailable; + #GenericError : { error_code : Nat; message : Text }; + }; + + type RetrieveBtcWithApprovalArgs = { + address : Text; + amount : Nat64; + from_subaccount : ?Blob; + }; + + type RetrieveBtcResult = { + #Ok : { block_index : Nat64 }; + #Err : RetrieveBtcError; + }; + + type RetrieveBtcError = { + #MalformedAddress : Text; + #AlreadyProcessing; + #AmountTooLow : Nat64; + #InsufficientFunds : { balance : Nat64 }; + #InsufficientAllowance : { allowance : Nat64 }; + #TemporarilyUnavailable : Text; + #GenericError : { error_code : Nat64; error_message : Text }; + }; + + // -- Remote canister references (mainnet) -- + + transient let ckbtcLedger : actor { + icrc1_transfer : shared (TransferArgs) -> async TransferResult; + icrc1_balance_of : shared query (Account) -> async Nat; + icrc1_fee : shared query () -> async Nat; + icrc2_approve : shared (ApproveArgs) -> async { #Ok : Nat; #Err : ApproveError }; + } = actor "mxzaz-hqaaa-aaaar-qaada-cai"; + + transient let ckbtcMinter : actor { + get_btc_address : shared ({ + owner : ?Principal; + subaccount : ?Blob; + }) -> async Text; + update_balance : shared ({ + owner : ?Principal; + subaccount : ?Blob; + }) -> async UpdateBalanceResult; + retrieve_btc_with_approval : shared (RetrieveBtcWithApprovalArgs) -> async RetrieveBtcResult; + } = actor "mqygn-kiaaa-aaaar-qaadq-cai"; + + // -- Subaccount derivation -- + // Derive a 32-byte subaccount from a principal for per-user deposit addresses. + + func principalToSubaccount(p : Principal) : Blob { + let bytes = Blob.toArray(Principal.toBlob(p)); + let size = bytes.size(); + // First byte is length, remaining padded to 32 bytes + let sub = Array.tabulate(32, func(i : Nat) : Nat8 { + if (i == 0) { Nat8.fromNat(size) } + else if (i <= size) { bytes[i - 1] } + else { 0 } + }); + Blob.fromArray(sub) + }; + + // -- Deposit: Get user's BTC deposit address -- + + public shared ({ caller }) func getDepositAddress() : async Text { + if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; + let subaccount = principalToSubaccount(caller); + await ckbtcMinter.get_btc_address({ + owner = ?Principal.fromActor(Self); + subaccount = ?subaccount; + }) + }; + + // -- Deposit: Check for new BTC and mint ckBTC -- + + public shared ({ caller }) func updateBalance() : async UpdateBalanceResult { + if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; + let subaccount = principalToSubaccount(caller); + await ckbtcMinter.update_balance({ + owner = ?Principal.fromActor(Self); + subaccount = ?subaccount; + }) + }; + + // -- Check user's ckBTC balance -- + + public shared ({ caller }) func getBalance() : async Nat { + if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; + let subaccount = principalToSubaccount(caller); + await ckbtcLedger.icrc1_balance_of({ + owner = Principal.fromActor(Self); + subaccount = ?subaccount; + }) + }; + + // -- Transfer ckBTC to another user -- + + public shared ({ caller }) func transfer(to : Principal, amount : Nat) : async TransferResult { + if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; + let fromSubaccount = principalToSubaccount(caller); + await ckbtcLedger.icrc1_transfer({ + from_subaccount = ?fromSubaccount; + to = { owner = to; subaccount = null }; + amount = amount; + fee = ?10; // 10 satoshis + memo = null; + created_at_time = null; + }) + }; + + // -- Withdraw: Convert ckBTC back to BTC -- + + public shared ({ caller }) func withdraw(btcAddress : Text, amount : Nat64) : async RetrieveBtcResult { + if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; + + // Step 1: Approve the minter to spend ckBTC from the user's subaccount + let fromSubaccount = principalToSubaccount(caller); + let approveResult = await ckbtcLedger.icrc2_approve({ + from_subaccount = ?fromSubaccount; + spender = { + owner = Principal.fromText("mqygn-kiaaa-aaaar-qaadq-cai"); + subaccount = null; + }; + amount = Nat64.toNat(amount) + 10; // amount + fee for the minter's burn + expected_allowance = null; + expires_at = null; + fee = ?10; + memo = null; + created_at_time = null; + }); + + switch (approveResult) { + case (#Err(e)) { return #Err(#GenericError({ error_code = 0; error_message = "Approve for minter failed" })) }; + case (#Ok(_)) {}; + }; + + // Step 2: Call retrieve_btc_with_approval on the minter + await ckbtcMinter.retrieve_btc_with_approval({ + address = btcAddress; + amount = amount; + from_subaccount = ?fromSubaccount; + }) + }; +}; +``` + +### Rust + +#### Cargo.toml + +```toml +[package] +name = "ckbtc_backend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ic-cdk = "0.19" +ic-cdk-timers = "1.0" +candid = "0.10" +serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" +icrc-ledger-types = "0.1" +``` + +#### src/lib.rs + +```rust +use candid::{CandidType, Deserialize, Nat, Principal}; +use ic_cdk::update; +use ic_cdk::call::Call; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError}; +use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; + +// -- Canister IDs -- +const CKBTC_LEDGER: &str = "mxzaz-hqaaa-aaaar-qaada-cai"; +const CKBTC_MINTER: &str = "mqygn-kiaaa-aaaar-qaadq-cai"; + +// -- Minter types -- + +#[derive(CandidType, Deserialize, Debug)] +struct GetBtcAddressArgs { + owner: Option, + subaccount: Option>, +} + +#[derive(CandidType, Deserialize, Debug)] +struct UpdateBalanceArgs { + owner: Option, + subaccount: Option>, +} + +#[derive(CandidType, Deserialize, Debug)] +struct RetrieveBtcWithApprovalArgs { + address: String, + amount: u64, + from_subaccount: Option>, +} + +#[derive(CandidType, Deserialize, Debug)] +struct RetrieveBtcOk { + block_index: u64, +} + +#[derive(CandidType, Deserialize, Debug)] +enum RetrieveBtcError { + MalformedAddress(String), + AlreadyProcessing, + AmountTooLow(u64), + InsufficientFunds { balance: u64 }, + InsufficientAllowance { allowance: u64 }, + TemporarilyUnavailable(String), + GenericError { error_code: u64, error_message: String }, +} + +#[derive(CandidType, Deserialize, Debug)] +struct Utxo { + outpoint: OutPoint, + value: u64, + height: u32, +} + +#[derive(CandidType, Deserialize, Debug)] +struct OutPoint { + txid: Vec, + vout: u32, +} + +#[derive(CandidType, Deserialize, Debug)] +struct PendingUtxo { + outpoint: OutPoint, + value: u64, + confirmations: u32, +} + +#[derive(CandidType, Deserialize, Debug)] +enum UtxoStatus { + ValueTooSmall(Utxo), + Tainted(Utxo), + Checked(Utxo), + Minted { + block_index: u64, + minted_amount: u64, + utxo: Utxo, + }, +} + +#[derive(CandidType, Deserialize, Debug)] +enum UpdateBalanceError { + NoNewUtxos { + required_confirmations: u32, + pending_utxos: Option>, + current_confirmations: Option, + }, + AlreadyProcessing, + TemporarilyUnavailable(String), + GenericError { error_code: u64, error_message: String }, +} + +type UpdateBalanceResult = Result, UpdateBalanceError>; +type RetrieveBtcResult = Result; + +// -- Subaccount derivation -- +// Derive a 32-byte subaccount from a principal for per-user deposit addresses. + +fn principal_to_subaccount(principal: &Principal) -> [u8; 32] { + let mut subaccount = [0u8; 32]; + let principal_bytes = principal.as_slice(); + subaccount[0] = principal_bytes.len() as u8; + subaccount[1..1 + principal_bytes.len()].copy_from_slice(principal_bytes); + subaccount +} + +fn ledger_id() -> Principal { + Principal::from_text(CKBTC_LEDGER).unwrap() +} + +fn minter_id() -> Principal { + Principal::from_text(CKBTC_MINTER).unwrap() +} + +// -- Deposit: Get user's BTC deposit address -- + +#[update] +async fn get_deposit_address() -> String { + let caller = ic_cdk::api::msg_caller(); + assert_ne!(caller, Principal::anonymous(), "Authentication required"); + + let subaccount = principal_to_subaccount(&caller); + let args = GetBtcAddressArgs { + owner: Some(ic_cdk::api::canister_self()), + subaccount: Some(subaccount.to_vec()), + }; + + let (address,): (String,) = Call::unbounded_wait(minter_id(), "get_btc_address") + .with_arg(args) + .await + .expect("Failed to get BTC address") + .candid_tuple() + .expect("Failed to decode response"); + + address +} + +// -- Deposit: Check for new BTC and mint ckBTC -- + +#[update] +async fn update_balance() -> UpdateBalanceResult { + let caller = ic_cdk::api::msg_caller(); + assert_ne!(caller, Principal::anonymous(), "Authentication required"); + + let subaccount = principal_to_subaccount(&caller); + let args = UpdateBalanceArgs { + owner: Some(ic_cdk::api::canister_self()), + subaccount: Some(subaccount.to_vec()), + }; + + let (result,): (UpdateBalanceResult,) = Call::unbounded_wait(minter_id(), "update_balance") + .with_arg(args) + .await + .expect("Failed to call update_balance") + .candid_tuple() + .expect("Failed to decode response"); + + result +} + +// -- Check user's ckBTC balance -- + +#[update] +async fn get_balance() -> Nat { + let caller = ic_cdk::api::msg_caller(); + assert_ne!(caller, Principal::anonymous(), "Authentication required"); + + let subaccount = principal_to_subaccount(&caller); + let account = Account { + owner: ic_cdk::api::canister_self(), + subaccount: Some(subaccount), + }; + + let (balance,): (Nat,) = Call::unbounded_wait(ledger_id(), "icrc1_balance_of") + .with_arg(account) + .await + .expect("Failed to get balance") + .candid_tuple() + .expect("Failed to decode response"); + + balance +} + +// -- Transfer ckBTC to another user -- + +#[update] +async fn transfer(to: Principal, amount: Nat) -> Result { + let caller = ic_cdk::api::msg_caller(); + assert_ne!(caller, Principal::anonymous(), "Authentication required"); + + let from_subaccount = principal_to_subaccount(&caller); + let args = TransferArg { + from_subaccount: Some(from_subaccount), + to: Account { + owner: to, + subaccount: None, + }, + amount, + fee: Some(Nat::from(10u64)), // 10 satoshis + memo: None, + created_at_time: None, + }; + + let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc1_transfer") + .with_arg(args) + .await + .expect("Failed to call icrc1_transfer") + .candid_tuple() + .expect("Failed to decode response"); + + result +} + +// -- Withdraw: Convert ckBTC back to BTC -- + +#[update] +async fn withdraw(btc_address: String, amount: u64) -> RetrieveBtcResult { + let caller = ic_cdk::api::msg_caller(); + assert_ne!(caller, Principal::anonymous(), "Authentication required"); + + // Step 1: Approve the minter to spend ckBTC from the user's subaccount + let from_subaccount = principal_to_subaccount(&caller); + let approve_args = ApproveArgs { + from_subaccount: Some(from_subaccount), + spender: Account { + owner: minter_id(), + subaccount: None, + }, + amount: Nat::from(amount) + Nat::from(10u64), // amount + fee for the minter's burn + expected_allowance: None, + expires_at: None, + fee: Some(Nat::from(10u64)), + memo: None, + created_at_time: None, + }; + + let (approve_result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc2_approve") + .with_arg(approve_args) + .await + .expect("Failed to call icrc2_approve") + .candid_tuple() + .expect("Failed to decode response"); + + if let Err(e) = approve_result { + return Err(RetrieveBtcError::GenericError { + error_code: 0, + error_message: format!("Approve for minter failed: {:?}", e), + }); + } + + // Step 2: Call retrieve_btc_with_approval on the minter + let args = RetrieveBtcWithApprovalArgs { + address: btc_address, + amount, + from_subaccount: Some(from_subaccount.to_vec()), + }; + + let (result,): (RetrieveBtcResult,) = Call::unbounded_wait(minter_id(), "retrieve_btc_with_approval") + .with_arg(args) + .await + .expect("Failed to call retrieve_btc_with_approval") + .candid_tuple() + .expect("Failed to decode response"); + + result +} + +// -- Export Candid interface -- +ic_cdk::export_candid!(); +``` + +## Deploy & Test + +### Local Development + +There is no local ckBTC minter. For local testing, mock the minter interface or test against mainnet/testnet. + +### Deploy to Mainnet + +```bash +# Deploy your backend canister +icp deploy backend -e ic + +# Your canister calls the mainnet ckBTC canisters directly by principal +``` + +### Using icp to Interact with ckBTC Directly + +```bash +# Check ckBTC balance for an account +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ + '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# Get deposit address +icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_btc_address \ + '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# Check for new deposits and mint ckBTC +icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance \ + '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic + +# Transfer ckBTC (amount in e8s — 1 ckBTC = 100_000_000) +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer \ + '(record { + to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; + amount = 100_000; + fee = opt 10; + memo = null; + from_subaccount = null; + created_at_time = null; + })' -e ic + +# Withdraw ckBTC to a BTC address (amount in satoshis, minimum 50_000) +# Note: In production, use icrc2_approve + retrieve_btc_with_approval (see withdraw function above) +icp canister call mqygn-kiaaa-aaaar-qaadq-cai retrieve_btc_with_approval \ + '(record { address = "bc1q...your-btc-address"; amount = 50_000; from_subaccount = null })' \ + -e ic + +# Check transfer fee +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_fee '()' -e ic +``` + +## Verify It Works + +### Check Balance + +```bash +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ + '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ + -e ic +# Expected: (AMOUNT : nat) — balance in satoshis (e8s) +``` + +### Verify Transfer + +```bash +# Transfer 1000 satoshis +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer \ + '(record { + to = record { owner = principal "RECIPIENT"; subaccount = null }; + amount = 1_000; + fee = opt 10; + memo = null; + from_subaccount = null; + created_at_time = null; + })' -e ic +# Expected: (variant { Ok = BLOCK_INDEX : nat }) + +# Verify recipient received it +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ + '(record { owner = principal "RECIPIENT"; subaccount = null })' \ + -e ic +# Expected: balance increased by 1000 +``` + +### Verify Deposit Flow + +```bash +# 1. Get deposit address +icp canister call YOUR-CANISTER getDepositAddress -e ic +# Expected: "bc1q..." or "3..." — a valid Bitcoin address + +# 2. Send BTC to that address (external wallet) + +# 3. Check for new deposits +icp canister call YOUR-CANISTER updateBalance -e ic +# Expected: (variant { Ok = vec { variant { Minted = record { ... } } } }) + +# 4. Check ckBTC balance +icp canister call YOUR-CANISTER getBalance -e ic +# Expected: balance reflects minted ckBTC +``` + +### Verify Withdrawal + +```bash +icp canister call YOUR-CANISTER withdraw '("bc1q...destination", 50_000 : nat64)' -e ic +# Expected: (variant { Ok = record { block_index = BLOCK_INDEX : nat64 } }) +# The BTC will arrive at the destination address after Bitcoin confirmations +``` diff --git a/skills/canister-calls/references/evm-rpc.md b/skills/canister-calls/references/evm-rpc.md new file mode 100644 index 0000000..45f954e --- /dev/null +++ b/skills/canister-calls/references/evm-rpc.md @@ -0,0 +1,797 @@ +# EVM RPC Canister — Calling Ethereum from IC + +## What This Is + +The EVM RPC canister is an IC system canister that proxies JSON-RPC calls to Ethereum and EVM-compatible chains via HTTPS outcalls. Your canister sends a request to the EVM RPC canister, which fans it out to multiple RPC providers, compares responses for consensus, and returns the result. No API keys required for default providers. No bridges or oracles needed. + +## Prerequisites + +- For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml +- For Rust: `ic-cdk`, `candid`, `serde` + +## Canister IDs + +| Canister | ID | Subnet | +|---|---|---| +| EVM RPC (mainnet) | `7hfb6-caaaa-aaaar-qadga-cai` | 34-node fiduciary | + +## Supported Chains + +| Chain | RpcServices Variant | Chain ID | +|---|---|---| +| Ethereum Mainnet | `#EthMainnet` | 1 | +| Ethereum Sepolia | `#EthSepolia` | 11155111 | +| Arbitrum One | `#ArbitrumOne` | 42161 | +| Base Mainnet | `#BaseMainnet` | 8453 | +| Optimism Mainnet | `#OptimismMainnet` | 10 | +| Custom EVM chain | `#Custom` | any | + +## RPC Providers + +Built-in providers (no API key needed for defaults): + +| Provider | Ethereum | Sepolia | Arbitrum | Base | Optimism | +|---|---|---|---|---|---| +| Alchemy | yes | yes | yes | yes | yes | +| Ankr | yes | - | yes | yes | yes | +| BlockPi | yes | yes | yes | yes | yes | +| Cloudflare | yes | - | - | - | - | +| LlamaNodes | yes | - | yes | yes | yes | +| PublicNode | yes | yes | yes | yes | yes | + +## Cycle Costs + +**Formula:** +``` +(5_912_000 + 60_000 * nodes + 2400 * request_bytes + 800 * max_response_bytes) * nodes * rpc_count +``` + +Where `nodes` = 34 (fiduciary subnet), `rpc_count` = number of providers queried. + +**Practical guidance:** Send 10_000_000_000 cycles (10B) as a starting budget. Unused cycles are refunded. Typical calls cost 100M-1B cycles (~$0.0001-$0.001 USD). + +Use `requestCost` to get an exact estimate before calling. + +## Mistakes That Break Your Build + +1. **Not sending enough cycles.** Every EVM RPC call requires cycles attached. If you send too few, the call fails silently or traps. Start with 10B cycles and adjust down after verifying. + +2. **Ignoring the `Inconsistent` result variant.** Multi-provider calls return `#Consistent(result)` or `#Inconsistent(results)`. If providers disagree, you get `Inconsistent`. Always handle both arms or your canister traps on provider disagreement. + +3. **Using wrong chain variant.** `#EthMainnet` is for Ethereum L1. For Arbitrum use `#ArbitrumOne`, for Base use `#BaseMainnet`. Using the wrong variant queries the wrong chain. + +4. **Forgetting `null` for optional config.** The second argument to every RPC method is an optional config record. Pass `null` for defaults. Omitting it causes a Candid type mismatch. + +5. **Response size limits.** Large responses (e.g., `eth_getLogs` with broad filters) can exceed the max response size. Set `max_response_bytes` appropriately or the call fails. + +6. **Calling `eth_sendRawTransaction` without signing first.** The EVM RPC canister does not sign transactions. You must sign the transaction yourself (using threshold ECDSA via the IC management canister) and pass the raw signed bytes. + +7. **Using `Cycles.add` instead of `await (with cycles = ...)` in mo:core.** In mo:core 2.0, `Cycles.add` does not exist. Attach cycles using `await (with cycles = AMOUNT) canister.method(args)`. This is the only way to attach cycles in mo:core. + +## Implementation + +### icp.yaml Configuration + +The EVM RPC canister is deployed as a pre-built WASM alongside your backend. On mainnet, it is already deployed at `7hfb6-caaaa-aaaar-qadga-cai` — your backend calls it by principal directly. + +```yaml +canisters: + - name: backend + recipe: + type: "@dfinity/motoko@v4.1.0" + configuration: + main: src/backend/main.mo + - name: evm_rpc + build: + steps: + - type: pre-built + url: https://github.com/dfinity/evm-rpc-canister/releases/download/v2.2.0/evm_rpc.wasm.gz + init_args: "(record {})" +``` + +### Motoko + +#### mops.toml + +```toml +[package] +name = "evm-rpc-app" +version = "0.1.0" + +[dependencies] +core = "2.0.0" +``` + +#### src/backend/main.mo — Get ETH Balance + +```motoko +import EvmRpc "canister:evm_rpc"; +import Runtime "mo:core/Runtime"; +import Text "mo:core/Text"; + +persistent actor { + + // Get ETH balance for an address on Ethereum mainnet + public func getEthBalance(address : Text) : async Text { + let services = #EthMainnet(null); // Use all default providers + let config = null; + + // eth_call with balance check via raw JSON-RPC + let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}"; + let maxResponseBytes : Nat64 = 1000; + + // Get exact cost first + let cyclesResult = await EvmRpc.requestCost(#EthMainnet(#PublicNode), json, maxResponseBytes); + let cost = switch (cyclesResult) { + case (#Ok(c)) { c }; + case (#Err(err)) { Runtime.trap("requestCost failed: " # debug_show err) }; + }; + + let result = await (with cycles = cost) EvmRpc.request( + #EthMainnet(#PublicNode), + json, + maxResponseBytes + ); + + switch (result) { + case (#Ok(response)) { response }; + case (#Err(err)) { Runtime.trap("RPC error: " # debug_show err) }; + } + }; + + // Get latest block using the typed API + public func getLatestBlock() : async ?EvmRpc.Block { + let services = #EthMainnet(null); + let config = null; + + let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getBlockByNumber( + services, + config, + #Latest + ); + + switch (result) { + case (#Consistent(#Ok(block))) { ?block }; + case (#Consistent(#Err(error))) { + Runtime.trap("Error: " # debug_show error); + }; + case (#Inconsistent(_results)) { + Runtime.trap("Providers returned inconsistent results"); + }; + } + }; + + // Read ERC-20 token balance (e.g., USDC on Ethereum) + // Function selector for balanceOf(address): 0x70a08231 + // Pad address to 32 bytes (remove 0x prefix, left-pad with zeros) + public func getErc20Balance(tokenContract : Text, walletAddress : Text) : async ?Text { + let services = #EthMainnet(null); + let config = null; + + // Encode: balanceOf(address) = 0x70a08231 + address padded to 32 bytes + // walletAddress should be like "0xABC..." — strip 0x and left-pad to 64 hex chars + let calldata = "0x70a08231000000000000000000000000" # stripHexPrefix(walletAddress); + + let result = await (with cycles = 10_000_000_000) EvmRpc.eth_call( + services, + config, + { + block = null; + transaction = { + to = ?tokenContract; + input = ?calldata; + // All optional fields set to null + accessList = null; + blobVersionedHashes = null; + blobs = null; + chainId = null; + from = null; + gas = null; + gasPrice = null; + maxFeePerBlobGas = null; + maxFeePerGas = null; + maxPriorityFeePerGas = null; + nonce = null; + type_ = null; + value = null; + }; + } + ); + + switch (result) { + case (#Consistent(#Ok(response))) { ?response }; + case (#Consistent(#Err(error))) { + Runtime.trap("eth_call error: " # debug_show error); + }; + case (#Inconsistent(_)) { + Runtime.trap("Inconsistent results from providers"); + }; + } + }; + + // Helper: strip "0x" prefix from hex string + func stripHexPrefix(hex : Text) : Text { + let chars = hex.chars(); + switch (chars.next(), chars.next()) { + case (?"0", ?"x") { + var rest = ""; + for (c in chars) { rest #= Text.fromChar(c) }; + rest + }; + case _ { hex }; + } + }; + + // Send a signed raw transaction + public func sendRawTransaction(signedTxHex : Text) : async ?EvmRpc.SendRawTransactionStatus { + let services = #EthMainnet(null); + let config = null; + + let result = await (with cycles = 10_000_000_000) EvmRpc.eth_sendRawTransaction( + services, + config, + signedTxHex + ); + + switch (result) { + case (#Consistent(#Ok(status))) { ?status }; + case (#Consistent(#Err(error))) { + Runtime.trap("sendRawTransaction error: " # debug_show error); + }; + case (#Inconsistent(_)) { + Runtime.trap("Inconsistent results"); + }; + } + }; + + // Get transaction receipt + public func getTransactionReceipt(txHash : Text) : async ?EvmRpc.TransactionReceipt { + let services = #EthMainnet(null); + let config = null; + + let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getTransactionReceipt( + services, + config, + txHash + ); + + switch (result) { + case (#Consistent(#Ok(receipt))) { receipt }; + case (#Consistent(#Err(error))) { + Runtime.trap("Error: " # debug_show error); + }; + case (#Inconsistent(_)) { + Runtime.trap("Inconsistent results"); + }; + } + }; + + // Using a specific provider (instead of multi-provider consensus) + public func getBalanceViaPublicNode(address : Text) : async Text { + let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}"; + let maxResponseBytes : Nat64 = 1000; + + let result = await (with cycles = 10_000_000_000) EvmRpc.request( + #EthMainnet(#PublicNode), // Single specific provider + json, + maxResponseBytes + ); + + switch (result) { + case (#Ok(response)) { response }; + case (#Err(err)) { Runtime.trap("Error: " # debug_show err) }; + } + }; + + // Querying a different chain (Arbitrum) + public func getArbitrumBlock() : async ?EvmRpc.Block { + let result = await (with cycles = 10_000_000_000) EvmRpc.eth_getBlockByNumber( + #ArbitrumOne(null), // Arbitrum One + null, + #Latest + ); + + switch (result) { + case (#Consistent(#Ok(block))) { ?block }; + case (#Consistent(#Err(error))) { + Runtime.trap("Error: " # debug_show error); + }; + case (#Inconsistent(_)) { + Runtime.trap("Inconsistent results"); + }; + } + }; + + // Using a custom RPC endpoint + public func getBalanceCustomRpc(address : Text, rpcUrl : Text) : async Text { + let json = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"" # address # "\",\"latest\"],\"id\":1}"; + + let result = await (with cycles = 10_000_000_000) EvmRpc.request( + #Custom({ url = rpcUrl; headers = null }), + json, + 1000 + ); + + switch (result) { + case (#Ok(response)) { response }; + case (#Err(err)) { Runtime.trap("Error: " # debug_show err) }; + } + }; +}; +``` + +### Rust + +#### Cargo.toml + +```toml +[package] +name = "evm_rpc_backend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ic-cdk = "0.19" +candid = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +#### src/lib.rs + +```rust +use candid::{CandidType, Deserialize, Principal}; +use ic_cdk::call::Call; +use ic_cdk::update; + +const EVM_RPC_CANISTER: &str = "7hfb6-caaaa-aaaar-qadga-cai"; + +fn evm_rpc_id() -> Principal { + Principal::from_text(EVM_RPC_CANISTER).unwrap() +} + +// -- Types matching the EVM RPC canister Candid interface -- + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum RpcServices { + EthMainnet(Option>), + EthSepolia(Option>), + ArbitrumOne(Option>), + BaseMainnet(Option>), + OptimismMainnet(Option>), + Custom { + #[serde(rename = "chainId")] + chain_id: u64, + services: Vec, + }, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum RpcService { + EthMainnet(EthMainnetService), + EthSepolia(EthSepoliaService), + ArbitrumOne(L2MainnetService), + BaseMainnet(L2MainnetService), + OptimismMainnet(L2MainnetService), + Custom(CustomRpcService), + Provider(u64), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum EthMainnetService { + Alchemy, + Ankr, + BlockPi, + Cloudflare, + Llama, + PublicNode, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum EthSepoliaService { + Alchemy, + Ankr, + BlockPi, + PublicNode, + Sepolia, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum L2MainnetService { + Alchemy, + Ankr, + BlockPi, + Llama, + PublicNode, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +struct HttpHeader { + name: String, + value: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +struct CustomRpcService { + url: String, + headers: Option>, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum BlockTag { + Latest, + Safe, + Finalized, + Earliest, + Pending, + Number(candid::Nat), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum MultiResult { + Consistent(RpcResult), + Inconsistent(Vec<(RpcService, RpcResult)>), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum RpcResult { + Ok(T), + Err(RpcError), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum RpcError { + ProviderError(ProviderError), + HttpOutcallError(HttpOutcallError), + JsonRpcError(JsonRpcError), + ValidationError(ValidationError), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum ProviderError { + TooFewCycles { expected: candid::Nat, received: candid::Nat }, + MissingRequiredProvider, + ProviderNotFound, + NoPermission, + InvalidRpcConfig(String), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum RejectionCode { + NoError, + CanisterError, + SysTransient, + DestinationInvalid, + Unknown, + SysFatal, + CanisterReject, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum HttpOutcallError { + IcError { code: RejectionCode, message: String }, + InvalidHttpJsonRpcResponse { + status: u16, + body: String, + #[serde(rename = "parsingError")] + parsing_error: Option, + }, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +struct JsonRpcError { + code: i64, + message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum ValidationError { + Custom(String), + InvalidHex(String), +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +struct Block { + #[serde(rename = "baseFeePerGas")] + base_fee_per_gas: Option, + number: candid::Nat, + difficulty: Option, + #[serde(rename = "extraData")] + extra_data: String, + #[serde(rename = "gasLimit")] + gas_limit: candid::Nat, + #[serde(rename = "gasUsed")] + gas_used: candid::Nat, + hash: String, + #[serde(rename = "logsBloom")] + logs_bloom: String, + miner: String, + #[serde(rename = "mixHash")] + mix_hash: String, + nonce: candid::Nat, + #[serde(rename = "parentHash")] + parent_hash: String, + #[serde(rename = "receiptsRoot")] + receipts_root: String, + #[serde(rename = "sha3Uncles")] + sha3_uncles: String, + size: candid::Nat, + #[serde(rename = "stateRoot")] + state_root: String, + timestamp: candid::Nat, + #[serde(rename = "totalDifficulty")] + total_difficulty: Option, + transactions: Vec, + #[serde(rename = "transactionsRoot")] + transactions_root: Option, + uncles: Vec, +} + +#[derive(CandidType, Deserialize, Clone, Debug)] +enum SendRawTransactionStatus { + Ok(Option), + NonceTooLow, + NonceTooHigh, + InsufficientFunds, +} + +// -- Get ETH balance via raw JSON-RPC -- + +#[update] +async fn get_eth_balance(address: String) -> String { + let json = format!( + r#"{{"jsonrpc":"2.0","method":"eth_getBalance","params":["{}","latest"],"id":1}}"#, + address + ); + let max_response_bytes: u64 = 1000; + let cycles: u128 = 10_000_000_000; + + let (result,): (Result,) = Call::unbounded_wait(evm_rpc_id(), "request") + .with_args(&( + RpcService::EthMainnet(EthMainnetService::PublicNode), + json, + max_response_bytes, + )) + .with_cycles(cycles) + .await + .expect("Failed to call EVM RPC canister") + .candid_tuple() + .expect("Failed to decode response"); + + match result { + Ok(response) => response, + Err(err) => ic_cdk::trap(&format!("RPC error: {:?}", err)), + } +} + +// -- Get latest block via typed API -- + +#[update] +async fn get_latest_block() -> Block { + let cycles: u128 = 10_000_000_000; + + let (result,): (MultiResult,) = Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber") + .with_args(&( + RpcServices::EthMainnet(None), + None::<()>, // config + BlockTag::Latest, + )) + .with_cycles(cycles) + .await + .expect("Failed to call eth_getBlockByNumber") + .candid_tuple() + .expect("Failed to decode response"); + + match result { + MultiResult::Consistent(RpcResult::Ok(block)) => block, + MultiResult::Consistent(RpcResult::Err(err)) => { + ic_cdk::trap(&format!("RPC error: {:?}", err)) + } + MultiResult::Inconsistent(_) => { + ic_cdk::trap("Providers returned inconsistent results") + } + } +} + +// -- Read ERC-20 balance -- + +#[update] +async fn get_erc20_balance(token_contract: String, wallet_address: String) -> String { + // balanceOf(address) selector: 0x70a08231 + // Pad the address to 32 bytes (strip 0x, left-pad with zeros) + let addr = wallet_address.trim_start_matches("0x"); + let calldata = format!("0x70a08231{:0>64}", addr); + + let json = format!( + r#"{{"jsonrpc":"2.0","method":"eth_call","params":[{{"to":"{}","data":"{}"}},"latest"],"id":1}}"#, + token_contract, calldata + ); + let cycles: u128 = 10_000_000_000; + + let (result,): (Result,) = Call::unbounded_wait(evm_rpc_id(), "request") + .with_args(&( + RpcService::EthMainnet(EthMainnetService::PublicNode), + json, + 2048_u64, + )) + .with_cycles(cycles) + .await + .expect("Failed to call EVM RPC canister") + .candid_tuple() + .expect("Failed to decode response"); + + match result { + Ok(response) => response, + Err(err) => ic_cdk::trap(&format!("RPC error: {:?}", err)), + } +} + +// -- Send signed raw transaction -- + +#[update] +async fn send_raw_transaction(signed_tx_hex: String) -> SendRawTransactionStatus { + let cycles: u128 = 10_000_000_000; + + let (result,): (MultiResult,) = Call::unbounded_wait(evm_rpc_id(), "eth_sendRawTransaction") + .with_args(&( + RpcServices::EthMainnet(None), + None::<()>, + signed_tx_hex, + )) + .with_cycles(cycles) + .await + .expect("Failed to call eth_sendRawTransaction") + .candid_tuple() + .expect("Failed to decode response"); + + match result { + MultiResult::Consistent(RpcResult::Ok(status)) => status, + MultiResult::Consistent(RpcResult::Err(err)) => { + ic_cdk::trap(&format!("RPC error: {:?}", err)) + } + MultiResult::Inconsistent(_) => { + ic_cdk::trap("Providers returned inconsistent results") + } + } +} + +// -- Query Arbitrum (different chain example) -- + +#[update] +async fn get_arbitrum_block() -> Block { + let cycles: u128 = 10_000_000_000; + + let (result,): (MultiResult,) = Call::unbounded_wait(evm_rpc_id(), "eth_getBlockByNumber") + .with_args(&( + RpcServices::ArbitrumOne(None), + None::<()>, + BlockTag::Latest, + )) + .with_cycles(cycles) + .await + .expect("Failed to call eth_getBlockByNumber") + .candid_tuple() + .expect("Failed to decode response"); + + match result { + MultiResult::Consistent(RpcResult::Ok(block)) => block, + MultiResult::Consistent(RpcResult::Err(err)) => { + ic_cdk::trap(&format!("RPC error: {:?}", err)) + } + MultiResult::Inconsistent(_) => { + ic_cdk::trap("Inconsistent results") + } + } +} + +ic_cdk::export_candid!(); +``` + +## Deploy & Test + +### Local Development + +```bash +# Start local replica +icp network start -d + +# Pull the EVM RPC canister +icp deps pull +icp deps init evm_rpc --argument '(record {})' +icp deps deploy + +# Deploy your backend +icp deploy backend +``` + +### Deploy to Mainnet + +```bash +# On mainnet, the EVM RPC canister is already deployed. +# Your canister calls it directly by principal. +icp deploy backend -e ic +``` + +### Test via icp CLI + +```bash +# Set up variables +export CYCLES=10000000000 + +# Get ETH balance (raw JSON-RPC via single provider) +icp canister call evm_rpc request '( + variant { EthMainnet = variant { PublicNode } }, + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\",\"latest\"],\"id\":1}", + 1000 +)' --with-cycles=$CYCLES + +# Get latest block (typed API, multi-provider) +icp canister call evm_rpc eth_getBlockByNumber '( + variant { EthMainnet = null }, + null, + variant { Latest } +)' --with-cycles=$CYCLES + +# Get transaction receipt +icp canister call evm_rpc eth_getTransactionReceipt '( + variant { EthMainnet = null }, + null, + "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" +)' --with-cycles=$CYCLES + +# Check available providers +icp canister call evm_rpc getProviders + +# Estimate cost before calling +icp canister call evm_rpc requestCost '( + variant { EthMainnet = variant { PublicNode } }, + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\",\"latest\"],\"id\":1}", + 1000 +)' +``` + +## Verify It Works + +### Check ETH Balance + +```bash +icp canister call backend get_eth_balance '("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")' +# Expected: JSON string like '{"jsonrpc":"2.0","id":1,"result":"0x..."}' +# The result is the balance in wei (hex encoded) +``` + +### Check Latest Block + +```bash +icp canister call backend get_latest_block +# Expected: record { number = ...; hash = "0x..."; timestamp = ...; ... } +``` + +### Check ERC-20 Balance (USDC) + +```bash +# USDC contract on Ethereum: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 +icp canister call backend get_erc20_balance '( + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" +)' +# Expected: JSON with hex-encoded uint256 balance +``` + +### Verify Cycle Refunds + +Check your canister cycle balance before and after an RPC call: + +```bash +# Before +icp canister status backend -e ic + +# Make a call +icp canister call backend get_eth_balance '("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")' -e ic + +# After — unused cycles from the 10B budget are refunded +icp canister status backend -e ic +``` diff --git a/skills/canister-calls/references/icrc-ledger.md b/skills/canister-calls/references/icrc-ledger.md new file mode 100644 index 0000000..b8e05be --- /dev/null +++ b/skills/canister-calls/references/icrc-ledger.md @@ -0,0 +1,508 @@ +# ICRC Ledger Standards + +## What This Is +ICRC-1 is the fungible token standard on Internet Computer, defining transfer, balance, and metadata interfaces. ICRC-2 extends it with approve/transferFrom (allowance) mechanics, enabling third-party spending like ERC-20 on Ethereum. + +## Prerequisites + +- For Motoko: mops with `core = "2.0.0"` in mops.toml +- For Rust: `ic-cdk = "0.19"`, `candid = "0.10"`, `icrc-ledger-types = "0.1"` in Cargo.toml + +## Canister IDs + +| Token | Ledger Canister ID | Fee | Decimals | +|-------|-------------------|-----|----------| +| ICP | `ryjl3-tyaaa-aaaaa-aaaba-cai` | 10000 e8s (0.0001 ICP) | 8 | +| ckBTC | `mxzaz-hqaaa-aaaar-qaada-cai` | 10 satoshis | 8 | +| ckETH | `ss2fx-dyaaa-aaaar-qacoq-cai` | 2000000000000 wei (0.000002 ETH) | 18 | + +Index canisters (for transaction history): +- ICP Index: `qhbym-qaaaa-aaaaa-aaafq-cai` +- ckBTC Index: `n5wcd-faaaa-aaaar-qaaea-cai` +- ckETH Index: `s3zol-vqaaa-aaaar-qacpa-cai` + +## Mistakes That Break Your Build + +1. **Wrong fee amount** -- ICP fee is 10000 e8s, NOT 10000 ICP. ckBTC fee is 10 satoshis, NOT 10 ckBTC. Using the wrong unit drains your entire balance in one transfer. + +2. **Forgetting approve before transferFrom** -- ICRC-2 transferFrom will reject with `InsufficientAllowance` if the token owner has not called `icrc2_approve` first. This is a two-step flow: owner approves, then spender calls transferFrom. + +3. **Not handling Err variants** -- `icrc1_transfer` returns `Result`, not just `Nat`. The error variants are: `BadFee`, `BadBurn`, `InsufficientFunds`, `TooOld`, `CreatedInFuture`, `Duplicate`, `TemporarilyUnavailable`, `GenericError`. You must match on every variant or at minimum propagate the error. + +4. **Using wrong Account format** -- An ICRC-1 Account is `{ owner: Principal; subaccount: ?Blob }`, NOT just a Principal. The subaccount is a 32-byte blob. Passing null/None for subaccount uses the default subaccount (all zeros). + +5. **Omitting created_at_time** -- Without `created_at_time`, you lose deduplication protection. Two identical transfers submitted within 24h will both execute. Set `created_at_time` to `Time.now()` (Motoko) or `ic_cdk::api::time()` (Rust) for dedup. + +6. **Hardcoding canister IDs as text** -- Always use `Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai")` (Motoko) or `Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai")` (Rust). Never pass raw strings where a Principal is expected. + +7. **Calling ledger from frontend** -- ICRC-1 transfers should originate from a backend canister, not directly from the frontend. Frontend-initiated transfers expose the user to reentrancy and can bypass business logic. Use a backend canister as the intermediary. + +8. **Shell substitution in `--argument-file` / `init_arg_file`** -- Expressions like `$(icp identity principal)` do NOT expand inside files referenced by `init_arg_file` or `--argument-file`. The file is read as literal text. Either use `--argument` on the command line (where the shell expands variables), or pre-generate the file with `envsubst` / `sed` before deploying. + +## Implementation + +### Motoko + +#### Imports and Types + +```motoko +import Principal "mo:core/Principal"; +import Nat "mo:core/Nat"; +import Nat8 "mo:core/Nat8"; +import Nat64 "mo:core/Nat64"; +import Blob "mo:core/Blob"; +import Time "mo:core/Time"; +import Int "mo:core/Int"; +import Runtime "mo:core/Runtime"; +``` + +#### Define the ICRC-1 Actor Interface + +```motoko +persistent actor { + + type Account = { + owner : Principal; + subaccount : ?Blob; + }; + + type TransferArg = { + from_subaccount : ?Blob; + to : Account; + amount : Nat; + fee : ?Nat; + memo : ?Blob; + created_at_time : ?Nat64; + }; + + type TransferError = { + #BadFee : { expected_fee : Nat }; + #BadBurn : { min_burn_amount : Nat }; + #InsufficientFunds : { balance : Nat }; + #TooOld; + #CreatedInFuture : { ledger_time : Nat64 }; + #Duplicate : { duplicate_of : Nat }; + #TemporarilyUnavailable; + #GenericError : { error_code : Nat; message : Text }; + }; + + type ApproveArg = { + from_subaccount : ?Blob; + spender : Account; + amount : Nat; + expected_allowance : ?Nat; + expires_at : ?Nat64; + fee : ?Nat; + memo : ?Blob; + created_at_time : ?Nat64; + }; + + type ApproveError = { + #BadFee : { expected_fee : Nat }; + #InsufficientFunds : { balance : Nat }; + #AllowanceChanged : { current_allowance : Nat }; + #Expired : { ledger_time : Nat64 }; + #TooOld; + #CreatedInFuture : { ledger_time : Nat64 }; + #Duplicate : { duplicate_of : Nat }; + #TemporarilyUnavailable; + #GenericError : { error_code : Nat; message : Text }; + }; + + type TransferFromArg = { + spender_subaccount : ?Blob; + from : Account; + to : Account; + amount : Nat; + fee : ?Nat; + memo : ?Blob; + created_at_time : ?Nat64; + }; + + type TransferFromError = { + #BadFee : { expected_fee : Nat }; + #BadBurn : { min_burn_amount : Nat }; + #InsufficientFunds : { balance : Nat }; + #InsufficientAllowance : { allowance : Nat }; + #TooOld; + #CreatedInFuture : { ledger_time : Nat64 }; + #Duplicate : { duplicate_of : Nat }; + #TemporarilyUnavailable; + #GenericError : { error_code : Nat; message : Text }; + }; + + // Remote ledger actor reference (ICP ledger shown; swap canister ID for other tokens) + transient let icpLedger = actor ("ryjl3-tyaaa-aaaaa-aaaba-cai") : actor { + icrc1_balance_of : shared query (Account) -> async Nat; + icrc1_transfer : shared (TransferArg) -> async { #Ok : Nat; #Err : TransferError }; + icrc2_approve : shared (ApproveArg) -> async { #Ok : Nat; #Err : ApproveError }; + icrc2_transfer_from : shared (TransferFromArg) -> async { #Ok : Nat; #Err : TransferFromError }; + icrc1_fee : shared query () -> async Nat; + icrc1_decimals : shared query () -> async Nat8; + }; + + // Check balance + public func getBalance(who : Principal) : async Nat { + await icpLedger.icrc1_balance_of({ + owner = who; + subaccount = null; + }) + }; + + // Transfer tokens (this canister sends from its own account) + // WARNING: Add access control in production — this allows any caller to transfer tokens + public func sendTokens(to : Principal, amount : Nat) : async Nat { + let now = Nat64.fromNat(Int.abs(Time.now())); + let result = await icpLedger.icrc1_transfer({ + from_subaccount = null; + to = { owner = to; subaccount = null }; + amount = amount; + fee = ?10000; // ICP fee: 10000 e8s + memo = null; + created_at_time = ?now; + }); + switch (result) { + case (#Ok(blockIndex)) { blockIndex }; + case (#Err(#InsufficientFunds({ balance }))) { + Runtime.trap("Insufficient funds. Balance: " # Nat.toText(balance)) + }; + case (#Err(#BadFee({ expected_fee }))) { + Runtime.trap("Wrong fee. Expected: " # Nat.toText(expected_fee)) + }; + case (#Err(_)) { Runtime.trap("Transfer failed") }; + } + }; + + // ICRC-2: Approve a spender + public shared ({ caller }) func approveSpender(spender : Principal, amount : Nat) : async Nat { + // caller is captured at function entry in Motoko -- safe across await + let now = Nat64.fromNat(Int.abs(Time.now())); + let result = await icpLedger.icrc2_approve({ + from_subaccount = null; + spender = { owner = spender; subaccount = null }; + amount = amount; + expected_allowance = null; + expires_at = null; + fee = ?10000; + memo = null; + created_at_time = ?now; + }); + switch (result) { + case (#Ok(blockIndex)) { blockIndex }; + case (#Err(_)) { Runtime.trap("Approve failed") }; + } + }; + + // ICRC-2: Transfer from another account (requires prior approval) + // WARNING: Add access control in production — this allows any caller to transfer tokens + public func transferFrom(from : Principal, to : Principal, amount : Nat) : async Nat { + let now = Nat64.fromNat(Int.abs(Time.now())); + let result = await icpLedger.icrc2_transfer_from({ + spender_subaccount = null; + from = { owner = from; subaccount = null }; + to = { owner = to; subaccount = null }; + amount = amount; + fee = ?10000; + memo = null; + created_at_time = ?now; + }); + switch (result) { + case (#Ok(blockIndex)) { blockIndex }; + case (#Err(#InsufficientAllowance({ allowance }))) { + Runtime.trap("Insufficient allowance: " # Nat.toText(allowance)) + }; + case (#Err(_)) { Runtime.trap("TransferFrom failed") }; + } + }; +} +``` + +### Rust + +#### Cargo.toml Dependencies + +```toml +[package] +name = "icrc_ledger_backend" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +ic-cdk = "0.19" +candid = "0.10" +icrc-ledger-types = "0.1" +serde = { version = "1", features = ["derive"] } +``` + +#### Complete Implementation + +```rust +use candid::{Nat, Principal}; +use icrc_ledger_types::icrc1::account::Account; +use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError}; +use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; +use icrc_ledger_types::icrc2::transfer_from::{TransferFromArgs, TransferFromError}; +use ic_cdk::update; +use ic_cdk::call::Call; + +const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; +const ICP_FEE: u64 = 10_000; // 10000 e8s + +fn ledger_id() -> Principal { + Principal::from_text(ICP_LEDGER).unwrap() +} + +// Check balance +#[update] +async fn get_balance(who: Principal) -> Nat { + let account = Account { + owner: who, + subaccount: None, + }; + let (balance,): (Nat,) = Call::unbounded_wait(ledger_id(), "icrc1_balance_of") + .with_arg(account) + .await + .expect("Failed to call icrc1_balance_of") + .candid_tuple() + .expect("Failed to decode response"); + balance +} + +// Transfer tokens from this canister's account +// WARNING: Add access control in production — this allows any caller to transfer tokens +#[update] +async fn send_tokens(to: Principal, amount: Nat) -> Result { + let transfer_arg = TransferArg { + from_subaccount: None, + to: Account { + owner: to, + subaccount: None, + }, + amount, + fee: Some(Nat::from(ICP_FEE)), + memo: None, + created_at_time: Some(ic_cdk::api::time()), + }; + + let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc1_transfer") + .with_arg(transfer_arg) + .await + .map_err(|e| format!("Call failed: {:?}", e))? + .candid_tuple() + .map_err(|e| format!("Decode failed: {:?}", e))?; + + match result { + Ok(block_index) => Ok(block_index), + Err(TransferError::InsufficientFunds { balance }) => { + Err(format!("Insufficient funds. Balance: {}", balance)) + } + Err(TransferError::BadFee { expected_fee }) => { + Err(format!("Wrong fee. Expected: {}", expected_fee)) + } + Err(e) => Err(format!("Transfer error: {:?}", e)), + } +} + +// ICRC-2: Approve a spender +#[update] +async fn approve_spender(spender: Principal, amount: Nat) -> Result { + let args = ApproveArgs { + from_subaccount: None, + spender: Account { + owner: spender, + subaccount: None, + }, + amount, + expected_allowance: None, + expires_at: None, + fee: Some(Nat::from(ICP_FEE)), + memo: None, + created_at_time: Some(ic_cdk::api::time()), + }; + + let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc2_approve") + .with_arg(args) + .await + .map_err(|e| format!("Call failed: {:?}", e))? + .candid_tuple() + .map_err(|e| format!("Decode failed: {:?}", e))?; + + result.map_err(|e| format!("Approve error: {:?}", e)) +} + +// ICRC-2: Transfer from another account (requires prior approval) +// WARNING: Add access control in production — this allows any caller to transfer tokens +#[update] +async fn transfer_from(from: Principal, to: Principal, amount: Nat) -> Result { + let args = TransferFromArgs { + spender_subaccount: None, + from: Account { + owner: from, + subaccount: None, + }, + to: Account { + owner: to, + subaccount: None, + }, + amount, + fee: Some(Nat::from(ICP_FEE)), + memo: None, + created_at_time: Some(ic_cdk::api::time()), + }; + + let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc2_transfer_from") + .with_arg(args) + .await + .map_err(|e| format!("Call failed: {:?}", e))? + .candid_tuple() + .map_err(|e| format!("Decode failed: {:?}", e))?; + + result.map_err(|e| format!("TransferFrom error: {:?}", e)) +} +``` + +## Deploy & Test + +### Deploy a Local ICRC-1 Ledger for Testing + +Add to `icp.yaml`: + +Pin the release version before deploying: get the latest release tag from https://github.com/dfinity/ic/releases?q=%22ledger-suite-icrc%22&expanded=false, then substitute it for `` in both URLs below. + +```yaml +canisters: + icrc1_ledger: + name: icrc1_ledger + recipe: + type: custom + candid: "https://github.com/dfinity/ic/releases/download//ledger.did" + wasm: "https://github.com/dfinity/ic/releases/download//ic-icrc1-ledger.wasm.gz" + config: + init_arg_file: "icrc1_ledger_init.args" +``` + +Create `icrc1_ledger_init.args` (replace `YOUR_PRINCIPAL` with the output of `icp identity principal`): + +> **Pitfall:** Shell substitutions like `$(icp identity principal)` will NOT expand inside this file. You must paste the literal principal string. + +``` +(variant { Init = record { + token_symbol = "TEST"; + token_name = "Test Token"; + minting_account = record { owner = principal "YOUR_PRINCIPAL" }; + transfer_fee = 10_000 : nat; + metadata = vec {}; + initial_balances = vec { + record { + record { owner = principal "YOUR_PRINCIPAL" }; + 100_000_000_000 : nat; + }; + }; + archive_options = record { + num_blocks_to_archive = 1000 : nat64; + trigger_threshold = 2000 : nat64; + controller_id = principal "YOUR_PRINCIPAL"; + }; + feature_flags = opt record { icrc2 = true }; +}}) +``` + +Deploy: + +```bash +# Start local replica +icp network start -d + +# Deploy the ledger +icp deploy icrc1_ledger + +# Verify it deployed +icp canister id icrc1_ledger +``` + +### Interact with Mainnet Ledgers + +```bash +# Check ICP balance +icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_balance_of \ + "(record { owner = principal \"$(icp identity principal)\"; subaccount = null })" \ + -e ic + +# Check token metadata +icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_metadata '()' -e ic + +# Check fee +icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_fee '()' -e ic + +# Transfer ICP (amount in e8s: 100000000 = 1 ICP) +icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_transfer \ + "(record { + to = record { owner = principal \"TARGET_PRINCIPAL_HERE\"; subaccount = null }; + amount = 100_000_000 : nat; + fee = opt (10_000 : nat); + memo = null; + from_subaccount = null; + created_at_time = null; + })" -e ic +``` + +## Verify It Works + +### Local Ledger Verification + +```bash +# 1. Check your balance (should show initial minted amount) +icp canister call icrc1_ledger icrc1_balance_of \ + "(record { owner = principal \"$(icp identity principal)\"; subaccount = null })" +# Expected: (100_000_000_000 : nat) + +# 2. Check fee +icp canister call icrc1_ledger icrc1_fee '()' +# Expected: (10_000 : nat) + +# 3. Check decimals +icp canister call icrc1_ledger icrc1_decimals '()' +# Expected: (8 : nat8) + +# 4. Check symbol +icp canister call icrc1_ledger icrc1_symbol '()' +# Expected: ("TEST") + +# 5. Transfer to another identity +icp identity new test-recipient --storage plaintext 2>/dev/null +RECIPIENT=$(icp identity principal --identity test-recipient) +icp canister call icrc1_ledger icrc1_transfer \ + "(record { + to = record { owner = principal \"$RECIPIENT\"; subaccount = null }; + amount = 1_000_000 : nat; + fee = opt (10_000 : nat); + memo = null; + from_subaccount = null; + created_at_time = null; + })" +# Expected: (variant { Ok = 0 : nat }) + +# 6. Verify recipient balance +icp canister call icrc1_ledger icrc1_balance_of \ + "(record { owner = principal \"$RECIPIENT\"; subaccount = null })" +# Expected: (1_000_000 : nat) +``` + +### Mainnet Verification + +```bash +# Verify ICP ledger is reachable +icp canister call ryjl3-tyaaa-aaaaa-aaaba-cai icrc1_symbol '()' -e ic +# Expected: ("ICP") + +# Verify ckBTC ledger is reachable +icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_symbol '()' -e ic +# Expected: ("ckBTC") + +# Verify ckETH ledger is reachable +icp canister call ss2fx-dyaaa-aaaar-qacoq-cai icrc1_symbol '()' -e ic +# Expected: ("ckETH") +```