ckSOL Integration
What This Is
ckSOL is an ICRC-1/ICRC-2 token backed 1:1 by SOL (9 decimals, 1 ckSOL = 1_000_000_000 lamports). The ckSOL minter derives one Solana deposit address per ICRC account from its threshold-Ed25519 key. It sweeps deposited SOL to its main address and mints once the sweep is finalized. For withdrawals it burns ckSOL via an ICRC-2 allowance and sends SOL using durable-nonce transactions, which never expire. It talks to Solana through the SOL RPC canister. Only native SOL is supported: no SPL tokens.
Source: github.com/dfinity/cksol. Minter v0.1.0 has run on mainnet since 2026-10.
Canister IDs
| Mainnet (Solana mainnet) | Staging (Solana Devnet, token “ckDevnetSOL”) | |
|---|---|---|
| Minter | lh22c-kyaaa-aaaar-qb5nq-cai |
ljyxk-riaaa-aaaar-qb5mq-cai |
| Ledger | ls5lp-lqaaa-aaaar-qb5oa-cai |
la34w-haaaa-aaaar-qb5na-cai |
| Index | 2ezyf-hqaaa-aaaar-qb6ga-cai |
2r6ji-gyaaa-aaaar-qb6fq-cai |
| Minter main Solana address | GXVewvv6HehcLFYCmwpqh5CrMjqN9zJ8rqzGq3HEt9Ax |
Br8eRkeya8hy3sHCYqtWqGeNNZ349aPSUKGVYFWKKer1 |
The ledger fee is 500 lamports. get_minter_info (query) on 2026-11-02 returned: minimum_deposit_amount = 20_000_000 (0.02 SOL), minimum_withdrawal_amount = 2_000_000 (0.002 SOL), withdrawal_fee = 1_000_000 (0.001 SOL), deposit_sol_required_cycles = 1_000_000_000_000, and deposit_sol_fee = 45_000_000_000. deposit_sol_fee is in cycles, not lamports. Read these values at runtime.
How the deposit address is computed
The minter uses non-hardened derivation from the IC threshold Ed25519 key key_1, with path [minter canister id bytes, [0x01], owner principal bytes, 32-byte subaccount (zeros for default)]. The Solana address is the base58 of the derived 32-byte public key. It is deterministic and stable, one address per (owner, subaccount), and is a plain system account. The minter’s main address is the key at the empty path, so it can never collide with a deposit address. You can derive it offline (deriveDepositAddressOffline below) or call get_deposit_address, an update call that anyone may make for any non-anonymous owner.
Deposit: SOL → ckSOL (deployed “manual” flow)
- Address.
get_deposit_address(record { owner = opt <principal>; subaccount = opt <32 bytes> | null })returns a base58 address. Ifowneris null, it defaults to the caller. - Send SOL to that address from any wallet or exchange, in one or several transfers (no memo). The total balance must reach
minimum_deposit_amount. - Notify.
deposit_sol(record { owner = opt <principal>; subaccount })with cycles attached (≥deposit_sol_required_cycles). The minter reads the finalized balance and charges about 2.3B cycles forgetBalanceplusdeposit_sol_fee(45B), refunding the rest. It then queuessweepable = balance − 890_880(the rent-exemption threshold stays on the address, once per address) and returnsOk(deposit_id : nat64). Only canisters can attach cycles, so a frontend cannot calldeposit_soldirectly: route it through your backend canister.ownermay be any principal, so a backend can pay for a user-owned account. - Sweep and mint happen on minter timers, about a minute each. Up to 10 deposits are swept per Solana transaction, and each deposit pays a 5_000-lamport fee share. ckSOL is minted only after the sweep is finalized. The user receives
sweepable − 5_000lamports. - Track with
deposit_sol_status(deposit_id)(query):Queued { sweepable_amount } → Swept { signature } → Finalized { signature } → Minted { block_index; minted_amount }. ADropped { signature }status means the sweep failed or expired, nothing was minted, and the funds are still on the address: calldeposit_solagain. AQuarantined { signature }status needs a minter upgrade: use a different subaccount for new deposits.
At most one deposit per account is in flight. Calling deposit_sol again while one is queued, swept or finalized returns the same deposit_id and refunds all cycles, so retries are safe. Example: the first deposit of exactly 1 SOL mints 999_104_120 lamports, the minimum 0.02 SOL mints 19_104_120, and a later deposit to the same address loses only the 5_000 fee share.
Upcoming automatic flow, not on mainnet as of 2026-11-02. watch_deposit_address(record { subaccount }) is free, needs no cycles, and has owner = caller. The minter then scans the address for about 24 h and sweeps automatically, deducting automatic_deposit_fee (0.002 SOL). Status is Watching | Detected { deposit_id }, and you follow it with deposit_sol_status. Before using it, check that the endpoint exists: icp canister metadata lh22c-kyaaa-aaaar-qb5nq-cai candid:service -n ic.
Withdrawal: ckSOL → SOL
icrc2_approveon the ckSOL ledger, with spender = minter and amount ≥ the withdrawal amount. Burns are fee-free, and the approve itself costs 500 lamports.withdraw_sol(record { address = "<base58>"; amount = <lamports : nat64>; from_subaccount = opt … })burns fromcaller + from_subaccount. There is no owner argument. It returnsOk { block_index }, the burn index, which is also the withdrawal id.- The minter batches up to 10 withdrawals per durable-nonce transaction. The destination receives
amount − withdrawal_fee. - Track with
withdraw_sol_status(record { block_index }), an update call:Pending → TxSent { transaction_id } → TxFinalized (Success | Failure) { transaction_id }.
Business errors come back as Err, not as traps: ValueTooSmall, MalformedAddress, InvalidDestination (Solana reserved/sysvar/program keys, the minter’s main address, its nonce accounts), InsufficientFunds, InsufficientAllowance, AlreadyProcessing (one withdrawal per principal at a time), and TemporarilyUnavailable.
Who calls what
| Step | Frontend (user identity) | Backend canister |
|---|---|---|
get_deposit_address |
✅ (owner = user) | ✅ |
deposit_sol (needs cycles) |
❌ | ✅ passes owner = opt user |
deposit_sol_status |
✅ query | ✅ |
icrc2_approve + withdraw_sol |
✅ (burns from the user’s own account) | ✅ only for tokens the canister owns (custodial subaccounts) |
withdraw_sol_status |
✅ | ✅ |
Non-custodial pattern: the frontend shows the address, then calls the backend’s notifySolDeposit, which calls deposit_sol for owner = caller with cycles. The frontend then polls deposit_sol_status, and the user withdraws directly from the frontend.
Mistakes That Break Your Build
- Calling
deposit_solfrom the browser or without cycles. It fails withInsufficientCycles. It must be called by a canister with(with cycles = …)/.with_cycles(…), or viaicp canister call … --proxy <canister> --cycles <n>. - Omitting
ownerwhen a canister callsget_deposit_address/deposit_sol. It defaults to the caller, which is your canister. The user’s SOL is then looked up at, and minted to, the canister’s account. Passowner = opt user, and use the same(owner, subaccount)in both calls. - Using stale API names from older docs. There is no
process_depositand no transaction-signature argument, and the withdrawal endpoint iswithdraw_sol, notwithdraw. The minter reads the address balance, so the user never needs a transaction signature. Track the result withdeposit_sol_status(deposit_id). - Expecting automatic detection on mainnet today. Without
deposit_sol, deposited SOL just sits on the deposit address. Nothing is lost, but nothing is minted until someone callsdeposit_sol, orwatch_deposit_addressonce it is deployed. - Depositing below the minimum.
ValueTooSmall { balance; minimum_deposit_amount }is checked against the address balance, which includes the 890_880-lamport rent threshold left by earlier sweeps. Top up and call again. Each rejected call still costs your canister about 2.3B cycles. - Sending SPL tokens (USDC on Solana, etc.) or using another chain. Only native SOL is credited.
- Promising 1:1 on the first deposit. The first sweep of an address keeps 890_880 lamports there, each sweep costs a 5_000-lamport share, and withdrawals cost
withdrawal_fee. Show usersminted_amountfromdeposit_sol_status. - Unbounded
deposit_solrelays. Each accepted call spends about 47B cycles (about 0.07 USD) of your canister. Authenticate, rate-limit per user, and reject anonymous callers (seecanister-security). - Using
u64vsnatincorrectly. Minter amounts arenat64lamports (Lamport), and ledger amounts arenat.deposit_sol_feeanddeposit_sol_required_cyclesarenatcycles. - Concurrent withdrawals from one principal.
withdraw_solreturnsAlreadyProcessingwhile another withdrawal by the same principal is in progress. A custodial canister must serialize its withdrawals.
Implementation: frontend (TypeScript)
The @icp-sdk/canisters package has no ckSOL module, so generate bindings from the deployed interface:
npm i @icp-sdk/core@^6 @icp-sdk/canisters@^4 @dfinity/utils@^5 @dfinity/ic-pub-key@^1 bs58@^6
icp canister metadata lh22c-kyaaa-aaaar-qb5nq-cai candid:service -n ic > cksol_minter.did
npx @icp-sdk/bindgen --did-file ./cksol_minter.did --out-dir ./src/bindings
import { HttpAgent, type Identity } from "@icp-sdk/core/agent";
import { Principal } from "@icp-sdk/core/principal";
import { IcrcLedgerCanister } from "@icp-sdk/canisters/ledger/icrc";
import { createActor } from "./bindings/cksol_minter";
import { schnorr } from "@dfinity/ic-pub-key";
import bs58 from "bs58";
const CKSOL_MINTER = Principal.fromText("lh22c-kyaaa-aaaar-qb5nq-cai");
const CKSOL_LEDGER = Principal.fromText("ls5lp-lqaaa-aaaar-qb5oa-cai");
export async function ckSol(identity: Identity) {
const agent = await HttpAgent.create({ identity });
return {
minter: createActor(CKSOL_MINTER.toText(), { agent }),
ledger: IcrcLedgerCanister.create({ agent, canisterId: CKSOL_LEDGER }),
};
}
type Ctx = Awaited<ReturnType<typeof ckSol>>;
// Deposit address: an update call; owner defaults to the caller when omitted.
export async function depositAddress({ minter }: Ctx, owner: Principal, subaccount?: Uint8Array) {
return minter.get_deposit_address({ owner, subaccount }); // base58 Solana address
}
// Same address computed offline (no IC call): mainnet Ed25519 "key_1",
// path = [minter canister id, [0x01], owner principal bytes, 32-byte subaccount].
export function deriveDepositAddressOffline(owner: Principal, subaccount: Uint8Array = new Uint8Array(32)) {
const master = schnorr.ed25519.PublicKeyWithChainCode.forMainnetKey("key_1");
const path = new schnorr.ed25519.DerivationPath([
CKSOL_MINTER.toUint8Array(), new Uint8Array([1]), owner.toUint8Array(), subaccount,
]);
const hex = master.deriveSubkeyWithChainCode(path).public_key.toHex();
return bs58.encode(Uint8Array.from(hex.match(/../g)!.map((b) => parseInt(b, 16))));
}
// Track a deposit queued by deposit_sol (called by your backend, which attaches cycles).
export async function depositStatus({ minter }: Ctx, depositId: bigint) {
return minter.deposit_sol_status(depositId); // NotFound | Queued | Swept | Finalized | Minted | Dropped | Quarantined
}
export async function withdrawSol(ctx: Ctx, address: string, amountLamports: bigint) {
const info = await ctx.minter.get_minter_info();
if (amountLamports < info.minimum_withdrawal_amount) throw new Error("below minimum_withdrawal_amount");
await ctx.ledger.approve({ spender: { owner: CKSOL_MINTER, subaccount: [] }, amount: amountLamports });
const res = await ctx.minter.withdraw_sol({ address, amount: amountLamports });
if (res.__kind__ === "Err") throw new Error(res.Err.__kind__);
// recipient receives amountLamports - info.withdrawal_fee
return res.Ok.block_index; // poll withdraw_sol_status({ block_index })
}
export async function withdrawStatus({ minter }: Ctx, blockIndex: bigint) {
return minter.withdraw_sol_status({ block_index: blockIndex }); // NotFound | Pending | TxSent | TxFinalized
}
The user sends SOL with their own wallet (Phantom, Solflare, an exchange) to the returned address. No Solana SDK is needed in your app unless you want to build that transfer yourself.
Implementation: backend relay for deposit_sol
Rust
Cargo.toml dependencies: ic-cdk = "0.19", candid = "0.10", serde = { version = "1", features = ["derive"] }, serde_bytes = "0.11".
use candid::{CandidType, Deserialize, Nat, Principal};
use ic_cdk::call::Call;
use ic_cdk::update;
const CKSOL_MINTER: &str = "lh22c-kyaaa-aaaar-qb5nq-cai";
const DEPOSIT_SOL_CYCLES: u128 = 1_000_000_000_000; // >= get_minter_info().deposit_sol_required_cycles; unused part is refunded
#[derive(CandidType, Deserialize)]
struct AccountArgs { owner: Option<Principal>, subaccount: Option<serde_bytes::ByteBuf> }
#[derive(CandidType, Deserialize, Debug)]
struct InsufficientCyclesError { expected: Nat, received: Nat }
#[derive(CandidType, Deserialize, Debug)]
enum DepositSolError {
InsufficientCycles(InsufficientCyclesError),
TemporarilyUnavailable(String),
AlreadyProcessing,
ValueTooSmall { balance: u64, minimum_deposit_amount: u64 },
Quarantined { deposit_id: u64 },
}
fn minter() -> Principal { Principal::from_text(CKSOL_MINTER).unwrap() }
fn caller_not_anonymous() -> Principal {
let caller = ic_cdk::api::msg_caller();
assert_ne!(caller, Principal::anonymous(), "Authentication required");
caller
}
/// Solana deposit address of the caller's own ckSOL account (owner = caller, default subaccount).
#[update]
async fn sol_deposit_address() -> Result<String, String> {
let user = caller_not_anonymous();
let (address,): (String,) = Call::unbounded_wait(minter(), "get_deposit_address")
.with_arg(AccountArgs { owner: Some(user), subaccount: None })
.await
.map_err(|e| format!("{e:?}"))?
.candid_tuple()
.map_err(|e| format!("{e:?}"))?;
Ok(address)
}
/// After the user sent SOL to the deposit address: pay the cycles and queue the sweep.
/// ckSOL is minted to the USER's account (owner = user), not to this canister.
#[update]
async fn notify_sol_deposit() -> Result<u64, String> {
let user = caller_not_anonymous();
// Abuse guard: this call spends ~47B cycles of THIS canister per successful deposit.
// Add per-user rate limiting / an in-flight set in production (see canister-security skill).
let (res,): (Result<u64, DepositSolError>,) = Call::unbounded_wait(minter(), "deposit_sol")
.with_arg(AccountArgs { owner: Some(user), subaccount: None })
.with_cycles(DEPOSIT_SOL_CYCLES)
.await
.map_err(|e| format!("deposit_sol rejected: {e:?}"))?
.candid_tuple()
.map_err(|e| format!("{e:?}"))?;
res.map_err(|e| format!("{e:?}")) // Ok(deposit_id) -> poll deposit_sol_status(deposit_id)
}
ic_cdk::export_candid!();
Motoko
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
persistent actor {
type DepositSolError = {
#InsufficientCycles : { expected : Nat; received : Nat };
#TemporarilyUnavailable : Text;
#AlreadyProcessing;
#ValueTooSmall : { balance : Nat64; minimum_deposit_amount : Nat64 };
#Quarantined : { deposit_id : Nat64 };
};
transient let minter : actor {
get_deposit_address : shared { owner : ?Principal; subaccount : ?Blob } -> async Text;
deposit_sol : shared { owner : ?Principal; subaccount : ?Blob } -> async { #Ok : Nat64; #Err : DepositSolError };
} = actor "lh22c-kyaaa-aaaar-qb5nq-cai";
// >= get_minter_info().deposit_sol_required_cycles (1T on 2026-11); the unused part is refunded.
transient let DEPOSIT_SOL_CYCLES = 1_000_000_000_000;
// Solana deposit address of the caller's own ckSOL account.
public shared ({ caller }) func solDepositAddress() : async Text {
if (caller.isAnonymous()) { Runtime.trap("Authentication required") };
await minter.get_deposit_address({ owner = ?caller; subaccount = null })
};
// After the user sent SOL: pay the cycles and queue the sweep. ckSOL is minted to the CALLER's account.
// Spends ~47B cycles of this canister per accepted deposit: rate-limit per user in production.
public shared ({ caller }) func notifySolDeposit() : async { #Ok : Nat64; #Err : DepositSolError } {
if (caller.isAnonymous()) { Runtime.trap("Authentication required") };
await (with cycles = DEPOSIT_SOL_CYCLES) minter.deposit_sol({ owner = ?caller; subaccount = null })
};
};
Verify It Works
# Fees, minimums, required cycles
icp canister call lh22c-kyaaa-aaaar-qb5nq-cai get_minter_info '()' -n ic --query
# Deposit address for a principal (update call; any caller)
icp canister call lh22c-kyaaa-aaaar-qb5nq-cai get_deposit_address \
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' -n ic
# Expected: ("7N9yLidkZCEvb1uWUQqRPD53KDUVXoghTF9gxJFBChDS") — a base58 Solana address
# After sending SOL: notify through a cycles-holding proxy canister you control
icp canister call lh22c-kyaaa-aaaar-qb5nq-cai deposit_sol \
'(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \
-n ic --proxy <PROXY_CANISTER_ID> --cycles 1000000000000
# Expected: (variant { Ok = 42 : nat64 })
icp canister call lh22c-kyaaa-aaaar-qb5nq-cai deposit_sol_status '(42 : nat64)' -n ic --query
# Expected eventually: (variant { Minted = record { block_index = …; minted_amount = … } })
# Withdraw 0.01 SOL
icp canister call ls5lp-lqaaa-aaaar-qb5oa-cai icrc2_approve \
'(record { spender = record { owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai"; subaccount = null }; amount = 10_000_000 : nat })' -n ic
icp canister call lh22c-kyaaa-aaaar-qb5nq-cai withdraw_sol \
'(record { address = "<SOLANA_ADDRESS>"; amount = 10_000_000 : nat64; from_subaccount = null })' -n ic
icp canister call lh22c-kyaaa-aaaar-qb5nq-cai withdraw_sol_status '(record { block_index = <BLOCK_INDEX> : nat64 })' -n ic