feat(near): the sponsor creates the intents account from the wallet
ci / gate (push) Failing after 1m27s

The Accounts page now offers "Create account" when the wallet's NEAR
Intents account does not exist yet. The sponsor (#81) creates it as
b<…>.blackbeard-observer.near with the wallet's ML-DSA-65 key, and pays
its NEAR fees from then on, so the panel no longer shows gas.

- NearSponsor is the port. HttpSponsor reads the terms, asks for an
  account and relays a signed delegate, keeping the sponsor's own reason
  when it refuses.
- The wallet acts only on terms it accepts: the sponsor is running, it
  names the account the wallet expects, and its fee is at most
  MAX_FEE_BPS (50). Otherwise the panel says why and offers nothing.
- The account the sponsor reports must be the one the wallet derived
  from its key, or the wallet refuses to use it.
- The sponsor answers once its transaction is in a final block, but the
  account is made by a receipt a block or two later. The command waits
  for it to be readable before reading the summary back.

Verified in a dev run with the dev-phrase fixture: Create account made
bffbod6n6ey7jdk4j.blackbeard-observer.near (0.0025 NEAR deposited, 182
bytes, the sponsor logged "created"). It read back as active once
refreshed, which is the race the wait now covers. The live tests
(ignored by default) read the terms and send a malformed key, which the
sponsor refuses without creating anything.

Closes #74

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 20:18:59 +03:00
co-authored by Claude Opus 5.5
parent f5c214e4bf
commit 384c79446b
12 changed files with 361 additions and 20 deletions
+66 -2
View File
@@ -1078,9 +1078,73 @@ pub async fn near_account(
)
})?
.map_err(|e| WalletError::Internal(e.to_string()))?;
account_summary(state.near.as_ref(), &state.near_network, &state.assets, id)
let mut summary = account_summary(state.near.as_ref(), &state.near_network, &state.assets, id)
.await
.map_err(|e| WalletError::Internal(format!("NEAR: {e}")))
.map_err(|e| WalletError::Internal(format!("NEAR: {e}")))?;
if !summary.exists {
match state.near_sponsor.terms().await {
Ok(t) => match wallet_core::near::usable_terms(&t, &state.near_network.sponsor) {
Ok(_) => summary.can_create = true,
Err(why) => summary.sponsor_note = Some(why),
},
Err(e) => summary.sponsor_note = Some(format!("the sponsor cannot be reached: {e}")),
}
}
Ok(summary)
}
/// Ask the sponsor to create the open wallet's NEAR Intents account (#81),
/// then read it back. The sponsor names the account from the key; a name
/// other than the one the wallet derived is refused rather than used.
#[tauri::command]
pub async fn near_create_account(
state: State<'_, AppState>,
wallet: String,
) -> Result<NearAccountSummary, WalletError> {
use wallet_near::{NEAR_DEFAULT_PATH, NearKey, sponsored_account};
let (public_key, expected) = state
.session
.with_seed(&wallet, |seed| {
NearKey::derive(seed.as_bytes(), NEAR_DEFAULT_PATH).map(|k| {
(
k.key_string(),
sponsored_account(&k.handle_digest(), &state.near_network.sponsor),
)
})
})
.map_err(session_error)?
.ok_or_else(|| WalletError::Refused("this wallet has no phrase, so no NEAR key".into()))?
.map_err(|e| WalletError::Internal(e.to_string()))?;
let terms = state
.near_sponsor
.terms()
.await
.map_err(|e| WalletError::Internal(format!("sponsor: {e}")))?;
wallet_core::near::usable_terms(&terms, &state.near_network.sponsor)
.map_err(WalletError::Refused)?;
let made = state
.near_sponsor
.create_account(&public_key)
.await
.map_err(|e| WalletError::Internal(format!("sponsor: {e}")))?;
if made != expected {
return Err(WalletError::Internal(format!(
"the sponsor made {made}, but this wallet's key names {expected}"
)));
}
tracing::info!(account = %made, "NEAR account created by the sponsor");
// The sponsor answers once its transaction is in a final block; the
// account itself is made by a receipt a block or two later. Read it
// back only once it is there, or the panel says "not created yet" over
// an account that exists.
for _ in 0..10 {
if matches!(state.near.account(&made).await, Ok(Some(_))) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
near_account(state, wallet).await
}
#[tauri::command]
+1
View File
@@ -77,6 +77,7 @@ pub fn run() {
commands::history_page,
commands::wormhole_summary,
commands::near_account,
commands::near_create_account,
swap::swap_assets,
swap::swap_preview,
swap::swap_order_create,
+5 -2
View File
@@ -12,13 +12,13 @@ use wallet_core::assets::AssetRegistry;
use wallet_core::backup::BackupChallenge;
use wallet_core::near::NearNetwork;
use wallet_core::ports::{
FiatRamp, HistoryCache, HistorySource, NearRpc, OrderStore, ShieldedCache,
FiatRamp, HistoryCache, HistorySource, NearRpc, NearSponsor, OrderStore, ShieldedCache,
};
use wallet_core::profiles::ProfileRegistry;
use wallet_core::session::Session;
use wallet_data::history::{ObserverHistory, SqliteHistoryCache};
use wallet_data::keystore::FileKeystore;
use wallet_data::near::HttpNearRpc;
use wallet_data::near::{HttpNearRpc, HttpSponsor};
use wallet_data::ramp::StubRamp;
use wallet_data::settings::SettingsStore;
use wallet_data::substrate::ChainManager;
@@ -64,6 +64,8 @@ pub struct AppState {
pub ramp: Arc<dyn FiatRamp>,
/// NEAR, under the intents account (#71): RPC routed by shard (#73).
pub near: Arc<dyn NearRpc>,
/// The sponsor that creates the account and pays its gas (#81).
pub near_sponsor: Arc<dyn NearSponsor>,
pub near_network: NearNetwork,
}
@@ -135,6 +137,7 @@ impl AppState {
assets,
ramp: Arc::new(StubRamp::new()),
near: Arc::new(HttpNearRpc::new(near_network.clone())),
near_sponsor: Arc::new(HttpSponsor::new(&near_network.sponsor_api)),
near_network,
})
}
+65 -1
View File
@@ -33,6 +33,8 @@ pub struct NearNetwork {
pub fallback: Vec<String>,
/// The account that creates wallets' accounts as its sub-accounts (#81).
pub sponsor: String,
/// The sponsor's API (`…/sponsor`, `…/accounts`, `…/relay`).
pub sponsor_api: String,
}
impl NearNetwork {
@@ -77,6 +79,39 @@ impl NearNetwork {
}
}
/// The highest swap fee the wallet accepts from a sponsor (#82): the
/// official Quantus wallet charges 0.5%, and this one charges no more.
pub const MAX_FEE_BPS: u32 = 50;
/// Terms the wallet will act on: enabled, naming the account the wallet
/// expects, and a fee within [`MAX_FEE_BPS`]. Anything else is refused by
/// name rather than used.
pub fn usable_terms(
terms: &crate::ports::SponsorTerms,
expected_sponsor: &str,
) -> Result<(u32, String), String> {
if !terms.enabled {
return Err("the sponsor is not running".into());
}
if terms.account.as_deref() != Some(expected_sponsor) {
return Err(format!(
"the sponsor says it is {:?}, not {expected_sponsor}",
terms.account
));
}
let fee = terms.fee_bps.ok_or("the sponsor publishes no fee")?;
if fee > MAX_FEE_BPS {
return Err(format!(
"the sponsor's fee is {fee} bps, above the {MAX_FEE_BPS} this wallet accepts"
));
}
let recipient = terms
.fee_recipient
.clone()
.ok_or("the sponsor names no fee recipient")?;
Ok((fee, recipient))
}
/// The NEAR Intents verifier contract.
pub const INTENTS: &str = "intents.near";
@@ -108,6 +143,8 @@ pub async fn account_summary(
exists: account.is_some(),
key_on_account: false,
gas: account.map(|a| a.balance),
can_create: false,
sponsor_note: None,
intents: vec![],
};
if !summary.exists {
@@ -389,6 +426,33 @@ mod tests {
assert!(s.exists && !s.key_on_account);
}
#[test]
fn terms_outside_what_the_wallet_accepts_are_refused() {
use crate::ports::SponsorTerms;
let ok = SponsorTerms {
enabled: true,
account: Some("blackbeard-observer.near".into()),
fee_bps: Some(30),
fee_recipient: Some("blackbeard-observer.near".into()),
};
assert_eq!(usable_terms(&ok, "blackbeard-observer.near").unwrap().0, 30);
let greedy = SponsorTerms {
fee_bps: Some(51),
..ok.clone()
};
assert!(usable_terms(&greedy, "blackbeard-observer.near").is_err());
let off = SponsorTerms {
enabled: false,
..ok.clone()
};
assert!(usable_terms(&off, "blackbeard-observer.near").is_err());
let impostor = SponsorTerms {
account: Some("evil.near".into()),
..ok
};
assert!(usable_terms(&impostor, "blackbeard-observer.near").is_err());
}
#[test]
fn a_sponsored_account_is_named_from_its_key_and_lands_on_shard_8() {
let n = NearNetwork::shipped().unwrap();
@@ -407,7 +471,7 @@ mod tests {
fn a_shard_url_without_its_placeholder_is_refused() {
assert!(
NearNetwork::from_toml(
"shard_url = \"https://x\"\nserved_shards = []\nfallback = [\"https://y\"]\nsponsor = \"s.near\""
"shard_url = \"https://x\"\nserved_shards = []\nfallback = [\"https://y\"]\nsponsor = \"s.near\"\nsponsor_api = \"https://z\""
)
.is_err()
);
+2
View File
@@ -19,3 +19,5 @@ 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"]
sponsor = "blackbeard-observer.near"
# Its API: GET terms, POST accounts, POST relay.
sponsor_api = "https://blackbeard.observer/v1/near"
+26
View File
@@ -360,6 +360,32 @@ pub trait NearRpc: Send + Sync {
async fn send(&self, signer: &str, signed_tx: &[u8]) -> Result<[u8; 32], NearError>;
}
/// The sponsor (#81): creates the wallet's NEAR account and relays its
/// legs as NEP-366 meta-transactions, paying the gas. Its terms are read
/// before anything is asked of it.
#[async_trait]
pub trait NearSponsor: Send + Sync {
async fn terms(&self) -> Result<SponsorTerms, NearError>;
/// Create (or find) the account for an ML-DSA-65 public key
/// (`ml-dsa-65:<base58>`). Returns its name.
async fn create_account(&self, public_key: &str) -> Result<String, NearError>;
/// Relay a signed delegate action; returns the sponsor's transaction
/// hash once it is in a final block.
async fn relay(&self, signed_delegate: &[u8]) -> Result<[u8; 32], NearError>;
}
/// What the sponsor publishes about itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SponsorTerms {
pub enabled: bool,
pub account: Option<String>,
/// The swap fee in basis points of a pool swap's input.
pub fee_bps: Option<u32>,
pub fee_recipient: Option<String>,
}
/// What the wallet reads of a NEAR account: its native balance, for gas.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NearAccount {
+2
View File
@@ -1,5 +1,7 @@
//! NEAR, the chain under the intents account (#71): RPC routed by shard.
pub mod rpc;
pub mod sponsor;
pub use rpc::{HttpNearRpc, NEAR_DECIMALS};
pub use sponsor::HttpSponsor;
+145
View File
@@ -0,0 +1,145 @@
//! The sponsor's API on blackbeard.observer (#81): its terms, account
//! creation, and relaying signed delegate actions. Errors keep the
//! sponsor's own words, which say why a request was refused.
use base64::Engine;
use serde::Deserialize;
use serde_json::json;
use wallet_core::ports::{NearError, NearSponsor, SponsorTerms};
pub struct HttpSponsor {
client: reqwest::Client,
base: String,
}
impl HttpSponsor {
pub fn new(base: &str) -> Self {
let client = reqwest::Client::builder()
.user_agent(concat!("blackbeard-wallet/", env!("CARGO_PKG_VERSION")))
// A relay returns once its transaction is in a final block.
.timeout(std::time::Duration::from_secs(60))
.build()
.expect("a TLS client can be built");
Self {
client,
base: base.trim_end_matches('/').to_string(),
}
}
async fn answer<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, NearError> {
let status = resp.status();
if status.is_success() {
return resp
.json()
.await
.map_err(|e| NearError::BadAnswer(format!("sponsor: {e}")));
}
#[derive(Deserialize)]
struct ApiError {
code: String,
message: String,
}
match resp.json::<ApiError>().await {
Ok(e) => Err(NearError::Rpc {
name: format!("sponsor {}", e.code),
detail: e.message,
}),
Err(_) => Err(NearError::Unreachable(format!("sponsor: HTTP {status}"))),
}
}
}
#[derive(Deserialize)]
struct Terms {
enabled: bool,
account: Option<String>,
fee_bps: Option<u32>,
fee_recipient: Option<String>,
}
#[async_trait::async_trait]
impl NearSponsor for HttpSponsor {
async fn terms(&self) -> Result<SponsorTerms, NearError> {
let resp = self
.client
.get(format!("{}/sponsor", self.base))
.send()
.await
.map_err(|e| NearError::Unreachable(format!("sponsor: {e}")))?;
let t: Terms = Self::answer(resp).await?;
Ok(SponsorTerms {
enabled: t.enabled,
account: t.account,
fee_bps: t.fee_bps,
fee_recipient: t.fee_recipient,
})
}
async fn create_account(&self, public_key: &str) -> Result<String, NearError> {
#[derive(Deserialize)]
struct Created {
account: String,
}
let resp = self
.client
.post(format!("{}/accounts", self.base))
.json(&json!({ "public_key": public_key }))
.send()
.await
.map_err(|e| NearError::Unreachable(format!("sponsor: {e}")))?;
Ok(Self::answer::<Created>(resp).await?.account)
}
async fn relay(&self, signed_delegate: &[u8]) -> Result<[u8; 32], NearError> {
#[derive(Deserialize)]
struct Relayed {
tx: String,
}
let resp = self
.client
.post(format!("{}/relay", self.base))
.json(&json!({
"signed_delegate": base64::engine::general_purpose::STANDARD.encode(signed_delegate)
}))
.send()
.await
.map_err(|e| NearError::Unreachable(format!("sponsor: {e}")))?;
let tx = Self::answer::<Relayed>(resp).await?.tx;
bs58::decode(&tx)
.into_vec()
.ok()
.and_then(|v| v.try_into().ok())
.ok_or_else(|| {
NearError::BadAnswer(format!("sponsor: {tx:?} is not a transaction hash"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "reaches blackbeard.observer (reads the terms only)"]
async fn the_live_sponsor_publishes_terms_the_wallet_accepts() {
let net = wallet_core::near::NearNetwork::shipped().unwrap();
let t = HttpSponsor::new(&net.sponsor_api).terms().await.unwrap();
wallet_core::near::usable_terms(&t, &net.sponsor).unwrap();
}
#[tokio::test]
#[ignore = "reaches blackbeard.observer (a refused request; creates nothing)"]
async fn a_malformed_key_is_refused_in_the_sponsors_words() {
let net = wallet_core::near::NearNetwork::shipped().unwrap();
let e = HttpSponsor::new(&net.sponsor_api)
.create_account("ml-dsa-65:notakey")
.await
.unwrap_err();
assert!(
matches!(e, NearError::Rpc { ref name, .. } if name == "sponsor refused"),
"{e}"
);
}
}
+7 -1
View File
@@ -725,8 +725,14 @@ pub struct NearAccountSummary {
/// Whether this wallet's key is one of the account's access keys. False
/// for an existing account means it is not this wallet's to use.
pub key_on_account: bool,
/// Native NEAR, which pays the account's gas. `None` until it exists.
/// Native NEAR on the account. The sponsor pays its gas (#81), so this
/// is only the storage the sponsor deposited; `None` until it exists.
pub gas: Option<Amount>,
/// Whether the sponsor will create it now: it does not exist, and the
/// sponsor is running on terms the wallet accepts.
pub can_create: bool,
/// Why it cannot be created, when it does not exist and cannot be.
pub sponsor_note: Option<String>,
/// What the account holds inside NEAR Intents, by the assets the wallet
/// knows; nonzero balances only.
pub intents: Vec<IntentsBalance>,
+11 -1
View File
@@ -31,9 +31,19 @@ exists: boolean,
*/
key_on_account: boolean,
/**
* Native NEAR, which pays the account's gas. `None` until it exists.
* Native NEAR on the account. The sponsor pays its gas (#81), so this
* is only the storage the sponsor deposited; `None` until it exists.
*/
gas: Amount | null,
/**
* Whether the sponsor will create it now: it does not exist, and the
* sponsor is running on terms the wallet accepts.
*/
can_create: boolean,
/**
* Why it cannot be created, when it does not exist and cannot be.
*/
sponsor_note: string | null,
/**
* What the account holds inside NEAR Intents, by the assets the wallet
* knows; nonzero balances only.
+5
View File
@@ -228,6 +228,11 @@ export function nearAccount(wallet: string): Promise<NearAccountSummary> {
return command<NearAccountSummary>('near_account', { wallet })
}
// Ask the sponsor to create that account. Returns it, read back.
export function nearCreateAccount(wallet: string): Promise<NearAccountSummary> {
return command<NearAccountSummary>('near_create_account', { wallet })
}
// Swaps, in preview: the assets the provider and the registry agree on, a
// dry quote, an order (a wet quote persisted with its deposit address),
// and the list. Rust follows open orders and emits `orders:updated`.
+26 -13
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { copyText } from '../api/clipboard'
import { nearAccount } from '../api/wallet'
import { nearAccount, nearCreateAccount } from '../api/wallet'
import { formatAmount } from '../lib/format'
/**
@@ -16,6 +16,11 @@ export function NearIntentsPanel({ wallet }: { wallet: string }) {
staleTime: 30_000,
retry: 1,
})
const qc = useQueryClient()
const create = useMutation({
mutationFn: () => nearCreateAccount(wallet),
onSuccess: (made) => qc.setQueryData(['near-account', wallet], made),
})
const a = account.data
return (
<section className="panel">
@@ -41,10 +46,25 @@ export function NearIntentsPanel({ wallet }: { wallet: string }) {
</span>
</div>
{!a.exists && (
<p className="muted">
Swaps run through this account. It is created for you by the wallet’s sponsor,
which is not open yet.
</p>
<div className="field">
<p className="muted">
Swaps run through this account. The wallet’s sponsor creates it for you and pays
its NEAR fees, so you never need NEAR yourself.
</p>
{a.can_create ? (
<button
className="button"
type="button"
onClick={() => create.mutate()}
disabled={create.isPending}
>
{create.isPending ? 'Creating…' : 'Create account'}
</button>
) : (
<p className="error">{a.sponsor_note ?? 'The sponsor cannot create it now.'}</p>
)}
{create.isError && <p className="error">{create.error.message}</p>}
</div>
)}
{a.exists && !a.key_on_account && (
<p className="error">
@@ -55,13 +75,6 @@ export function NearIntentsPanel({ wallet }: { wallet: string }) {
{a.exists && (
<table className="table review">
<tbody>
<tr>
<th>Gas</th>
<td className="numeral">
{a.gas ? formatAmount(a.gas) : '—'} NEAR
<span className="muted"> pays this account’s transactions</span>
</td>
</tr>
{a.intents.length === 0 && (
<tr>
<th>Held</th>