Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

ckSOL logo

ckSOL is a chain-key token on the Internet Computer that is backed 1:1 by SOL, the native token of the Solana blockchain.

Each ckSOL is backed by exactly 1 SOL held by the ckSOL minter canister. ckSOL can be converted to SOL at any time, and vice versa.

Deployment

ckSOL (Solana Mainnet)

ckDevnetSOL (Solana Devnet)

Architecture

graph TD
    subgraph IC["Internet Computer"]
        Minter["ckSOL Minter\n(this repo)"]
        Ledger["ckSOL Ledger"]
        RPC["SOL RPC Canister"]
        Minter --> Ledger
        Minter --> RPC
    end
    RPC -->|HTTPS outcalls| Providers["Solana JSON-RPC providers\n(Ankr, Helius, dRPC, ...)"]
    Providers --> Solana["Solana Blockchain"]

ckSOL Minter — The main canister in this repository. It manages the deposit and withdrawal lifecycle, holds custody of SOL via chain-key addresses, signs Solana transactions using threshold Schnorr over Ed25519, and interacts with the ckSOL ledger.

ckSOL Ledger — A standard ICRC-1/ICRC-2 ledger canister. It tracks ckSOL balances and processes mints and burns as instructed by the minter.

SOL RPC Canister — A shared infrastructure canister on the Internet Computer that relays Solana JSON-RPC calls to multiple providers via HTTPS outcalls and aggregates their responses. See the SOL RPC canister repository for details.

Where to next?

How It Works

The ckSOL minter canister is the core component of the system. It manages the conversion between SOL and ckSOL, maintains custody of the SOL backing all outstanding ckSOL tokens, and interacts with the Solana blockchain via the SOL RPC canister.

The ckSOL token itself is implemented as an ICRC-1/ICRC-2 ledger canister.

The minter controls one or more Solana addresses derived from a threshold Schnorr over Ed25519 public key and a per-account derivation path. No private key ever exists in plaintext — Solana transactions are signed via the IC management canister’s sign_with_schnorr API (SchnorrAlgorithm::Ed25519).

Deposit: SOL → ckSOL

  1. Get a deposit address. Call get_deposit_address on the minter with your ICP principal (and an optional subaccount). The minter returns a Solana address derived specifically for your account.

  2. Send SOL. Transfer SOL to that deposit address from any Solana wallet, in one or several transfers. No memo or transaction signature is needed, so deposits can come straight from an exchange.

  3. Notify the minter. Call deposit_sol on the minter with the same owner/subaccount used in step 1. This call requires attaching cycles (see deposit_sol_required_cycles in get_minter_info; unused cycles are refunded). The minter reads the balance of your deposit address, queues it for a sweep, and returns a deposit id.

  4. Sweep and mint. On timers, the minter sweeps the deposit address to its main Solana account and, once the sweep transaction is finalized, mints the swept amount of ckSOL (minus the deposit’s share of the sweep transaction fee) to your ICRC-1 ledger account.

  5. Track the deposit. Call deposit_sol_status with the deposit id to follow the progress: Queued → Swept → Finalized → Minted.

sequenceDiagram
    actor User
    participant Minter as ckSOL Minter
    participant Ledger as ckSOL Ledger
    participant Solana

    User->>Minter: get_deposit_address(owner, subaccount)
    Minter-->>User: deposit_address

    User->>Solana: transfer SOL to deposit_address

    User->>Minter: deposit_sol(owner, subaccount) + cycles
    Minter->>Solana: read deposit_address balance
    Minter-->>User: deposit_id

    Note over Minter,Solana: (processed asynchronously by the minter)
    Minter->>Solana: sweep deposit_address to main account
    Minter->>Ledger: mint with icrc1_transfer(to=user, swept amount - fee share)

    User->>Minter: deposit_sol_status(deposit_id)
    Minter-->>User: Minted { block_index, minted_amount }

Withdrawal: ckSOL → SOL

  1. Approve the minter. Grant the minter an ICRC-2 allowance on your ckSOL ledger account.

  2. Submit a withdrawal request. Call withdraw_sol on the minter with the destination Solana address and the amount in lamports. The minter:

    • Burns the requested ckSOL from your ledger account via icrc2_transfer_from.
    • Queues the corresponding SOL transfer.
  3. Transaction submission. The minter constructs a Solana transaction, signs it using chain-key Ed25519, and submits it via the SOL RPC canister.

  4. Monitor status. Call withdraw_sol_status with the ledger burn index returned by withdraw_sol to track the status of your withdrawal request (Pending → TxSent → TxFinalized).

sequenceDiagram
    actor User
    participant Minter as ckSOL Minter
    participant Ledger as ckSOL Ledger
    participant Solana

    User->>Ledger: icrc2_approve(spender=minter, amount)
    Ledger-->>User: ok

    User->>Minter: withdraw_sol(destination_address, amount)
    Minter->>Ledger: burn with icrc2_transfer_from(from=user, to=burn, amount)
    Ledger-->>Minter: burn_block_index
    Minter-->>User: burn_block_index

    Note over Minter,Solana: (processed asynchronously by the minter)
    Minter->>Solana: submit SOL transfer to destination_address

    User->>Minter: withdraw_sol_status(burn_block_index)
    Minter-->>User: TxFinalized(Success)

The full design rationale, including fees and flows, is in the Design chapter.

Command Line Interface

You can interact with the ckSOL minter using icp-cli. Pass -e prod (or -e staging) to target the corresponding environment defined in icp.yaml.

Get minter info

Query fees, minimum amounts, and the current minter balance:

icp canister call -e prod cksol_minter get_minter_info '()'

Get your deposit address

Returns the Solana address you should send SOL to in order to deposit. When owner is null, it defaults to your calling identity’s principal:

icp canister call -e prod cksol_minter get_deposit_address \
  '(record { owner = null; subaccount = null })'

Notify the minter of a deposit

After sending SOL to your deposit address, call deposit_sol to queue the deposit address for a sweep to the minter’s main account. Pass the same owner/subaccount used when calling get_deposit_address.

This call requires attaching cycles — check the required amount via get_minter_info (deposit_sol_required_cycles field); unused cycles are refunded. Since only canisters can attach cycles to a call, route it through a proxy canister that you control and that holds cycles (you can convert ICP to cycles to fund it). Replace <PROXY_PRINCIPAL> with the proxy canister and <CYCLES> with at least deposit_sol_required_cycles:

icp canister call -e prod cksol_minter deposit_sol \
  '(record { owner = opt principal "<YOUR_PRINCIPAL>"; subaccount = null })' \
  --proxy <PROXY_PRINCIPAL> \
  --cycles <CYCLES>

Note

The minter sees the proxy canister as the caller, so owner must be set explicitly: replace <YOUR_PRINCIPAL> with the owner of your deposit address, which is your identity’s principal (icp identity principal) if you called get_deposit_address with owner = null. With owner = null, the minter would check the deposit address of the proxy canister instead.

A successful response returns the deposit id, which you can use to track the deposit:

(variant { Ok = 42 : nat64 })

Check a deposit status

After calling deposit_sol, track the deposit using the deposit id returned in the response. The status moves through Queued → Swept → Finalized → Minted as the minter sweeps the deposit address and mints ckSOL:

icp canister call -e prod cksol_minter deposit_sol_status '(42 : nat64)' --query

Approve the minter to burn your ckSOL

withdraw_sol burns ckSOL from your ledger account through an ICRC-2 icrc2_transfer_from, so the minter needs an allowance on your account first. Grant one with icrc2_approve on the ckSOL ledger, using the minter’s principal as the spender (see the canister table for each environment). The allowance must cover the withdrawal amount plus the ledger transfer fee, and a single larger allowance can cover several withdrawals:

icp canister call -e prod cksol_ledger icrc2_approve \
  '(record { spender = record { owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai"; subaccount = null }; amount = <ALLOWANCE> })'

Without an allowance, withdraw_sol returns InsufficientAllowance.

Submit a withdrawal request

Burns ckSOL from your ledger account and initiates a transfer of the equivalent SOL to the given Solana address. Replace <SOLANA_ADDRESS> with the destination address and <AMOUNT> with the amount in lamports. The optional from_subaccount field defaults to null (the default subaccount):

icp canister call -e prod cksol_minter withdraw_sol \
  '(record { address = "<SOLANA_ADDRESS>"; amount = <AMOUNT>; from_subaccount = null })'

A successful response returns the burn block index, which you can use to track the withdrawal:

(variant { Ok = record { block_index = 42 } })

Check a withdrawal status

After calling withdraw_sol, track the status using the block_index returned in the response:

icp canister call -e prod cksol_minter withdraw_sol_status \
  '(record { block_index = 42 })'

Chain-Key SOL (ckSOL) Design

1. High-Level Goal

The goal is to support a new ICRC-2 (and ICRC-3) compliant token on the IC, called chain-key Solana (ckSOL), that is backed 1:1 by SOL, the primary token on the Solana blockchain. Users should be able to convert their SOL tokens to ckSOL and vice versa.

2. Overview

The ckSOL functionality is introduced with a new canister (ckSOL minter) together with an instance of the ICRC ledger suite, in particular a ledger canister (ckSOL ledger) and an index canister. Archiving is deliberately disabled, by setting the archive trigger threshold beyond any reachable block count, so the suite spawns no archive canisters. The ckSOL minter default account is the minting account of the ckSOL ledger.

The ckSOL minter is the canister responsible for managing deposited SOL and minting/burning ckSOL. Concretely, it provides the following functionality:

  • Mint: If a user transfers SOL to a specific account under the ckSOL minter’s control, the ckSOL minter can instruct the ckSOL ledger to mint ckSOL for the user, owned by a given ICRC account (principal ID-subaccount pair).
  • Burn: After granting the ckSOL minter access to some of the user’s funds, the user can request a withdrawal of SOL to be sent to a user-provided destination address. The funds are sent out after instructing the ckSOL ledger to burn the requested amount of ckSOL tokens.

Both operations, as well as the transfer of ckSOL, incur a fee as specified in Section 3.3.

The general model is that the ckSOL minter needs to receive SOL before it mints ckSOL, and it burns ckSOL before it transfers SOL back to the users, in order to ensure that the total supply of ckSOL is always upper bounded by the amount of SOL held by the ckSOL minter.

In addition to performing the mint and burn transactions, the ckSOL ledger is responsible for keeping account balances and for transferring ckSOL between accounts. As mentioned before, the ckSOL ledger must be ICRC-1, ICRC-2, and ICRC-3 compliant. As the ckSOL ledger is a standard ICRC ledger, the following sections are concerned with the design of the ckSOL minter.

The following figure summarizes how the user, the ckSOL minter, the ckSOL ledger, and the SOL RPC canister interact. Each interaction is described in detail in Section 3.

graph LR
    User((User))
    subgraph IC["Internet Computer"]
        Minter["ckSOL minter"]
        Ledger["ckSOL ledger suite<br/>(ledger, index)"]
        RPC["SOL RPC canister"]
    end
    Solana["Solana"]

    User -- "get_deposit_address / deposit_sol / withdraw_sol" --> Minter
    User -- "icrc1_transfer / icrc2_approve" --> Ledger
    Minter -- "icrc1_transfer (mint)<br/>icrc2_transfer_from (burn)" --> Ledger
    Minter -- "JSON-RPC requests" --> RPC
    RPC -- "HTTPS outcalls" --> Solana
    User -- "transfer SOL" --> Solana

3. Technical Details

The ckSOL minter interacts with the Solana blockchain via the SOL RPC canister. The ckSOL minter uses the following subset of endpoints:

  • getAccountInfo: Returns the account information for the given address. This function is used to read the state of a durable nonce account before building a withdrawal transaction and to determine whether an in-flight withdrawal transaction has landed (see Section 3.2.1 and Section 3.2.5).
  • getBalance: Returns the balance of the given address. This function is used by deposit_sol to read the balance of a deposit address at the finalized commitment level and determine the sweepable amount.
  • getBlock: Returns the block for the given slot. This function is used to get a recent block hash, which is contained in the response. Note that transactionDetails is set to null in the request. As a result, signatures and transactions are not returned.
  • getSignaturesForAddress: Returns the signatures for a given address. This function is used to learn about new transactions (in Solana, signatures are used to identify transactions, as the first signature in a transaction is considered the transaction ID).
  • getSignatureStatuses: Returns the status of each transaction specified through its identifier, i.e., the first signature in the transaction. This function is used to learn whether a sweep transaction has been finalized or has expired, as described in Section 3.2.5. Withdrawal transactions are not checked with it.
  • getSlot: Returns the current slot. Since the slot number changes rapidly, the SOL RPC canister merely obtains a rounded and therefore slightly outdated slot number. The slot number is required to obtain a recent block hash using getBlock, whose block height is persisted with the transaction and later compared against the current block height to check if an unconfirmed transaction has expired.
  • getTransaction: Returns the whole transaction for the given signature. This function is used to read the outcome of a finalized sweep transaction and of a landed withdrawal transaction.
  • sendTransaction: Sends out the provided transaction. It requires the execution of the functions getSlot and getBlock to obtain a recent block hash, which must be part of the transaction, except for withdrawal transactions, which carry a durable nonce instead (see Section 3.2.1).

3.1. Converting SOL to ckSOL

The process of converting SOL to ckSOL should be as simple as possible for the user, or more generally for any frontend that offers this functionality. It should further be possible to convert SOL held on centralized exchanges straight to ckSOL. While SOL transfers can bear a memo, which would make it possible to use the same destination address, controlled by the ckSOL minter, for all conversions, most popular centralized exchanges do not offer the option to specify a memo, making a direct transfer to a single address infeasible, as the ckSOL minter could not determine the ICRC account (principal ID plus subaccount) that should be credited for received funds.

The chosen approach is to derive a deposit address for a given ICRC account: The user calls the endpoint get_deposit_address with a principal ID and subaccount as parameters on the ckSOL minter and receives a derived SOL address in return. Note that the SOL address is derived deterministically, i.e., always the same address is returned for the same principal ID and subaccount. Since it is a deterministic function, other pieces of software, such as frontends or other canisters, can derive deposit addresses themselves instead of querying the ckSOL minter.

Concretely, the deposit address is derived from the ckSOL minter’s master public key with the user’s account (principal ID and subaccount) used as the derivation path, i.e., effectively the same mechanism as for ckBTC is used to generate a user-specific address. The user can then transfer the desired amount to this address. The minter’s own main address, in contrast, is the master public key itself (the empty derivation path): since every deposit address is derived under a non-empty, schema-tagged path, the main address can never coincide with a deposit address, which would otherwise let the swept funds held by the minter be mistaken for a user deposit. Two deposit flows are described next: the automated flow, which is deferred to a post-launch upgrade and not implemented, and the manual flow, which is the one the ckSOL minter offers today.

3.1.1. Automated Flow (Outdated)

Warning

The automated flow is currently not implemented. It is deferred to a post-launch upgrade and must be revisited on top of the manual flow of Section 3.1.2, so that a deposit address is only ever credited by one mechanism.

When a user calls the endpoint update_balance, the ckSOL minter will check transfers to the deposit address derived for the caller’s principal ID and the provided subaccount (if any) on a timer by calling the getSignaturesForAddress endpoint on the SOL RPC canister, filtering out failed transactions (based on the err field in the response). If previously unknown (finalized) signatures are returned, the ckSOL minter will call the getTransaction endpoint for the newly obtained signatures. The transaction data contains information about the transferred amount, which will then be minted, minus a certain fee (defined in Section 3.3.2), on the ckSOL ledger using an icrc1_transfer call, crediting the user’s account.

The user must transfer at least the minimum deposit amount, defined in Section 3.3.3. Any deposit below this amount is ignored.

The returned transaction can contain SOL transfers as top-level instructions, i.e., the transaction (message) instructions contain instructions of type “transfer” for program “system”. Alternatively, SOL transfers can be made via inner instructions, i.e., the transfer happens through a cross-program invocation (CPI). Such transfers can be extracted from the “meta” data of the transaction. The implementation must capture transfers of both types.

Note: Some transfers are not captured, e.g., transfers via the CloseAccount instruction. The fact that there are corner cases where no mint occurs is accepted for now and will be addressed at a later stage.

The flow is depicted in the following figure (fee refers to the deposit fee).

sequenceDiagram
    actor User
    participant Solana as Solana Network
    participant RPC as SOL RPC canister
    participant Minter as ckSOL Minter
    participant Ledger as ckSOL Ledger

    User->>+Minter: get_deposit_address(principal, subaccount)
    Minter-->>-User: sol_address
    User->>+Minter: update_balance(subaccount)
    Minter-->>-User: Ok

    Note over Minter: ⏱️ Timer
    activate Minter
    Minter->>+RPC: getSignaturesForAddress(sol_address)
    RPC->>+Solana: getSignaturesForAddress(sol_address)
    Solana-->>-RPC: []
    RPC-->>-Minter: []
    Note over Minter: No new signature, reschedule timer<br/>with exponential back-off
    deactivate Minter

    User->>+Solana: transfer(sol_address, amount)
    Solana-->>-User: signature

    Note over Minter: ⏱️ Timer
    activate Minter
    Minter->>+RPC: getSignaturesForAddress(sol_address)
    RPC->>+Solana: getSignaturesForAddress(sol_address)
    Solana-->>-RPC: [signature]
    RPC-->>-Minter: [signature]
    Minter->>+RPC: getTransaction(signature)
    RPC->>+Solana: getTransaction(signature)
    Solana-->>-RPC: transaction
    RPC-->>-Minter: transaction
    Minter->>+Ledger: icrc1_transfer(cksol_minter, principal, subaccount, amount - fee)
    Ledger-->>-Minter: block index
    deactivate Minter

The user’s account is cached, together with the derived Solana address, so that the function called on a timer to check for newly arrived funds can access the required information. The timer mechanism works as follows. After an initial waiting time, the timer executes for the first time.

The following scheme is proposed to bound the number of timer invocations per deposit address. At most MAX_GET_SIGNATURES_CALLS = 10 calls are made, with the interval between calls doubling from 1, 2, 4, 8, 16, 32, 64, 128, 256, up to 512 minutes, for a total of 1023 minutes, i.e., slightly more than 17 hours. The timer is not set anymore whenever a call returns at least one new signature that results in a mint operation.

In order to mitigate the risk of a denial-of-service attack, each account has a certain quota for the automatic flow, which consists of a quota for getSignaturesForAddress calls and a quota for getTransaction calls. Initially, the quotas are MAX_GET_SIGNATURES_CALLS and MAX_RETRIEVED_TRANSACTIONS, respectively. Calls of either type are only made if there is a positive remaining quota.

Since each IC account gets a newly derived deposit address, these addresses are likely involved in deposits only, i.e., there should not be many getTransaction calls in vain in the common case. Whenever there is a successful call that results in a mint, both quotas are increased by 2 for the following reason: They are both bumped by 1 so that calls that result in a mint do not count against the quotas. The additional bump of each quota serves to ensure that an occasional call that does not result in a mint operation does not slowly drain the free quota. Note that RPC calls may sporadically fail for various reasons, such as network issues or RPC providers being unavailable. There is a ceiling of MAX_GET_SIGNATURES_CALLS and MAX_RETRIEVED_TRANSACTIONS for the getSignaturesForAddress quota and the getTransaction quota, respectively.

The mechanism to replenish a depleted quota is discussed in the next section.

The quota is meant as a deterrent but does not stop an attacker from triggering many getTransaction calls for different addresses. In order to limit the impact of such an attack, the constant MAX_MONITORED_ADDRESSES specifies the global limit on the number of addresses for which automatic deposits are allowed at any given time. This constant also limits the memory consumption. Furthermore, update_balance only takes a subaccount parameter, i.e., it is not possible to call the function for other principals, preventing drainage attacks against the quotas of other users.

If a returned transaction contains one or more transfers to the user’s deposit address, the sum of the transferred amounts, minus a fee, is minted in a single icrc1_transfer call to the ckSOL ledger. As mentioned before, no ckSOL is minted if the amount is below the minimum deposit amount.

The ckSOL minter must keep track of the covered range for each address for the sake of performance. Unfortunately, the getSignaturesForAddress endpoint does not allow a forward search starting from some transaction signature. Therefore, for each address that has ever been tracked, the covered range must be maintained. If an update_balance call is made and both quotas are positive, the following steps are executed.

// Initially max_parsed = ⊥, gap_upper = gap_lower = genesis_sig
// limit = MAX_RETRIEVED_TRANSACTIONS
if gap_upper = gap_lower:
    [s_1,...,s_n] := getSignaturesForAddress(limit, until=gap_upper)
    if n < limit:    // All signatures have been returned in the range
        max := s_1, gap_upper := max, gap_lower := max
    else:    // There might be a gap between s_n and gap_lower
        max := s_1, gap_upper := s_n
else:    // The gap between gap_upper and gap_lower must be closed
    [s_1,...,s_n] := getSignaturesForAddress(limit, before=gap_upper, until=gap_lower)
    if n < limit:    // All signatures have been returned in the range
        gap_upper := max, gap_lower := max
    else:    // The gap has been reduced but may not be closed yet
        gap_upper := s_n
process(s_1,...,s_n)

The idea is to cover the whole range since genesis. Assuming that the latest transaction signature that has ever been returned in previous calls is max, if reading from the current block returns MAX_RETRIEVED_TRANSACTIONS transactions, it is unclear whether there are more transaction signatures between the oldest returned transaction and max. In subsequent calls, the algorithm above closes this gap before requesting transaction signatures that are more recent than max.

Note: It is possible that an address appears in many transactions that do not change the balance of the account. If there are more than MAX_RETRIEVED_TRANSACTIONS such transactions, the quota can be exhausted before a valid deposit is discovered. Deposits can always be processed using the manual flow, but the automatic flow may not work until all past transactions for this address have been processed. This risk is currently accepted.

Note: There may be only one active timer per deposit address at any time across all endpoints that interact with the deposit address.

Since tracking stops when a deposit is discovered, the question is how transaction signatures are treated that have been obtained via getSignaturesForAddress calls but have not been requested and processed. A related question is how getTransaction calls are scheduled if there are multiple tracked addresses with outstanding getTransaction calls. The ckSOL minter treats the problem of deciding which transactions to query and which transactions to request next separately. The mechanism outlined above decides which transactions are to be queried next. These are put into a map with the account as the key and the value being a queue of transaction signatures, corresponding to the transactions to be obtained and checked next. Whereas the mechanism above adds transaction signatures to the map, the ckSOL minter iterates over the monitored accounts in insertion order and collects signatures to check in a round-robin fashion. Once tracking of an account stops, the corresponding entry in the map is removed.

HTTPS outcalls are a scarce resource. Therefore, the number of in-flight requests should be bounded. Concretely, there should never be more than MAX_IN_FLIGHT_HTTPS_OUTCALLS outcalls being processed at the same time. Requests on a timer are only scheduled if there is capacity for outcalls.

Proposed values for the parameters are provided in this list:

  • MAX_RETRIEVED_TRANSACTIONS: This constant is the product of the number of transaction requests that can be packed into a JSON batch request and the maximum number of getTransaction calls that the ckSOL minter may make for a single get_deposit_address call. Batching is not yet available, so currently the only parameter is the number of transaction requests, which can initially be set to 50.
  • MAX_MONITORED_ADDRESSES: The number of monitored addresses must be upper bounded as well. Since HTTPS outcalls are protected by imposing an upper bound on the number of in-flight outcalls and an address does not take up too much space, a fairly large number of addresses could be monitored. The reason to keep this number on the small side is that a cycle drainage attack could be launched by having the ckSOL minter spend many cycles monitoring a large number of addresses. A compromise would be to set the parameter to a conservatively low value of 100 initially.
  • MAX_IN_FLIGHT_HTTPS_OUTCALLS: The maximum number of in-flight HTTPS outcalls. The suggested parameter is 56, as used in the exchange rate canister.

3.1.2. Manual Flow

A user first obtains their deposit address with get_deposit_address and transfers SOL to it. The user then asks the ckSOL minter to sweep that address. The user does not identify individual Solana transactions: the ckSOL minter reads the balance of the deposit address, moves it to its main account, and mints ckSOL once that sweep is finalized. As a consequence, several transfers that are each below the minimum deposit amount are credited together once their sum exceeds it, and deposits from centralized exchanges, which typically do not show the transaction signature to the user, need nothing but the deposit address.

The manual flow is depicted in the following figure. The sweep reuses the transaction submission flow and the finalization flow described in Section 3.1.3 and Section 3.2.5.

sequenceDiagram
    actor User
    participant Solana as Solana Network
    participant RPC as SOL RPC canister
    participant Minter as ckSOL Minter
    participant Ledger as ckSOL Ledger

    User->>+Minter: get_deposit_address(principal, subaccount)
    Minter-->>-User: deposit_address
    User->>+Solana: transfer(deposit_address, amount)
    Solana-->>-User: signature
    User->>+Minter: deposit_sol(principal, subaccount) + cycles
    Minter->>+RPC: getBalance(deposit_address, finalized)
    RPC->>+Solana: getBalance(deposit_address)
    Solana-->>-RPC: balance
    RPC-->>-Minter: balance
    Note over Minter: sweepable := max(0, balance - rent exemption threshold)<br/>queue the deposit if sweepable ≥ minimum deposit amount
    Minter-->>-User: Ok(deposit id)

    Note over Minter: ⏱️ Sweep timer
    activate Minter
    Note over Solana,Minter: Transaction submission flow (Section 3.1.3)<br/>one transfer per queued deposit address, largest deposit pays the fee
    Minter->>+RPC: sendTransaction(sweep)
    RPC-->>-Minter: signature
    deactivate Minter

    Note over Minter: ⏱️ Finalization timer (Section 3.2.5)
    activate Minter
    Minter->>+RPC: getSignatureStatuses([signature])
    RPC-->>-Minter: finalized
    Minter->>+RPC: getTransaction(signature)
    RPC-->>-Minter: transaction (fee paid, balances)
    Note over Minter: enqueue pending mint of sweepable - fee share
    deactivate Minter

    Note over Minter: ⏱️ Mint timer
    activate Minter
    Minter->>+Ledger: icrc1_transfer(cksol_minter, principal, subaccount, sweepable - fee share)
    Ledger-->>-Minter: block index
    deactivate Minter

    User->>+Minter: deposit_sol_status(deposit id)
    Minter-->>-User: Minted { block index, amount }

Request. The flow is triggered by calling deposit_sol with the user’s account (principal ID and subaccount) as parameters. The principal may differ from the caller’s, so that a frontend or another canister can pay for a user’s deposit, but it must be neither the anonymous principal nor the ckSOL minter’s own principal, whose deposit address is the main address. The endpoint requires cycles to be attached; the required amount is exposed as deposit_sol_required_cycles in get_minter_info and, as explained below, most of it is refunded. At most one deposit per account can be in flight: if a deposit for the given account is queued, swept, or finalized but not yet minted, the call returns the id of that deposit and refunds all attached cycles without contacting the SOL RPC canister, so that a retried call is idempotent. Only a deposit that is minted or dropped allows a new sweep of the same account. If the latest deposit of the account is quarantined, the call is rejected with an error carrying the id of the quarantined deposit, since the outcome of its sweep did not match the plan the minter submitted and the deposit could not be credited safely, and the account remains rejected until a minter upgrade resolves the quarantined deposit; a new deposit has to use a different subaccount. The account is reserved before the first inter-canister call, so that concurrent calls for the same account cannot both pass this check, and the reservation is released on every error path.

Balance check. The ckSOL minter calls the getBalance endpoint of the SOL RPC canister for the deposit address at the finalized commitment level. No minContextSlot is set: the SOL RPC canister returns no context slot, so the slot at which a previous sweep of the address was finalized cannot be learned, and the 3-out-of-4 provider consensus is part of the trust model. A balance that a lagging provider reports too high cannot cause an over-mint either, since ckSOL is minted only once the sweep transaction has been finalized and the amount credited is read from that transaction, and a sweep built on an inflated balance is not a valid transaction. The sweepable amount is the balance minus the rent exemption threshold (890,880 lamports for an account without data), or zero if the balance does not exceed the threshold. This threshold is deliberately left on the deposit address: Solana rejects any transaction that would leave an account with a nonzero balance below the threshold, so sweeping the whole balance would fail as soon as a small transfer arrived between the balance check and the execution of the sweep. Keeping the threshold on the address makes the sweep independent of concurrent transfers. The threshold is paid once per deposit address, since later sweeps find it already in place.

If the balance is below the minimum deposit amount defined in Section 3.3.3, the call fails with ValueTooSmall, reporting the balance and the minimum, so that the user knows how much to top up. The minimum applies to the balance of the deposit address and therefore includes the rent exemption threshold: a deposit of exactly the minimum deposit amount is accepted. Only the cost of the getBalance call is charged in this case. Otherwise the deposit is recorded as queued with the account, the deposit address, and the sweepable amount, and the call returns a deposit id, a sequence number assigned by the ckSOL minter that identifies this sweep of the account for the rest of its life, in the same way as a withdrawal is identified by its burn index. The cycles charged are the cost of the getBalance call plus the deposit_sol fee, which covers the threshold signature of the sweep and the deposit’s share of the RPC calls made by the sweep and finalization timers, as detailed in Section 3.3.2. The remaining cycles are refunded.

Sweep. A timer, running every minute, takes the queued deposits in batches of at most 10 and submits one Solana transaction per batch, following the transaction submission flow of Section 3.1.3; a single round covers at most 100 deposits and reschedules itself when more are queued. Each deposit address signs a transfer of its sweepable amount to the main account of the ckSOL minter. The deposit address with the largest sweepable amount is the fee payer; it is listed first in the transaction and its transfer is reduced by the transaction fee of 5000 * k lamports for k signatures. Since the minimum deposit amount is larger than the fee of a full batch (see Section 3.3.4), the fee payer always has enough funds, and every deposit address is left with the rent exemption threshold plus whatever arrived after the balance check. The deposits are recorded as swept together with the transaction signature. No ckSOL is minted yet.

Finalization. The sweep transaction is monitored like any other transaction, as described in Section 3.2.5. Once the transaction is finalized successfully, the deposits it contains are recorded as finalized: the SOL has moved to the main account, and the remaining steps only account for it. From this point on, deposit_sol_status reports Finalized with the sweep signature until the mint lands.

The ckSOL minter then fetches the transaction with getTransaction and settles it against the plan the sweep was submitted with. Nothing is inferred from the outcome: the executed message must be exactly the planned one, every deposit address must have decreased by exactly its transfer, plus the fee reported in the metadata for the fee payer, and must end with at least the rent exemption threshold, since a transfer that arrived after the balance check legitimately leaves more, and the main account must have received exactly the planned amount, the sum of the sweepable amounts minus the assumed fee of 5000 * k lamports. The reported fee may be lower than assumed, in which case the difference stays on the fee payer’s address, but it may not be higher. If the getTransaction call fails or its result cannot be read, the deposits stay finalized and the fetch is retried on the next run of the finalization timer. If the result is readable but contradicts the plan, the ckSOL minter’s model of the transaction is wrong, so nothing is minted: the deposits are quarantined, reusing the existing mechanism that prevents double minting, are reported on the dashboard together with the sweep signature, and the number of quarantined deposits is exposed as a metric. They are not processed further without a minter upgrade.

Once settled, the balance of the main account is increased by the amount received. The transaction fee is borne by the batch: for each deposit in the transaction, the ckSOL minter enqueues a pending mint of the sweepable amount minus the deposit’s share of the fee, ceil(fee / k), to the user’s account. Rounding the share up guarantees that the total of the pending mints is never larger than the amount received on the main account, so the ckSOL supply is always covered. The amount to mint of each deposit is recorded in the event together with the amount received, so that replaying the event log restores the pending mints as they were enqueued rather than recomputing them.

Pending mints are processed on a timer: each one is a single icrc1_transfer call whose memo contains the sweep signature and the deposit id. The created_at_time of the transfer is fixed when the pending mint is enqueued and stored with it, so that every retry sends exactly the same arguments and the ckSOL ledger deduplicates it: a mint whose ledger call fails stays in the queue and is retried on the next run, and a Duplicate reply from the ledger is recorded as a successful mint with the block index it carries. Since the ledger only deduplicates transfers within its 24-hour window, a pending mint older than that is quarantined instead of retried. The sweep transaction itself is never resubmitted once it is finalized.

A user can follow the progress with deposit_sol_status, which takes a deposit id and reports its status: Queued with the sweepable amount, Swept and Finalized with the sweep signature, Minted with the ledger block index and the minted amount, and Dropped and Quarantined with the sweep signature when there is one. Since every sweep has its own id, a Minted status stays observable after the account has been swept again; the mint itself is also visible on the ckSOL ledger, whose memo links it to the sweep signature and the deposit id.

Two failure cases exist before the transaction is finalized, and neither is retried by the ckSOL minter:

  1. The transaction is finalized with an error. The funds are still on the deposit addresses, minus the fee paid by the fee payer. This should never happen with the invariants above, so it is reported as an error in the logs and in the metrics.
  2. The transaction expires, i.e., its blockhash is no longer valid and it has no on-chain status. The sweep is dropped and not resubmitted with a new blockhash. Expiry is determined against the last valid block height persisted with the transaction, as described in Section 3.2.5, never by counting slots; otherwise a transaction declared expired could still land, and the funds would reach the main account without being credited. Expiry only establishes that the transaction can no longer land, not that it never did, so the status check searches the available transaction history rather than the recent status cache, which holds about two minutes. Otherwise a sweep that landed while the checks had a longer gap, caused by the timer interval, by rounds skipped on RPC errors, or by an upgrade, would be reported as having no status and dropped after it had moved the funds.

In both cases the queued deposits are marked as dropped, and the number of dropped deposits is exposed as a metric. Since nothing was minted, no ckSOL is owed, and the user calls deposit_sol again to queue a new sweep of the balance that is still on the deposit address. For a failed transaction that balance is certain, because the transfers never executed. For an expired one it rests on the sweep never having landed, which the status check above establishes but cannot prove: were a landed sweep dropped regardless, its funds would sit in the main account with nobody credited for them, and returning them would take an upgrade. In other words, the retry is triggered and paid for by the caller. In the first case Solana charges the fee even though the transaction failed, so the fee payer of that batch loses up to 5000 * k lamports and its deposit address can fall just below the minimum deposit amount, in which case the next deposit_sol call reports the balance as too small until the user tops it up. This loss is accepted rather than reimbursed: the case should never occur, which is why it is alerted on, and a reimbursement flow would add a second path that moves funds without a deposit behind it.

Example. The following example uses the parameters of Section 3.3, a price of 1 SOL = 100 USD, and 1T cycles = 1 XDR = 1.44 USD. Alice deposits 1 SOL and Bob deposits 0.05 SOL, each to their own deposit address, which was empty before, and both deposits are swept in the same transaction.

StageAliceBobPaid byNotes
1. Transfer to the deposit address1,000,000,000 lamports (1 SOL)50,000,000 lamports (0.05 SOL)User’s walletThe Solana fee of 5,000 lamports for this transfer is paid by the sender on top of the amount and is not visible to the ckSOL minter.
2. deposit_sol with 1T cycles attachedcharged 47.31B cycles (0.068 USD)charged 47.31B cycles (0.068 USD)Caller, in cycles2.31B cycles for getBalance plus the deposit_sol fee of 45B cycles. 952.69B cycles are refunded.
3. Sweepable amount999,109,120 lamports49,109,120 lamportsBalance minus the rent exemption threshold of 890,880 lamports (0.089 USD), which stays on the deposit address.
4. Sweep transaction fee10,000 lamports paid on-chain0Fee payer (Alice)Two signatures at 5,000 lamports each. Alice transfers 999,099,120 lamports, Bob transfers 49,109,120 lamports. Both deposit addresses end at 890,880 lamports; the main account receives 1,048,208,240 lamports.
5. Mint999,104,120 lamports (0.99910412 ckSOL)49,104,120 lamports (0.04910412 ckSOL)Sweepable amount minus the shortfall share of ceil(10,000 / 2) = 5,000 lamports, the shortfall being the 1,048,218,240 lamports swept minus the 1,048,208,240 received. The total minted equals the amount received on the main account.

For Alice, converting 1 SOL to ckSOL costs 895,880 lamports (0.0896 USD) on Solana, of which 890,880 lamports remain on her deposit address and are not charged again for her next deposit, plus 47.31B cycles (0.068 USD). Bob pays the same, so smaller deposits pay a proportionally larger share; a first deposit of exactly the minimum deposit amount of 0.02 SOL would be credited 19,104,120 lamports, i.e., 95.5% of the amount deposited.

If Bob then withdraws everything, he first approves the ckSOL minter, which costs the ledger transfer fee of 500 lamports, and then withdraws the remaining 49,103,620 lamports. After the withdrawal fee of 1,000,000 lamports, the destination address receives 48,103,620 lamports (0.04810362 SOL). The whole round trip from 0.05 SOL to 0.04810362 SOL costs 1,896,380 lamports (0.19 USD), of which 890,880 lamports are still under the control of the ckSOL minter on Bob’s deposit address, plus the cycles attached to deposit_sol.

3.1.3. Transaction Submission

This section describes the flow that submits a Solana transaction carrying a recent block hash, which the sweeps of Section 3.1.2 follow. Withdrawals do not: they carry the value of a durable nonce account instead of a recent block hash, as described in Section 3.2.4. Users deposit into dedicated deposit addresses, so the funds the ckSOL minter owes are spread across those addresses; a sweep moves them to the main address, and ckSOL is minted only once the sweep is finalized. The flow is shown in the following figure.

sequenceDiagram
    participant Solana as Solana Network
    participant RPC as SOL RPC canister
    participant Minter as ckSOL Minter
    participant Signer as Threshold Signing

    Note over Minter: Timer fires
    activate Minter
    Minter->>+RPC: getSlot
    RPC->>+Solana: getSlot
    Solana-->>-RPC: slot
    RPC-->>-Minter: slot
    Minter->>+RPC: getBlock(slot)
    RPC->>+Solana: getBlock(slot)
    Solana-->>-RPC: block hash
    RPC-->>-Minter: block hash
    Note over Minter: Build transaction with recent block hash
    loop For each transfer in the transaction
        Minter->>+Signer: sign_with_schnorr(Ed25519, derivation path, message)
        Signer-->>-Minter: signature
    end
    Note over Minter: Serialize signed transaction
    Minter->>+RPC: sendTransaction(transaction)
    RPC->>+Solana: sendTransaction(transaction)
    Solana-->>-RPC: signature
    RPC-->>-Minter: signature
    deactivate Minter

Sweep transactions are created on a timer. Since a sweep must contain a recent block hash, such a block hash must be obtained first: A getSlot call is used to get a recent slot, followed by a getBlock call to retrieve block details, in particular the block hash, for the slot received in the first step. Note that it is possible that there is no block for a certain slot, in which case getSlot needs to be called again, followed by another call to getBlock. The figure only shows the happy path of one call each. Given a recent block hash, the transaction is built, obtaining an EdDSA signature for each transfer to be made within that transaction. The block height of the block whose hash is used is persisted together with the transaction. A block hash is valid for 150 blocks after that height, so the last valid block height of the transaction is the persisted height plus 150, and that is what expiry is later checked against. Once the transaction is signed and serialized, it is sent to the SOL RPC canister, which forwards it to the RPC providers.

The block hash of a sweep is read at the confirmed commitment level, and the preflight simulation that the SOL RPC canister runs before broadcasting uses the same level. The 150-block validity window of a block hash lasts roughly 90 seconds on mainnet and about 36 seconds on Devnet, and a block hash read at finalized has already spent part of it by the time it is obtained, since finalization lags the chain tip. A sweep built on such a hash can therefore expire before it reaches the chain: on Devnet this dropped 18 of 50 deposits in a staging stress test, two sweeps being rejected by every provider with BlockhashNotFound. Reading the block hash at confirmed gives the sweep close to the full window. The expiry check of Section 3.2.5 keeps reading the current block height at finalized, so a sweep is only ever declared expired against a height that can no longer be rolled back.

The sweep timer runs every minute, at the same frequency as withdrawal processing. A single SOL transfer requires roughly 70-90 bytes in a transaction. Since the maximum transaction size is 1232 bytes, up to approximately 10 transfers per transaction are possible. Whenever the timer executes and there is any queued deposit, i.e., a deposit queued by deposit_sol as described in Section 3.1.2, the queued deposits are taken in batches of at most 10 and one transaction is submitted per batch. A round submits at most MAX_CONCURRENT_RPC_CALLS such transactions, i.e., at most 100 deposits, and reschedules itself immediately when deposits are left over, so that all queued deposits are swept across consecutive rounds. Each transaction is sent in its own HTTPS outcall; batching several transactions into one outcall is a deviation from this design that has not been implemented. A deposit address that has not been queued by deposit_sol is never swept, since no record would exist to credit its owner.

A concrete mainnet example of a transaction that makes two transfers to the same destination address can be viewed here.

Note that the default compute unit (CU) limits are 200,000 CUs per instruction and 1,400,000 per transaction. A standard transfer consumes around 300 CUs, well below the instruction limit. Moreover, 10 transfers together is still clearly below the transaction limit. In short, there is no need to bump the compute allocation for such transactions.

The transaction fee only depends on the number of signatures, and the two transaction types differ there. A sweep is signed by the deposit addresses it empties: if it makes k ≤ 10 transfers, it carries one signature per transfer, the first signer being the fee payer, and the fee is 5000 * k lamports. The deposit address with the largest sweepable amount is listed first and pays the transaction fee; its transfer is reduced by the fee, so that every deposit address is left with at least the rent exemption threshold, and with more when a transfer arrived after the balance check, as described in Section 3.1.2. A withdrawal transaction, in contrast, is signed by the main address alone, so it carries a single signature and a fee of 5000 lamports however many transfers it batches, as described in Section 3.2.4.

3.2. Converting ckSOL to SOL

3.2.1. Durable Nonce Accounts

A withdrawal pays out SOL irrevocably, so before a withdrawal transaction may be replaced, the ckSOL minter must be certain that the original transaction can never land anymore; otherwise both transactions may land and the withdrawal is paid out twice. Status queries cannot provide that certainty, because they are negative queries against storage with unspecified retention: getSignatureStatuses searches the recent status cache of roughly 300 rooted slots, and with searchTransactionHistory: true it additionally searches the node’s local blockstore, whose size is an operator choice (--limit-ledger-size, by default on the order of days of history), followed by an archival database only if the operator runs one. getTransaction always searches these same tiers. A null response therefore only means “not found in whatever this node retains”, and three out of four providers without archival storage agreeing on null would turn pruning into a confident but wrong answer. For withdrawals, the ckSOL minter therefore never decides anything based on a missing status; it relies on durable nonces instead.

A durable nonce account is an account owned by the system program that stores a nonce value and a nonce authority. A transaction whose first instruction is AdvanceNonceAccount, signed by the nonce authority, may carry the stored nonce value in place of a recent block hash. Such a transaction never expires: it is valid for exactly as long as the nonce account still stores that value. When the transaction lands, the nonce is advanced to a new, unpredictable value, and the advance persists even if the transaction itself fails during execution.

The ckSOL minter uses a pool of durable nonce accounts exclusively for withdrawal transactions, governed by two invariants:

  1. One in-flight transaction per nonce account: a nonce account is bound to at most one submitted transaction at a time, and it is reused only once that transaction has been observed to have landed and its outcome is known, at which point the account already stores a fresh nonce value.
  2. One message per nonce value: the ckSOL minter never signs two different messages for the same nonce account and nonce value.

The ckSOL minter’s main address is the nonce authority of every account in the pool. Under these invariants, the state of a nonce account, read with getAccountInfo at the finalized commitment level, is an oracle that no provider’s retention policy can distort: if the account still stores the nonce value used by an in-flight withdrawal transaction, the transaction has not landed, and since it can still land, the only safe action is to re-broadcast the identical transaction. If the nonce value has advanced, the transaction has landed, since nobody else can advance the nonce and no other message was signed for that value.

For the oracle to be sound, a stale read must never be mistaken for an advance: nonce values are opaque hashes, so a value differing from the in-flight transaction’s nonce could by itself be the account’s past as well as its future, and a provider lagging behind an already observed state serves exactly such a past value. Since only the ckSOL minter can advance the nonce, the account’s complete value history is the set of nonce values the ckSOL minter has bound to transactions, which the event log already records. Every read is therefore classified against that set: the bound value means the transaction has not landed; any other previously seen value is a stale response and yields no decision; a never-seen value can only be the account’s new frontier, which proves the advance.

The pool size bounds the withdrawal throughput, since every withdrawal transaction occupies one nonce account while it is in flight. When no free nonce account is available, the affected withdrawal batches simply remain queued until a nonce account frees up; the processing timer retries after a short delay only if the round made progress, i.e., it created or signed at least one withdrawal transaction, and either both a free nonce account and an affordable batch remain, or a bound withdrawal transaction is still unsigned (after a signing failure, or because a round signs at most 10 transactions). Otherwise it waits for its regular interval, so an exhausted pool whose bound transactions are all signed stops retrying entirely, and a round in which every nonce read or every signature failed, e.g., because of a misconfigured nonce account or an unavailable signing service, is retried at the regular cadence rather than every few seconds. As a nonce account costs nothing beyond its rent exemption minimum, the pool can be sized generously; 5 accounts are proposed initially, allowing 50 concurrent in-flight withdrawals at 10 transfers per transaction.

Deposit sweeps continue to use recent block hashes. The double-pay hazard is specific to withdrawals: a sweep only moves funds between addresses controlled by the ckSOL minter, nothing is credited before the finalized transaction has been positively observed, and an expired sweep is dropped rather than resubmitted, as described in Section 3.1.2.

3.2.2. Nonce Account Setup

The nonce accounts are set up offline by the operators. The ckSOL minter’s main address can be computed before the minter is installed, since it only depends on the canister ID and the subnet’s threshold key. Each nonce account is created with the rent exemption minimum of 1,447,680 lamports for its 80 bytes of state and initialized directly with the ckSOL minter’s main address as the nonce authority: the account is never initialized under an operator’s authority and handed over later, and it is never advanced before joining the pool, so the first AdvanceNonceAccount instruction ever executed on it comes from the ckSOL minter. This makes the ckSOL minter’s value history of the account complete from its first read, which the soundness of the classification in Section 3.2.1 relies on. The ckSOL minter itself never creates, funds, or closes nonce accounts.

The addresses of the pool are passed in the init arguments, and further addresses can be added through the upgrade arguments; the pool only ever grows, and removing an address is not supported. A removed account would keep the ckSOL minter’s main address as its nonce authority while no longer being rejected by the withdrawal destination filter of Section 3.2.4, turning it into a destination trap, and an account that ever left and re-joined the pool would break the precondition of Section 3.2.1 that the ckSOL minter’s value history of each account is complete. Nobody needs removal, so retiring a mis-configured account is accepted to require a code upgrade. Init and upgrade validation checks that the addresses are well-formed and pairwise distinct. That each address is an initialized nonce account with the ckSOL minter’s main address as its authority is verified as part of the verification process of the NNS proposal carrying the init or upgrade arguments. Since the ckSOL minter reads each nonce account with getAccountInfo before using it anyway, it additionally checks that the account is a non-executable account owned by the system program, holding an initialized nonce in the current format with the ckSOL minter’s main address as its authority. A failing check indicates an operator error: it is reported as an error, and the account is skipped for that round. Later rounds do not avoid such an account: the withdrawals it delays show up in the age of the oldest incomplete withdrawal, which is exposed as a metric for operators to alert on.

3.2.3. Reinstallation

The nonce accounts of the pool are bound to one installation of the ckSOL minter. A reinstallation, as opposed to an upgrade, erases the event log and with it the nonce values bound so far, while the main address, which only depends on the canister ID and the threshold key, and therefore the nonce authority of the existing accounts remain the same. Reusing an existing account as is would be unsafe on two counts. First, a withdrawal transaction signed by the previous installation never expires and may still land: a new transaction bound to the same nonce value would then fail silently, and the new installation would mistake the advance for the landing of its own transaction. Second, the previously bound values are no longer known, so a stale read returning one of them could no longer be told apart from an advance (see Section 3.2.1).

A reinstallation therefore uses fresh nonce accounts, set up as described in Section 3.2.2. This is the default and requires no further mechanism.

Existing accounts could nevertheless be reused with the following procedure, which is currently not implemented:

  1. The previous installation is stopped, so that the nonce value of each account no longer changes.
  2. The current nonce value of each account, verifiable on chain as part of the NNS proposal verification, is passed in the init arguments together with the account’s address.
  3. Before first use, the ckSOL minter sends for each account a transaction consisting only of an AdvanceNonceAccount instruction, bound to that value.
  4. The account becomes usable once a read returns a value other than the one passed in the init arguments. Whether the advance came from this transaction or from a pending transaction of the previous installation, no transaction signed before the reinstallation can land anymore, and the value history of the account starts with the value passed in the init arguments and the newly read one.

The values bound by the previous installation before the current one remain unknown, so a read returning one of them would still be mistaken for an advance. Such a read would have to be stale by at least the time between stopping the previous installation and the reinstallation, i.e., the duration of the NNS proposal, and be agreed upon by the providers, which is not considered a realistic failure mode.

3.2.4. Submitting Withdrawal Requests

Converting ckSOL back to SOL requires two user actions: The user must approve the ckSOL minter to withdraw from their ckSOL account by calling icrc2_approve and then call withdraw_sol. Naturally, the user may approve the ckSOL minter to withdraw a large amount from their account so that multiple withdraw_sol calls can be performed without the need to create new approvals.

The withdraw_sol endpoint has the following parameters: An optional subaccount, the destination address on Solana, and the amount to be withdrawn. While the nonce account pool is empty — a valid configuration during the deployment workflow of Section 3.2.2, before the accounts have been added — the ckSOL minter rejects every withdrawal request with TemporarilyUnavailable before anything is burned, so no ckSOL is ever burned into a queue that nothing can serve; an exhausted but non-empty pool, in contrast, only delays queued withdrawals. Otherwise, the ckSOL minter issues an icrc2_transfer_from call, sending the requested amount from the account corresponding to the caller’s principal ID plus the provided subaccount (if any) to its own account. Since its account is the minting account, this transfer is a burn operation, burning the given amount.

If this burn operation is successful, the retrieval request is added to an internal queue and the block index of the burn operation is returned to the user. Otherwise, an error is returned.

When the timer strikes, up to 10 retrieval requests are batched into a single transaction and sent to the SOL RPC canister. The flow is shown in the following figure, using the withdrawal transaction submission flow defined below.

sequenceDiagram
    actor User
    participant Solana as Solana Network
    participant RPC as SOL RPC canister
    participant Minter as ckSOL Minter
    participant Ledger as ckSOL Ledger

    User->>+Ledger: icrc2_approve(cksol_minter, amount)
    Ledger-->>-User: block index
    User->>+Minter: withdraw_sol(subaccount, dest_addr, amount)
    Minter->>+Ledger: icrc2_transfer_from(user account, cksol_minter, amount)
    Note over Ledger: Burn (cksol_minter is the minting account)
    Ledger-->>-Minter: burn block index
    Note over Minter: Queue retrieval request
    Minter-->>-User: burn block index

    Note over Solana,Minter: ⏱️ Withdrawal transaction submission flow

Since Solana has a high block rate, the timer should execute more frequently compared to ckBTC. A shorter interval between calls implies that there is a lower chance of retrieval requests being batched together; however, it is preferable to have smaller batches, as transactions are cheap and it provides a better user experience. The interval originally proposed here was 10 seconds; the implementation runs the timer every minute and reschedules it after 10 seconds when a round made progress and work remains. Whether to close that gap is still open.

There is a minimum withdrawal amount, which is defined in Section 3.3.3.

The funds for each withdrawal are taken from the main account. Since ckSOL is only minted once the corresponding SOL has reached the main account (see Section 3.1.2), the main account always covers the minted supply, and a withdrawal never waits for or triggers a sweep. The only delay a user can experience is between a deposit_sol call and the mint of their own deposit.

Contrary to sweeps, a withdrawal transaction does not follow the transaction submission flow of Section 3.1.3, since it must not reference a recent block hash. Instead, the ckSOL minter picks a free durable nonce account from the pool of Section 3.2.1 and reads its current nonce value with getAccountInfo at the finalized commitment level; a response showing a nonce value already bound to an earlier transaction of that account is stale, and the batch waits for the next round. The transaction consists of an AdvanceNonceAccount instruction first, followed by one transfer per withdrawal request, and carries the nonce value in place of the recent block hash. The main address is the fee payer, the source of all transfers, and the nonce authority, so the transaction has a single signature. A single timer round processes withdrawals at a time, and it assigns distinct free nonce accounts to its batches synchronously, before the first await point, so that concurrently processed batches can never pick the same account. Before the threshold signature is requested, a CreatedWithdrawalTransaction event binds the nonce account and its nonce value to the burn indices of the withdrawals the transaction serves. Since each burn index identifies a withdrawal request, i.e., a destination and an amount, the binding fully determines the message to be signed. Threshold signing is treated as fallible: should it fail or be interrupted, the binding survives, and the ckSOL minter rebuilds the identical message from the binding, without reading the nonce account again, and signs it again, so that no two different messages are ever signed for the same nonce value. The signed transaction is recorded with a SubmittedTransaction event before it is sent, so that the identical transaction can later be re-broadcast. When processing this event, the ckSOL minter checks that the transaction advances a bound nonce account, serves the bound withdrawal requests, and carries exactly the message it rebuilds from the binding, signed by the main address only.

sequenceDiagram
    participant Solana as Solana Network
    participant RPC as SOL RPC canister
    participant Minter as ckSOL Minter
    participant Signer as Threshold Signing

    Note over Minter: ⏱️ Timer fires
    activate Minter
    Note over Minter: Pick a free nonce account from the pool
    Minter->>+RPC: getAccountInfo(nonce_account)
    RPC->>+Solana: getAccountInfo(nonce_account)
    Solana-->>-RPC: nonce account state
    RPC-->>-Minter: nonce account state
    Note over Minter: Record CreatedWithdrawalTransaction (burn indices,<br/>nonce account, nonce value)
    Note over Minter: Build transaction from the binding: AdvanceNonceAccount first,<br/>then one transfer per withdrawal,<br/>nonce value in place of the recent block hash
    Minter->>+Signer: sign_with_schnorr(Ed25519, main derivation path, message)
    Signer-->>-Minter: signature
    Note over Minter: Record SubmittedTransaction (signed transaction)
    Minter->>+RPC: sendTransaction(transaction)
    RPC->>+Solana: sendTransaction(transaction)
    Solana-->>-RPC: signature
    RPC-->>-Minter: signature
    deactivate Minter

The AdvanceNonceAccount instruction adds the nonce account and the recent-blockhashes sysvar to the account keys plus one short instruction, roughly 80 bytes in total, so a batch of 10 transfers still fits comfortably within the maximum transaction size of 1232 bytes.

Withdrawal transactions are sent with skipPreflight: true. By default, an RPC provider simulates a transaction before broadcasting it and rejects it when the simulation fails, which happens for example when a withdrawal destination cannot receive lamports. Such a transaction would never be broadcast, its nonce would never advance, and its nonce account would stay occupied until an operator intervenes, so a handful of withdrawals to such destinations could exhaust the pool and halt all withdrawals. Skipping preflight lets a doomed transaction land and fail on-chain instead, which advances the nonce, frees the nonce account, and settles the transaction in the Failed state at the cost of the transaction fee. Preflight would also be an unreliable gate: each RPC provider evaluates it independently, so one lenient or faster provider may broadcast a transaction that the others rejected, making the outcome nondeterministic.

Since transactions are atomic, a single destination that cannot receive lamports fails the whole batch, including the withdrawals of unrelated users, and the burned ckSOL is not reimbursed. This risk is accepted, but a cheap filter removes the statically known part of it: the withdraw_sol endpoint rejects, before burning, any destination contained in the runtime’s reserved account keys set (sysvars, builtin programs, and precompiles, whose write locks are always demoted so a transfer can never credit them), which the ckSOL minter embeds from the same SDK crate the runtime uses. The set grows over time through feature gates, so the embedded copy is best-effort and only as fresh as its crate version. The withdraw_sol endpoint likewise rejects the ckSOL minter’s own addresses, i.e., the main address and the nonce accounts of the pool, since a withdrawal to the main address would land as a self-transfer while the tracked balance was debited by the full amount, leaving the tracked balance permanently below the actual one. Destinations that fail dynamically, such as executable program accounts, cannot be filtered statically and checking them per withdrawal is not worth an RPC call; this residual risk is accepted, and it shrinks further once remove_accounts_executable_flag_checks (SIMD-0162) activates, after which transfers to program accounts simply succeed.

Skipping preflight also means that a transaction the main account cannot pay for would land and fail, consuming its nonce and terminally failing the batch. The tracked balance prevents such a transaction from ever being built, and a tracked balance exceeding the actual balance would already be a critical bug, since it means the ckSOL supply is no longer fully backed. In addition, the batch selection keeps the rent exemption threshold back from the available balance, so a withdrawal transaction can never leave the main address with a balance below the rent exemption threshold, which Solana would equally reject at execution.

3.2.5. Finalization and Re-broadcast

Submitted transactions are monitored by the finalization timer, which decides sweeps and withdrawals differently: a sweep is decided by its status, a withdrawal by the state of its nonce account.

Blockhash transactions (sweeps) are checked by calling the getSignatureStatuses endpoint on the SOL RPC canister, searching the transaction history. The status of any accepted transaction is either processed, confirmed, or finalized.

sequenceDiagram
    participant Solana as Solana Network
    participant RPC as SOL RPC canister
    participant Minter as ckSOL Minter

    Note over Minter: ⏱️ Timer
    activate Minter
    Minter->>+RPC: getSignatureStatuses([signature])
    RPC->>+Solana: getSignatureStatuses([signature])
    Solana-->>-RPC: [confirmation_status]
    RPC-->>-Minter: [confirmation_status]
    Note over Minter: confirmation_status ∈ {processed, confirmed, finalized}
    deactivate Minter

It is possible that a sweep is not accepted, i.e., it is not found in any of the statuses listed above. A sweep refers to a recent block hash, which may not be more than 150 blocks in the past, corresponding to roughly 90 seconds. Such a transaction is expired once the current block height exceeds its last valid block height, i.e., the block height persisted with the transaction plus 150. The current block height is read from the getBlock response that the finalization timer fetches whenever a sweep is in flight. Expiry is never determined by counting slots, since slots can be skipped and a transaction declared expired too early could still land. An expired sweep is dropped and never resubmitted, as described in Section 3.1.2.

Sweeps remain exposed to the limited retention of the providers’ transaction history: measurements showed that some providers keep only about 16 hours of history and that others answer inconsistently for older statuses, so the status of a sweep that is checked only long after it landed, e.g., because the ckSOL minter was stopped, may not reach a consensus. This is out of scope here.

Durable-nonce transactions (withdrawals) never expire, and their status is not checked: since a status query is a negative query against storage with unspecified retention (see Section 3.2.1), a withdrawal whose status was pruned by the providers would remain undecided forever. Instead, on every round of the finalization timer, the ckSOL minter reads the nonce account of each in-flight withdrawal transaction with getAccountInfo at the finalized commitment level, with at most 10 concurrent calls, and classifies the returned value as described in Section 3.2.1:

  • Nonce value unchanged: the transaction has not landed, or has not been finalized yet, and can still land. Once at least 90 seconds have passed since it was recorded as submitted, the ckSOL minter re-broadcasts the persisted signed transaction unchanged, without preflight, as for the initial send, and without recording an event. No new threshold signature is required, and the transaction keeps its identifier. Re-signing the message would be safe, since at most one of two transactions sharing the same nonce value can land, but it would be pointless: threshold Ed25519 signing is not deterministic, so a fresh signature would merely create a second identifier to track for the same transaction. Re-signing only becomes useful to bump the transaction fee, which is not supported.
  • Nonce value seen before: the response is stale, coming from a provider lagging behind an already observed state. No decision is taken in this round.
  • Nonce value never seen: the transaction has landed, but with an unknown outcome, since the nonce advance persists even when a transaction fails during execution: the advanced nonce proves inclusion, not payment. The ckSOL minter resolves the outcome in the same round by fetching the transaction with getTransaction at the finalized commitment level. The response is attributed to the withdrawal by checking that it carries exactly the signed transaction the ckSOL minter submitted. The transaction transitions to Succeeded if the error field of its metadata is null, and to Failed otherwise, which releases its nonce account. If getTransaction returns null, fails, or yields inconsistent results, nothing is recorded and the read is repeated in the next round.
  • Read failure: no decision is taken in this round. A failing RPC call is expected to be transient, while an account that is no longer a nonce account of the ckSOL minter, after it was already bound, is unexpected and reported as an error.

The minimum age before a re-broadcast leaves the retries to the RPC node first: since the ckSOL minter sends transactions without maxRetries, an Agave RPC node keeps re-sending a durable-nonce transaction every 2 seconds until it lands, its nonce advances, or 150 blocks (about 60 to 90 seconds) pass, so an earlier re-broadcast would only duplicate that work. The submission time is taken from the timestamp of the SubmittedTransaction event, so it survives upgrades. Re-broadcasting covers a transaction whose initial send failed as well as one dropped before landing, and it is always safe: all copies carry the same nonce value, so whichever lands first advances the nonce and all others fail the nonce check. A failed re-broadcast is retried in the next round. Re-broadcasting has no backoff and no budget: each round adds one sendTransaction call per withdrawal transaction whose nonce is unchanged, i.e., at most one call per transaction every 2 minutes, and the number of such transactions is bounded by the size of the nonce pool. This cost is not covered by the withdrawal fee of Section 3.3.2 and is accepted, since a transaction stays unlanded only while it fails to land, which should be rare and is visible through the re-broadcast metric.

The nonce account stays bound to a landed withdrawal transaction until getTransaction has resolved its outcome; there is no intermediate state in which the account is released while the outcome is still unknown. This keeps the state machine of a withdrawal transaction unchanged, i.e., Submitted until it is Succeeded or Failed, and it adds a second, independent confirmation of the landing: a consistent but bogus nonce read alone, e.g., agreed upon by misbehaving providers, cannot finalize a withdrawal, release its nonce account for reuse, or report it to the user, since the providers must additionally return the exact submitted transaction as finalized. Unlike a status query, getTransaction is answered consistently by the default providers for transactions that landed several months ago, and it is only called once the nonce advance has proven that the transaction exists. While the outcome is unresolved, withdraw_sol_status keeps reporting TxSent, and the getTransaction call is retried on every round without a backoff or retry budget. The number of in-flight withdrawal transactions left undecided by the last round is exposed as a metric, labelled by reason: a stale nonce read, a failed nonce read, or a nonce advance whose outcome could not be fetched. A withdrawal transaction that resolves to an on-chain error follows the regular failure handling described below: the transaction is stored whole and the burned ckSOL is not reimbursed, as there is no reimbursement flow.

The following residual risks are accepted:

  1. If getTransaction cannot reach a consensus, e.g., because the default providers of the SOL RPC canister change to providers with a short history while the ckSOL minter is stopped for a long time, the outcome of a landed withdrawal stays unresolved and its nonce account stays bound. This shows up in the age of the oldest incomplete withdrawal and requires an operator to intervene, e.g., by configuring other providers.
  2. A transaction rejected before inclusion never advances its nonce and keeps being re-broadcast, e.g., when the fee payer cannot pay the transaction fee. Holding back the rent exemption threshold from the available balance prevents this case, as described in Section 3.2.4.
  3. A transaction that is processed or confirmed but not yet finalized still shows its nonce unchanged at the finalized commitment level and may be re-broadcast needlessly. This is harmless and rare, since a transaction is only re-broadcast 90 seconds after its submission, while finalization takes about 13 seconds.

The different states and their transitions internal to the ckSOL minter are shown in the following figure.

stateDiagram-v2
    Withdrawal --> Submitted
    Sweep --> Submitted
    Submitted --> Succeeded: sweep finalized with err = null, or withdrawal nonce advanced and getTransaction with err = null
    Submitted --> Failed: sweep finalized with err != null, or withdrawal nonce advanced and getTransaction with err != null
    Submitted --> Submitted: withdrawal nonce unchanged (re-broadcast)
    Submitted --> Dropped: sweep expired

    Succeeded: Succeeded (store transaction_id)
    Failed: Failed (store whole transaction)
    Dropped: Dropped (deposits marked dropped, no resubmission)

The withdrawal and sweep flows result in the submission of a transaction, which is then in the Submitted state and monitored on a timer as outlined above. Once a transaction is known to be finalized, there are two cases: If the transaction was finalized successfully, i.e., without errors, the transaction transitions to the state Succeeded and its ID is stored permanently. If there was an error, the transaction transitions to the state Failed and is stored in its entirety so that it can be analyzed what happened. Ideally, no transaction ever ends up in this state. However, it is possible for transactions to fail, for example by attempting to withdraw SOL to a program account, which is not allowed. As there is no reimbursement flow, the user’s funds would be stuck in this case. Storing the whole failed transaction ensures that the funds are not lost and appropriate actions may be taken when such transactions are encountered.

The states above describe the lifecycle of the transaction itself, not the crediting of the deposits it carries. When a sweep transaction of the manual flow reaches Succeeded, the ckSOL minter additionally records its deposits as finalized, fetches the transaction to read the fee that was charged, runs the sanity check on the pre- and post-balances, and enqueues the pending mints, as described in Section 3.1.2.

An expired sweep is never resubmitted but dropped, as described in Section 3.1.2, since nothing has been minted for it yet and the user can simply queue a new sweep. A withdrawal transaction with an unchanged nonce stays in the Submitted state, and the finalization timer re-broadcasts the persisted signed transaction as-is.

Since withdrawal transactions never expire, a transaction that persistently fails to land occupies its nonce account indefinitely. Re-broadcasting cures the causes that previously led to expiry, such as temporary RPC outages or transactions dropped on the way to a leader, and since preflight is skipped, a transaction whose transfer cannot succeed lands and fails on-chain rather than being silently filtered out by the RPC providers, so under normal conditions every withdrawal transaction eventually lands, successfully or not. The age of the oldest incomplete withdrawal and the number of re-broadcast attempts are exposed as metrics to detect the abnormal case. Deliberately abandoning a stuck transaction would require the ckSOL minter to advance the nonce itself with a separate transaction, which races the stuck transaction; this is left out of scope as a manual, operator-driven procedure.

A prioritization fee is not required under normal load, therefore it is omitted. If the need arises to bump fees, this topic will be revisited.

3.3. Fees & Minimum Swap Amounts

3.3.1. ckSOL Ledger Fees

Summary:

  • ckSOL transfer fee: 0.0000005 SOL (500 lamports)

A SOL or SPL token transfer costs 0.000005 SOL = 5000 lamports, which corresponds to 0.0005 USD at 1 SOL = 100 USD.

Executing a token transfer on an ICRC ledger consumes roughly 1.2M cycles on a 13-node subnet, which corresponds to 3.14M cycles on a 34-node subnet such as the fiduciary subnet.

This cycles cost corresponds to 3.14 × 10⁻⁶ XDR = 4.5216 × 10⁻⁶ USD = 4.5216 × 10⁻⁸ SOL = 45.2 lamports. As there are additional costs, such as maintaining the index canister, the fee is set to 500 lamports, which is one order of magnitude cheaper than a SOL transfer on Solana.

3.3.2. ckSOL Minter Fees

Summary:

  • Manual deposit fee: the deposit’s share of the sweep transaction fee, 5,000 lamports, plus the rent exemption threshold of 890,880 lamports left on the deposit address the first time it is swept
  • deposit_sol fee: 45B cycles, charged to the caller
  • Withdrawal fee: 0.001 SOL

The ckSOL minter charges fees for the deposit and withdrawal of SOL, which imply lower bounds on the minimum deposit and retrieval amounts.

The fees must be large enough to cover the cost of the required calls to the SOL RPC canister and the threshold-Schnorr API.

The response size affects the cost of HTTPS outcalls. The relevant RPC endpoints were each called 1000 times for randomly chosen transactions.

The maximum response sizes for each endpoint are listed here:

  • getSignaturesForAddress: 11,793 bytes (with a limit of 50 signatures)
  • getTransaction: 31,985 bytes
  • getSignatureStatuses: 275 (1 sig.), 1784 (10 sig.), 14868 (100 sig.) bytes
  • getBlock: 232 bytes
  • sendTransaction: 128 bytes
  • getSlot: 44 bytes

The measurements above were taken over arbitrary mainnet transactions. The ckSOL minter does not size any of its requests from them: it sets no response size estimate at all, so every call is priced with the SOL RPC canister’s default estimate for that method, each of which includes a 2 KiB allowance for headers. The defaults are 10 KiB for getTransaction and 256 bytes per signature for getSignatureStatuses, and 512 + 2048 bytes for getAccountInfo, with the other methods sized comparably.

The getTransaction default is smaller than the 31,985 bytes measured above, which is not a problem: the only transactions the ckSOL minter ever fetches are the ones it built itself, and those are capped at the maximum transaction size of 1232 bytes, so their responses stay around 1 KB rather than the tens of kilobytes an arbitrary mainnet transaction can reach. An underestimate is not fatal either, since the SOL RPC canister retries an oversized response with double the estimate.

Using the cost estimation endpoints of the SOL RPC canister, the following estimates are computed for the cycles cost of each required endpoint, using the response sizes above on mainnet for commitment level finalized and requiring 3 out of 4 responses to agree. The ckSOL minter attaches more than these amounts, leaving a margin for provider or price changes and for the SOL RPC canister doubling its response size estimate after an oversized response; the unused part is refunded:

  • getAccountInfo: 2.34B cycles
  • getSignaturesForAddress: 4.3B cycles
  • getTransaction: 3.2B cycles
  • sendTransaction: 2.2B cycles
  • getSlot: 2.29B cycles
  • getBlock: 2.34B cycles
  • getBalance: 2.31B cycles
  • getSignatureStatuses: 2.3B (1 sig.), 10.7B (256 sig., the largest batch) cycles

The automatic deposit fee is not derived here. The automated flow of Section 3.1.1 is not implemented, so there is no parameter to size, and its fee has to be derived again against the then-current RPC costs when the flow is revisited.

The manual deposit fee is not a parameter but the depositor’s share of the shortfall between what the sweep transaction moved and what arrived on the main account, derived from the finalized transaction as described in Section 3.1.2. Under the current fee schedule that shortfall is the transaction fee of 5000 * k lamports for k signatures, so the share is ceil(5000 * k / k) = 5000 lamports. In addition, the depositor leaves the rent exemption threshold of 890,880 lamports on the deposit address the first time it is swept. The cycles consumed by the manual flow are charged to the caller of deposit_sol: the cost of the getBalance call, roughly 2.31B cycles, plus the deposit_sol fee. The latter must cover the threshold signature of 26.2B cycles and, for a sweep containing a single deposit, all the RPC calls of the sweep and finalization timers, i.e., getSlot, getBlock, sendTransaction, getSignatureStatuses, and getTransaction, for about 12.3B cycles when each of them succeeds on its first attempt. A deposit_sol fee of 45B cycles covers that case.

Three costs are deliberately left outside the fee and absorbed by the ckSOL minter. Fetching a recent block is configured for up to three attempts of one getSlot and one getBlock each, since a slot may have no block, so it costs up to 13.9B instead of 4.6B cycles; a single-deposit sweep that needs all three attempts therefore comes to about 47.8B cycles and exceeds the fee. Retries of the getTransaction call after a failure are not charged either, nor are the status checks of the finalization timer, which are batched for all in-flight transactions. In each case the ckSOL minter accepts the risk of spending more cycles than it received for a deposit, as the alternative of an attempt budget adds complexity for failures that should be rare.

When a deposit address holding x SOL is swept for the first time, the user receives x SOL minus the rent exemption threshold minus the fee share in their account. Later sweeps of the same address only deduct the fee share, since the threshold is already in place.

The withdrawal fee can be lower, as it only requires the execution of the withdrawal transaction submission flow and its finalization, i.e., per withdrawal transaction one getAccountInfo call to read the nonce account at creation, one sendTransaction call, one getAccountInfo call per round of the finalization timer until the transaction has landed, typically a single one since a round runs every 2 minutes and a transaction finalizes within seconds, and one getTransaction call to read its outcome, for a total cost of 2.34B + 2.2B + 2.34B + 3.2B = 10.1B cycles, which corresponds to 0.0101 XDR = 0.014544 USD = 0.00014544 SOL. Adding the threshold signature cost of 0.000377 SOL, the total cost is 0.00052244 SOL. Rounding up, the withdrawal fee of 0.001 SOL remains sufficient, even though it is charged per withdrawal while the costs above are incurred per withdrawal transaction, which batches up to 10 withdrawals. Re-broadcasts of a withdrawal transaction that has not landed, together with the additional nonce reads they imply, and retries of the getTransaction call are not covered by this fee: the ckSOL minter accepts their cost, as described in Section 3.2.5.

When the user withdraws x SOL, the user receives x SOL minus the withdrawal fee in the destination account.

3.3.3. Minimum Swap Amounts

Summary:

  • Minimum deposit amount: 0.02 SOL
  • Minimum withdrawal amount: 0.002 SOL

The minimum deposit amount applies to the balance of a deposit address, so that a user who deposits exactly the minimum is served. Following the constraints of Section 3.3.4, it must cover the rent exemption threshold of 890,880 lamports twice, once left on the deposit address and once reaching the main account on the very first sweep, plus the fee of one signature, for a total of 1,786,760 lamports. The minimum deposit amount is set to 0.02 SOL, more than 11 times that lower bound, so that a first deposit of exactly the minimum is credited 19,104,120 lamports, i.e., 95.5% of the amount deposited, and the cycles attached to deposit_sol remain small compared to the deposit.

A similar principle can be applied for the minimum withdrawal amount. Given the withdrawal fee of 0.001 SOL, the minimum withdrawal amount can be set to 0.002 SOL. The user will thus receive at least 0.001 SOL, which is strictly above the rent exemption threshold of 0.00089088 SOL (890,880 lamports).

3.3.4. Parameter Constraints

The following constraints regarding the parameters introduced in this section must hold and should be enforced in the initialization and upgrade code:

  1. minimum deposit amount ≥ rent exemption threshold + 10 × Solana transfer fee: The minimum deposit amount applies to the balance of the deposit address, which includes the rent exemption threshold left on it. The sweepable amount of a minimum deposit must still cover the fee of a full sweep transaction of 10 signatures, since the largest deposit of a batch pays the whole fee.
  2. minimum deposit amount ≥ 2 × rent exemption threshold + Solana transfer fee: The very first sweep must leave the main account rent exempt, otherwise Solana rejects the transfer into it. A sweep of a single minimum deposit moves the balance minus the rent exemption threshold left on the deposit address and minus the fee of one signature, so that remainder must itself reach the threshold. This constraint subsumes the previous one at the current parameter values and is stated separately because the two express different requirements.
  3. deposit_sol required cycles ≥ cycles attached to the getBalance call (GET_BALANCE_CYCLES, 10B) + deposit_sol fee: deposit_sol charges, after the RPC call, the attached RPC cycles minus what was refunded plus, on success, the deposit_sol fee. A caller attaching exactly the required amount must always be able to pay this, otherwise the charge traps in the reply callback. The trap undoes the call, so the caller pays nothing and the ckSOL minter pays for the getBalance call out of its own balance, and since the trap happens before the deposit is recorded, no deposit can ever be queued while the parameters are wrong. Since the constraint is also checked when the event log is replayed on upgrade, raising GET_BALANCE_CYCLES requires first confirming that the init and every upgrade event of each deployed minter still satisfy it.

3.4. OFAC Checks

There are no Solana addresses in the OFAC SDN list, which can be verified as follows: Download the SDN_XML.ZIP file from the OFAC website, decompress it to obtain the file SDN.XML, and then run the following command:

grep "Digital Currency Address" SDN.XML | grep -v -e "XBT" -e "ETH" -e "LTC" -e "USDT" -e "TRX" -e "XMR"

The command prints all digital currency idType entries, filtering out the cryptocurrencies Bitcoin (“XBT”), Ethereum (“ETH”), Litecoin (“LTC”), USDT (“USDT”), Tron (“TRX”), and Monero (“XMR”).

At the time of writing, running the command yields the following output.

<idType>Digital Currency Address - ZEC</idType>
<idType>Digital Currency Address - DASH</idType>
<idType>Digital Currency Address - BTG</idType>
<idType>Digital Currency Address - ETC</idType>
<idType>Digital Currency Address - DASH</idType>
<idType>Digital Currency Address - ZEC</idType>
<idType>Digital Currency Address - BSV</idType>
<idType>Digital Currency Address - BCH</idType>
<idType>Digital Currency Address - XVG</idType>
<idType>Digital Currency Address - ZEC</idType>
<idType>Digital Currency Address - DASH</idType>
<idType>Digital Currency Address - BCH</idType>
<idType>Digital Currency Address - BCH</idType>
<idType>Digital Currency Address - BCH</idType>
<idType>Digital Currency Address - XRP</idType>
<idType>Digital Currency Address - BCH</idType>
<idType>Digital Currency Address - BCH</idType>
<idType>Digital Currency Address - BCH</idType>
<idType>Digital Currency Address - ARB</idType>
<idType>Digital Currency Address - BSC</idType>
<idType>Digital Currency Address - USDC</idType>
<idType>Digital Currency Address - USDC</idType>

None of the entries in the list correspond to a Solana address. Therefore, implementing any checks against the OFAC SDN list is not required. The DeFi team at DFINITY periodically checks the OFAC SDN list to update the blocklists in the ckBTC and ckETH minter. The appearance of Solana addresses can be checked at the same time.

3.5. Events

The ckETH minter captures all internal state changes in events. The ckSOL minter also follows this event-based design. Since Solana has a much shorter block time than Ethereum, the potentially faster growth of the event log must be considered. In particular, the following critical questions need to be addressed: Is there a risk that the ckSOL minter cannot be upgraded anymore because the full event log can no longer be replayed? Is there a risk that the event log will not fit into stable memory anymore?

The event types of ckETH and ckSOL are quite similar. Due to this similarity, and the lack of concrete numbers for ckSOL, we conjecture that the size of the events and the number of instructions required to process them is roughly the same. The ckETH minter was launched about 800 days ago and has since produced 49,263 recorded events, i.e., there were roughly 2.6 events per hour since launch. The total size of the event log is 16 MiB, i.e., the average event size is 342 bytes. On average, executing an event costs about 20,000 instructions.

An upgrade would fail if the limit of 300G instructions is reached. Rather than computing the theoretical limit of the growth of the event log, which would be highly unrealistic, we instead compute numbers for a rate of 1 event per second, which is roughly 4 orders of magnitude more than the current rate of ckETH.

At this rate, it would take 300B instructions / (20K instructions per second) = 15M seconds = 5.7 months until the event log would become too large. We can draw two conclusions from this number:

  1. The number of events must indeed be tracked, because it is possible, but still quite unlikely, that the event log becomes so large that upgrades may no longer be possible.
  2. Even at a high rate of 1 event per second, there is still time in the order of months to act and introduce a mechanism to compact the event log before it grows too big.

Regarding the stable memory, the total size of 500 GiB is reached after 49.6 years at a rate of 1 event per second. This is more than enough time to introduce some pruning mechanism. In other words, the limit on the stable memory size will not be the limiting factor; the upgrade instruction limit will be reached much sooner.

3.6. API

The ckSOL ledger exposes all endpoints of a standard ICRC ledger, complying with the ICRC-1, ICRC-2, and ICRC-3 standards.

The ckSOL minter exposes the following endpoints:

  1. get_deposit_address(opt principal, opt subaccount): Returns the Solana address derived from the provided principal ID and subaccount. If no principal ID is provided, the principal ID of the caller is used.
  2. deposit_sol(opt principal, opt subaccount): Reads the balance of the deposit address derived from the given account and queues it for a sweep if the balance is at least the minimum deposit amount. Returns the deposit id of the queued sweep, or of the sweep already in flight for the account. Otherwise, an error is returned.
  3. deposit_sol_status(deposit_id): Returns the status (Queued, Swept, Finalized, Minted, Dropped, Quarantined) of the deposit with the given id, or NotFound if the id is unknown. An explicit variant rather than an optional result lets a client that predates a later variant distinguish an unknown id from a status it cannot decode, as withdraw_sol_status does.
  4. withdraw_sol(opt subaccount, amount, address): Burns the given amount of ckSOL from the user’s account and transfers the same amount minus a fee in SOL to the given user address. Returns the block index of the burn operation on the ckSOL ledger in case of success. Otherwise, an error is returned.
  5. withdraw_sol_status(block_index): Returns the withdrawal status (NotFound, Pending, TxSent, TxFinalized) for the withdrawal identified by the given block index.
  6. get_minter_info: Returns information about the ckSOL minter, specifically the various fees, the minimum deposit and withdrawal amounts, the current balance of the ckSOL minter, its main address, and the addresses of its durable nonce accounts.
  7. get_events(start, length): Returns a page of the event log, at most 2000 events per call. This is a debug endpoint; backwards compatibility is not guaranteed for it.

The authoritative interface is the Candid file minter/cksol_minter.did.

Deployment

Minter’s address on Solana

Given a canister ID, the address controlled by the minter can be derived offline without installing any code. See should_derive_mainnet_minter_addresses_offline for an example.

Create nonce accounts

Withdrawals are sent as durable-nonce transactions, so the minter needs a pool of durable nonce accounts whose nonce authority is the minter’s address. Without them, withdraw_sol returns TemporarilyUnavailable("The durable nonce account pool is empty, no withdrawal can be processed").

The commands below use the Solana CLI and the staging minter on Devnet. For production, use the production minter’s address and --url mainnet-beta.

Fund a fee payer that pays for the rent of the nonce accounts:

solana-keygen new --no-bip39-passphrase -o ~/.config/solana/devnet-payer.json
solana airdrop 1 --keypair ~/.config/solana/devnet-payer.json --url devnet

Create the nonce accounts with the minter as nonce authority:

MINTER_ADDRESS=Br8eRkeya8hy3sHCYqtWqGeNNZ349aPSUKGVYFWKKer1
mkdir -p nonce-accounts
for i in 1 2 3; do
  solana-keygen new --no-bip39-passphrase --silent -o nonce-accounts/nonce-$i.json
  solana create-nonce-account nonce-accounts/nonce-$i.json 0.002 \
    --nonce-authority $MINTER_ADDRESS \
    --keypair ~/.config/solana/devnet-payer.json \
    --url devnet
done

Each account is funded with 0.002 SOL, above the rent-exempt minimum for the 80 bytes of a nonce account (see solana rent 80). The nonce-$i.json keypairs are only needed to create the accounts: afterwards, only the nonce authority can advance the nonce or withdraw from the account. Each in-flight withdrawal transaction occupies one nonce account until it is finalized, and a transaction batches up to 10 withdrawals.

Check that the nonce authority of each account is the minter’s address:

for i in 1 2 3; do
  ADDRESS=$(solana-keygen pubkey nonce-accounts/nonce-$i.json)
  echo $ADDRESS
  solana nonce-account $ADDRESS --url devnet
done

The minter only parses the addresses when they are added and verifies each account when it creates a withdrawal transaction, so a wrong nonce authority only surfaces once a withdrawal is processed.

Add the accounts to the minter, either in nonce_accounts of the initialization arguments or with an upgrade:

(
  variant {
    Upgrade = record {
      nonce_accounts_to_add = opt vec { "<address-1>"; "<address-2>"; "<address-3>" };
    }
  },
)

The other fields of UpgradeArgs are optional and can be omitted.

Minter

Initialization arguments:

Production Staging
(
  variant {
    Init = record {
      sol_rpc_canister_id = principal "tghme-zyaaa-aaaar-qarca-cai";
      ledger_canister_id = principal "ls5lp-lqaaa-aaaar-qb5oa-cai";
      master_key_name = variant { MainnetProdKey1 };
      solana_network = variant { Mainnet };
      deposit_sol_fee = 45_000_000_000 : nat64;
      deposit_sol_required_cycles = 1_000_000_000_000 : nat64;
      minimum_deposit_amount = 20_000_000 : nat64;
      withdrawal_fee = 1_000_000 : nat64;
      minimum_withdrawal_amount = 2_000_000 : nat64;
      nonce_accounts = vec {};
    }
  },
)
(
  variant {
    Init = record {
      sol_rpc_canister_id = principal "tghme-zyaaa-aaaar-qarca-cai";
      ledger_canister_id = principal "la34w-haaaa-aaaar-qb5na-cai";
      master_key_name = variant { MainnetProdKey1 };
      solana_network = variant { Devnet };
      deposit_sol_fee = 45_000_000_000 : nat64;
      deposit_sol_required_cycles = 1_000_000_000_000 : nat64;
      minimum_deposit_amount = 20_000_000 : nat64;
      withdrawal_fee = 1_000_000 : nat64;
      minimum_withdrawal_amount = 2_000_000 : nat64;
      nonce_accounts = vec {};
    }
  },
)

The fees and minimum amounts follow Section 3.3 of the design:

ArgumentValueRationale
deposit_sol_fee45B cyclesCovers the threshold signature and the RPC calls of a sweep containing a single deposit.
deposit_sol_required_cycles1T cyclesMust be at least GET_BALANCE_CYCLES (10B) plus deposit_sol_fee; unused cycles are refunded.
minimum_deposit_amount0.02 SOL (20,000,000)Must be at least twice the rent exemption threshold plus the fee of one signature.
withdrawal_fee0.001 SOL (1,000,000)Covers getAccountInfo, sendTransaction, getTransaction and the threshold signature.
minimum_withdrawal_amount0.002 SOL (2,000,000)Must be at least the withdrawal fee plus the rent exemption threshold.
nonce_accountsemptyReplace with the addresses created in Create nonce accounts.

Ledger

Initialization arguments:

Production Staging
(
  variant {
    Init = record {
      minting_account = record { owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai"; subaccount = null };
      fee_collector_account = opt record {
        owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai";
        subaccount = opt blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\0f\ee";
      };
      transfer_fee = 500 : nat;
      decimals = opt (9 : nat8);
      max_memo_length = opt (80 : nat16);
      token_symbol = "ckSOL";
      token_name = "ckSOL";
      metadata = vec {
        record { "icrc1:logo"; variant { Text = "data:image/svg+xml;base64,${LOGO}" } };
      };
      initial_balances = vec {};
      feature_flags = opt record { icrc2 = true };
      archive_options = record {
        num_blocks_to_archive = 1_000 : nat64;
        trigger_threshold = 4_200_000_000 : nat64;
        node_max_memory_size_bytes = opt (3_221_225_472 : nat64);
        max_message_size_bytes = null;
        max_transactions_per_response = null;
        cycles_for_archive_creation = opt (100_000_000_000_000 : nat64);
        controller_id = principal "r7inp-6aaaa-aaaaa-aaabq-cai";
        more_controller_ids = null;
      };
      index_principal = opt principal "2ezyf-hqaaa-aaaar-qb6ga-cai";
    }
  },
)
(
  variant {
    Init = record {
      minting_account = record { owner = principal "ljyxk-riaaa-aaaar-qb5mq-cai"; subaccount = null };
      fee_collector_account = opt record {
        owner = principal "ljyxk-riaaa-aaaar-qb5mq-cai";
        subaccount = opt blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\0f\ee";
      };
      transfer_fee = 500 : nat;
      decimals = opt (9 : nat8);
      max_memo_length = opt (80 : nat16);
      token_symbol = "ckDevnetSOL";
      token_name = "ckDevnetSOL";
      metadata = vec {
        record { "icrc1:logo"; variant { Text = "data:image/svg+xml;base64,${LOGO}" } };
      };
      initial_balances = vec {};
      feature_flags = opt record { icrc2 = true };
      archive_options = record {
        num_blocks_to_archive = 1_000 : nat64;
        trigger_threshold = 4_200_000_000 : nat64;
        node_max_memory_size_bytes = opt (3_221_225_472 : nat64);
        max_message_size_bytes = null;
        max_transactions_per_response = null;
        cycles_for_archive_creation = opt (100_000_000_000_000 : nat64);
        controller_id = principal "cmqvo-qqaaa-aaaai-q3waa-cai";
        more_controller_ids = null;
      };
      index_principal = opt principal "2r6ji-gyaaa-aaaar-qb6fq-cai";
    }
  },
)

LOGO is the base64 encoding of the token logo, without line breaks. For production, use LOGO=$(base64 -i static/images/cksol-token.svg | tr -d '\n'). For staging, use LOGO=$(base64 -i static/images/ckdevnetsol-token.svg | tr -d '\n').

The minting account is the minter’s default account and the fee collector is the minter’s subaccount 0x0fee. The transfer fee of 500 lamports follows Section 3.3.1 of the design. Archiving is effectively disabled by setting trigger_threshold to 4,200,000,000 blocks. The other archive options match those of the ckETH ledger (proposal 126170), so that archives are controlled by the NNS root canister if archiving is enabled by a later upgrade.

Index

Initialization arguments:

Production Staging
(
  opt variant {
    Init = record {
      ledger_id = principal "ls5lp-lqaaa-aaaar-qb5oa-cai";
      retrieve_blocks_from_ledger_interval_seconds = null;
    }
  },
)
(
  opt variant {
    Init = record {
      ledger_id = principal "la34w-haaaa-aaaar-qb5na-cai";
      retrieve_blocks_from_ledger_interval_seconds = null;
    }
  },
)

The index takes an optional argument, hence the leading opt. Leaving retrieve_blocks_from_ledger_interval_seconds unset uses the index’s default polling interval.

Test

The commands below use the staging minter and ledger. For production, replace the canister IDs with those of the production minter and ledger.

MINTER=ljyxk-riaaa-aaaar-qb5mq-cai
LEDGER=la34w-haaaa-aaaar-qb5na-cai
OWNER=$(icp identity principal --identity demo)

Deposit SOL

Get the deposit address

icp canister call $MINTER get_deposit_address "(record { owner = opt principal \"$OWNER\"; subaccount = null })" -n ic

Send SOL to the returned address (faucet). The balance of the deposit address must be at least minimum_deposit_amount, which get_minter_info returns:

icp canister call $MINTER get_minter_info '()' --query -n ic

Queue the deposit

deposit_sol requires deposit_sol_required_cycles (1T cycles) to be attached to the call. Since an identity cannot attach cycles, route the call through a proxy canister that holds cycles:

icp canister call $MINTER deposit_sol "(record { owner = opt principal \"$OWNER\"; subaccount = null })" \
  --proxy h35ft-riaaa-aaaar-qb37a-cai \
  --cycles 1000000000000 \
  --identity hsm \
  --identity-password-file ~/.config/icp/hsm.pin \
  -n ic

The minter sees the proxy canister as the caller, so owner must be set explicitly. Otherwise, the ckSOL would be minted to the proxy canister. The call returns the deposit ID, e.g. (variant { Ok = 0 : nat64 }).

Check the deposit status

icp canister call $MINTER deposit_sol_status '(0 : nat64)' --query -n ic

The status goes through Queued, Swept, Finalized and Minted. Once minted, the ckSOL balance of the owner is:

icp canister call $LEDGER icrc1_balance_of "(record { owner = principal \"$OWNER\"; subaccount = null })" --query -n ic

Withdraw SOL

Withdrawals require nonce accounts, see Create nonce accounts. Without them, withdraw_sol returns TemporarilyUnavailable and burns nothing.

Approve the minter

The minter burns the withdrawn ckSOL with icrc2_transfer_from, so the owner must first approve the minter for the amount to withdraw. The approval costs the ledger transfer fee of 500 lamports, so withdrawing the whole balance means withdrawing the balance minus 500 lamports:

AMOUNT=2000000
DESTINATION=6TqNg48mSd5evmY66JVfGeGTwszrU1YLCeSw3GJ2qsUC
icp canister call $LEDGER icrc2_approve "(record { spender = record { owner = principal \"$MINTER\"; subaccount = null }; amount = $AMOUNT : nat; from_subaccount = null; expected_allowance = null; expires_at = null; fee = null; memo = null; created_at_time = null })" --identity demo -n ic

Withdraw

icp canister call $MINTER withdraw_sol "(record { from_subaccount = null; amount = $AMOUNT : nat64; address = \"$DESTINATION\" })" --identity demo -n ic

The amount must be at least minimum_withdrawal_amount, and the destination receives the amount minus withdrawal_fee. The call returns the index of the burn transaction on the ledger, e.g. (variant { Ok = record { block_index = 2 : nat64 } }).

Check the withdrawal status

icp canister call $MINTER withdraw_sol_status '(record { block_index = 2 : nat64 })' -n ic

The status goes through Pending, TxSent and TxFinalized.

Repository Structure

.
├── minter/                  # ckSOL minter canister
│   ├── src/
│   │   ├── address/         # Deposit address derivation
│   │   ├── dashboard/       # HTTP dashboard
│   │   ├── lifecycle.rs     # Canister init/upgrade and event log
│   │   ├── metrics.rs       # Prometheus metrics
│   │   ├── monitor/         # Transaction monitoring
│   │   ├── state/           # Minter state and event sourcing
│   │   ├── deposit/sweep/   # Deposit sweeping and minting
│   │   ├── withdraw/        # Withdrawal processing
│   │   └── ...
│   └── cksol_minter.did     # Candid interface
├── libs/
│   ├── types/               # Public ckSOL types (cksol-types crate)
│   └── types-internal/      # Internal types and event definitions
├── integration_tests/       # End-to-end tests using PocketIC and solana-test-validator
├── docs/
│   ├── book.toml            # mdbook configuration
│   └── src/                 # This documentation
└── scripts/
    ├── build                # Build script for the minter Wasm
    ├── docker-build         # Reproducible build of the minter Wasm inside Docker
    └── coverage             # Unit-test coverage report

Building and Testing

Prerequisites

Install mise and then provision the pinned toolchain (Rust with the wasm32-unknown-unknown target, cargo-sort, ic-wasm, cargo-llvm-cov, jq and mdbook):

mise install

Tool versions are defined in mise.toml and their download checksums in mise.lock, so local, CI and the reproducible Docker build stay in sync.

canbench is kept out of that set, because only the benchmarks need it and building it from source costs about two minutes. It lives in mise.bench.toml instead, which mise loads only for the bench environment:

MISE_ENV=bench mise install

Note

Upstream publishes a single Linux x86-64 canbench binary, and mise offers it for every platform. On macOS or Windows that binary will install but not run, so build it from source instead: cargo install --locked canbench.

Additionally:

  • gzip — used by ./scripts/build to compress the output Wasm.

Building

Build the minter Wasm:

./scripts/build --cksol_minter

The resulting cksol_minter.wasm.gz will be written to the wasms/ directory.

You can also build the Rust workspace directly (without Wasm post-processing):

cargo build

Testing

The test suite has three parts:

Unit tests — no external dependencies:

cargo test --lib

PocketIC integration tests — require the minter Wasm, which is read from wasms/cksol_minter.wasm.gz (written by both ./scripts/docker-build and ./scripts/build --cksol_minter), unless the CKSOL_MINTER_WASM_PATH environment variable points to it:

./scripts/build --cksol_minter
cargo test -p cksol-int-tests --test tests

Solana validator integration tests — require the Solana CLI to be installed, so that each test can start its own solana-test-validator:

cargo test -p cksol-int-tests --test solana_test_validator

Caution

Running cargo test without arguments will attempt all tests, including the Solana validator suite, and will fail if the Solana CLI is not installed.

Coverage — measures the unit tests only, since the integration tests drive a prebuilt Wasm that host instrumentation cannot observe:

./scripts/coverage

This writes coverage/, including a browsable report at coverage/html/index.html. CI runs the same script and publishes the summary and the report as a build artifact.

Documentation

This book is built with mdbook from the docs/ directory:

just book        # build into docs/book
just book-serve  # serve locally with live reload

Pushes to main publish it to dfinity.github.io/cksol.

Mainnet Proposals

NNS proposals submitted for the ckSOL canisters, most recent first.

Proposal to install the ckSOL minter canister

Repository: https://github.com/dfinity/cksol.git

Git hash: bb61dcf2045bca4333a732268c1b6d19e113ba93

New compressed Wasm hash: 21374814ae5ed3063762d53a0a66d9d60524792ae0d30670465172ad9c1273a8

Install args hash: 012da32ebb0db7687a719e03a0db46aaccdc1bce9a484784f5a6ebbd39c47151

Target canister: lh22c-kyaaa-aaaar-qb5nq-cai


Motivation

This proposal installs the mainnet ckSOL minter to the governance-controlled canister ID lh22c-kyaaa-aaaar-qb5nq-cai on subnet pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae.

ckSOL is a chain-key token on the Internet Computer backed 1:1 by SOL, the native token of the Solana blockchain. The ckSOL minter converts SOL to ckSOL by minting ckSOL on the ckSOL ledger for SOL deposited to an address controlled by the minter, and converts ckSOL to SOL by burning ckSOL and sending SOL to a Solana address chosen by the user. The minter controls its Solana addresses with threshold Ed25519 signatures and interacts with Solana through the SOL RPC canister. See the design document for details.

Install args

git fetch
git checkout bb61dcf2045bca4333a732268c1b6d19e113ba93
didc encode -d minter/cksol_minter.did -t '(MinterArg)' '(variant { Init = record {
    sol_rpc_canister_id = principal "tghme-zyaaa-aaaar-qarca-cai";
    ledger_canister_id = principal "ls5lp-lqaaa-aaaar-qb5oa-cai";
    master_key_name = variant { MainnetProdKey1 };
    solana_network = variant { Mainnet };
    deposit_sol_fee = 45_000_000_000 : nat64;
    deposit_sol_required_cycles = 1_000_000_000_000 : nat64;
    minimum_deposit_amount = 20_000_000 : nat64;
    withdrawal_fee = 1_000_000 : nat64;
    minimum_withdrawal_amount = 2_000_000 : nat64;
    nonce_accounts = vec {
      "6YJ5Tnfi6SurWYjSbUzKmb3vR2WHAw6R5jTV7B2xPk6V";
      "AkcgQ75Wfj8vywSntxRXCr965cx3innDm5YiRzgtk5JH";
      "FEtnTdiKa7riU93y8K3HancFcDHrTFnviv8ndDZ2EuDk";
      "G2VedB18ZtzqwwGX6waEGWgXi21GqQ4CLr7JHvhvDGk";
      "Au6vUGW5Db5eEPpq3G4UEN6vN5np6j5NujbNhMDo6DBm";
    };
  } })' | xxd -r -p | sha256sum

Wasm Verification

Verify that the hash of the gzipped WASM matches the proposed hash.

git fetch
git checkout bb61dcf2045bca4333a732268c1b6d19e113ba93
"./scripts/docker-build"
sha256sum ./wasms/cksol_minter.wasm.gz

Proposal to install the ckSOL ledger canister

Repository: https://github.com/dfinity/ic.git

Git hash: cf41372e3d4dc1accfe2c09a7969f8bddc729dc1

New compressed Wasm hash: 354dd6ecfdc72b5409805b31dea22c9db11df6e14095a5a68924eb63535e6d8a

Install args hash: 2e049fd0690e9c2aa324b97c8792b9dd64df768c6ca1eefd52cb04fe3f05b7bf

Target canister: ls5lp-lqaaa-aaaar-qb5oa-cai


Motivation

This proposal installs the mainnet ckSOL ledger to the governance-controlled canister ID ls5lp-lqaaa-aaaar-qb5oa-cai on subnet pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae. The installed Wasm is the ledger of the latest ICRC ledger suite release ledger-suite-icrc-2026-03-09.

ckSOL is a chain-key token on the Internet Computer backed 1:1 by SOL, the native token of the Solana blockchain. The ckSOL ledger is an ICRC-1, ICRC-2 and ICRC-3 compliant ledger that holds the ckSOL balances. The ckSOL minter mints ckSOL on this ledger when SOL is deposited and burns ckSOL when SOL is withdrawn. See the design document for details.

Install args

git fetch
git checkout cf41372e3d4dc1accfe2c09a7969f8bddc729dc1
didc encode -d rs/ledger_suite/icrc1/ledger/ledger.did -t '(LedgerArg)' '(variant { Init = record {
    minting_account = record { owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai"; subaccount = null };
    fee_collector_account = opt record {
      owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai";
      subaccount = opt blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\0f\ee";
    };
    transfer_fee = 500 : nat;
    decimals = opt (9 : nat8);
    max_memo_length = opt (80 : nat16);
    token_symbol = "ckSOL";
    token_name = "ckSOL";
    metadata = vec {
      record { "icrc1:logo"; variant { Text = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYwIiBoZWlnaHQ9IjM2MCIgdmlld0JveD0iMCAwIDM2MCAzNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMDIzXzQ5KSI+CjxwYXRoIGQ9Ik0xODAgMEMyNzkuNCAwIDM2MCA4MC42IDM2MCAxODBDMzYwIDI3OS40IDI3OS40IDM2MCAxODAgMzYwQzgwLjYgMzYwIDAgMjc5LjQgMCAxODBDMCA4MC42IDgwLjYgMCAxODAgMFoiIGZpbGw9IiMzQjAwQjkiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00MC4zOTk4IDE5MC40QzQ1LjM5OTggMjU5LjQgMTAwLjYgMzE0LjYgMTY5LjYgMzE5LjZWMzM1LjJDOTEuOTk5OCAzMzAgMjkuOTk5OCAyNjggMjQuNzk5OCAxOTAuNEg0MC4zOTk4WiIgZmlsbD0idXJsKCNwYWludDBfbGluZWFyXzEwMjNfNDkpIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY5LjYgNDAuNEMxMDAuNiA0NS40IDQ1LjM5OTggMTAwLjYgNDAuMzk5OCAxNjkuNkgyNC43OTk4QzI5Ljc5OTggOTIgOTEuOTk5OCAyOS44IDE2OS42IDI0LjhWNDAuNFoiIGZpbGw9IiMyOUFCRTIiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zMTkuNiAxNjkuNEMzMTQuNiAxMDAuNCAyNTkuNCA0NS4yIDE5MC40IDQwLjJWMjQuNkMyNjggMjkuOCAzMzAuMiA5MS44IDMzNS4yIDE2OS40SDMxOS42WiIgZmlsbD0idXJsKCNwYWludDFfbGluZWFyXzEwMjNfNDkpIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTkwLjQgMzE5LjZDMjU5LjQgMzE0LjYgMzE0LjYgMjU5LjQgMzE5LjYgMTkwLjRIMzM1LjJDMzMwLjIgMjY4IDI2OCAzMzAgMTkwLjQgMzM1LjJWMzE5LjZaIiBmaWxsPSIjMjlBQkUyIi8+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMV8xMDIzXzQ5KSI+CjxwYXRoIGQ9Ik0yNjQuMTI1IDIyMi43ODFMMjM2LjA2MSAyNTIuMTAyQzIzNS40NTEgMjUyLjczOCAyMzQuNzEzIDI1My4yNDYgMjMzLjg5MyAyNTMuNTkzQzIzMy4wNzIgMjUzLjk0IDIzMi4xODcgMjU0LjExOSAyMzEuMjkzIDI1NC4xMTlIOTguMjU4Qzk3LjYyMzIgMjU0LjExOSA5Ny4wMDIyIDI1My45MzggOTYuNDcxNCAyNTMuNTk5Qzk1Ljk0MDYgMjUzLjI2IDk1LjUyMyAyNTIuNzc3IDk1LjI3IDI1Mi4yMUM5NS4wMTcgMjUxLjY0MyA5NC45Mzk1IDI1MS4wMTYgOTUuMDQ3MiAyNTAuNDA3Qzk1LjE1NDggMjQ5Ljc5NyA5NS40NDI5IDI0OS4yMzIgOTUuODc2IDI0OC43NzlMMTIzLjk2MSAyMTkuNDU5QzEyNC41NjkgMjE4LjgyNCAxMjUuMzA1IDIxOC4zMTcgMTI2LjEyMiAyMTcuOTdDMTI2Ljk0IDIxNy42MjMgMTI3LjgyMiAyMTcuNDQzIDEyOC43MTQgMjE3LjQ0MkgyNjEuNzQyQzI2Mi4zNzcgMjE3LjQ0MiAyNjIuOTk4IDIxNy42MjMgMjYzLjUyOSAyMTcuOTYyQzI2NC4wNTkgMjE4LjMwMSAyNjQuNDc3IDIxOC43ODQgMjY0LjczMSAyMTkuMzUxQzI2NC45ODMgMjE5LjkxOCAyNjUuMDYxIDIyMC41NDQgMjY0Ljk1MyAyMjEuMTU0QzI2NC44NDUgMjIxLjc2MyAyNjQuNTU3IDIyMi4zMjkgMjY0LjEyNSAyMjIuNzgxWk0yMzYuMDYxIDE2My43MzhDMjM1LjQ1MSAxNjMuMTAxIDIzNC43MTMgMTYyLjU5MyAyMzMuODkzIDE2Mi4yNDZDMjMzLjA3MiAxNjEuODk5IDIzMi4xODcgMTYxLjcyIDIzMS4yOTMgMTYxLjcyMUg5OC4yNThDOTcuNjIzMiAxNjEuNzIxIDk3LjAwMjIgMTYxLjkwMiA5Ni40NzE0IDE2Mi4yNDFDOTUuOTQwNiAxNjIuNTggOTUuNTIzIDE2My4wNjMgOTUuMjcgMTYzLjYzQzk1LjAxNyAxNjQuMTk3IDk0LjkzOTUgMTY0LjgyNCA5NS4wNDcyIDE2NS40MzNDOTUuMTU0OCAxNjYuMDQyIDk1LjQ0MjkgMTY2LjYwOCA5NS44NzYgMTY3LjA2TDEyMy45NjEgMTk2LjM4MUMxMjQuNTY5IDE5Ny4wMTYgMTI1LjMwNSAxOTcuNTIzIDEyNi4xMjIgMTk3Ljg3QzEyNi45NCAxOTguMjE3IDEyNy44MjIgMTk4LjM5NyAxMjguNzE0IDE5OC4zOThIMjYxLjc0MkMyNjIuMzc3IDE5OC4zOTggMjYyLjk5OCAxOTguMjE3IDI2My41MjkgMTk3Ljg3OEMyNjQuMDU5IDE5Ny41MzkgMjY0LjQ3NyAxOTcuMDU2IDI2NC43MzEgMTk2LjQ4OUMyNjQuOTgzIDE5NS45MjIgMjY1LjA2MSAxOTUuMjk1IDI2NC45NTMgMTk0LjY4NkMyNjQuODQ1IDE5NC4wNzYgMjY0LjU1NyAxOTMuNTExIDI2NC4xMjUgMTkzLjA1OUwyMzYuMDYxIDE2My43MzhaTTk4LjI1OCAxNDIuNjc3SDIzMS4yOTNDMjMyLjE4NyAxNDIuNjc3IDIzMy4wNzIgMTQyLjQ5OSAyMzMuODkzIDE0Mi4xNTJDMjM0LjcxMyAxNDEuODA1IDIzNS40NTEgMTQxLjI5NyAyMzYuMDYxIDE0MC42NkwyNjQuMTI1IDExMS4zMzlDMjY0LjU1NyAxMTAuODg3IDI2NC44NDUgMTEwLjMyMiAyNjQuOTUzIDEwOS43MTJDMjY1LjA2MSAxMDkuMTAzIDI2NC45ODMgMTA4LjQ3NiAyNjQuNzMxIDEwNy45MDlDMjY0LjQ3NyAxMDcuMzQyIDI2NC4wNTkgMTA2Ljg1OSAyNjMuNTI5IDEwNi41MkMyNjIuOTk4IDEwNi4xODEgMjYyLjM3NyAxMDYgMjYxLjc0MiAxMDZIMTI4LjcxNEMxMjcuODIyIDEwNi4wMDEgMTI2Ljk0IDEwNi4xODEgMTI2LjEyMiAxMDYuNTI4QzEyNS4zMDUgMTA2Ljg3NSAxMjQuNTY5IDEwNy4zODIgMTIzLjk2MSAxMDguMDE3TDk1Ljg4MzIgMTM3LjMzOEM5NS40NTA2IDEzNy43ODkgOTUuMTYyNiAxMzguMzU0IDk1LjA1NDcgMTM4Ljk2M0M5NC45NDY4IDEzOS41NzIgOTUuMDIzNyAxNDAuMTk4IDk1LjI3NTggMTQwLjc2NUM5NS41Mjc5IDE0MS4zMzIgOTUuOTQ0NCAxNDEuODE1IDk2LjQ3NDEgMTQyLjE1NEM5Ny4wMDM5IDE0Mi40OTQgOTcuNjIzOCAxNDIuNjc2IDk4LjI1OCAxNDIuNjc3WiIgZmlsbD0id2hpdGUiLz4KPC9nPgo8L2c+CjxkZWZzPgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MF9saW5lYXJfMTAyM180OSIgeDE9IjEzMC43MiIgeTE9IjMwNC4xMiIgeDI9IjMzLjQ3OTgiIHkyPSIyMjIuMjIiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agb2Zmc2V0PSIwLjIxIiBzdG9wLWNvbG9yPSIjRUQxRTc5Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzUyMjc4NSIvPgo8L2xpbmVhckdyYWRpZW50Pgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MV9saW5lYXJfMTAyM180OSIgeDE9IjMwOS4zMiIgeTE9IjEyMy4wNiIgeDI9IjIxMi4wOCIgeTI9IjQxLjE2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CjxzdG9wIG9mZnNldD0iMC4yMSIgc3RvcC1jb2xvcj0iI0YxNUEyNCIvPgo8c3RvcCBvZmZzZXQ9IjAuNjgiIHN0b3AtY29sb3I9IiNGQkIwM0IiLz4KPC9saW5lYXJHcmFkaWVudD4KPGNsaXBQYXRoIGlkPSJjbGlwMF8xMDIzXzQ5Ij4KPHJlY3Qgd2lkdGg9IjM2MCIgaGVpZ2h0PSIzNjAiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjxjbGlwUGF0aCBpZD0iY2xpcDFfMTAyM180OSI+CjxyZWN0IHdpZHRoPSIxNzAiIGhlaWdodD0iMTQ4LjExOSIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDk1IDEwNikiLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K" } };
    };
    initial_balances = vec {};
    feature_flags = opt record { icrc2 = true };
    archive_options = record {
      num_blocks_to_archive = 1_000 : nat64;
      trigger_threshold = 4_200_000_000 : nat64;
      node_max_memory_size_bytes = opt (3_221_225_472 : nat64);
      max_message_size_bytes = null;
      max_transactions_per_response = null;
      cycles_for_archive_creation = opt (100_000_000_000_000 : nat64);
      controller_id = principal "r7inp-6aaaa-aaaaa-aaabq-cai";
      more_controller_ids = null;
    };
    index_principal = opt principal "2ezyf-hqaaa-aaaar-qb6ga-cai";
  } })' | xxd -r -p | sha256sum
  • lh22c-kyaaa-aaaar-qb5nq-cai is the ckSOL minter canister. Its default account is the minting account.
  • The fee collector is the 0000000000000000000000000000000000000000000000000000000000000fee subaccount of the minter canister.
  • The transfer fee is 500 lamports (0.0000005 SOL, about 0.00006 USD with 1 SOL at about 110 USD as of October 9, 2026), which is one order of magnitude cheaper than a SOL transfer on Solana (5,000 lamports), see Section 3.3.1 of the design.
  • The token has 9 decimals, like SOL, so that 1 ckSOL unit corresponds to 1 lamport.
  • The maximum memo length is 80 bytes.
  • The logo is the base64 encoding of cksol-token.svg and can be checked from the root of the cksol repository with base64 -i static/images/cksol-token.svg | tr -d '\n'.
  • ICRC-2 is enabled because the minter burns ckSOL with icrc2_transfer_from when processing withdrawals.
  • Archiving is effectively disabled by setting trigger_threshold to 4,200,000,000 blocks. The other archive options match those of the ckETH ledger (proposal 126170), so that archives are controlled by the NNS root canister if archiving is enabled by a later upgrade.
  • 2ezyf-hqaaa-aaaar-qb6ga-cai is the governance-controlled canister ID that will become the ckSOL index.

Wasm Verification

Verify that the hash of the gzipped WASM matches the proposed hash.

git fetch
git checkout cf41372e3d4dc1accfe2c09a7969f8bddc729dc1
"./ci/container/build-ic.sh" "--canisters"
sha256sum ./artifacts/canisters/ic-icrc1-ledger.wasm.gz

Proposal to install the ckSOL index canister

Repository: https://github.com/dfinity/ic.git

Git hash: cf41372e3d4dc1accfe2c09a7969f8bddc729dc1

New compressed Wasm hash: dab6808d0dfc06e5e88336d0c3d3e45e5448c6e36c2a781f3e9e09bd450f528c

Install args hash: 0375db897c4d679bc73fdf60efe7787771604df54f8bc673750af2896fa62318

Target canister: 2ezyf-hqaaa-aaaar-qb6ga-cai


Motivation

This proposal installs the mainnet ckSOL index to the governance-controlled canister ID 2ezyf-hqaaa-aaaar-qb6ga-cai on subnet pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae. The installed Wasm is the index of the latest ICRC ledger suite release ledger-suite-icrc-2026-03-09.

ckSOL is a chain-key token on the Internet Computer backed 1:1 by SOL, the native token of the Solana blockchain. The ckSOL index fetches the blocks of the ckSOL ledger and allows clients, such as wallets, to retrieve the transactions of a given account. See the design document for details.

Install args

git fetch
git checkout cf41372e3d4dc1accfe2c09a7969f8bddc729dc1
didc encode -d rs/ledger_suite/icrc1/index-ng/index-ng.did -t '(opt IndexArg)' '(opt variant { Init = record {
    ledger_id = principal "ls5lp-lqaaa-aaaar-qb5oa-cai";
    retrieve_blocks_from_ledger_interval_seconds = null;
  } })' | xxd -r -p | sha256sum
  • ls5lp-lqaaa-aaaar-qb5oa-cai is the ckSOL ledger canister, whose blocks are indexed.
  • retrieve_blocks_from_ledger_interval_seconds is unset, so the index uses its default polling interval, which adapts between 1 and 10 seconds depending on whether new blocks were found.

Wasm Verification

Verify that the hash of the gzipped WASM matches the proposed hash.

git fetch
git checkout cf41372e3d4dc1accfe2c09a7969f8bddc729dc1
"./ci/container/build-ic.sh" "--canisters"
sha256sum ./artifacts/canisters/ic-icrc1-index-ng.wasm.gz

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

0.1.0 - 2026-10-09

First release of the ckSOL minter, which mints ckSOL on the ckSOL ledger for SOL deposited on Solana and sends SOL back when ckSOL is burned. It is deployed on staging against Solana Devnet as ckDevnetSOL.

Added

  • Deposit addresses: get_deposit_address derives a Solana deposit address for any ICRC-1 account, as an update call so that it can later depend on state without a breaking change. The minter’s own main address is derived separately from the master key, so no account can obtain it as a deposit address (#3, #183, #227)
  • Deposits: deposit_sol reads the finalized balance of the given account’s deposit address and queues it for a sweep, charging the attached cycles for the RPC call plus the deposit_sol_fee. It rejects balances below minimum_deposit_amount, and a second call while a deposit for the same account is in flight. deposit_sol_status follows a deposit through Queued, Swept, Finalized and Minted, or Dropped and Quarantined on the unhappy paths (#5, #25, #208, #210, #211, #212)
  • Sweeps: a timer sweeps up to ten queued deposits into the main address per transaction. Once the transaction is finalized, the minter checks its outcome against the planned transfers, credits what the main address received and mints ckSOL for each deposit, with the sweep signature and deposit id in the mint memo. Sweeps that fail or expire are dropped and can be retried with deposit_sol; sweeps whose outcome does not match the plan are quarantined and nothing is minted (#213, #214, #218, #220, #222, #230, #232)
  • Withdrawals: withdraw_sol burns ckSOL through an ICRC-2 approval and queues a transfer of the amount minus withdrawal_fee; withdraw_sol_status reports Pending, TxSent and TxFinalized. Amounts below minimum_withdrawal_amount are rejected, and so are destinations that could never receive the transfer or would corrupt the minter’s accounting: reserved account keys such as sysvars and builtin programs, the minter’s main address, and its nonce accounts (#9, #13, #18, #29, #46, #109, #243, #262)
  • Durable-nonce withdrawals: queued withdrawals are batched up to ten per transaction, each built on a durable nonce account from a pool configured through the init and upgrade arguments, so a withdrawal transaction never expires. Nonce accounts can only be added, and each is verified on read. A transaction that does not reach the chain is re-broadcast unchanged, and a withdrawal is finalized from the state of its nonce account, which unlike the transaction history stays readable indefinitely. Only withdrawals the main address can pay for, including the transaction fee, are submitted (#84, #115, #202, #241, #244, #248, #252, #255, #257, #258, #260)
  • Event log: every state change is recorded as an event and replayed on upgrade, and get_events exposes the log. The minter’s public key is recorded on first fetch, so it is available immediately after an upgrade (#8, #20, #26, #65, #67, #70, #235)
  • Configuration: init and upgrade arguments for the SOL RPC canister, the ledger, the threshold key, the Solana network, fees, minimum amounts and nonce accounts, and get_minter_info to read them together with the minter’s balance and main address (#7, #10, #83, #86)
  • Observability: structured canister logs, Prometheus metrics at /metrics, including the age of the oldest in-flight deposit and withdrawal for alerting, and a dashboard at /dashboard with the minter’s metadata, minted and quarantined deposits, and withdrawals (#10, #64, #72, #90, #114, #127, #140, #152, #237)
  • Reproducible build: the release wasm is built in Docker and its SHA-256 can be checked against the published one (#80)

Fixed

  • Trust RPC responses only for what was asked: a getTransaction response must contain the queried transaction, and getSignatureStatuses searches the transaction history, so a landed transaction is not reported as unknown once it leaves the recent status cache (#236, #239)
  • Tolerate concurrent first fetches of the master public key, which used to trap the second caller (#226)
  • Fixes from a full deposit and withdrawal round trip and a deposit stress test on staging (#263)