Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +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.
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:<base58>) and its on-chain handle
Expand Down
4 changes: 2 additions & 2 deletions src/cli/near.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -591,7 +591,7 @@ fn handle_show_key(
}

fn explorer_tx_url(network: &str, tx_hash: &str) -> Option<String> {
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,
Expand Down
26 changes: 24 additions & 2 deletions src/near/cold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<u8>) -> Result<NearSignRequest> {
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(
Expand All @@ -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());
Expand Down Expand Up @@ -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]
Expand Down
69 changes: 60 additions & 9 deletions src/near/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -37,16 +53,29 @@ 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;
/// 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<String>) -> Result<Self> {
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, other) =>
return Err(QuantusError::Generic(format!(
"unknown network '{other}' — use testnet, mainnet, 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)
}
Expand Down Expand Up @@ -331,6 +360,28 @@ fn decode_block_hash(result: &Value) -> Result<[u8; 32]> {
mod tests {
use super::*;

#[test]
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!(
url("https://rpc.testnet.near.org", None).unwrap(),
"https://rpc.testnet.near.org"
);
assert_eq!(
url("mainnet", Some("http://localhost:3030".into())).unwrap(),
"http://localhost:3030"
);
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]
fn send_tx_outcome_accepts_finalized_success() {
let result = json!({
Expand Down
Loading