The intents account (#71) lives on NEAR, where a node knows nothing of shards it does not track. NearRpc is the port: every call names the account it is about. HttpNearRpc works out that account's shard from the layout and asks our node for it (s<shard>.near.blackbeard.observer), then the public RPCs. The endpoints are in near.toml, which an operator file in the data directory replaces. Our nodes are trusted only while synced. A fresh node answers from the height it has reached, so the shard-13 node still syncing today said wrap.near does not exist. The adapter therefore checks each of our names' /status at most once a minute and skips one that is syncing or more than two minutes behind. A node that disowns its shard drops the cached layout, which is how a reshard shows up. Nothing waits for a transaction's outcome, which no single shard node can assemble; send returns at INCLUDED_FINAL. Two of #72's notes were wrong and are corrected here: - view_access_key wants the full key (ml-dsa-65:<base58>); the ml-dsa-65-hash: handle that key listings show is refused as a parse error. - A missing key comes back as a result carrying `error`, not as an RPC error. It is read as UNKNOWN_ACCESS_KEY. Live tests (ignored by default) read accounts on shards 4, 7, 8 and 13, find #72's ML-DSA key and not its deleted ed25519 one, and make a view call. Shard 13 fell through to the public RPC past the syncing node. The app wires it in with #74, the first thing that reads it. Closes #73 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
This commit is contained in:
Generated
+2
@@ -7004,8 +7004,10 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bip39",
|
||||
"blake2",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"directories",
|
||||
"getrandom 0.3.4",
|
||||
|
||||
@@ -37,6 +37,8 @@ qp-poseidon-core = "3.1.0"
|
||||
# SS58: blake2b-512 checksum and base58. Nothing else from sp-core is needed.
|
||||
blake2 = { version = "0.10", default-features = false }
|
||||
bs58 = "0.5"
|
||||
# NEAR RPC carries transactions and view arguments as base64.
|
||||
base64 = "0.22"
|
||||
getrandom = "0.3"
|
||||
# The quantus-cli keystore format: Argon2id (frozen m=19456,t=2,p=1) into
|
||||
# AES-256-GCM. The versions are pinned to what the CLI itself uses.
|
||||
|
||||
@@ -17,6 +17,7 @@ blake2.workspace = true
|
||||
bs58.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
pub mod assets;
|
||||
pub mod backup;
|
||||
pub mod near;
|
||||
pub mod orders;
|
||||
pub mod paths;
|
||||
pub mod ports;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! NEAR: the chain under the wallet's intents account (#71). Which endpoint
|
||||
//! serves which account, worked out from the shard layout. Pure: the
|
||||
//! adapter in `wallet-data` does the HTTP.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const SHIPPED: &str = include_str!("near.toml");
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NearConfigError {
|
||||
#[error("near.toml: {0}")]
|
||||
Parse(String),
|
||||
#[error("near.toml: {0}")]
|
||||
Invalid(String),
|
||||
#[error("shard layout: {0}")]
|
||||
Layout(String),
|
||||
}
|
||||
|
||||
/// Where the wallet sends NEAR RPC: our per-shard names, then public
|
||||
/// fallbacks.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct NearNetwork {
|
||||
/// `{shard}` is replaced by the shard id.
|
||||
pub shard_url: String,
|
||||
/// The shards our nodes track. Any other shard goes to `fallback` only.
|
||||
pub served_shards: Vec<u64>,
|
||||
pub fallback: Vec<String>,
|
||||
}
|
||||
|
||||
impl NearNetwork {
|
||||
pub fn shipped() -> Result<Self, NearConfigError> {
|
||||
Self::from_toml(SHIPPED)
|
||||
}
|
||||
|
||||
pub fn from_toml(text: &str) -> Result<Self, NearConfigError> {
|
||||
let n: Self = toml::from_str(text).map_err(|e| NearConfigError::Parse(e.to_string()))?;
|
||||
if !n.shard_url.contains("{shard}") {
|
||||
return Err(NearConfigError::Invalid(
|
||||
"shard_url has no {shard} placeholder".into(),
|
||||
));
|
||||
}
|
||||
if n.served_shards.is_empty() && n.fallback.is_empty() {
|
||||
return Err(NearConfigError::Invalid("no endpoint at all".into()));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The endpoints to try, in order, for anything about an account on
|
||||
/// `shard`.
|
||||
pub fn endpoints_for(&self, shard: u64) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
if self.served_shards.contains(&shard) {
|
||||
out.push(self.shard_url.replace("{shard}", &shard.to_string()));
|
||||
}
|
||||
out.extend(self.fallback.iter().cloned());
|
||||
out
|
||||
}
|
||||
|
||||
/// Endpoints for questions about no account in particular (the layout,
|
||||
/// a recent block hash): every served shard, then the fallbacks.
|
||||
pub fn any_endpoints(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> = self
|
||||
.served_shards
|
||||
.iter()
|
||||
.map(|s| self.shard_url.replace("{shard}", &s.to_string()))
|
||||
.collect();
|
||||
out.extend(self.fallback.iter().cloned());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The boundary-account shard layout (V1 to V3 all share this shape).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ShardLayout {
|
||||
boundary_accounts: Vec<String>,
|
||||
shard_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl ShardLayout {
|
||||
pub fn new(
|
||||
boundary_accounts: Vec<String>,
|
||||
shard_ids: Vec<u64>,
|
||||
) -> Result<Self, NearConfigError> {
|
||||
if shard_ids.len() != boundary_accounts.len() + 1 {
|
||||
return Err(NearConfigError::Layout(format!(
|
||||
"{} boundaries need {} shard ids, got {}",
|
||||
boundary_accounts.len(),
|
||||
boundary_accounts.len() + 1,
|
||||
shard_ids.len()
|
||||
)));
|
||||
}
|
||||
if boundary_accounts.windows(2).any(|w| w[0] >= w[1]) {
|
||||
return Err(NearConfigError::Layout("boundaries are not sorted".into()));
|
||||
}
|
||||
Ok(Self {
|
||||
boundary_accounts,
|
||||
shard_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// From `EXPERIMENTAL_protocol_config`'s `shard_layout`, which is an
|
||||
/// object with one key, the layout's version (`V2`, `V3`).
|
||||
pub fn from_protocol_config(shard_layout: &serde_json::Value) -> Result<Self, NearConfigError> {
|
||||
let inner = shard_layout
|
||||
.as_object()
|
||||
.and_then(|o| o.values().next())
|
||||
.ok_or_else(|| NearConfigError::Layout("no shard_layout object".into()))?;
|
||||
let boundaries: Vec<String> = serde_json::from_value(inner["boundary_accounts"].clone())
|
||||
.map_err(|e| NearConfigError::Layout(format!("boundary_accounts: {e}")))?;
|
||||
let ids: Vec<u64> = serde_json::from_value(inner["shard_ids"].clone())
|
||||
.map_err(|e| NearConfigError::Layout(format!("shard_ids: {e}")))?;
|
||||
Self::new(boundaries, ids)
|
||||
}
|
||||
|
||||
/// The shard an account lives on: one past every boundary that sorts at
|
||||
/// or before it, as nearcore's `account_id_to_shard_id` does. Account
|
||||
/// ids compare as bytes.
|
||||
pub fn shard_of(&self, account: &str) -> u64 {
|
||||
let i = self
|
||||
.boundary_accounts
|
||||
.partition_point(|b| b.as_str() <= account);
|
||||
self.shard_ids[i]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Mainnet's layout V3 as of 2026-10-09 (protocol 86).
|
||||
fn mainnet() -> ShardLayout {
|
||||
ShardLayout::from_protocol_config(&serde_json::json!({ "V3": {
|
||||
"boundary_accounts": ["650", "aurora", "aurora-0", "earn.kaiching", "game.hot.tg",
|
||||
"game.hot.tg-0", "kkuuue2akv_1630967379.near", "tge-lockup.sweat", "wallet.ka"],
|
||||
"shard_ids": [10, 11, 1, 8, 9, 6, 7, 4, 12, 13],
|
||||
}}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_land_on_the_shards_the_nodes_answer_for() {
|
||||
// Each of these was answered by its shard's node, and refused with
|
||||
// UNAVAILABLE_SHARD by the others, on 2026-10-09.
|
||||
let l = mainnet();
|
||||
for (account, shard) in [
|
||||
("intents.near", 7),
|
||||
("wrap.near", 13),
|
||||
("dclv2.ref-labs.near", 8),
|
||||
("bbspike72.near", 8),
|
||||
("qtc.omft.near", 4),
|
||||
("near", 4),
|
||||
("v2.ref-finance.near", 12),
|
||||
(
|
||||
"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1",
|
||||
10,
|
||||
),
|
||||
(
|
||||
"42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978",
|
||||
10,
|
||||
),
|
||||
] {
|
||||
assert_eq!(l.shard_of(account), shard, "{account}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boundary_account_belongs_to_the_shard_after_it() {
|
||||
let l = mainnet();
|
||||
assert_eq!(l.shard_of("aurora"), 1);
|
||||
assert_eq!(l.shard_of("auror"), 11);
|
||||
assert_eq!(l.shard_of("0"), 10);
|
||||
assert_eq!(l.shard_of("zzz.near"), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_that_does_not_add_up_is_refused() {
|
||||
assert!(ShardLayout::new(vec!["a".into()], vec![1]).is_err());
|
||||
assert!(ShardLayout::new(vec!["b".into(), "a".into()], vec![1, 2, 3]).is_err());
|
||||
assert!(ShardLayout::from_protocol_config(&serde_json::json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn served_shards_go_to_our_name_first_and_others_straight_to_fallback() {
|
||||
let n = NearNetwork::shipped().unwrap();
|
||||
let ours = n.endpoints_for(7);
|
||||
assert_eq!(ours[0], "https://s7.near.blackbeard.observer");
|
||||
assert_eq!(&ours[1..], &n.fallback[..]);
|
||||
assert_eq!(n.endpoints_for(9), n.fallback);
|
||||
assert!(n.any_endpoints().len() == n.served_shards.len() + n.fallback.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shard_url_without_its_placeholder_is_refused() {
|
||||
assert!(
|
||||
NearNetwork::from_toml(
|
||||
"shard_url = \"https://x\"\nserved_shards = []\nfallback = [\"https://y\"]"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# NEAR mainnet, as the wallet reaches it. NEAR is not a network the person
|
||||
# switches to: it is the chain under the intents account (#71), so it has
|
||||
# this file rather than a chain profile.
|
||||
#
|
||||
# Our nodes are public one name per shard they track (#73): a NEAR node
|
||||
# answers UNAVAILABLE_SHARD for any account on a shard it does not track, so
|
||||
# the wallet works out an account's shard from the layout and asks that
|
||||
# shard's name. A shard we do not serve, or a name that is down, falls back
|
||||
# to the public RPCs, in order.
|
||||
#
|
||||
# An operator file `<data>/near.toml` of the same shape replaces this one.
|
||||
|
||||
shard_url = "https://s{shard}.near.blackbeard.observer"
|
||||
served_shards = [4, 7, 8, 10, 12, 13]
|
||||
fallback = ["https://rpc.mainnet.fastnear.com", "https://rpc.mainnet.near.org"]
|
||||
@@ -328,6 +328,58 @@ pub enum SwapError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// NEAR, the chain under the intents account (#71). Every call names the
|
||||
/// account it is about, so the adapter can send it to a node that tracks
|
||||
/// that account's shard: a NEAR node knows nothing of other shards. No
|
||||
/// call waits for a transaction's outcome, which no single shard node can
|
||||
/// assemble; the wallet judges each step by the state it leaves.
|
||||
#[async_trait]
|
||||
pub trait NearRpc: Send + Sync {
|
||||
/// The account, or `None` if it does not exist.
|
||||
async fn account(&self, account: &str) -> Result<Option<NearAccount>, NearError>;
|
||||
|
||||
/// The nonce of one access key, or `None` if the account has no such
|
||||
/// key. The key is named in full (`ml-dsa-65:<base58>` for ML-DSA-65):
|
||||
/// a node refuses the `ml-dsa-65-hash:` handle that key listings show,
|
||||
/// although the handle is all that state keeps.
|
||||
async fn access_key_nonce(&self, account: &str, key: &str) -> Result<Option<u64>, NearError>;
|
||||
|
||||
/// A view call on `contract`, with JSON arguments and a JSON answer.
|
||||
async fn view(
|
||||
&self,
|
||||
contract: &str,
|
||||
method: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, NearError>;
|
||||
|
||||
/// A recent final block's hash, which a transaction must name.
|
||||
async fn final_block_hash(&self) -> Result<[u8; 32], NearError>;
|
||||
|
||||
/// Submit a signed transaction from `signer`, returning once it is in a
|
||||
/// final block (or refused). Returns its hash.
|
||||
async fn send(&self, signer: &str, signed_tx: &[u8]) -> Result<[u8; 32], NearError>;
|
||||
}
|
||||
|
||||
/// What the wallet reads of a NEAR account: its native balance, for gas.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NearAccount {
|
||||
pub balance: Amount,
|
||||
pub locked: Amount,
|
||||
pub storage_usage: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NearError {
|
||||
#[error("no NEAR endpoint answered: {0}")]
|
||||
Unreachable(String),
|
||||
/// The node answered with an error; `name` is NEAR's (`UNKNOWN_ACCOUNT`,
|
||||
/// `INVALID_TRANSACTION`, …) and `detail` the rest of it.
|
||||
#[error("NEAR refused it ({name}): {detail}")]
|
||||
Rpc { name: String, detail: String },
|
||||
#[error("unexpected answer from NEAR: {0}")]
|
||||
BadAnswer(String),
|
||||
}
|
||||
|
||||
/// A fiat on/off ramp. The wallet builds a session through the relay on our
|
||||
/// infrastructure (which holds the provider secrets) and opens it in the
|
||||
/// system browser; it never embeds a provider's page in the wallet's webview.
|
||||
|
||||
@@ -34,6 +34,8 @@ reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
parity-scale-codec.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
bs58.workspace = true
|
||||
tokio.workspace = true
|
||||
async-trait.workspace = true
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod history;
|
||||
pub mod keys;
|
||||
pub mod keystore;
|
||||
pub mod mnemonic;
|
||||
pub mod near;
|
||||
pub mod ramp;
|
||||
pub mod settings;
|
||||
pub mod substrate;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! NEAR, the chain under the intents account (#71): RPC routed by shard.
|
||||
|
||||
pub mod rpc;
|
||||
|
||||
pub use rpc::{HttpNearRpc, NEAR_DECIMALS};
|
||||
@@ -0,0 +1,377 @@
|
||||
//! NEAR JSON-RPC over HTTPS, routed by shard (#73).
|
||||
//!
|
||||
//! Each call goes first to our node for the shard of the account it is
|
||||
//! about (`s<shard>.near.blackbeard.observer`), then to the public
|
||||
//! fallbacks. A node that answers `UNAVAILABLE_SHARD`, times out, throttles
|
||||
//! or cannot be reached is skipped for the next endpoint; any other error is
|
||||
//! NEAR's answer and is returned. The shard layout is read once from the
|
||||
//! first endpoint that gives it, and read again when one of our own names
|
||||
//! disowns a shard it should serve, which is what a reshard looks like.
|
||||
//!
|
||||
//! Our own names are asked only while their node is synced. A node still
|
||||
//! syncing answers from whatever height it has reached, so a fresh one says
|
||||
//! `wrap.near` does not exist, from genesis; its `/status` is checked (at
|
||||
//! most once a minute) and a syncing or stale node is skipped like a down
|
||||
//! one. The public fallbacks are taken as synced.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use wallet_core::near::{NearNetwork, ShardLayout};
|
||||
use wallet_core::ports::{NearAccount, NearError, NearRpc};
|
||||
use wallet_entities::Amount;
|
||||
|
||||
/// NEAR's native token has 24 decimals (yoctoNEAR).
|
||||
pub const NEAR_DECIMALS: u8 = 24;
|
||||
|
||||
/// Errors that say "ask someone else", not "the answer is no".
|
||||
const SKIP: &[&str] = &[
|
||||
"UNAVAILABLE_SHARD",
|
||||
"TIMEOUT_ERROR",
|
||||
"NO_SYNCED_BLOCKS",
|
||||
"NOT_SYNCED_YET",
|
||||
];
|
||||
|
||||
/// How long a node's health is believed, and how old its head may be.
|
||||
const HEALTH_TTL: Duration = Duration::from_secs(60);
|
||||
const STALE_HEAD: chrono::TimeDelta = chrono::TimeDelta::seconds(120);
|
||||
|
||||
pub struct HttpNearRpc {
|
||||
client: reqwest::Client,
|
||||
net: NearNetwork,
|
||||
layout: RwLock<Option<ShardLayout>>,
|
||||
health: Mutex<HashMap<String, (Instant, bool)>>,
|
||||
}
|
||||
|
||||
impl HttpNearRpc {
|
||||
pub fn new(net: NearNetwork) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("blackbeard-wallet/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("a TLS client can be built");
|
||||
Self {
|
||||
client,
|
||||
net,
|
||||
layout: RwLock::new(None),
|
||||
health: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether one of our nodes is synced and current. Public fallbacks are
|
||||
/// not asked.
|
||||
async fn healthy(&self, url: &str) -> bool {
|
||||
if self.net.fallback.iter().any(|f| f == url) {
|
||||
return true;
|
||||
}
|
||||
if let Some((at, ok)) = self.health.lock().await.get(url)
|
||||
&& at.elapsed() < HEALTH_TTL
|
||||
{
|
||||
return *ok;
|
||||
}
|
||||
let ok = match self.client.get(format!("{url}/status")).send().await {
|
||||
Ok(r) => match r.json::<Value>().await {
|
||||
Ok(v) => {
|
||||
let sync = &v["sync_info"];
|
||||
let fresh = sync["latest_block_time"]
|
||||
.as_str()
|
||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||
.is_some_and(|t| chrono::Utc::now().signed_duration_since(t) < STALE_HEAD);
|
||||
sync["syncing"] == Value::Bool(false) && fresh
|
||||
}
|
||||
Err(_) => false,
|
||||
},
|
||||
Err(_) => false,
|
||||
};
|
||||
if !ok {
|
||||
tracing::debug!(url, "NEAR node is syncing, stale or down; skipping it");
|
||||
}
|
||||
self.health
|
||||
.lock()
|
||||
.await
|
||||
.insert(url.to_string(), (Instant::now(), ok));
|
||||
ok
|
||||
}
|
||||
|
||||
async fn layout(&self) -> Result<ShardLayout, NearError> {
|
||||
if let Some(l) = self.layout.read().await.as_ref() {
|
||||
return Ok(l.clone());
|
||||
}
|
||||
let cfg = self
|
||||
.call(
|
||||
&self.net.any_endpoints(),
|
||||
"EXPERIMENTAL_protocol_config",
|
||||
json!({ "finality": "final" }),
|
||||
)
|
||||
.await?;
|
||||
let l = ShardLayout::from_protocol_config(&cfg["shard_layout"])
|
||||
.map_err(|e| NearError::BadAnswer(e.to_string()))?;
|
||||
*self.layout.write().await = Some(l.clone());
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
async fn endpoints_for(&self, account: &str) -> Result<Vec<String>, NearError> {
|
||||
Ok(self
|
||||
.net
|
||||
.endpoints_for(self.layout().await?.shard_of(account)))
|
||||
}
|
||||
|
||||
/// A call about `account`, sent to its shard's endpoints.
|
||||
async fn call_for(
|
||||
&self,
|
||||
account: &str,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Result<Value, NearError> {
|
||||
let urls = self.endpoints_for(account).await?;
|
||||
self.call(&urls, method, params).await
|
||||
}
|
||||
|
||||
/// Try each endpoint in turn; the first real answer wins.
|
||||
async fn call(&self, urls: &[String], method: &str, params: Value) -> Result<Value, NearError> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": "wallet", "method": method, "params": params });
|
||||
let mut skipped = vec![];
|
||||
for url in urls {
|
||||
if !self.healthy(url).await {
|
||||
skipped.push(format!("{url}: not synced"));
|
||||
continue;
|
||||
}
|
||||
let resp = match self.client.post(url).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
skipped.push(format!("{url}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 429 || status.is_server_error() && status.as_u16() != 500 {
|
||||
skipped.push(format!("{url}: HTTP {status}"));
|
||||
continue;
|
||||
}
|
||||
let v: Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
skipped.push(format!("{url}: HTTP {status}, {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(err) = v.get("error") {
|
||||
let name = err["cause"]["name"]
|
||||
.as_str()
|
||||
.or_else(|| err["name"].as_str())
|
||||
.unwrap_or("UNKNOWN")
|
||||
.to_string();
|
||||
if SKIP.contains(&name.as_str()) {
|
||||
if name == "UNAVAILABLE_SHARD" && !self.net.fallback.contains(url) {
|
||||
// One of ours disowned its shard: the layout moved.
|
||||
*self.layout.write().await = None;
|
||||
}
|
||||
skipped.push(format!("{url}: {name}"));
|
||||
continue;
|
||||
}
|
||||
return Err(NearError::Rpc {
|
||||
name,
|
||||
detail: err["data"].to_string().chars().take(400).collect(),
|
||||
});
|
||||
}
|
||||
// Some refusals arrive as a result carrying `error`: a view call
|
||||
// that panicked, and (on 2.13) a missing access key.
|
||||
if let Some(e) = v["result"].get("error").and_then(Value::as_str) {
|
||||
let name = if e.starts_with("access key") && e.contains("does not exist") {
|
||||
"UNKNOWN_ACCESS_KEY"
|
||||
} else {
|
||||
"VIEW_ERROR"
|
||||
};
|
||||
return Err(NearError::Rpc {
|
||||
name: name.into(),
|
||||
detail: e.chars().take(400).collect(),
|
||||
});
|
||||
}
|
||||
return v
|
||||
.get("result")
|
||||
.cloned()
|
||||
.ok_or_else(|| NearError::BadAnswer(format!("{url}: no result")));
|
||||
}
|
||||
Err(NearError::Unreachable(skipped.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
fn yocto(v: &Value) -> Result<u128, NearError> {
|
||||
v.as_str()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or_else(|| NearError::BadAnswer(format!("not a yoctoNEAR amount: {v}")))
|
||||
}
|
||||
|
||||
fn hash32(b58: &str) -> Result<[u8; 32], NearError> {
|
||||
let v = bs58::decode(b58)
|
||||
.into_vec()
|
||||
.map_err(|e| NearError::BadAnswer(format!("hash {b58}: {e}")))?;
|
||||
v.try_into()
|
||||
.map_err(|_| NearError::BadAnswer(format!("hash {b58} is not 32 bytes")))
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl NearRpc for HttpNearRpc {
|
||||
async fn account(&self, account: &str) -> Result<Option<NearAccount>, NearError> {
|
||||
let r = self
|
||||
.call_for(
|
||||
account,
|
||||
"query",
|
||||
json!({ "request_type": "view_account", "finality": "final", "account_id": account }),
|
||||
)
|
||||
.await;
|
||||
match r {
|
||||
Ok(v) => Ok(Some(NearAccount {
|
||||
balance: Amount::new(yocto(&v["amount"])?, NEAR_DECIMALS),
|
||||
locked: Amount::new(yocto(&v["locked"])?, NEAR_DECIMALS),
|
||||
storage_usage: v["storage_usage"].as_u64().unwrap_or(0),
|
||||
})),
|
||||
Err(NearError::Rpc { name, .. }) if name == "UNKNOWN_ACCOUNT" => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn access_key_nonce(&self, account: &str, key: &str) -> Result<Option<u64>, NearError> {
|
||||
let r = self
|
||||
.call_for(
|
||||
account,
|
||||
"query",
|
||||
json!({ "request_type": "view_access_key", "finality": "final",
|
||||
"account_id": account, "public_key": key }),
|
||||
)
|
||||
.await;
|
||||
match r {
|
||||
Ok(v) => v["nonce"]
|
||||
.as_u64()
|
||||
.map(Some)
|
||||
.ok_or_else(|| NearError::BadAnswer(format!("access key without a nonce: {v}"))),
|
||||
Err(NearError::Rpc { name, .. })
|
||||
if name == "UNKNOWN_ACCESS_KEY" || name == "UNKNOWN_ACCOUNT" =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn view(&self, contract: &str, method: &str, args: &Value) -> Result<Value, NearError> {
|
||||
let args = base64::engine::general_purpose::STANDARD.encode(args.to_string());
|
||||
let v = self
|
||||
.call_for(
|
||||
contract,
|
||||
"query",
|
||||
json!({ "request_type": "call_function", "finality": "final",
|
||||
"account_id": contract, "method_name": method, "args_base64": args }),
|
||||
)
|
||||
.await?;
|
||||
let bytes: Vec<u8> = serde_json::from_value(v["result"].clone())
|
||||
.map_err(|e| NearError::BadAnswer(format!("{contract}.{method}: {e}")))?;
|
||||
serde_json::from_slice(&bytes).map_err(|e| {
|
||||
NearError::BadAnswer(format!("{contract}.{method} returned non-JSON: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn final_block_hash(&self) -> Result<[u8; 32], NearError> {
|
||||
let v = self
|
||||
.call(
|
||||
&self.net.any_endpoints(),
|
||||
"block",
|
||||
json!({ "finality": "final" }),
|
||||
)
|
||||
.await?;
|
||||
hash32(
|
||||
v["header"]["hash"]
|
||||
.as_str()
|
||||
.ok_or_else(|| NearError::BadAnswer("block without a hash".into()))?,
|
||||
)
|
||||
}
|
||||
|
||||
async fn send(&self, signer: &str, signed_tx: &[u8]) -> Result<[u8; 32], NearError> {
|
||||
let tx = base64::engine::general_purpose::STANDARD.encode(signed_tx);
|
||||
let v = self
|
||||
.call_for(
|
||||
signer,
|
||||
"send_tx",
|
||||
json!({ "signed_tx_base64": tx, "wait_until": "INCLUDED_FINAL" }),
|
||||
)
|
||||
.await?;
|
||||
hash32(
|
||||
v["transaction"]["hash"]
|
||||
.as_str()
|
||||
.ok_or_else(|| NearError::BadAnswer(format!("send_tx without a hash: {v}")))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rpc() -> HttpNearRpc {
|
||||
HttpNearRpc::new(NearNetwork::shipped().unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "reaches s<shard>.near.blackbeard.observer and public NEAR RPCs"]
|
||||
async fn reads_accounts_on_several_shards() {
|
||||
let r = rpc();
|
||||
for a in [
|
||||
"intents.near",
|
||||
"dclv2.ref-labs.near",
|
||||
"qtc.omft.near",
|
||||
"wrap.near",
|
||||
] {
|
||||
let acct = r
|
||||
.account(a)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{a} exists"));
|
||||
assert_eq!(acct.balance.decimals, NEAR_DECIMALS);
|
||||
}
|
||||
assert!(
|
||||
r.account("no-such-account-blackbeard.near")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "reaches s<shard>.near.blackbeard.observer and public NEAR RPCs"]
|
||||
async fn an_ml_dsa_key_is_found_by_its_full_key() {
|
||||
// #72's account and its only key, named in full: the node refuses
|
||||
// the `ml-dsa-65-hash:` handle a key listing shows.
|
||||
let key = include_str!("../../tests/fixtures/near/bbspike72-ml-dsa-65.pub").trim();
|
||||
let n = rpc().access_key_nonce("bbspike72.near", key).await.unwrap();
|
||||
assert!(n.unwrap() > 0);
|
||||
assert_eq!(
|
||||
rpc()
|
||||
.access_key_nonce(
|
||||
"bbspike72.near",
|
||||
"ed25519:5VvDUBUnynzbgqdKWUw3idMA23h3gw5N5pmZr7QM6jvf"
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
None,
|
||||
"the ed25519 key was deleted in #72"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "reaches s<shard>.near.blackbeard.observer and public NEAR RPCs"]
|
||||
async fn a_view_call_answers_json() {
|
||||
let v = rpc()
|
||||
.view(
|
||||
"intents.near",
|
||||
"mt_batch_balance_of",
|
||||
&json!({ "account_id": "bbspike72.near", "token_ids": ["nep141:wrap.near"] }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(v.as_array().is_some_and(|a| a.len() == 1));
|
||||
assert_eq!(rpc().final_block_hash().await.unwrap().len(), 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ml-dsa-65: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
|
||||
Reference in New Issue
Block a user