From eb4afb9eedc18bcf575ebd9cc36786842c497a5f Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 3 Oct 2026 09:56:16 +0800 Subject: [PATCH 1/2] Use FastNear RPC for NEAR by default and accept an RPC URL as the network rpc.mainnet.near.org and rpc.testnet.near.org are deprecated and rate-limit, which made transactions expire before broadcast. --network now resolves mainnet and testnet to FastNear and also accepts an RPC URL directly. --- README.md | 3 ++- src/near/rpc.rs | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 56bf586..e211768 100644 --- a/README.md +++ b/README.md @@ -661,7 +661,8 @@ Notes: NEAR accepts ML-DSA-65 access keys and signatures from protocol version 85, so a Quantus ML-DSA-65 wallet (`quantus wallet create --scheme ml-dsa-65`) can be the sole key on a NEAR account. ML-DSA-87 wallets are rejected: NEAR -defined ML-DSA-65 only. +defined ML-DSA-65 only. `--network` is `testnet` or `mainnet` (both via +FastNear RPC) or an RPC URL; `--rpc-url` overrides it. ```bash # The wallet's key in NEAR text form (ml-dsa-65:) and its on-chain handle diff --git a/src/near/rpc.rs b/src/near/rpc.rs index aa66153..b570d7e 100644 --- a/src/near/rpc.rs +++ b/src/near/rpc.rs @@ -37,15 +37,19 @@ impl NearRpcClient { Ok(Self { url: url.into(), http }) } - /// Resolve `--network`/`--rpc-url` to a client. An explicit URL wins. + /// Resolve `--network`/`--rpc-url` to a client. An explicit URL wins; the + /// network name may itself be an RPC URL. The named networks use FastNear: + /// `rpc.{mainnet,testnet}.near.org` is deprecated and rate-limits. pub fn for_network(network: &str, rpc_url: Option) -> Result { let url = match (rpc_url, network) { (Some(url), _) => url, - (None, "testnet") => "https://rpc.testnet.near.org".to_string(), - (None, "mainnet") => "https://rpc.mainnet.near.org".to_string(), + (None, "testnet") => "https://test.rpc.fastnear.com".to_string(), + (None, "mainnet") => "https://rpc.mainnet.fastnear.com".to_string(), + (None, url) if url.starts_with("https://") || url.starts_with("http://") => + url.to_string(), (None, other) => return Err(QuantusError::Generic(format!( - "unknown network '{other}' — use testnet, mainnet, or --rpc-url" + "unknown network '{other}' — use testnet, mainnet, an RPC URL, or --rpc-url" ))), }; Self::new(url) @@ -331,6 +335,29 @@ fn decode_block_hash(result: &Value) -> Result<[u8; 32]> { mod tests { use super::*; + #[test] + fn named_networks_resolve_to_fastnear_and_urls_pass_through() { + assert_eq!( + NearRpcClient::for_network("mainnet", None).unwrap().url, + "https://rpc.mainnet.fastnear.com" + ); + assert_eq!( + NearRpcClient::for_network("testnet", None).unwrap().url, + "https://test.rpc.fastnear.com" + ); + assert_eq!( + NearRpcClient::for_network("https://near.lava.build", None).unwrap().url, + "https://near.lava.build" + ); + assert_eq!( + NearRpcClient::for_network("mainnet", Some("http://localhost:3030".into())) + .unwrap() + .url, + "http://localhost:3030" + ); + assert!(NearRpcClient::for_network("devnet", None).is_err()); + } + #[test] fn send_tx_outcome_accepts_finalized_success() { let result = json!({ From dc6f9c65673a7c50efe9f3f001212cce72163c3a Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 3 Oct 2026 11:27:10 +0800 Subject: [PATCH 2/2] Resolve known RPC endpoints to their network label before cold signing --network accepts mainnet, testnet, or one of their known endpoints (FastNear and rpc.*.near.org); the cold-signing request and the explorer link always use the label, which is what the wallet checks. Any other endpoint goes in --rpc-url. --- README.md | 5 +-- src/cli/near.rs | 4 +-- src/near/cold.rs | 26 ++++++++++++++-- src/near/rpc.rs | 80 +++++++++++++++++++++++++++++++----------------- 4 files changed, 81 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index e211768..056c6ad 100644 --- a/README.md +++ b/README.md @@ -661,8 +661,9 @@ Notes: NEAR accepts ML-DSA-65 access keys and signatures from protocol version 85, so a Quantus ML-DSA-65 wallet (`quantus wallet create --scheme ml-dsa-65`) can be the sole key on a NEAR account. ML-DSA-87 wallets are rejected: NEAR -defined ML-DSA-65 only. `--network` is `testnet` or `mainnet` (both via -FastNear RPC) or an RPC URL; `--rpc-url` overrides it. +defined ML-DSA-65 only. `--network` is `testnet` or `mainnet`, served by +FastNear RPC, or one of their known endpoints (FastNear or `rpc.*.near.org`); +any other endpoint goes in `--rpc-url`. ```bash # The wallet's key in NEAR text form (ml-dsa-65:) and its on-chain handle diff --git a/src/cli/near.rs b/src/cli/near.rs index 0a8f283..6e4bd83 100644 --- a/src/cli/near.rs +++ b/src/cli/near.rs @@ -33,7 +33,7 @@ use crate::{ validate_account_id, AccessKey, Action, AddKeyAction, FunctionCallAction, PublicKey, SignedTransaction, Transaction, TransferAction, NEAR_DECIMALS, }, - rpc::{decode_success_value, NearRpcClient}, + rpc::{decode_success_value, network_label, NearRpcClient}, sign::{load_credentials, sign_transaction_ed25519, sign_transaction_ml_dsa_65}, }, qr::NearPublicKeyExport, @@ -591,7 +591,7 @@ fn handle_show_key( } fn explorer_tx_url(network: &str, tx_hash: &str) -> Option { - match network { + match network_label(network)? { "testnet" => Some(format!("https://testnet.nearblocks.io/txns/{tx_hash}")), "mainnet" => Some(format!("https://nearblocks.io/txns/{tx_hash}")), _ => None, diff --git a/src/near/cold.rs b/src/near/cold.rs index 436dcdf..a0a1ab5 100644 --- a/src/near/cold.rs +++ b/src/near/cold.rs @@ -28,6 +28,7 @@ use crate::{ render_text, PublicKey, Signature, SignedTransaction, Transaction, ML_DSA_65_SIGNATURE_LEN, }, + rpc::network_label, sign::transaction_hash, }, qr::NearSignRequest, @@ -136,6 +137,18 @@ pub fn parse_cold_account(wallet_name: &str, cold_address_ss58: &str) -> Result< }) } +/// The request for the device, with the network as its canonical label: the +/// CLI takes a known RPC endpoint as `--network` too, the wallet takes labels only. +fn sign_request_for(network: &str, tx_bytes: Vec) -> Result { + let label = network_label(network).ok_or_else(|| { + QuantusError::Generic(format!( + "cannot cold-sign for network {network:?}: not mainnet, testnet, or one of their known \ + RPC endpoints" + )) + })?; + NearSignRequest::new(label, tx_bytes) +} + /// Run the QR roundtrip for `tx` against cold wallet `wallet_name` and return /// the signed transaction. Nothing is submitted here. pub async fn sign_transaction_cold( @@ -154,11 +167,11 @@ pub async fn sign_transaction_cold( }; let account = parse_cold_account(wallet_name, cold_address_ss58)?; let tx_bytes = tx.to_bytes()?; - let request = NearSignRequest::new(network, tx_bytes)?; + let request = sign_request_for(network, tx_bytes)?; log_print!("🧊 Cold wallet signing with '{}'", wallet_name.bright_blue().bold()); log_print!(" Wallet: {}", cold_address_ss58.bright_cyan()); - log_print!(" Network: {network}"); + log_print!(" Network: {}", request.network); log_print!(" Signer: {}", render_text(&tx.signer_id).bright_cyan()); log_print!(" Key: {}", tx.public_key.to_near_string()); log_print!(" Receiver: {}", render_text(&tx.receiver_id).bright_cyan()); @@ -340,6 +353,15 @@ mod tests { assert!(load_unsigned_transaction("@/nonexistent/path").is_err()); } + #[test] + fn known_endpoints_cold_sign_under_their_network_label() { + let network = |n| sign_request_for(n, vec![1]).map(|r| r.network); + assert_eq!(network("https://rpc.mainnet.fastnear.com").unwrap(), "mainnet"); + assert_eq!(network("https://rpc.testnet.near.org").unwrap(), "testnet"); + assert_eq!(network("testnet").unwrap(), "testnet"); + assert!(network("https://near.lava.build").is_err()); + } + /// The full simulator loop as the CLI drives it: v2 request → UR → device /// decodes, signs → UR → CLI validates. #[test] diff --git a/src/near/rpc.rs b/src/near/rpc.rs index b570d7e..2dfaef4 100644 --- a/src/near/rpc.rs +++ b/src/near/rpc.rs @@ -10,6 +10,22 @@ use serde_json::{json, Value}; /// version (the `PostQuantumSignatures` feature). pub const MIN_ML_DSA_PROTOCOL_VERSION: u64 = 85; +/// Known NEAR networks: the label a cold wallet checks, and the RPC endpoints +/// accepted for it, default first. FastNear is the default because +/// `rpc.{mainnet,testnet}.near.org` is deprecated and rate-limits. +pub const NETWORKS: [(&str, [&str; 2]); 2] = [ + ("mainnet", ["https://rpc.mainnet.fastnear.com", "https://rpc.mainnet.near.org"]), + ("testnet", ["https://test.rpc.fastnear.com", "https://rpc.testnet.near.org"]), +]; + +/// The canonical label for a network name or one of its known endpoints. +pub fn network_label(network: &str) -> Option<&'static str> { + NETWORKS + .iter() + .find(|(label, endpoints)| *label == network || endpoints.contains(&network)) + .map(|(label, _)| *label) +} + pub struct NearRpcClient { url: String, http: reqwest::Client, @@ -37,20 +53,29 @@ impl NearRpcClient { Ok(Self { url: url.into(), http }) } - /// Resolve `--network`/`--rpc-url` to a client. An explicit URL wins; the - /// network name may itself be an RPC URL. The named networks use FastNear: - /// `rpc.{mainnet,testnet}.near.org` is deprecated and rate-limits. + /// Resolve `--network`/`--rpc-url` to a client. An explicit URL wins; + /// otherwise the network is a label or one of its known endpoints (see + /// [`NETWORKS`]), so a cold wallet always learns the label. pub fn for_network(network: &str, rpc_url: Option) -> Result { - let url = match (rpc_url, network) { - (Some(url), _) => url, - (None, "testnet") => "https://test.rpc.fastnear.com".to_string(), - (None, "mainnet") => "https://rpc.mainnet.fastnear.com".to_string(), - (None, url) if url.starts_with("https://") || url.starts_with("http://") => - url.to_string(), - (None, other) => - return Err(QuantusError::Generic(format!( - "unknown network '{other}' — use testnet, mainnet, an RPC URL, or --rpc-url" - ))), + let url = match rpc_url { + Some(url) => url, + None => match NETWORKS + .iter() + .find(|(label, endpoints)| *label == network || endpoints.contains(&network)) + { + Some((label, endpoints)) if *label == network => endpoints[0].to_string(), + Some(_) => network.to_string(), + None => { + let known: Vec<&str> = NETWORKS + .iter() + .flat_map(|(_, endpoints)| endpoints.iter().copied()) + .collect(); + return Err(QuantusError::Generic(format!( + "unknown network '{network}' — use mainnet, testnet, or one of {known:?}; any \ + other RPC endpoint goes in --rpc-url" + ))); + }, + }, }; Self::new(url) } @@ -336,26 +361,25 @@ mod tests { use super::*; #[test] - fn named_networks_resolve_to_fastnear_and_urls_pass_through() { + fn networks_resolve_to_fastnear_and_known_endpoints_pass_through() { + let url = |network, rpc_url| NearRpcClient::for_network(network, rpc_url).map(|c| c.url); + assert_eq!(url("mainnet", None).unwrap(), "https://rpc.mainnet.fastnear.com"); + assert_eq!(url("testnet", None).unwrap(), "https://test.rpc.fastnear.com"); assert_eq!( - NearRpcClient::for_network("mainnet", None).unwrap().url, - "https://rpc.mainnet.fastnear.com" + url("https://rpc.testnet.near.org", None).unwrap(), + "https://rpc.testnet.near.org" ); assert_eq!( - NearRpcClient::for_network("testnet", None).unwrap().url, - "https://test.rpc.fastnear.com" - ); - assert_eq!( - NearRpcClient::for_network("https://near.lava.build", None).unwrap().url, - "https://near.lava.build" - ); - assert_eq!( - NearRpcClient::for_network("mainnet", Some("http://localhost:3030".into())) - .unwrap() - .url, + url("mainnet", Some("http://localhost:3030".into())).unwrap(), "http://localhost:3030" ); - assert!(NearRpcClient::for_network("devnet", None).is_err()); + assert!(url("https://near.lava.build", None).is_err()); + assert!(url("devnet", None).is_err()); + + assert_eq!(network_label("https://test.rpc.fastnear.com"), Some("testnet")); + assert_eq!(network_label("https://rpc.mainnet.near.org"), Some("mainnet")); + assert_eq!(network_label("mainnet"), Some("mainnet")); + assert_eq!(network_label("devnet"), None); } #[test]