From 7993d5493a88901a8b389ea7c721899577f35c28 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 16 Apr 2026 18:17:54 +0200 Subject: [PATCH 1/2] docs: Solana chain fusion guide Adds docs/guides/chain-fusion/solana.mdx covering the SOL RPC canister for querying Solana via JSON-RPC and threshold Ed25519 for signing transactions. Includes Rust and Motoko examples for getBalance, public key derivation, and message signing. Documents current status and limitations of the integration. Renames stub solana.md to solana.mdx (tabs require MDX). --- docs/guides/chain-fusion/solana.md | 21 -- docs/guides/chain-fusion/solana.mdx | 351 ++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 21 deletions(-) delete mode 100644 docs/guides/chain-fusion/solana.md create mode 100644 docs/guides/chain-fusion/solana.mdx diff --git a/docs/guides/chain-fusion/solana.md b/docs/guides/chain-fusion/solana.md deleted file mode 100644 index 3da58a8d..00000000 --- a/docs/guides/chain-fusion/solana.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: "Solana Integration" -description: "Interact with Solana from ICP canisters using the Sol RPC canister" -sidebar: - order: 3 ---- - -TODO: Write content for this page. - - -Interact with Solana from ICP canisters. Cover the Sol RPC canister, transaction signing with threshold Ed25519, SPL token reads, and current status/limitations of the integration. Note that Solana support is newer and evolving -- document what is stable and link to the sol-rpc-canister repo for latest updates. - - -- Portal: building-apps/chain-fusion/solana/overview.mdx -- Examples: basic_solana (Rust, redirects to sol-rpc-canister repo) -- Learn Hub: [SOL RPC Canister](https://learn.internetcomputer.org/hc/en-us/articles/46782465439764) - - -- concepts/chain-fusion -- chain fusion overview -- guides/chain-fusion/ethereum -- similar EVM RPC pattern -- guides/chain-fusion/bitcoin -- comparison with Bitcoin integration diff --git a/docs/guides/chain-fusion/solana.mdx b/docs/guides/chain-fusion/solana.mdx new file mode 100644 index 00000000..d5b08f6e --- /dev/null +++ b/docs/guides/chain-fusion/solana.mdx @@ -0,0 +1,351 @@ +--- +title: "Solana Integration" +description: "Interact with Solana from ICP canisters using the SOL RPC canister and threshold Ed25519 signatures" +sidebar: + order: 3 +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +ICP canisters can interact directly with the Solana network: read account balances, query transaction history, and sign and submit transactions — all without bridges, oracles, or external signers. This guide covers the SOL RPC canister for querying Solana and threshold Ed25519 signatures for signing Solana transactions. + +For a conceptual overview of how ICP connects to other blockchains, see [Chain Fusion](../../concepts/chain-fusion.md). + +## How it works + +Two ICP features enable Solana integration: + +- **[HTTPS outcalls](../backends/https-outcalls.md)** — canisters can make HTTP requests to external services. The SOL RPC canister uses HTTPS outcalls to reach Solana JSON-RPC providers and aggregates their responses for consensus. +- **Threshold Ed25519** — Solana uses Ed25519 signatures for authorizing transactions. ICP provides a threshold signature scheme where a canister can sign messages using a key that no single node holds outright. This lets canisters sign valid Solana transactions without ever exposing a private key. + +## SOL RPC canister + +The SOL RPC canister (`2xib7-jqaaa-aaaar-qai6q-cai`) is deployed on ICP mainnet and handles Solana JSON-RPC calls on your behalf. When your canister calls it: + +1. Your canister sends a JSON-RPC request with cycles attached. +2. The SOL RPC canister fans the request out to multiple Solana RPC providers via HTTPS outcalls. +3. Responses are aggregated — the canister returns the result once providers agree. +4. Unused cycles are refunded. + +No API keys are required. The SOL RPC canister is controlled by the [Network Nervous System](https://learn.internetcomputer.org/hc/en-us/articles/34209955782420-Subnet-Creation), so any change to it requires an NNS proposal. + +The SOL RPC canister contacts these JSON-RPC providers: +- [Helius](https://www.helius.dev/) +- [Alchemy](https://www.alchemy.com/) +- [Ankr](https://www.ankr.com/) +- [dRPC](https://drpc.org/) +- [Public Node](https://www.publicnode.com/) + +## Querying Solana + +Use the SOL RPC canister's `request` method to send any Solana JSON-RPC call. Pass cycles to cover the HTTPS outcall cost; unused cycles are refunded. + +### Get an account balance + +The following example queries the SOL balance of a Solana public key using `getBalance`. + + + + +```rust +use candid::Principal; +use ic_cdk::call::Call; +use ic_cdk::update; + +const SOL_RPC_CANISTER: &str = "2xib7-jqaaa-aaaar-qai6q-cai"; + +fn sol_rpc_id() -> Principal { + Principal::from_text(SOL_RPC_CANISTER).unwrap() +} + +#[update] +async fn get_sol_balance(pubkey: String) -> String { + let json = format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"getBalance","params":["{}"]}}"#, + pubkey + ); + + let (result,): (Result,) = + Call::unbounded_wait(sol_rpc_id(), "request") + .with_args(&(json, 1000_u64)) + .with_cycles(10_000_000_000_u128) + .await + .expect("Failed to call SOL RPC canister") + .candid_tuple() + .expect("Failed to decode response"); + + match result { + Ok(response) => response, + Err(err) => ic_cdk::trap(&format!("RPC error: {}", err)), + } +} +``` + + + + +```motoko +import Runtime "mo:core/Runtime"; + +persistent actor { + + type SolRpc = actor { + request : (Text, Nat64) -> async { #Ok : Text; #Err : Text }; + }; + + transient let solRpc : SolRpc = actor ("2xib7-jqaaa-aaaar-qai6q-cai"); + + public func getSolBalance(pubkey : Text) : async Text { + let json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\"," + # "\"params\":[\"" # pubkey # "\"]}"; + + let result = await (with cycles = 10_000_000_000) + solRpc.request(json, 1000); + + switch (result) { + case (#Ok response) { response }; + case (#Err err) { + Runtime.trap("RPC error: " # err); + }; + }; + }; +}; +``` + + + + +The response is the raw JSON-RPC response string. The `getBalance` result contains a `value` field with the balance in lamports (1 SOL = 1,000,000,000 lamports). Parse the JSON string to extract the value your canister needs. + +### Other common queries + +Any Solana JSON-RPC method works the same way — replace the JSON payload and adjust `max_response_bytes` for the expected response size: + +```rust +// Get latest slot +let json = r#"{"jsonrpc":"2.0","id":1,"method":"getSlot"}"#; + +// Get account information +let json = format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"getAccountInfo", + "params":["{}",{{"encoding":"base64"}}]}}"#, + pubkey +); + +// Get recent transaction signatures for an address +let json = format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"getSignaturesForAddress", + "params":["{}"]}}"#, + pubkey +); +``` + +For the full list of supported methods, see the [Solana JSON-RPC documentation](https://solana.com/docs/rpc/http). + +## Signing Solana transactions + +Solana uses Ed25519 signatures for all transactions. ICP supports threshold Ed25519 via the management canister's `sign_with_schnorr` method (using the `ed25519` algorithm variant). The key is distributed across ICP subnet nodes — no single node ever holds the full private key. + +The signing flow for a Solana transaction: +1. Get your canister's Ed25519 public key from the management canister. +2. Derive the Solana address (base58-encode the 32-byte public key). +3. Build the Solana transaction message. +4. Sign the serialized message bytes with `sign_with_schnorr`. +5. Submit the signed transaction via the SOL RPC canister's `sendTransaction` method. + +### Get an Ed25519 public key + + + + +```rust +use ic_cdk::management_canister::{ + schnorr_public_key, SchnorrAlgorithm, SchnorrKeyId, SchnorrPublicKeyArgs, +}; +use ic_cdk::update; + +#[update] +async fn get_ed25519_public_key() -> Vec { + let args = SchnorrPublicKeyArgs { + canister_id: None, + derivation_path: vec![], + key_id: SchnorrKeyId { + algorithm: SchnorrAlgorithm::Ed25519, + name: "test_key_1".to_string(), // Use "key_1" for production + }, + }; + + let result = schnorr_public_key(&args) + .await + .expect("schnorr_public_key failed"); + + result.public_key +} +``` + + + + +```motoko +import Principal "mo:core/Principal"; +import Blob "mo:core/Blob"; + +persistent actor { + + type IC = actor { + schnorr_public_key : ({ + canister_id : ?Principal; + derivation_path : [Blob]; + key_id : { algorithm : { #ed25519 }; name : Text }; + }) -> async ({ public_key : Blob; chain_code : Blob }); + }; + + transient let ic : IC = actor ("aaaaa-aa"); + + public func getEd25519PublicKey() : async Blob { + let { public_key } = await ic.schnorr_public_key({ + canister_id = null; + derivation_path = []; + key_id = { + algorithm = #ed25519; + name = "test_key_1"; // Use "key_1" for production + }; + }); + public_key; + }; +}; +``` + + + + +The returned `public_key` is the raw 32-byte Ed25519 public key. To use it as a Solana address, base58-encode these 32 bytes. + +### Sign a transaction message + +`sign_with_schnorr` takes the full message bytes — not a hash. For Solana transactions, pass the serialized transaction message bytes directly. + + + + +```rust +use ic_cdk::management_canister::{ + sign_with_schnorr, SchnorrAlgorithm, SchnorrKeyId, SignWithSchnorrArgs, +}; +use ic_cdk::update; + +#[update] +async fn sign_solana_message(message: Vec) -> Vec { + let args = SignWithSchnorrArgs { + message, + derivation_path: vec![], + key_id: SchnorrKeyId { + algorithm: SchnorrAlgorithm::Ed25519, + name: "test_key_1".to_string(), // Use "key_1" for production + }, + aux: None, + }; + + // sign_with_schnorr attaches the required cycles automatically + let result = sign_with_schnorr(&args) + .await + .expect("sign_with_schnorr failed"); + + result.signature +} +``` + + + + +```motoko +import Blob "mo:core/Blob"; + +persistent actor { + + type IC = actor { + sign_with_schnorr : ({ + message : Blob; + derivation_path : [Blob]; + key_id : { algorithm : { #ed25519 }; name : Text }; + aux : ?{ #bip341 : { merkle_root_hash : Blob } }; + }) -> async ({ signature : Blob }); + }; + + transient let ic : IC = actor ("aaaaa-aa"); + + public func signSolanaMessage(message : Blob) : async Blob { + let { signature } = await (with cycles = 30_000_000_000) + ic.sign_with_schnorr({ + message; + derivation_path = []; + key_id = { + algorithm = #ed25519; + name = "test_key_1"; // Use "key_1" for production + }; + aux = null; + }); + signature; + }; +}; +``` + +{/* Needs human verification: Motoko cycle attachment for sign_with_schnorr uses a fixed 30B cycles based on the cdk-rs SIGN_WITH_SCHNORR_FEE constant. The Rust cdk-rs uses cost_sign_with_schnorr() for dynamic fee calculation. Verify the exact fee against the IC interface spec before using in production. */} + + + + +The returned 64-byte signature is a valid Ed25519 signature that Solana accepts for transactions signed by this canister's key. + +### Key IDs + +| Key ID | Environment | +|---|---| +| `test_key_1` | ICP mainnet — test key, reduced security. Use for development and testing only. | +| `key_1` | ICP mainnet — production key. Use for production deployments. | + +Ed25519 does not have a local development key (`dfx_test_key` is not available for Ed25519). Use `test_key_1` on ICP mainnet for development, and `key_1` for production. + +## Complete transaction example + +Constructing a full Solana transaction requires: +1. Fetching a recent blockhash via `getLatestBlockhash` +2. Building the transaction structure (account keys, instructions, message header) +3. Serializing the transaction message +4. Signing the serialized bytes with `sign_with_schnorr` +5. Submitting the signed transaction via `sendTransaction` + +For a complete end-to-end Rust implementation, see the [basic_solana example](https://github.com/dfinity/sol-rpc-canister/tree/main/examples/basic_solana) in the SOL RPC canister repository. It demonstrates a SOL transfer, including blockhash fetching, transaction serialization, signing, and submission. + +## Cycle costs + +Every SOL RPC call requires cycles to cover HTTPS outcall costs. The `sign_with_schnorr` management canister call also requires cycles. + +| Operation | Approximate cost | +|---|---| +| SOL RPC `request` (small response, 1–2 providers) | ~1–5B cycles | +| `sign_with_schnorr` (Ed25519, Rust cdk auto-attached) | ~26B cycles | + +Send 10B cycles per RPC call as a starting budget — unused cycles are refunded. Set `max_response_bytes` to the minimum needed; smaller values reduce costs. + +## Current status and limitations + +The Solana integration is newer than the Bitcoin and Ethereum integrations: + +- **SOL RPC canister is live on mainnet** — deployed and functional, with the API surface still evolving. +- **Threshold Ed25519 is available** — both test (`test_key_1`) and production (`key_1`) keys are live on ICP mainnet. +- **No SPL token helpers** — SPL token operations (reading token accounts, transferring tokens) require constructing JSON-RPC calls and transaction instructions manually. +- **No ckSOL token** — unlike Bitcoin (ckBTC) and Ethereum (ckETH), there is no chain-key SOL token yet. +- **Transaction construction is manual** — there is no official ICP library for building Solana transactions. See the [basic_solana example](https://github.com/dfinity/sol-rpc-canister/tree/main/examples/basic_solana) for a reference implementation. + +Follow the [SOL RPC canister repository](https://github.com/dfinity/sol-rpc-canister/blob/main/README.md) for the latest updates. + +## Next steps + +- [SOL RPC canister README](https://github.com/dfinity/sol-rpc-canister/blob/main/README.md) — full documentation and the `basic_solana` end-to-end example +- [Bitcoin integration](bitcoin.md) — direct protocol-level BTC integration +- [Ethereum integration](ethereum.md) — EVM RPC canister, similar JSON-RPC pattern +- [HTTPS outcalls](../backends/https-outcalls.md) — the mechanism underlying the SOL RPC canister +- [Chain Fusion concepts](../../concepts/chain-fusion.md) — how ICP connects to other blockchains + +{/* Upstream: informed by dfinity/portal — docs/building-apps/chain-fusion/solana/overview.mdx; dfinity/cdk-rs — ic-cdk/src/management_canister.rs, ic-management-canister-types/src/lib.rs; dfinity/examples — rust/basic_solana/README.md */} From 8510735a302c94fa0c554245e87211fb82816e32 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Thu, 16 Apr 2026 20:52:35 +0200 Subject: [PATCH 2/2] docs(solana): address PR #96 feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix NNS link (was pointing to Subnet Creation article, now links to internal governance page) - Reorder all three Tabs blocks to Motoko → Rust per project convention - Resolve human-verification comment on sign_with_schnorr cycles: replace with note explaining 30B is an intentional buffer above the 26.15B fee, unused cycles are refunded - Update cycle cost table: ~26B → ~26.15B to match cdk-rs SIGN_WITH_SCHNORR_FEE constant - Clarify max_response_bytes as second argument to request() in "Other common queries" - Add base58 address derivation pointer to basic_solana solana_helpers.rs - Expand local testing limitation note: explicitly state no local replica support for Ed25519 signing --- docs/guides/chain-fusion/solana.mdx | 164 ++++++++++++++-------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/docs/guides/chain-fusion/solana.mdx b/docs/guides/chain-fusion/solana.mdx index d5b08f6e..775d72c4 100644 --- a/docs/guides/chain-fusion/solana.mdx +++ b/docs/guides/chain-fusion/solana.mdx @@ -27,7 +27,7 @@ The SOL RPC canister (`2xib7-jqaaa-aaaar-qai6q-cai`) is deployed on ICP mainnet 3. Responses are aggregated — the canister returns the result once providers agree. 4. Unused cycles are refunded. -No API keys are required. The SOL RPC canister is controlled by the [Network Nervous System](https://learn.internetcomputer.org/hc/en-us/articles/34209955782420-Subnet-Creation), so any change to it requires an NNS proposal. +No API keys are required. The SOL RPC canister is controlled by the [Network Nervous System](../../concepts/governance.md), so any change to it requires an NNS proposal. The SOL RPC canister contacts these JSON-RPC providers: - [Helius](https://www.helius.dev/) @@ -45,6 +45,37 @@ Use the SOL RPC canister's `request` method to send any Solana JSON-RPC call. Pa The following example queries the SOL balance of a Solana public key using `getBalance`. + + +```motoko +import Runtime "mo:core/Runtime"; + +persistent actor { + + type SolRpc = actor { + request : (Text, Nat64) -> async { #Ok : Text; #Err : Text }; + }; + + transient let solRpc : SolRpc = actor ("2xib7-jqaaa-aaaar-qai6q-cai"); + + public func getSolBalance(pubkey : Text) : async Text { + let json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\"," + # "\"params\":[\"" # pubkey # "\"]}"; + + let result = await (with cycles = 10_000_000_000) + solRpc.request(json, 1000); + + switch (result) { + case (#Ok response) { response }; + case (#Err err) { + Runtime.trap("RPC error: " # err); + }; + }; + }; +}; +``` + + ```rust @@ -81,37 +112,6 @@ async fn get_sol_balance(pubkey: String) -> String { } ``` - - - -```motoko -import Runtime "mo:core/Runtime"; - -persistent actor { - - type SolRpc = actor { - request : (Text, Nat64) -> async { #Ok : Text; #Err : Text }; - }; - - transient let solRpc : SolRpc = actor ("2xib7-jqaaa-aaaar-qai6q-cai"); - - public func getSolBalance(pubkey : Text) : async Text { - let json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\"," - # "\"params\":[\"" # pubkey # "\"]}"; - - let result = await (with cycles = 10_000_000_000) - solRpc.request(json, 1000); - - switch (result) { - case (#Ok response) { response }; - case (#Err err) { - Runtime.trap("RPC error: " # err); - }; - }; - }; -}; -``` - @@ -119,7 +119,7 @@ The response is the raw JSON-RPC response string. The `getBalance` result contai ### Other common queries -Any Solana JSON-RPC method works the same way — replace the JSON payload and adjust `max_response_bytes` for the expected response size: +Any Solana JSON-RPC method works the same way — pass the JSON payload as the first argument to `request` and set the second argument (`max_response_bytes`) to the expected response size. Larger values cost more cycles; set it to the minimum needed: ```rust // Get latest slot @@ -156,34 +156,6 @@ The signing flow for a Solana transaction: ### Get an Ed25519 public key - - -```rust -use ic_cdk::management_canister::{ - schnorr_public_key, SchnorrAlgorithm, SchnorrKeyId, SchnorrPublicKeyArgs, -}; -use ic_cdk::update; - -#[update] -async fn get_ed25519_public_key() -> Vec { - let args = SchnorrPublicKeyArgs { - canister_id: None, - derivation_path: vec![], - key_id: SchnorrKeyId { - algorithm: SchnorrAlgorithm::Ed25519, - name: "test_key_1".to_string(), // Use "key_1" for production - }, - }; - - let result = schnorr_public_key(&args) - .await - .expect("schnorr_public_key failed"); - - result.public_key -} -``` - - ```motoko @@ -217,45 +189,43 @@ persistent actor { ``` - - -The returned `public_key` is the raw 32-byte Ed25519 public key. To use it as a Solana address, base58-encode these 32 bytes. - -### Sign a transaction message - -`sign_with_schnorr` takes the full message bytes — not a hash. For Solana transactions, pass the serialized transaction message bytes directly. - - ```rust use ic_cdk::management_canister::{ - sign_with_schnorr, SchnorrAlgorithm, SchnorrKeyId, SignWithSchnorrArgs, + schnorr_public_key, SchnorrAlgorithm, SchnorrKeyId, SchnorrPublicKeyArgs, }; use ic_cdk::update; #[update] -async fn sign_solana_message(message: Vec) -> Vec { - let args = SignWithSchnorrArgs { - message, +async fn get_ed25519_public_key() -> Vec { + let args = SchnorrPublicKeyArgs { + canister_id: None, derivation_path: vec![], key_id: SchnorrKeyId { algorithm: SchnorrAlgorithm::Ed25519, name: "test_key_1".to_string(), // Use "key_1" for production }, - aux: None, }; - // sign_with_schnorr attaches the required cycles automatically - let result = sign_with_schnorr(&args) + let result = schnorr_public_key(&args) .await - .expect("sign_with_schnorr failed"); + .expect("schnorr_public_key failed"); - result.signature + result.public_key } ``` + + +The returned `public_key` is the raw 32-byte Ed25519 public key. To use it as a Solana address, base58-encode these 32 bytes. For a complete implementation of this step, see [`solana_helpers.rs`](https://github.com/dfinity/sol-rpc-canister/blob/main/examples/basic_solana/src/basic_solana_backend/src/solana_helpers.rs) in the `basic_solana` example. + +### Sign a transaction message + +`sign_with_schnorr` takes the full message bytes — not a hash. For Solana transactions, pass the serialized transaction message bytes directly. + + ```motoko @@ -290,7 +260,37 @@ persistent actor { }; ``` -{/* Needs human verification: Motoko cycle attachment for sign_with_schnorr uses a fixed 30B cycles based on the cdk-rs SIGN_WITH_SCHNORR_FEE constant. The Rust cdk-rs uses cost_sign_with_schnorr() for dynamic fee calculation. Verify the exact fee against the IC interface spec before using in production. */} +{/* Note: 30B cycles is an intentional buffer above the ~26.15B fee (26_153_846_153 cycles, per cdk-rs SIGN_WITH_SCHNORR_FEE). Unused cycles are refunded. The Rust cdk-rs attaches the exact fee automatically; Motoko requires an explicit `with cycles` attachment. */} + + + + +```rust +use ic_cdk::management_canister::{ + sign_with_schnorr, SchnorrAlgorithm, SchnorrKeyId, SignWithSchnorrArgs, +}; +use ic_cdk::update; + +#[update] +async fn sign_solana_message(message: Vec) -> Vec { + let args = SignWithSchnorrArgs { + message, + derivation_path: vec![], + key_id: SchnorrKeyId { + algorithm: SchnorrAlgorithm::Ed25519, + name: "test_key_1".to_string(), // Use "key_1" for production + }, + aux: None, + }; + + // sign_with_schnorr attaches the required cycles automatically + let result = sign_with_schnorr(&args) + .await + .expect("sign_with_schnorr failed"); + + result.signature +} +``` @@ -304,7 +304,7 @@ The returned 64-byte signature is a valid Ed25519 signature that Solana accepts | `test_key_1` | ICP mainnet — test key, reduced security. Use for development and testing only. | | `key_1` | ICP mainnet — production key. Use for production deployments. | -Ed25519 does not have a local development key (`dfx_test_key` is not available for Ed25519). Use `test_key_1` on ICP mainnet for development, and `key_1` for production. +Ed25519 does not have a local development key — unlike ECDSA (which has `dfx_test_key` for local replica testing), there is no Ed25519 equivalent. All Ed25519 signing must be tested on ICP mainnet using `test_key_1`. Plan your test workflow accordingly: local replica development is not possible for the signing steps. ## Complete transaction example @@ -324,7 +324,7 @@ Every SOL RPC call requires cycles to cover HTTPS outcall costs. The `sign_with_ | Operation | Approximate cost | |---|---| | SOL RPC `request` (small response, 1–2 providers) | ~1–5B cycles | -| `sign_with_schnorr` (Ed25519, Rust cdk auto-attached) | ~26B cycles | +| `sign_with_schnorr` (Ed25519, Rust cdk auto-attached) | ~26.15B cycles | Send 10B cycles per RPC call as a starting budget — unused cycles are refunded. Set `max_response_bytes` to the minimum needed; smaller values reduce costs.