feat: the NEAR sponsor for blackbeard wallets, off until configured

blackbeard-observer.near will create each blackbeard wallet's NEAR
account and relay its legs as NEP-366 meta-transactions, paying the gas
(blackbeard/wallet#81). This is that sponsor, built and off by default.

- blackbeard-core::sponsor judges a signed delegate action. It is decoded
  and verified by wallet-near, the code the wallet signs with, pinned by
  rev.
  - The sender must be the sponsored account of the delegate's own key.
  - Only the wallet's calls are relayed (intents.near ft_withdraw and
    mt_transfer; wrap.near ft_transfer_call, near_deposit and
    storage_deposit), each with a gas and deposit cap.
  - A pool swap to Ref DCL must pay the fee, at least ceil(amount × bps),
    in its input token to the fee account, in the same action.
  - Seven tests cover forged, misaddressed, over-limit and fee-less
    delegates.
- blackbeard-data::sponsor submits to gargravarr first, the node tracking
  the sponsor's shard, and returns at INCLUDED_FINAL.
- blackbeard-api::sponsor holds the key, one nonce counter, per-address,
  per-sender and daily limits. Routes:
  - GET /v1/near/sponsor: the terms;
  - POST /v1/near/accounts: create the account for a key, or find it;
  - POST /v1/near/relay: relay a signed delegate action.
  Without [near_sponsor] in the config, the terms say enabled: false and
  the POSTs answer 503. A configured key that will not load stops the
  daemon.
- blackbeard-cli near-sponsor-keygen writes a new ML-DSA-65 key, mode 600,
  where it will be used. It never overwrites.

The config template carries the section commented out, with the steps
to turn it on. Registering and funding the account are the operator's.

Refs #51

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 13:39:12 +03:00
co-authored by Claude Opus 5.5
parent ce347c12f3
commit 003a13e4ce
17 changed files with 1233 additions and 5 deletions
Generated
+78 -2
View File
@@ -247,6 +247,7 @@ dependencies = [
"blackbeard-core",
"blackbeard-data",
"blackbeard-entities",
"bs58",
"chrono",
"clap",
"figment",
@@ -261,6 +262,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"wallet-near",
]
[[package]]
@@ -273,11 +275,15 @@ dependencies = [
"blackbeard-entities",
"chrono",
"clap",
"getrandom 0.3.4",
"hex",
"qp-rusty-crystals-dilithium",
"reqwest",
"serde",
"serde_json",
"sqlx",
"tokio",
"wallet-near",
]
[[package]]
@@ -294,6 +300,7 @@ dependencies = [
"parity-scale-codec",
"primitive-types",
"qp-poseidon-core",
"qp-rusty-crystals-dilithium",
"rust_iso3166",
"scale-decode",
"scale-info",
@@ -302,6 +309,7 @@ dependencies = [
"thiserror 2.0.20",
"tracing",
"twox-hash",
"wallet-near",
]
[[package]]
@@ -312,6 +320,7 @@ dependencies = [
"bigdecimal",
"blackbeard-core",
"blackbeard-entities",
"bs58",
"chrono",
"futures-util",
"hex",
@@ -356,6 +365,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -1285,6 +1303,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "konst"
version = "0.2.20"
@@ -1750,6 +1777,15 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5872607e25ea4ee5fb37e64bf1462168e1a36a4e719cdc8a105533c708253918"
[[package]]
name = "qp-rusty-crystals-dilithium"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "789877c169226a35d2ea686bbd9d506becc693f7bb0ee91acc03f74491e80c0f"
dependencies = [
"zeroize",
]
[[package]]
name = "quinn"
version = "0.11.11"
@@ -2317,6 +2353,16 @@ dependencies = [
"digest",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -3239,6 +3285,22 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wallet-near"
version = "0.3.0"
source = "git+https://git.lair.cafe/blackbeard/wallet?rev=f5c214e4bffa34c5bc3c5283737f92547cd98e1c#f5c214e4bffa34c5bc3c5283737f92547cd98e1c"
dependencies = [
"bs58",
"getrandom 0.3.4",
"hmac",
"qp-rusty-crystals-dilithium",
"serde_json",
"sha2",
"sha3",
"thiserror 2.0.20",
"zeroize",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -3699,9 +3761,23 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.9.0"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
+7
View File
@@ -11,6 +11,13 @@ authors = ["Rob Thijssen <rob@lair.cafe>"]
repository = "https://git.lair.cafe/Quantus-Network/blackbeard.observer"
[workspace.dependencies]
getrandom = "0.3"
bs58 = "0.5"
# The blackbeard wallet's NEAR code (#51): the NEP-649 key, transactions and
# NEP-366 delegate actions. Pinned by rev so the sponsor and the wallet sign
# and read the same bytes; move it with the wallet, never ahead of it.
wallet-near = { git = "https://git.lair.cafe/blackbeard/wallet", rev = "f5c214e4bffa34c5bc3c5283737f92547cd98e1c" }
qp-rusty-crystals-dilithium = { version = "4.1.1", default-features = false, features = ["ml-dsa-65"] }
blackbeard-entities = { path = "crates/blackbeard-entities", version = "=0.1.0" }
blackbeard-core = { path = "crates/blackbeard-core", version = "=0.1.0" }
blackbeard-data = { path = "crates/blackbeard-data", version = "=0.1.0" }
+21
View File
@@ -241,3 +241,24 @@ max_gap_fill_blocks = 5000
# Mainnet launched on 2026-09-09 as telemetry's "Quantus", and is the
# `[[chains]]` entry above. "Quantus Staging Mainnet" is a different, staging
# chain and remains deliberately unlabelled.
# The NEAR sponsor for blackbeard wallets (#51, blackbeard/wallet#81). Off
# while this stays commented out: its terms say `enabled: false` and its POSTs
# answer 503. To turn it on:
# 1. on bob, as blackbeard:
# blackbeard-cli near-sponsor-keygen /var/lib/blackbeard/near-sponsor.key
# 2. register and fund blackbeard-observer.near, with that public key as its
# only access key;
# 3. uncomment this, then restart.
# rpc_urls: gargravarr tracks the sponsor's shard (8) and every account it
# creates. fee_bps is at most 50; the wallet refuses terms above that.
# creation_deposit_yocto is NEAR's storage minimum for an account holding one
# ML-DSA-65 key: enough to exist, nothing to farm.
#
# [near_sponsor]
# account = "blackbeard-observer.near"
# key_file = "/var/lib/blackbeard/near-sponsor.key"
# rpc_urls = ["http://gargravarr.hanzalova.internal:24403", "https://rpc.mainnet.fastnear.com"]
# fee_bps = 30
# fee_recipient = "blackbeard-observer.near"
# creation_deposit_yocto = "2000000000000000000000"
+3 -2
View File
@@ -18,6 +18,9 @@ blackbeard-data.workspace = true
blackbeard-entities.workspace = true
anyhow.workspace = true
base64.workspace = true
bs58.workspace = true
wallet-near.workspace = true
axum.workspace = true
chrono.workspace = true
clap.workspace = true
@@ -34,5 +37,3 @@ tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
base64.workspace = true
+60
View File
@@ -30,6 +30,52 @@ pub struct Config {
/// telemetry-only observation to a full miner leaderboard.
#[serde(default)]
pub chains: Vec<ChainConfig>,
/// The NEAR sponsor for blackbeard wallets (#51). Absent, the sponsor
/// is off: its terms say so and its POSTs answer 503.
#[serde(default)]
pub near_sponsor: Option<NearSponsorConfig>,
}
/// The NEAR sponsor (#51, blackbeard/wallet#81). The key is a file, never
/// a value here: this config holds no secret.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NearSponsorConfig {
/// The sponsor account, `blackbeard-observer.near`.
pub account: String,
/// Its ML-DSA-65 keypair, hex, as `blackbeard-cli near-sponsor-keygen`
/// writes it; readable by `blackbeard` only.
pub key_file: PathBuf,
/// Where its transactions go, in order: the node tracking its shard
/// first.
pub rpc_urls: Vec<String>,
/// The swap fee, in basis points of a pool swap's input. At most 50.
pub fee_bps: u32,
/// The Intents account the fee is paid to.
pub fee_recipient: String,
/// yoctoNEAR given to each account it creates: NEAR's storage minimum
/// for an account with one ML-DSA-65 key, and nothing to farm.
pub creation_deposit_yocto: String,
/// Accounts created per client address per hour.
#[serde(default = "default_creations_per_ip_hour")]
pub creations_per_ip_hour: usize,
/// Relays per sender per hour.
#[serde(default = "default_relays_per_sender_hour")]
pub relays_per_sender_hour: usize,
/// Transactions of any kind per UTC day, across everyone: the ceiling on
/// what the sponsor can spend.
#[serde(default = "default_daily_transactions")]
pub daily_transactions: usize,
}
fn default_creations_per_ip_hour() -> usize {
3
}
fn default_relays_per_sender_hour() -> usize {
60
}
fn default_daily_transactions() -> usize {
2000
}
/// The telemetry feed and how much of it to track.
@@ -469,6 +515,20 @@ impl Config {
observe. Telemetry supplies the chain list; [[chains]] only adds RPC endpoints."
);
}
if let Some(sp) = &self.near_sponsor {
if sp.fee_bps > 50 {
anyhow::bail!(
"near_sponsor.fee_bps is {}: the wallet refuses terms above 50 bps",
sp.fee_bps
);
}
if sp.rpc_urls.is_empty() {
anyhow::bail!("near_sponsor.rpc_urls is empty");
}
sp.creation_deposit_yocto.parse::<u128>().map_err(|_| {
anyhow::anyhow!("near_sponsor.creation_deposit_yocto is not a yoctoNEAR amount")
})?;
}
let mut seen = std::collections::HashSet::new();
for chain in &self.chains {
if !seen.insert(&chain.id) {
+9
View File
@@ -22,6 +22,7 @@ mod refv1;
mod registry;
mod routes;
mod search;
mod sponsor;
mod state;
mod ws;
@@ -110,11 +111,19 @@ async fn main() -> anyhow::Result<()> {
);
tokio::spawn(Arc::clone(&registry).supervise());
// The NEAR sponsor (#51): off unless configured, and a key that will
// not load stops the daemon rather than leaving a sponsor that cannot
// sign.
let sponsor = match config.near_sponsor.clone() {
Some(cfg) => Some(Arc::new(sponsor::Sponsor::start(cfg).await?)),
None => None,
};
let state = AppState {
registry: Arc::clone(&registry),
store,
started: chrono::Utc::now(),
leaderboard_max_age: Duration::from_secs(config.server.leaderboard_refresh_seconds),
sponsor,
};
let app = routes::router(state, &config.server.allowed_origins);
+104 -1
View File
@@ -11,9 +11,10 @@
use std::time::Duration;
use axum::extract::{Path, Query, State};
use axum::http::HeaderMap;
use axum::http::{HeaderValue, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::routing::{get, post};
use axum::{Json, Router};
use blackbeard_entities::{
AccountDetail, AccountEvent, AccountFlows, AccountRoleEntry, AccountRow, ActivitySource,
@@ -51,6 +52,9 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
Router::new()
.route("/v1/healthz", get(healthz))
.route("/v1/near/sponsor", get(sponsor_terms))
.route("/v1/near/accounts", post(sponsor_create))
.route("/v1/near/relay", post(sponsor_relay))
.route("/v1/chains", get(chains))
.route("/v1/chains/{chain}/summary", get(summary))
.route("/v1/chains/{chain}/leaderboard", get(leaderboard))
@@ -143,6 +147,105 @@ impl IntoResponse for Failure {
}
}
// --- the NEAR sponsor (#51) ----------------------------------------------
/// `GET /v1/near/sponsor`: the terms a blackbeard wallet reads before it
/// asks for anything.
async fn sponsor_terms(State(state): State<AppState>) -> Json<crate::sponsor::Terms> {
Json(match &state.sponsor {
Some(s) => s.terms(),
None => crate::sponsor::Terms::disabled(),
})
}
#[derive(Deserialize)]
struct CreateRequest {
public_key: String,
}
#[derive(Serialize)]
struct CreateResponse {
account: String,
}
#[derive(Deserialize)]
struct RelayRequest {
/// The signed delegate action, base64.
signed_delegate: String,
}
#[derive(Serialize)]
struct RelayResponse {
/// The sponsor's transaction, base58.
tx: String,
}
fn sponsor_of(state: &AppState) -> Result<&std::sync::Arc<crate::sponsor::Sponsor>, Failure> {
state.sponsor.as_ref().ok_or_else(|| {
Failure(
StatusCode::SERVICE_UNAVAILABLE,
ApiError::new("sponsor_disabled", "the NEAR sponsor is not running here"),
)
})
}
fn sponsor_failure(e: crate::sponsor::SponsorError) -> Failure {
use crate::sponsor::SponsorError as E;
match e {
E::Refused(m) => Failure(StatusCode::BAD_REQUEST, ApiError::new("refused", m)),
E::Conflict(m) => Failure(StatusCode::CONFLICT, ApiError::new("conflict", m)),
E::Limited(m) => Failure(StatusCode::TOO_MANY_REQUESTS, ApiError::new("limited", m)),
E::Upstream(m) => Failure(
StatusCode::BAD_GATEWAY,
ApiError::new("near_unavailable", m),
),
}
}
/// The client's address as the edge proxy saw it.
fn client_address(headers: &HeaderMap) -> String {
headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown")
.to_owned()
}
/// `POST /v1/near/accounts`: create (or find) the account for a key.
async fn sponsor_create(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<CreateRequest>,
) -> Result<Json<CreateResponse>, Failure> {
let s = sponsor_of(&state)?;
let account = s
.create(&req.public_key, &client_address(&headers))
.await
.map_err(sponsor_failure)?;
Ok(Json(CreateResponse { account }))
}
/// `POST /v1/near/relay`: relay a signed delegate action.
async fn sponsor_relay(
State(state): State<AppState>,
Json(req): Json<RelayRequest>,
) -> Result<Json<RelayResponse>, Failure> {
use base64::Engine;
let s = sponsor_of(&state)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(req.signed_delegate.trim())
.map_err(|_| {
Failure(
StatusCode::BAD_REQUEST,
ApiError::new("refused", "signed_delegate is not base64"),
)
})?;
let hash = s.relay(&bytes).await.map_err(sponsor_failure)?;
Ok(Json(RelayResponse {
tx: bs58::encode(hash).into_string(),
}))
}
fn unknown_chain(id: &str) -> Failure {
Failure(
StatusCode::NOT_FOUND,
+301
View File
@@ -0,0 +1,301 @@
//! The NEAR sponsor (#51, blackbeard/wallet#81): creates each blackbeard
//! wallet's NEAR account and relays its legs, paying the gas. The rules are
//! `blackbeard_core::sponsor`; this is the key, the nonce, the limits and
//! the transactions.
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use blackbeard_core::sponsor::{Policy, Refusal, judge};
use blackbeard_data::sponsor::NearSubmitter;
use wallet_near::key::handle_digest;
use wallet_near::tx::{Action, PublicKey, Transaction};
use wallet_near::{NearKey, sponsored_account};
use crate::config::NearSponsorConfig;
const ML_DSA_65_PUBLIC: usize = 1952;
/// Why a sponsor request did not go through, with the HTTP status it maps to.
#[derive(Debug)]
pub enum SponsorError {
/// The request is not one the sponsor serves (400).
Refused(String),
/// The account exists but is not this key's (409).
Conflict(String),
/// Over a limit (429).
Limited(String),
/// NEAR or the sponsor's own state failed (502).
Upstream(String),
}
impl From<Refusal> for SponsorError {
fn from(r: Refusal) -> Self {
Self::Refused(r.to_string())
}
}
/// The running sponsor.
pub struct Sponsor {
key: NearKey,
policy: Policy,
near: NearSubmitter,
creation_deposit: u128,
/// The sponsor key's last used nonce; `None` until read from chain, and
/// again after a refusal that may mean it moved. Held across a whole
/// submission so two never share a nonce.
nonce: tokio::sync::Mutex<Option<u64>>,
limits: Mutex<Limits>,
cfg: NearSponsorConfig,
}
#[derive(Default)]
struct Limits {
creations_by_ip: HashMap<String, VecDeque<Instant>>,
relays_by_sender: HashMap<String, VecDeque<Instant>>,
day: (i64, usize),
}
/// Count `key`'s events in the last `window`; refuse past `max`.
fn take(
map: &mut HashMap<String, VecDeque<Instant>>,
key: &str,
max: usize,
window: Duration,
) -> bool {
let now = Instant::now();
let q = map.entry(key.to_owned()).or_default();
while q.front().is_some_and(|t| now.duration_since(*t) > window) {
q.pop_front();
}
if q.len() >= max {
return false;
}
q.push_back(now);
true
}
/// What `GET /v1/near/sponsor` answers.
#[derive(serde::Serialize)]
pub struct Terms {
/// Whether the sponsor is running.
pub enabled: bool,
/// The sponsor account.
pub account: Option<String>,
/// The swap fee in basis points.
pub fee_bps: Option<u32>,
/// Where the fee is paid.
pub fee_recipient: Option<String>,
/// What it relays: `receiver.method`.
pub relays: Vec<String>,
}
impl Terms {
/// The answer when no sponsor is configured.
pub fn disabled() -> Self {
Self {
enabled: false,
account: None,
fee_bps: None,
fee_recipient: None,
relays: relays(),
}
}
}
fn relays() -> Vec<String> {
blackbeard_core::sponsor::ALLOWED
.iter()
.map(|r| format!("{}.{}", r.receiver, r.method))
.collect()
}
impl Sponsor {
/// Load the key and check it is the sponsor account's.
pub async fn start(cfg: NearSponsorConfig) -> anyhow::Result<Self> {
let hex_key = std::fs::read_to_string(&cfg.key_file).map_err(|e| {
anyhow::anyhow!("near_sponsor.key_file {}: {e}", cfg.key_file.display())
})?;
let bytes = hex::decode(hex_key.trim())
.map_err(|_| anyhow::anyhow!("near_sponsor.key_file is not hex"))?;
let key = NearKey::from_keypair_bytes(&bytes)?;
let near = NearSubmitter::new(cfg.rpc_urls.clone())?;
match near.access_key_nonce(&cfg.account, &key.key_string()).await {
Ok(Some(_)) => {
tracing::info!(account = %cfg.account, "near sponsor: key is on the account")
}
Ok(None) => tracing::warn!(
account = %cfg.account,
key = %key.key_string().chars().take(40).collect::<String>(),
"near sponsor: the key is not on the account yet; creations and relays will fail until it is"
),
Err(e) => {
tracing::warn!(error = %e, "near sponsor: could not check the key; carrying on")
}
}
Ok(Self {
policy: Policy {
sponsor: cfg.account.clone(),
fee_bps: cfg.fee_bps,
fee_recipient: cfg.fee_recipient.clone(),
},
creation_deposit: cfg.creation_deposit_yocto.parse()?,
key,
near,
nonce: tokio::sync::Mutex::new(None),
limits: Mutex::new(Limits::default()),
cfg,
})
}
/// The terms the wallet reads.
pub fn terms(&self) -> Terms {
Terms {
enabled: true,
account: Some(self.policy.sponsor.clone()),
fee_bps: Some(self.policy.fee_bps),
fee_recipient: Some(self.policy.fee_recipient.clone()),
relays: relays(),
}
}
fn charge_day(&self) -> Result<(), SponsorError> {
let today = chrono::Utc::now().date_naive().num_days_from_ce() as i64;
let mut l = self.limits.lock().expect("limits lock");
if l.day.0 != today {
l.day = (today, 0);
}
if l.day.1 >= self.cfg.daily_transactions {
return Err(SponsorError::Limited(
"the sponsor's daily budget is spent".into(),
));
}
l.day.1 += 1;
Ok(())
}
/// Create the account for an ML-DSA-65 public key, or find it.
pub async fn create(&self, public_key: &str, client: &str) -> Result<String, SponsorError> {
let pk = public_key
.strip_prefix("ml-dsa-65:")
.and_then(|b| bs58::decode(b).into_vec().ok())
.filter(|v| v.len() == ML_DSA_65_PUBLIC)
.ok_or_else(|| {
SponsorError::Refused("public_key must be ml-dsa-65:<base58 of 1952 bytes>".into())
})?;
let account = sponsored_account(&handle_digest(&pk), &self.policy.sponsor);
if self.near.account_exists(&account).await.map_err(up)? {
return match self
.near
.access_key_nonce(&account, public_key)
.await
.map_err(up)?
{
Some(_) => Ok(account),
None => Err(SponsorError::Conflict(format!(
"{account} exists without this key"
))),
};
}
{
let mut l = self.limits.lock().expect("limits lock");
if !take(
&mut l.creations_by_ip,
client,
self.cfg.creations_per_ip_hour,
Duration::from_secs(3600),
) {
return Err(SponsorError::Limited(
"too many accounts from this address this hour".into(),
));
}
}
self.charge_day()?;
self.submit(
&account,
vec![
Action::CreateAccount,
Action::Transfer {
deposit: self.creation_deposit,
},
Action::AddFullAccessKey(PublicKey::MlDsa65(pk)),
],
)
.await?;
tracing::info!(%account, "near sponsor: created");
Ok(account)
}
/// Relay a signed delegate action. Returns the transaction hash.
pub async fn relay(&self, signed: &[u8]) -> Result<[u8; 32], SponsorError> {
let d = judge(signed, &self.policy)?;
let sender = d.action.sender_id.clone();
{
let mut l = self.limits.lock().expect("limits lock");
if !take(
&mut l.relays_by_sender,
&sender,
self.cfg.relays_per_sender_hour,
Duration::from_secs(3600),
) {
return Err(SponsorError::Limited(format!(
"too many relays for {sender} this hour"
)));
}
}
self.charge_day()?;
// NEP-366: the relayer's transaction is addressed to the sender, and
// carries the signed delegate as its only action.
let hash = self
.submit(&sender, vec![Action::Delegate(d.encode())])
.await?;
tracing::info!(%sender, receiver = %d.action.receiver_id, tx = %bs58::encode(hash).into_string(), "near sponsor: relayed");
Ok(hash)
}
/// Sign and send one transaction from the sponsor, under the nonce lock.
async fn submit(&self, receiver: &str, actions: Vec<Action>) -> Result<[u8; 32], SponsorError> {
let mut nonce = self.nonce.lock().await;
let current = match *nonce {
Some(n) => n,
None => self
.near
.access_key_nonce(&self.policy.sponsor, &self.key.key_string())
.await
.map_err(up)?
.ok_or_else(|| {
SponsorError::Upstream("the sponsor's key is not on its account".into())
})?,
};
let (block_hash, _) = self.near.final_block().await.map_err(up)?;
let tx = Transaction {
signer_id: self.policy.sponsor.clone(),
public_key: self.key.public_key().to_vec(),
nonce: current + 1,
receiver_id: receiver.into(),
block_hash,
actions,
};
let (bytes, _) = tx
.sign(&self.key)
.map_err(|e| SponsorError::Upstream(e.to_string()))?;
match self.near.send(&bytes).await {
Ok(hash) => {
*nonce = Some(current + 1);
Ok(hash)
}
Err(e) => {
// A refusal may mean the nonce moved under us; read it again.
*nonce = None;
Err(up(e))
}
}
}
}
fn up(e: blackbeard_data::DataError) -> SponsorError {
SponsorError::Upstream(e.to_string())
}
use chrono::Datelike;
+2
View File
@@ -508,6 +508,8 @@ pub struct AppState {
pub started: DateTime<Utc>,
/// How stale a cached leaderboard may be before a REST request rebuilds it.
pub leaderboard_max_age: std::time::Duration,
/// The NEAR sponsor for blackbeard wallets (#51), when configured.
pub sponsor: Option<Arc<crate::sponsor::Sponsor>>,
}
impl AppState {
+4
View File
@@ -18,6 +18,10 @@ blackbeard-data.workspace = true
blackbeard-entities.workspace = true
anyhow.workspace = true
getrandom.workspace = true
hex.workspace = true
qp-rusty-crystals-dilithium.workspace = true
wallet-near.workspace = true
chrono.workspace = true
clap.workspace = true
reqwest.workspace = true
+33
View File
@@ -98,11 +98,20 @@ enum Command {
#[arg(long, default_value_t = 20)]
limit: usize,
},
/// Generate the NEAR sponsor's ML-DSA-65 key (#51) into a new file, mode
/// 600, and print its public key. Run it on the host that will use it,
/// as the user that will read it: the secret never travels.
NearSponsorKeygen {
/// Where to write the key. Refuses to overwrite.
path: std::path::PathBuf,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
match Cli::parse().command {
Command::NearSponsorKeygen { path } => near_sponsor_keygen(&path),
Command::Probe {
rpc_url,
target_block_time,
@@ -370,3 +379,27 @@ async fn standings(api: &str, chain: &str, window: &str, limit: usize) -> anyhow
}
Ok(())
}
fn near_sponsor_keygen(path: &std::path::Path) -> anyhow::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use qp_rusty_crystals_dilithium::{SensitiveBytes32, ml_dsa_65::Keypair};
let mut entropy = [0u8; 32];
getrandom::fill(&mut entropy).map_err(|e| anyhow::anyhow!("no randomness: {e}"))?;
let keypair = Keypair::generate(&mut SensitiveBytes32::new(&mut entropy));
let bytes = keypair.to_bytes();
let key = wallet_near::NearKey::from_keypair_bytes(&bytes[..])?;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| anyhow::anyhow!("{}: {e} (it is never overwritten)", path.display()))?;
writeln!(f, "{}", hex::encode(&bytes[..]))?;
println!("wrote {}", path.display());
println!("public key: {}", key.key_string());
println!("handle: {}", key.handle());
Ok(())
}
+4
View File
@@ -27,3 +27,7 @@ qp-poseidon-core.workspace = true
primitive-types.workspace = true
thiserror.workspace = true
tracing.workspace = true
wallet-near.workspace = true
[dev-dependencies]
qp-rusty-crystals-dilithium.workspace = true
+1
View File
@@ -28,6 +28,7 @@ pub mod quote_probe;
pub mod runtime;
pub mod scale;
pub mod series;
pub mod sponsor;
pub mod vesting;
pub mod window;
pub mod wormhole;
+449
View File
@@ -0,0 +1,449 @@
//! The NEAR sponsor's relay rules (#51, blackbeard/wallet#81): what the
//! sponsor will pay gas for. A blackbeard wallet's NEAR account is created
//! by `blackbeard-observer.near` and named from its key; its legs arrive as
//! NEP-366 delegate actions, which the sponsor wraps in a transaction of its
//! own. Everything the sponsor refuses here never reaches NEAR.
//!
//! No I/O: the delegate is decoded and verified by `wallet-near`, the same
//! code the wallet signs with, and judged here against the policy.
use wallet_near::delegate::SignedDelegate;
use wallet_near::key::handle_digest;
use wallet_near::sponsored_account;
use wallet_near::tx::Action;
/// One method the sponsor relays, with the most gas it pays for and the
/// largest deposit it lets ride along (the sender's own NEAR).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowedMethod {
/// The contract called.
pub receiver: &'static str,
/// The method called on it.
pub method: &'static str,
/// The most gas one call may attach.
pub max_gas: u64,
/// The largest deposit one call may carry, in yoctoNEAR.
pub max_deposit: u128,
}
const TGAS: u64 = 1_000_000_000_000;
/// NEP-145 storage registration on a fungible token.
const STORAGE_DEPOSIT: u128 = 1_250_000_000_000_000_000_000;
/// What the wallet's routes call (blackbeard/wallet #75 to #79), and nothing
/// else.
pub const ALLOWED: &[AllowedMethod] = &[
// A pool swap or a withdrawal out of Intents (to Quantus, to the pool).
AllowedMethod {
receiver: "intents.near",
method: "ft_withdraw",
max_gas: 250 * TGAS,
max_deposit: 1,
},
// Funding a 1Click quote, and the swap fee.
AllowedMethod {
receiver: "intents.near",
method: "mt_transfer",
max_gas: 30 * TGAS,
max_deposit: 1,
},
// wNEAR into Intents.
AllowedMethod {
receiver: "wrap.near",
method: "ft_transfer_call",
max_gas: 200 * TGAS,
max_deposit: 1,
},
AllowedMethod {
receiver: "wrap.near",
method: "near_deposit",
max_gas: 20 * TGAS,
max_deposit: u128::MAX,
},
AllowedMethod {
receiver: "wrap.near",
method: "storage_deposit",
max_gas: 20 * TGAS,
max_deposit: STORAGE_DEPOSIT,
},
];
/// The Ref DCL contract: an `ft_withdraw` to it is a pool swap, which pays
/// the fee.
pub const DCL: &str = "dclv2.ref-labs.near";
/// The sponsor's terms, from its configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
/// `blackbeard-observer.near`.
pub sponsor: String,
/// The swap fee, in basis points of a pool swap's input.
pub fee_bps: u32,
/// The Intents account the fee is paid to.
pub fee_recipient: String,
}
/// Why a delegate was not relayed. Shown to the wallet as is.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Refusal {
/// Not bytes `wallet-near` reads as a signed delegate action.
#[error("not a delegate action the wallet signs: {0}")]
Malformed(String),
/// The signature does not verify under the key it names.
#[error("the signature is not the named key's")]
BadSignature,
/// The sender is not the account the sponsor made for that key.
#[error("{sender} is not the sponsored account of its key ({expected})")]
WrongSender {
/// Who the delegate says it is from.
sender: String,
/// The sponsored account of its key.
expected: String,
},
/// A contract or method outside [`ALLOWED`].
#[error("{receiver}.{method} is not relayed")]
NotAllowed {
/// The contract.
receiver: String,
/// The method.
method: String,
},
/// An action other than a function call.
#[error("{0} actions: the sponsor relays function calls only")]
NotACall(String),
/// More gas or a larger deposit than the method's rule allows.
#[error("{method}: {why}")]
OverLimit {
/// The method.
method: String,
/// Which limit, and by how much.
why: String,
},
#[error(
"a pool swap of {amount} {token} must pay a fee of at least {fee} to {recipient} in the same action"
)]
/// A pool swap without the fee in the same delegate action.
FeeMissing {
/// The swap's input token.
token: String,
/// The amount swapped.
amount: u128,
/// The least fee it owes.
fee: u128,
/// Where the fee goes.
recipient: String,
},
/// Arguments that are not what the method takes.
#[error("{0}")]
BadArgs(String),
}
/// Decode, verify and judge a signed delegate. Returns it when the sponsor
/// will relay it.
pub fn judge(bytes: &[u8], policy: &Policy) -> Result<SignedDelegate, Refusal> {
let d = SignedDelegate::decode(bytes).map_err(|e| Refusal::Malformed(e.to_string()))?;
if !d.verify() {
return Err(Refusal::BadSignature);
}
let a = &d.action;
let expected = sponsored_account(&handle_digest(&a.public_key), &policy.sponsor);
if a.sender_id != expected {
return Err(Refusal::WrongSender {
sender: a.sender_id.clone(),
expected,
});
}
if a.actions.is_empty() {
return Err(Refusal::Malformed("no actions".into()));
}
// Every action an allowed call within its limits.
let mut withdrawals_to_pool: Vec<(String, u128)> = vec![];
let mut transfers: Vec<(String, String, u128)> = vec![];
for action in &a.actions {
let Action::FunctionCall {
method,
args,
gas,
deposit,
} = action
else {
return Err(Refusal::NotACall(
format!("{action:?}")
.split(['(', ' ', '{'])
.next()
.unwrap_or("other")
.into(),
));
};
let rule = ALLOWED
.iter()
.find(|r| r.receiver == a.receiver_id && r.method == method)
.ok_or_else(|| Refusal::NotAllowed {
receiver: a.receiver_id.clone(),
method: method.clone(),
})?;
if *gas > rule.max_gas {
return Err(Refusal::OverLimit {
method: method.clone(),
why: format!("{gas} gas, at most {}", rule.max_gas),
});
}
if *deposit > rule.max_deposit {
return Err(Refusal::OverLimit {
method: method.clone(),
why: format!("a deposit of {deposit}, at most {}", rule.max_deposit),
});
}
let v: serde_json::Value = serde_json::from_slice(args)
.map_err(|e| Refusal::BadArgs(format!("{method}: arguments are not JSON: {e}")))?;
match method.as_str() {
"ft_withdraw" if v["receiver_id"] == DCL => {
withdrawals_to_pool.push((str_arg(&v, "token")?, u128_arg(&v, "amount")?));
}
"mt_transfer" => {
transfers.push((
str_arg(&v, "receiver_id")?,
str_arg(&v, "token_id")?,
u128_arg(&v, "amount")?,
));
}
_ => {}
}
}
// A pool swap pays the fee in the same action, in its input token.
for (token, amount) in withdrawals_to_pool {
let fee = fee_on(amount, policy.fee_bps);
if fee == 0 {
continue;
}
let paid: u128 = transfers
.iter()
.filter(|(to, t, _)| *to == policy.fee_recipient && *t == format!("nep141:{token}"))
.map(|(.., n)| *n)
.sum();
if paid < fee {
return Err(Refusal::FeeMissing {
token,
amount,
fee,
recipient: policy.fee_recipient.clone(),
});
}
}
Ok(d)
}
/// The fee on an amount, rounded up: `ceil(amount × bps / 10000)`.
pub fn fee_on(amount: u128, bps: u32) -> u128 {
amount.saturating_mul(u128::from(bps)).div_ceil(10_000)
}
fn str_arg(v: &serde_json::Value, k: &str) -> Result<String, Refusal> {
v[k].as_str()
.map(str::to_owned)
.ok_or_else(|| Refusal::BadArgs(format!("no {k}")))
}
fn u128_arg(v: &serde_json::Value, k: &str) -> Result<u128, Refusal> {
v[k].as_str()
.and_then(|s| s.parse().ok())
.ok_or_else(|| Refusal::BadArgs(format!("{k} is not an amount")))
}
#[cfg(test)]
mod tests {
use super::*;
use qp_rusty_crystals_dilithium::{SensitiveBytes32, ml_dsa_65::Keypair};
use serde_json::json;
use wallet_near::NearKey;
use wallet_near::delegate::DelegateAction;
fn key(seed: u8) -> NearKey {
NearKey::from_keypair_bytes(
&Keypair::generate(&mut SensitiveBytes32::new(&mut [seed; 32])).to_bytes()[..],
)
.unwrap()
}
fn policy() -> Policy {
Policy {
sponsor: "blackbeard-observer.near".into(),
fee_bps: 30,
fee_recipient: "blackbeard-observer.near".into(),
}
}
fn sign(k: &NearKey, sender: Option<&str>, receiver: &str, actions: Vec<Action>) -> Vec<u8> {
let me = sponsored_account(&k.handle_digest(), "blackbeard-observer.near");
DelegateAction {
sender_id: sender.unwrap_or(&me).into(),
receiver_id: receiver.into(),
actions,
nonce: 5,
max_block_height: 100,
public_key: k.public_key().to_vec(),
}
.sign(k)
.unwrap()
.encode()
}
fn pool_swap(amount: u128) -> Action {
let msg = json!({"Swap": {"pool_ids": ["qtc.omft.near|wrap.near|10000"], "output_token": "wrap.near", "min_output_amount": "1"}});
Action::call(
"ft_withdraw",
&json!({"token": "qtc.omft.near", "receiver_id": DCL, "amount": amount.to_string(), "msg": msg.to_string()}),
250 * TGAS,
1,
)
}
fn fee(to: &str, token: &str, amount: u128) -> Action {
Action::call(
"mt_transfer",
&json!({"receiver_id": to, "token_id": token, "amount": amount.to_string()}),
30 * TGAS,
1,
)
}
#[test]
fn a_pool_swap_with_its_fee_is_relayed() {
let k = key(1);
let b = sign(
&k,
None,
"intents.near",
vec![
fee("blackbeard-observer.near", "nep141:qtc.omft.near", 30),
pool_swap(10_000),
],
);
assert!(judge(&b, &policy()).is_ok());
}
#[test]
fn a_pool_swap_without_its_fee_is_refused() {
let k = key(1);
let short = sign(
&k,
None,
"intents.near",
vec![
fee("blackbeard-observer.near", "nep141:qtc.omft.near", 29),
pool_swap(10_000),
],
);
assert!(matches!(
judge(&short, &policy()),
Err(Refusal::FeeMissing { fee: 30, .. })
));
let none = sign(&k, None, "intents.near", vec![pool_swap(10_000)]);
assert!(matches!(
judge(&none, &policy()),
Err(Refusal::FeeMissing { .. })
));
let wrong_token = sign(
&k,
None,
"intents.near",
vec![
fee("blackbeard-observer.near", "nep141:wrap.near", 30),
pool_swap(10_000),
],
);
assert!(matches!(
judge(&wrong_token, &policy()),
Err(Refusal::FeeMissing { .. })
));
}
#[test]
fn the_fee_rounds_up() {
assert_eq!(fee_on(10_000, 30), 30);
assert_eq!(fee_on(1, 30), 1);
assert_eq!(fee_on(0, 30), 0);
assert_eq!(fee_on(u128::MAX, 50), u128::MAX.div_ceil(10_000));
}
#[test]
fn a_withdrawal_or_a_1click_transfer_pays_no_relay_fee() {
let k = key(2);
let out = Action::call(
"ft_withdraw",
&json!({"token": "qtc.omft.near", "receiver_id": "qtc.omft.near", "amount": "5", "memo": "WITHDRAW_TO:qz…"}),
100 * TGAS,
1,
);
assert!(judge(&sign(&k, None, "intents.near", vec![out]), &policy()).is_ok());
let oneclick = fee(
"a663276079a5c5a0ccc8fd032a03bfc7483dfd0f2988cd9b9ebfbabbbdc8f2fd",
"nep141:wrap.near",
5,
);
assert!(judge(&sign(&k, None, "intents.near", vec![oneclick]), &policy()).is_ok());
}
#[test]
fn someone_elses_account_is_refused() {
let k = key(3);
let b = sign(
&k,
Some("bbspike72.near"),
"intents.near",
vec![pool_swap(1)],
);
assert!(matches!(
judge(&b, &policy()),
Err(Refusal::WrongSender { .. })
));
}
#[test]
fn calls_the_wallet_never_makes_are_refused() {
let k = key(4);
let other = Action::call("execute_intents", &json!({}), 10 * TGAS, 0);
assert!(matches!(
judge(&sign(&k, None, "intents.near", vec![other]), &policy()),
Err(Refusal::NotAllowed { .. })
));
let elsewhere = Action::call("ft_transfer", &json!({}), 10 * TGAS, 1);
assert!(matches!(
judge(
&sign(&k, None, "usdt.tether-token.near", vec![elsewhere]),
&policy()
),
Err(Refusal::NotAllowed { .. })
));
let send = Action::Transfer { deposit: 1 };
assert!(matches!(
judge(&sign(&k, None, "wrap.near", vec![send]), &policy()),
Err(Refusal::NotACall(_))
));
let greedy = Action::call(
"mt_transfer",
&json!({"receiver_id": "x", "token_id": "nep141:wrap.near", "amount": "1"}),
300 * TGAS,
1,
);
assert!(matches!(
judge(&sign(&k, None, "intents.near", vec![greedy]), &policy()),
Err(Refusal::OverLimit { .. })
));
}
#[test]
fn a_forged_or_garbled_delegate_is_refused() {
let k = key(5);
let mut b = sign(&k, None, "intents.near", vec![pool_swap(1)]);
let last = b.len() - 1;
b[last] ^= 1;
assert_eq!(judge(&b, &policy()).unwrap_err(), Refusal::BadSignature);
assert!(matches!(
judge(&b[..10], &policy()),
Err(Refusal::Malformed(_))
));
}
}
+1
View File
@@ -14,6 +14,7 @@ blackbeard-entities.workspace = true
base64.workspace = true
bigdecimal.workspace = true
bs58.workspace = true
chrono.workspace = true
futures-util.workspace = true
hex.workspace = true
+1
View File
@@ -10,6 +10,7 @@
pub mod near;
pub mod rpc;
pub mod sponsor;
pub mod store;
pub mod telemetry;
+155
View File
@@ -0,0 +1,155 @@
//! NEAR JSON-RPC for the sponsor (#51): what it needs to sign and submit
//! its own transactions. The sponsor account and every account it makes
//! sort onto one shard (8 on mainnet), so the first endpoint is the node
//! that tracks it (gargravarr); public RPCs follow. Nothing here waits for
//! a transaction's outcome, which a shard node cannot assemble: `send`
//! returns once it is in a final block, or refused.
use base64::Engine;
use serde_json::{Value, json};
use crate::DataError;
/// Errors that say "ask the next endpoint", not "the answer is no".
const SKIP: &[&str] = &[
"UNAVAILABLE_SHARD",
"TIMEOUT_ERROR",
"NO_SYNCED_BLOCKS",
"NOT_SYNCED_YET",
];
/// A JSON-RPC client over a list of NEAR endpoints, tried in order.
pub struct NearSubmitter {
client: reqwest::Client,
urls: Vec<String>,
}
impl NearSubmitter {
/// A client for these endpoints.
pub fn new(urls: Vec<String>) -> Result<Self, DataError> {
let client = reqwest::Client::builder()
.user_agent(concat!(
"blackbeard-observer-sponsor/",
env!("CARGO_PKG_VERSION")
))
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self { client, urls })
}
async fn call(&self, method: &str, params: Value) -> Result<Value, DataError> {
let body = json!({ "jsonrpc": "2.0", "id": "sponsor", "method": method, "params": params });
let mut skipped = vec![];
for url in &self.urls {
let v: Value = match self.client.post(url).json(&body).send().await {
Ok(r) => match r.json().await {
Ok(v) => v,
Err(e) => {
skipped.push(format!("{url}: {e}"));
continue;
}
},
Err(e) => {
skipped.push(format!("{url}: {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");
if SKIP.contains(&name) {
skipped.push(format!("{url}: {name}"));
continue;
}
return Err(DataError::Rpc {
method: method.into(),
message: format!("{name}: {}", err["data"]),
});
}
if let Some(e) = v["result"].get("error").and_then(Value::as_str) {
return Err(DataError::Rpc {
method: method.into(),
message: e.into(),
});
}
return v.get("result").cloned().ok_or_else(|| DataError::Rpc {
method: method.into(),
message: "no result".into(),
});
}
Err(DataError::Rpc {
method: method.into(),
message: format!("no endpoint answered: {}", skipped.join("; ")),
})
}
/// Whether the account exists.
pub async fn account_exists(&self, account: &str) -> Result<bool, DataError> {
match self
.call("query", json!({ "request_type": "view_account", "finality": "final", "account_id": account }))
.await
{
Ok(_) => Ok(true),
Err(DataError::Rpc { message, .. }) if message.starts_with("UNKNOWN_ACCOUNT") => Ok(false),
Err(e) => Err(e),
}
}
/// The nonce of one access key, named in full (`ml-dsa-65:<base58>`),
/// or `None` if the account has no such key.
pub async fn access_key_nonce(
&self,
account: &str,
key: &str,
) -> Result<Option<u64>, DataError> {
match self
.call(
"query",
json!({ "request_type": "view_access_key", "finality": "final", "account_id": account, "public_key": key }),
)
.await
{
Ok(v) => Ok(v["nonce"].as_u64()),
Err(DataError::Rpc { message, .. })
if message.contains("does not exist") || message.starts_with("UNKNOWN_ACC") =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
/// A recent final block's hash and height.
pub async fn final_block(&self) -> Result<([u8; 32], u64), DataError> {
let v = self.call("block", json!({ "finality": "final" })).await?;
let hash = decode_hash(v["header"]["hash"].as_str().unwrap_or_default())?;
let height = v["header"]["height"].as_u64().unwrap_or_default();
Ok((hash, height))
}
/// Submit a signed transaction; returns its hash once it is in a final
/// block. A refusal (bad nonce, not enough balance) is an error.
pub async fn send(&self, signed_tx: &[u8]) -> Result<[u8; 32], DataError> {
let tx = base64::engine::general_purpose::STANDARD.encode(signed_tx);
let v = self
.call(
"send_tx",
json!({ "signed_tx_base64": tx, "wait_until": "INCLUDED_FINAL" }),
)
.await?;
decode_hash(v["transaction"]["hash"].as_str().unwrap_or_default())
}
}
fn decode_hash(b58: &str) -> Result<[u8; 32], DataError> {
bs58::decode(b58)
.into_vec()
.ok()
.and_then(|v| v.try_into().ok())
.ok_or_else(|| DataError::Rpc {
method: "hash".into(),
message: format!("not a hash: {b58:?}"),
})
}