Connect Any Blockchain with Chain Key Cryptography on ICP
A Developer’s Guide to Trustless Cross-Chain Communication on ICP
The blockchain world has a dirty secret. Blockchain bridges have long been a security nightmare, exposing users to massive risks, highlighted by events like the $600M+ Ronin Bridge hack and totaling approximately $2 billion in losses during 2022 alone. Getting different blockchains to talk to each other securely remains a big hurdle.
Often, they rely on centralized operators or introduce new security weak points, defeating the purpose of decentralization. Hacking incidents involving these bridges have, unfortunately, become increasingly common, highlighting a significant risk.
The core issue is trust. How can applications on one chain interact with another without needing to trust a go-between? This requirement for trustless interaction is fundamental for building truly decentralized systems that span multiple blockchains. Without it, we stay stuck in isolated blockchain environments, limiting the potential of Web3 applications.
The answer is Chain Key Cryptography. Think of it as a built-in cryptographic mechanism that allows ICP nodes to directly hold keys and sign transactions for other blockchains, like Bitcoin, Ethereum and many more.
This guide provides a practical walkthrough for developers. You will find explanations of how Chain Key Cryptography works on ICP, code examples for implementation, security considerations, and techniques for connecting your ICP applications to external networks effectively.
You can explore the core concepts further in the Chain Fusion Overview and see the growing list of Supported Chains.
Chain Key Cryptography Explained
At its heart, Chain Key Cryptography on ICP uses threshold cryptography. Imagine that a private key isn't held by one person or server, but is instead split into many secret shares. These shares are distributed across the independent nodes that run the Internet Computer Protocol.
To do anything requiring that private key, like signing a transaction, a minimum number of these nodes must cooperate using their shares. No single node, or even a small group, has enough information to reconstruct the full key or act alone. This design removes single points of failure.
You might see assets managed this way referred to as ckTokens, like ckBTC or ckETH. When a canister needs to initiate a transaction on an external chain, say sending some ckBTC, it requests a signature from the ICP network itself.
The nodes coordinate using their distributed key shares to generate the necessary signature directly on ICP. This entire mechanism bypasses the need for traditional, often centralized, bridging solutions and their associated risks.
The advantages become quite clear. Operations become trustless; you depend on the decentralized ICP protocol, not external bridge operators. Security is improved because there's no single key to target. An attacker would need to compromise a significant number of completely independent nodes.
Practical Implementation of Cross-Chain Canisters
Getting started with building cross-chain applications on ICP means using the standard development tools. You'll primarily work with the ICP CDK (Canister Development Kit) and choose between Motoko, a language developed specifically for ICP, or Rust, which also has strong support. Canisters, ICP's version of smart contracts, are where your logic will live.
A key aspect is how your canister manages keys derived through Chain Key Cryptography. Your canister doesn't hold the actual private key shares; it holds identifiers and public keys associated with the underlying threshold key managed by the network.
When your canister needs to perform an action, like signing a Bitcoin transaction, it calls specific system APIs. These APIs instruct the ICP network nodes to collaboratively generate the required signature using their distributed key shares. The canister receives the signature to use, but never handles the private key material directly. This maintains security.
Let's look at how you might request a signature conceptually. Keep in mind these are simplified illustrations, not complete code:
Motoko code:
actor {
// Define a reference to the IC management canister, which exposes the ECDSA signing API.
// The 'sign_with_ecdsa' method is used to request a threshold ECDSA signature from the subnet.
let ic : actor {
sign_with_ecdsa : ({
message_hash : Blob; // The 32-byte hash of the message (e.g., a Bitcoin transaction) to be signed.
derivation_path : [Blob]; // The derivation path, typically used to derive different keys for different callers or addresses.
key_id : { curve: { #secp256k1 }; name: Text }; // Identifies the ECDSA key to use (curve and key name).
}) -> async { signature : Blob }; // Returns the signature as a Blob asynchronously.
} = actor("aaaaa-aa"); // "aaaaa-aa" is the principal of the management canister.
// Public function to request a Bitcoin transaction signature.
// 'message_hash' must be a 32-byte SHA-256 hash of the transaction to sign.
public shared (msg) func sign_btc_tx(message_hash: Blob) : async { #Ok : Blob; #Err : Text } {
try {
// Attach the required amount of cycles to pay for the threshold ECDSA signing operation.
// The amount may vary; 10_000_000_000 is used for local development as shown in official examples.
Cycles.add(10_000_000_000);
// Call the management canister's 'sign_with_ecdsa' method.
// - 'message_hash' is the hash to sign.
// - 'derivation_path' is set to the caller's principal, so each caller gets a unique key.
// - 'key_id' specifies the curve and key name; "dfx_test_key" is for local development.
let result = await ic.sign_with_ecdsa({
message_hash = message_hash;
derivation_path = [Principal.toBlob(msg.caller)];
key_id = { curve = #secp256k1; name = "dfx_test_key" };
});
// If successful, return the signature in the #Ok variant.
#Ok(result.signature)
} catch (err) {
// If an error occurs, return the error message in the #Err variant.
#Err(Error.message(err))
}
}
}
Interacting with Ethereum involves similar principles but uses different system APIs tailored for EVM compatibility, often managing nonces and gas details:
import EvmRpc "canister:evm_rpc";
import Cycles "mo:base/ExperimentalCycles";
import Debug "mo:base/Debug";
actor {
public func call() : async ?Text {
// Specify the Ethereum network (mainnet in this example)
let services = #EthMainnet(null);
let config = null;
// Attach cycles to pay for the RPC call
Cycles.add<system>(2_000_000_000);
// Call an Ethereum smart contract (e.g., ERC20 balanceOf)
let result = await EvmRpc.eth_call(services, config, {
block = null;
transaction = {
to = ?"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // Contract address
input = ?"0x70a08231000000000000000000000000b25eA1D493B49a1DeD42aC5B1208cC618f9A9B80"; // ABI-encoded call data
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
};
});
// Handle the result
switch result {
case (#Consistent(#Ok response)) {
Debug.print("Success: " # debug_show response);
?response // ABI-encoded response
};
case (#Consistent(#Err error)) {
Debug.trap("Error: " # debug_show error);
null
};
case (#Inconsistent(_results)) {
Debug.trap("Inconsistent results");
null
};
};
};
};
Rust code:
// Conceptual: Interact with Ethereum smart contract via EVM RPC canister
use candid::{CandidType, Deserialize, Principal};
use ic_cdk::api::call::call_with_payment128;
#[derive(CandidType, Deserialize)]
struct EthCallArgs {
block: Option<String>,
transaction: EthTransaction,
}
#[derive(CandidType, Deserialize)]
struct EthTransaction {
to: Option<String>,
input: Option<String>,
// ... other fields omitted for brevity
}
#[ic_cdk::update]
async fn call_eth_contract() -> Result<String, String> {
let canister_id = Principal::from_text("evm_rpc_canister_id").unwrap();
let args = EthCallArgs {
block: None,
transaction: EthTransaction {
to: Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string()),
input: Some("0x70a08231000000000000000000000000b25eA1D493B49a1DeD42aC5B1208cC618f9A9B80".to_string()),
// ... other fields as needed
},
};
// Attach cycles as required
let (result,): (Result<String, String>,) = call_with_payment128(
canister_id,
"eth_call",
(args,),
2_000_000_000u128,
)
.await
.map_err(|e| format!("Call failed: {:?}", e.1))?;
result
}
After broadcasting a transaction to an external network, your canister needs a way to confirm its success.
This usually involves querying the target blockchain. You might build logic within your canister, or query the target chain directly using ICP's integration APIs. For instance, you can check Bitcoin state using methods like bitcoin_get_utxos or bitcoin_get_balance available through the Bitcoin integration API, or query Ethereum and other EVM chains via the EVM RPC canister., to periodically check the status of the transaction hash on the external chain.
Once confirmed, your canister can update its own state accordingly, perhaps marking an order as complete or releasing corresponding assets on ICP. This verification step is essential for robust cross-chain applications.
Security Patterns for Trustless Communication
Chain Key Cryptography connects ICP applications to external blockchains. Here’s the direct approach for Bitcoin and Ethereum.
Bitcoin (ckBTC)
For Bitcoin integration using ckBTC, your canister talks to the ckBTC minter canister on ICP. Send Bitcoin to its address or ICP tokens to the minter to get ckBTC on ICP. To send Bitcoin out, prepare the transaction details and ask the ckBTC system canister to sign it using the Chain Key API. This process puts Bitcoin liquidity into ICP DeFi applications. Conceptually, requesting a withdrawal might look like this simplified example.
Motoko code:
import EvmRpc "canister:evm_rpc";
import Cycles "mo:base/ExperimentalCycles";
import Debug "mo:base/Debug";
actor {
public func call() : async ?Text {
// Specify the Ethereum network (mainnet in this example)
let services = #EthMainnet(null);
let config = null;
// Attach cycles to pay for the RPC call
Cycles.add<system>(2_000_000_000);
// Call an Ethereum smart contract (e.g., ERC20 balanceOf)
let result = await EvmRpc.eth_call(services, config, {
block = null;
transaction = {
to = ?"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // Contract address
input =
?"0x70a08231000000000000000000000000b25eA1D493B49a1DeD42aC5B1208cC618f9A9B80"; // ABI-encoded call data
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
};
});
// Handle the result
switch result {
case (#Consistent(#Ok response)) {
Debug.print("Success: " # debug_show response);
?response // ABI-encoded response
};
case (#Consistent(#Err error)) {
Debug.trap("Error: " # debug_show error);
null
};
case (#Inconsistent(_results)) {
Debug.trap("Inconsistent results");
null
};
};
};
};
Rust code:
// Conceptual Rust: Requesting a ckBTC Withdrawal
use ic_cdk::api::call::call;
use candid::{Nat, Principal};
// Step 1: Approve the ckBTC minter to spend your ckBTC
async fn approve_ckbtc(amount: Nat) -> Result<(), String> {
let spender = Principal::from_text("mqygn-kiaaa-aaaar-qaadq-cai").unwrap(); // ckBTC minter canister ID
let args = (
spender,
amount,
None::<Vec<u8>>, // from_subaccount
None::<Nat>, // expected_allowance
None::<u64>, // expires_at
None::<Nat>, // fee
None::<Vec<u8>>, // memo
None::<u64>, // created_at_time
);
let _: () = call(
Principal::from_text("ckbtc_ledger_canister_id").unwrap(),
"icrc2_approve",
args,
)
.await
.map_err(|e| format!("Approve failed: {:?}", e.1))?;
Ok(())
}
// Step 2: Request withdrawal to a BTC address
async fn withdraw_ckbtc(btc_address: String, amount: Nat) -> Result<u64, String> {
let args = (
btc_address,
amount,
None::<Vec<u8>>, // from_subaccount
);
let (block_index,): (u64,) = call(
Principal::from_text("mqygn-kiaaa-aaaar-qaadq-cai").unwrap(), // ckBTC minter canister ID
"retrieve_btc_with_approval",
args,
)
.await
.map_err(|e| format!("Withdraw failed: {:?}", e.1))?;
Ok(block_index)
}
This also follows the ICRC-2 approval and withdrawal flow. You can see the source by using this link.
For technical details, refer to the ckBTC Overview and the ckBTC API Reference.
Ethereum (ckETH)
When minting or burning ckETH, the integration between the Internet Computer and Ethereum is handled by the ckETH minter canister and a helper contract on Ethereum. As a developer, you do not need to manually construct the full Ethereum transaction or handle parameters like gas, nonce, or recipient yourself.
To mint ckETH, a user deposits ETH into the ckETH helper contract on Ethereum, specifying their ICP principal or wallet address. The ckETH minter canister monitors the Ethereum network for these deposits and, upon detecting a valid deposit, mints the corresponding amount of ckETH to the user’s account on ICP. The Ethereum transaction (including all required parameters) is constructed and submitted by the user on Ethereum, not by the ICP canister
To burn ckETH and redeem ETH, the user interacts with the ckETH minter canister on ICP, specifying the amount to withdraw and the destination Ethereum address. The minter canister then burns the ckETH and constructs the corresponding Ethereum transaction internally. The minter uses chain-key ECDSA signing to securely sign the transaction, and then relays it to the Ethereum network via HTTPS outcalls. The user does not need to manually construct or sign the Ethereum transaction; this is abstracted away by the ckETH minter canister
Here’s a conceptual example for sending ckETH, highlighting the additional parameters compared to Bitcoin.
Motoko example:
// Motoko: Requesting a ckETH Withdrawal (burning ckETH to receive ETH on Ethereum)
import CkethLedger "canister:cketh_ledger_canister";
import CkethMinter "canister:cketh_minter_canister";
import Principal "mo:base/Principal";
actor {
// Step 1: Approve the ckETH minter to spend your ckETH (ICRC-2 approval)
public shared ({ caller }) func approve_cketh(amount: Nat) : async () {
let spender = Principal.fromText("jzenf-aiaaa-aaaar-qaa7q-cai"); // ckETH minter canister ID
let res = await CkethLedger.icrc2_approve({
spender = spender;
amount = amount;
from_subaccount = null;
expected_allowance = null;
expires_at = null;
fee = null;
memo = null;
created_at_time = null;
});
// Handle result (omitted for brevity)
};
// Step 2: Request withdrawal to an Ethereum address
public shared func withdraw_cketh(eth_address: Text, amount: Nat) : async () {
let res = await CkethMinter.withdraw_eth_with_approval({
eth_address = eth_address;
amount = amount;
from_subaccount = null;
});
// Handle result (transaction hash or error)
};
}
Rust example:
// Rust: Requesting a ckETH Withdrawal (burning ckETH to receive ETH on Ethereum)
use ic_cdk::api::call::call;
use candid::{Nat, Principal};
// Step 1: Approve the ckETH minter to spend your ckETH
async fn approve_cketh(amount: Nat) -> Result<(), String> {
let spender = Principal::from_text("jzenf-aiaaa-aaaar-qaa7q-cai").unwrap(); // ckETH minter canister ID
let args = (
spender,
amount,
None::<Vec<u8>>, // from_subaccount
None::<Nat>, // expected_allowance
None::<u64>, // expires_at
None::<Nat>, // fee
None::<Vec<u8>>, // memo
None::<u64>, // created_at_time
);
let _: () = call(
Principal::from_text("cketh_ledger_canister_id").unwrap(),
"icrc2_approve",
args,
)
.await
.map_err(|e| format!("Approve failed: {:?}", e.1))?;
Ok(())
}
// Step 2: Request withdrawal to an Ethereum address
async fn withdraw_cketh(eth_address: String, amount: Nat) -> Result<String, String> {
let args = (
eth_address,
amount,
None::<Vec<u8>>, // from_subaccount
);
let (tx_hash,): (String,) = call(
Principal::from_text("jzenf-aiaaa-aaaar-qaa7q-cai").unwrap(), // ckETH minter canister ID
"withdraw_eth_with_approval",
args,
)
.await
.map_err(|e| format!("Withdraw failed: {:?}", e.1))?;
Ok(tx_hash)
}
Both code snippets follow the ICRC-2 approval and withdrawal flow as described in the ICP docs.
Developers can follow the ICP ETH Developer Workflow and utilize the EVM RPC Canister for these interactions.
This basic pattern extends to other blockchains supported by Chain Fusion. You typically interact with a dedicated system canister or API for that specific chain. These system components manage address generation, transaction formatting, requesting threshold signatures, and often help check external transaction status. This simplifies adding support for new chains as ICP's Chain Fusion expands.
Speed and Efficiency Without Compromise
When dealing with cross-chain calls, expect some latency. Actions on external blockchains take time to confirm, which is different from near-instant operations purely within ICP. Your application design needs to account for this.
ICP's actor model and asynchronous programming support help manage this potential slowness. Use async/await patterns extensively in your Motoko or Rust code. This prevents your canister from blocking while waiting for an external operation to complete.
You can often structure your application to perform multiple independent cross-chain requests in parallel rather than waiting for each one sequentially. If you need to send multiple similar transactions to an external chain, investigate if batching them into a single request is possible through the relevant system API or helper canister; this can often reduce overhead. Also, pay attention to how you manage state within your canister; inefficient state handling can become a bottleneck, unrelated to the cross-chain calls themselves.
Cost is another factor. Operations on ICP consume "cycles," which developers pre-pay into their canisters. Cryptographic operations, especially signing requests for external chains, have a cycle cost. Querying external chains for transaction status also consumes cycles.
Monitor your canister's cycle consumption. You can check balances using dfx canister status if you are a controller, through the NNS frontend dapp, or employ third-party monitoring services like CycleOps for automated tracking and top-ups." (Note: Added citation from search results for CycleOps mention).
Optimize expensive operations where possible, perhaps by reducing the frequency of status checks or finding more efficient ways to structure your cross-chain logic. Understanding the cycle costs associated with Chain Key operations helps you build sustainable applications.
Advanced Applications & Future Directions
Chain Key Cryptography opens doors beyond basic asset transfers. Consider combining it with AI agents running directly on ICP canisters. An AI agent could analyze market conditions across multiple blockchains, then securely use Chain Key signing to execute trades or rebalance assets on external networks based on its analysis, all initiated from its protected environment on ICP. This merges ICP's AI capabilities with its secure cross-chain functionality.
Another area to explore is cross-chain governance. ICP's native governance systems, like the Network Nervous System (NNS) or Service Nervous Systems (SNS), could potentially manage resources or protocol parameters deployed across several blockchains. Imagine a DAO proposal on ICP triggering configuration changes on a connected Ethereum smart contract, all managed trustlessly through Chain Key interactions.
Keep an eye on the broader Chain Fusion ecosystem as well. Work is continually underway to integrate more blockchains with ICP. As new chains become supported, the possibilities for building sophisticated, genuinely multichain applications using Chain Key Cryptography will only increase. These advanced uses show where this technology is headed.
Build Your Bridge to the Multichain Future
Chain Key Cryptography stands out as the Internet Computer's native approach to secure, direct blockchain interoperability. It removes the need for risky intermediate bridges. This technology gives you, the developer, the tools to create genuinely trustless applications that operate seamlessly across different blockchain networks. You can connect functionalities and assets from many other chains directly within your ICP canisters.
Ready to start building these connections? Join the active ICP HUBS Network community. Find your local hub to connect with developers in your area and globally.
Explore the official developer documentation and resources provided by DFINITY and the community to deepen your technical knowledge.
Keep an eye out for upcoming developer events, workshops, and hackathons where you can learn practical skills and collaborate on new projects. The multichain future is being built now; Chain Key Cryptography is your toolset to be part of it.