feat(near): NEAR RPC routed by shard, with our nodes first
ci / gate (push) Canceled after 3m50s

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:
2026-10-09 12:55:01 +03:00
co-authored by Claude Opus 5.5
parent b059a8d5d0
commit 1c9467c38d
12 changed files with 660 additions and 0 deletions
Generated
+2
View File
@@ -7004,8 +7004,10 @@ dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"base64 0.22.1",
"bip39",
"blake2",
"bs58",
"chrono",
"directories",
"getrandom 0.3.4",
+2
View File
@@ -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.
+1
View File
@@ -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]
+1
View File
@@ -13,6 +13,7 @@
pub mod assets;
pub mod backup;
pub mod near;
pub mod orders;
pub mod paths;
pub mod ports;
+201
View File
@@ -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()
);
}
}
+15
View File
@@ -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"]
+52
View File
@@ -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.
+2
View File
@@ -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
+1
View File
@@ -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;
+5
View File
@@ -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};
+377
View File
@@ -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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