feat(substrate): transfers prepared for review, priced, signed by the session, submitted and watched
ci / gate (push) Waiting to run

`SubstrateAdapter` implements the chain-adapter port for Quantus. `prepare`
builds the call from a chain-neutral intent, decodes it back from its own
bytes through the runtime metadata into a summary a person can check (a
32-byte account renders as the chain's SS58 address, an amount in the
token's units), prices it by assembling a full extrinsic with a zeroed
signature of the right size and asking `TransactionPaymentApi_query_info`,
and returns the signer payload for the session to sign. `submit` refuses
if the runtime spec moved or the payload no longer matches, assembles with
the session's signature, and streams statuses; a dispatch error is reported
at inclusion, named `Pallet::Variant` where the chain names it, so nobody
waits for finality to learn a transfer failed.

Reversible transfers: `reversible_for` routes through
`ReversibleTransfers::schedule_transfer_with_delay`; a high-security
account is routed through the pallet's default-delay call whether it asked
or not, since the chain's extension refuses a plain transfer from one.
`pending_reversible` lists what is waiting with the execution block from
the scheduler's lookup, whose task name is the tx id itself, and
`CancelReversible` cancels.

Three things the dev node taught, each now written into the code:

- Mortality must be anchored at the *best* block. Finality on this
  proof-of-work chain trails the tip by a hundred blocks, and an era born
  at the finalized head names a birth block whose hash the runtime resolves
  differently: "Transaction has a bad signature", for both schemes, while
  immortal transactions from the same keys passed. Probed all four
  scheme-by-era combinations to isolate it.
- Everything the UI shows is read at the best block, with the block number
  and the finalized height alongside so confirmations can be shown.
  subxt's `at_latest()` is the finalized block; the pending list read there
  showed the state of a hundred blocks ago while the chain's events showed
  every schedule landing.
- subxt's runtime-update subscription died on this node with "header not
  found" after a reorg and took the connection with it. It is advisory
  now: on error the runtime version is polled every ten heads and a change
  forces a reconnect, which refetches metadata.

Live, against `quantus-node --dev` with the funded dev accounts: a 3 DEV
transfer from crystal_alice to crystal_bob is included and moves bob's
balance; a reversible transfer with a 200-block window is scheduled, shows
as pending with its execution block, and is cancelled; an overspend fails
as `Token(FundsUnavailable)`. Fee for a plain transfer: 0.010157927 DEV.

In the app: `transfer_prepare` (amount parsed from its decimal form in
Rust), `transfer_submit` (signs through the session, emits `tx:status`),
`transfer_discard`, `reversible_pending`, `reversible_cancel_prepare`.

Closes #22

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
2026-09-15 22:58:39 +03:00
co-authored by Claude Fable 5.1
parent 26559ee148
commit 95bcfe9ca4
26 changed files with 54969 additions and 51 deletions
Generated
+3
View File
@@ -6182,6 +6182,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"getrandom 0.3.4",
"hex",
"serde",
"serde_json",
"tauri",
@@ -6204,6 +6205,7 @@ dependencies = [
"bs58",
"serde",
"thiserror 2.0.18",
"tokio",
"toml 0.8.2",
"wallet-entities",
"zeroize",
@@ -6215,6 +6217,7 @@ version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"bip39",
"blake2",
"chrono",
+1
View File
@@ -29,6 +29,7 @@ tracing-subscriber.workspace = true
anyhow.workspace = true
zeroize.workspace = true
getrandom.workspace = true
hex.workspace = true
[dev-dependencies]
toml.workspace = true
+255 -6
View File
@@ -8,6 +8,9 @@
use std::time::Instant;
use wallet_core::{ChainAdapter, SignedBy, TransferIntent, TxStatus};
use wallet_data::substrate::SubstrateAdapter;
use tauri::State;
use wallet_core::backup::BackupChallenge;
use wallet_core::session::SessionError;
@@ -17,8 +20,9 @@ use wallet_data::keys::seed_from_mnemonic;
use wallet_data::mnemonic;
use wallet_data::substrate::{ChainManager, account_info};
use wallet_entities::{
AccountBalance, Amount, ChainId, ChainProfile, ChainStatus, HighSecurityInfo,
SessionAccountInfo, SessionStatusInfo,
AccountBalance, AccountRef, Amount, AssetId, AssetKind, ChainId, ChainProfile, ChainStatus,
HighSecurityInfo, PendingReversible, PreparedTransferInfo, SessionAccountInfo,
SessionStatusInfo, TxStage, TxStatusInfo,
};
use wallet_entities::{
AppInfo, CreationChallenge, CreationStart, SignatureScheme, WalletError, WalletSummary,
@@ -270,6 +274,7 @@ pub fn chain_status(
endpoint: None,
spec_version: None,
transaction_version: None,
best_number: None,
finalized_number: None,
head_age_seconds: None,
last_error: None,
@@ -311,7 +316,7 @@ pub async fn read_balances(
if accounts.is_empty() {
return Err(WalletError::Locked);
}
let at_block = manager.status().finalized_number;
let finalized_block = manager.status().finalized_number;
let mut out = Vec::with_capacity(accounts.len());
for a in accounts {
let info = account_info(&conn, a.account_id)
@@ -333,7 +338,8 @@ pub async fn read_balances(
delay_blocks: h.delay_blocks,
delay_ms: h.delay_ms,
}),
at_block,
at_block: info.at_block,
finalized_block,
});
}
Ok(out)
@@ -347,7 +353,7 @@ pub async fn balances(
read_balances(&state, &chain).await
}
/// Start refreshing balances for `chain` on every finalized head and
/// Start refreshing balances for `chain` on every best head and
/// emitting `balances:updated` with the result. Idempotent per chain.
#[tauri::command]
pub async fn balances_watch(
@@ -376,7 +382,7 @@ pub async fn balances_watch(
{
return Ok(());
}
let mut heads = manager.finalized_heads();
let mut heads = manager.best_heads();
let task_chain = chain.clone();
let handle = tauri::async_runtime::spawn(async move {
let chain = task_chain;
@@ -397,3 +403,246 @@ pub async fn balances_watch(
watchers.insert(chain, handle);
Ok(())
}
fn adapter_for(state: &AppState, chain: &ChainId) -> Result<SubstrateAdapter, WalletError> {
let chains = state
.chains
.lock()
.map_err(|_| WalletError::Internal("state poisoned".into()))?;
let manager = chains
.get(chain)
.cloned()
.ok_or_else(|| WalletError::Internal(format!("{} not connected", chain.0)))?;
Ok(SubstrateAdapter::new(manager))
}
fn chain_error(e: wallet_core::ChainError) -> WalletError {
use wallet_core::ChainError::*;
match e {
InvalidAddress(m) | Unsupported(m) => WalletError::InvalidInput(m),
Disconnected(m) | Rpc(m) | Other(m) => WalletError::Chain(m),
}
}
/// Which of the open wallet's accounts `address` is, or `Locked`/`NotFound`.
fn my_account(state: &AppState, chain: &ChainId, address: &str) -> Result<AccountRef, WalletError> {
let profile = state
.profiles
.get(chain)
.ok_or_else(|| WalletError::NotFound(format!("chain {}", chain.0)))?;
let status = state
.session
.status(Instant::now())
.map_err(session_error)?;
if status.wallet.is_none() {
return Err(WalletError::Locked);
}
let account = status
.accounts
.iter()
.find(|a| ss58::encode(profile.ss58_prefix, &a.account_id) == address)
.ok_or_else(|| WalletError::NotFound(format!("account {address} in the open wallet")))?;
Ok(AccountRef {
chain: chain.clone(),
address: address.to_owned(),
scheme: account.scheme,
})
}
fn remember_prepared(
state: &AppState,
prepared: wallet_core::PreparedTransaction,
) -> Result<PreparedTransferInfo, WalletError> {
let mut id_bytes = [0u8; 12];
getrandom::fill(&mut id_bytes).map_err(|e| WalletError::Internal(e.to_string()))?;
let id = hex::encode(id_bytes);
let profile = state
.profiles
.get(&prepared.chain)
.ok_or_else(|| WalletError::NotFound("chain".into()))?;
let info = PreparedTransferInfo {
id: id.clone(),
chain: prepared.chain.clone(),
from: ss58::encode(profile.ss58_prefix, &prepared.from),
summary: prepared.summary.clone(),
fee: prepared.fee,
nonce: prepared.nonce,
valid_for_blocks: prepared.mortal_for,
};
state
.prepared
.lock()
.map_err(|_| WalletError::Internal("state poisoned".into()))?
.insert(id, prepared);
Ok(info)
}
/// Build a transfer for review. `amount` is the human decimal form; Rust
/// parses it against the asset's decimals. `reversible_for` is a
/// cancellation window in blocks.
#[tauri::command]
pub async fn transfer_prepare(
state: State<'_, AppState>,
chain: ChainId,
from: String,
to: String,
amount: String,
reversible_for: Option<u64>,
) -> Result<PreparedTransferInfo, WalletError> {
let profile = state
.profiles
.get(&chain)
.cloned()
.ok_or_else(|| WalletError::NotFound(format!("chain {}", chain.0)))?;
let from_ref = my_account(&state, &chain, &from)?;
let amount =
Amount::parse(&amount, profile.token_decimals).map_err(WalletError::InvalidInput)?;
let adapter = adapter_for(&state, &chain)?;
let intent = TransferIntent::Transfer {
asset: AssetId {
chain: chain.clone(),
kind: AssetKind::Native,
},
to,
amount,
reversible_for,
};
let prepared = adapter
.prepare(&from_ref, &intent)
.await
.map_err(chain_error)?;
remember_prepared(&state, prepared)
}
/// Build a cancellation of a pending reversible transfer for review.
#[tauri::command]
pub async fn reversible_cancel_prepare(
state: State<'_, AppState>,
chain: ChainId,
from: String,
tx_id: String,
) -> Result<PreparedTransferInfo, WalletError> {
let from_ref = my_account(&state, &chain, &from)?;
let bytes = hex::decode(tx_id.trim_start_matches("0x"))
.map_err(|e| WalletError::InvalidInput(format!("tx id: {e}")))?;
let tx_id: [u8; 32] = bytes
.try_into()
.map_err(|_| WalletError::InvalidInput("tx id is not 32 bytes".into()))?;
let adapter = adapter_for(&state, &chain)?;
let prepared = adapter
.prepare(&from_ref, &TransferIntent::CancelReversible { tx_id })
.await
.map_err(chain_error)?;
remember_prepared(&state, prepared)
}
/// Sign a prepared transaction with the session and submit it. Progress
/// arrives as `tx:status` events carrying the prepared id and the hash.
#[tauri::command]
pub async fn transfer_submit(
app: tauri::AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<String, WalletError> {
use tauri::Emitter;
let prepared = state
.prepared
.lock()
.map_err(|_| WalletError::Internal("state poisoned".into()))?
.remove(&id)
.ok_or_else(|| WalletError::NotFound(format!("prepared transaction {id}")))?;
let signature = state
.session
.sign(
&prepared.from,
&prepared.signing_payload,
&prepared.signing_context,
Instant::now(),
)
.map_err(session_error)?;
let public_key = state
.session
.status(Instant::now())
.map_err(session_error)?
.accounts
.into_iter()
.find(|a| a.account_id == prepared.from)
.map(|a| a.public_key)
.ok_or(WalletError::Locked)?;
let adapter = adapter_for(&state, &prepared.chain)?;
let (hash, mut rx) = adapter
.submit(
&prepared,
SignedBy {
scheme: prepared.scheme,
public_key,
signature,
},
)
.await
.map_err(chain_error)?;
let hash_hex = format!("0x{}", hex::encode(hash));
let emit_id = id.clone();
let emit_hash = hash_hex.clone();
tauri::async_runtime::spawn(async move {
while let Some(status) = rx.recv().await {
let stage = match status {
TxStatus::Submitted => TxStage::Submitted,
TxStatus::InBlock { block } => TxStage::InBlock {
block: format!("0x{}", hex::encode(block)),
},
TxStatus::Finalized { block } => TxStage::Finalized {
block: format!("0x{}", hex::encode(block)),
},
TxStatus::Failed { block, reason } => TxStage::Failed {
block: block.map(|b| format!("0x{}", hex::encode(b))),
reason,
},
TxStatus::Dropped { reason } => TxStage::Dropped { reason },
};
let _ = app.emit(
"tx:status",
TxStatusInfo {
id: emit_id.clone(),
hash: emit_hash.clone(),
stage,
},
);
}
});
Ok(hash_hex)
}
/// Forget a prepared transaction without submitting it.
#[tauri::command]
pub fn transfer_discard(state: State<'_, AppState>, id: String) -> Result<(), WalletError> {
state
.prepared
.lock()
.map_err(|_| WalletError::Internal("state poisoned".into()))?
.remove(&id);
Ok(())
}
/// Reversible transfers still pending from `address`.
#[tauri::command]
pub async fn reversible_pending(
state: State<'_, AppState>,
chain: ChainId,
address: String,
) -> Result<Vec<PendingReversible>, WalletError> {
let profile = state
.profiles
.get(&chain)
.ok_or_else(|| WalletError::NotFound(format!("chain {}", chain.0)))?;
let (prefix, me) =
ss58::decode(&address).map_err(|e| WalletError::InvalidInput(e.to_string()))?;
if prefix != profile.ss58_prefix {
return Err(WalletError::InvalidInput(format!(
"{address} is not a {} address",
profile.display_name
)));
}
let adapter = adapter_for(&state, &chain)?;
adapter.pending_reversible(me).await.map_err(chain_error)
}
+5
View File
@@ -52,6 +52,11 @@ pub fn run() {
commands::chain_status,
commands::balances,
commands::balances_watch,
commands::transfer_prepare,
commands::transfer_submit,
commands::transfer_discard,
commands::reversible_cancel_prepare,
commands::reversible_pending,
])
.run(tauri::generate_context!())
.expect("the Tauri runtime failed to start");
+6
View File
@@ -7,6 +7,7 @@ use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use wallet_core::PreparedTransaction;
use wallet_core::backup::BackupChallenge;
use wallet_core::profiles::ProfileRegistry;
use wallet_core::session::Session;
@@ -39,6 +40,10 @@ pub struct AppState {
/// One balance watcher per chain, refreshing the open wallet's accounts
/// on every finalized head and emitting `balances:updated`.
pub balance_watchers: Mutex<HashMap<ChainId, tauri::async_runtime::JoinHandle<()>>>,
/// Transactions prepared and awaiting the person's confirmation, by id.
/// A submit consumes one; a new prepare for the same purpose replaces
/// nothing, it just adds, and stale ones are refused by the chain.
pub prepared: Mutex<HashMap<String, PreparedTransaction>>,
}
impl AppState {
@@ -75,6 +80,7 @@ impl AppState {
profiles,
chains: Mutex::new(HashMap::new()),
balance_watchers: Mutex::new(HashMap::new()),
prepared: Mutex::new(HashMap::new()),
})
}
}
+1
View File
@@ -17,3 +17,4 @@ blake2.workspace = true
bs58.workspace = true
serde.workspace = true
toml.workspace = true
tokio.workspace = true
+55 -21
View File
@@ -3,7 +3,7 @@
use async_trait::async_trait;
use wallet_entities::{
AccountRef, Amount, AssetId, AssetInfo, ChainId, SignatureScheme, WalletSummary,
AccountRef, Amount, AssetId, AssetInfo, CallSummary, ChainId, SignatureScheme, WalletSummary,
};
use zeroize::Zeroizing;
@@ -134,39 +134,75 @@ pub enum KeystoreError {
Key(#[from] KeyError),
}
/// What the wallet wants done on a chain, in chain-neutral terms. The adapter
/// turns it into a transaction the chain understands.
/// What the wallet wants done on a chain, in chain-neutral terms. The
/// adapter turns it into a transaction the chain understands.
#[derive(Debug, Clone)]
pub enum TransferIntent {
/// Move `amount` of `asset` to `to`. `reversible` asks for a cancellation
/// window where the chain offers one; adapters without it must refuse
/// rather than silently send an irreversible transfer.
/// Move `amount` of `asset` to `to`. `reversible_for` asks for a
/// cancellation window of that many blocks where the chain offers one;
/// adapters without it must refuse rather than silently send an
/// irreversible transfer.
Transfer {
asset: AssetId,
to: String,
amount: Amount,
reversible: bool,
reversible_for: Option<u64>,
},
/// Cancel a pending reversible transfer the sender (or its guardian)
/// scheduled. `tx_id` is the chain's identifier for it.
CancelReversible { tx_id: [u8; 32] },
}
/// A transaction the user has reviewed, ready to sign. `summary` is what the
/// review screen showed, decoded by the adapter from the bytes, never from the
/// review screen shows, decoded by the adapter from the bytes, never from the
/// intent; the two must agree or the adapter has a bug worth a refusal.
#[derive(Debug, Clone)]
pub struct PreparedTransaction {
pub chain: ChainId,
pub from: [u8; 32],
pub scheme: SignatureScheme,
pub call_data: Vec<u8>,
pub summary: CallSummary,
pub fee: Amount,
pub nonce: u64,
/// The block the transaction is mortal from, and for how many blocks.
pub mortal_from: (u64, [u8; 32]),
pub mortal_for: u64,
pub signing_payload: Vec<u8>,
pub signing_context: Vec<u8>,
pub summary: String,
pub fee: Amount,
/// Runtime spec the payload was built against; refused at submit if the
/// chain has moved on, since the signature would no longer be valid.
pub spec_version: u32,
}
/// A signature the session produced for a prepared transaction.
#[derive(Debug, Clone)]
pub struct SignedBy {
pub scheme: SignatureScheme,
pub public_key: Vec<u8>,
pub signature: Vec<u8>,
}
/// Progress of a submitted transaction, in the order the chain reports it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TxStatus {
Pending,
InBlock { block: String },
Finalized { block: String },
Failed { reason: String },
Submitted,
InBlock {
block: [u8; 32],
},
Finalized {
block: [u8; 32],
},
/// Included and finalized, but the call failed; `reason` names the
/// pallet and error variant where the chain provides them.
Failed {
block: Option<[u8; 32]>,
reason: String,
},
/// Never made it: invalid, dropped, or the connection was lost.
Dropped {
reason: String,
},
}
/// One chain, as the wallet talks to it. Implementations own the RPC client
@@ -184,22 +220,20 @@ pub trait ChainAdapter: Send + Sync {
async fn balance(&self, account: &AccountRef, asset: &AssetId) -> Result<Amount, ChainError>;
/// Build the transaction, decode it back for review, and price it.
async fn prepare(
&self,
from: &AccountRef,
intent: &TransferIntent,
) -> Result<PreparedTransaction, ChainError>;
/// Attach a signature (with its public key, where the chain wants it on
/// the wire) and submit. Returns the chain's transaction identifier.
/// Assemble with the signature and submit. Statuses arrive on the
/// returned receiver until a terminal one. Returns the transaction hash.
async fn submit(
&self,
prepared: &PreparedTransaction,
signer: &dyn Signer,
signature: &[u8],
) -> Result<String, ChainError>;
async fn status(&self, tx_id: &str) -> Result<TxStatus, ChainError>;
signed: SignedBy,
) -> Result<([u8; 32], tokio::sync::mpsc::Receiver<TxStatus>), ChainError>;
}
#[derive(Debug, thiserror::Error)]
+1
View File
@@ -32,6 +32,7 @@ subxt.workspace = true
parity-scale-codec.workspace = true
hex.workspace = true
tokio.workspace = true
async-trait.workspace = true
[dev-dependencies]
tokio.workspace = true
+8 -2
View File
@@ -541,7 +541,7 @@ mod tests {
.filter(|n| n != "readme")
.collect();
names.sort();
assert_eq!(names.len(), 8, "{names:?}");
assert_eq!(names.len(), 10, "{names:?}");
names
}
@@ -558,6 +558,12 @@ mod tests {
SignatureScheme::MlDsa87
};
assert_eq!(opened.signer.scheme(), expected_scheme, "{name}");
if name.starts_with("crystal_") {
// Dev accounts: raw-seed keys, no phrase, path "m/".
assert_eq!(opened.derivation_path, "m/", "{name}");
assert!(opened.mnemonic.is_none(), "{name}");
continue;
}
assert!(
opened.derivation_path.starts_with("m/44'/189189'/"),
"{name}: {}",
@@ -598,7 +604,7 @@ mod tests {
fn lists_fixtures_without_decrypting_anything() {
let ks = FileKeystore::in_dir(fixtures());
let list = ks.list().unwrap();
assert_eq!(list.len(), 8);
assert_eq!(list.len(), 10);
assert!(
list.iter()
.all(|w| w.address.starts_with("qz") && w.kind == WalletKind::Hot)
+42 -3
View File
@@ -1,7 +1,12 @@
//! What the chain knows about an account: balance parts, nonce, and whether
//! it is a high-security account whose transfers must go through the
//! reversible-transfers pallet. Read from finalized state, so a number the
//! UI shows cannot be reorganised away.
//! reversible-transfers pallet.
//!
//! Read at the *best* block, with the block number reported alongside so
//! the UI can show how many confirmations deep the number is. On a
//! proof-of-work chain finality trails the tip by a hundred blocks; a
//! balance read at the finalized block would be twenty minutes stale on
//! mainnet, which is not what a person means by "my balance".
use subxt::dynamic::Value;
use subxt::ext::scale_value::At;
@@ -42,6 +47,8 @@ pub struct AccountInfo {
pub nonce: u64,
pub balance: Balance,
pub high_security: Option<HighSecurity>,
/// The best block the numbers were read at.
pub at_block: u64,
}
fn rpc_err(e: impl std::fmt::Display) -> ChainError {
@@ -62,11 +69,42 @@ fn u128_at(v: &Value<u32>, path: &[&str]) -> Result<u128, ChainError> {
/// Read `System::Account` and `ReversibleTransfers::HighSecurityAccounts`
/// at the latest finalized block. An account the chain has never seen has
/// zero balance and nonce zero, not an error.
/// Storage at the best block, and that block's number. `at_latest()` in
/// subxt means the finalized block, which on this chain is a hundred blocks
/// behind the tip; nothing the UI shows should be read there.
pub async fn storage_at_best(
conn: &ChainConnection,
) -> Result<
(
subxt::storage::Storage<
super::config::QuantusConfig,
subxt::OnlineClient<super::config::QuantusConfig>,
>,
u64,
),
ChainError,
> {
let header = conn
.rpc
.chain_get_header(None)
.await
.map_err(rpc_err)?
.ok_or_else(|| ChainError::Rpc("no best header".into()))?;
let at_block = u64::from(header.number);
let best_hash = conn
.rpc
.chain_get_block_hash(Some(at_block.into()))
.await
.map_err(rpc_err)?
.ok_or_else(|| ChainError::Rpc("best block has no hash".into()))?;
Ok((conn.client.storage().at(best_hash), at_block))
}
pub async fn account_info(
conn: &ChainConnection,
account: [u8; 32],
) -> Result<AccountInfo, ChainError> {
let storage = conn.client.storage().at_latest().await.map_err(rpc_err)?;
let (storage, at_block) = storage_at_best(conn).await?;
let addr = subxt::dynamic::storage("System", "Account", vec![Value::from_bytes(account)]);
let (nonce, balance) = match storage.fetch(&addr).await.map_err(rpc_err)? {
@@ -135,6 +173,7 @@ pub async fn account_info(
nonce,
balance,
high_security,
at_block,
})
}
File diff suppressed because it is too large Load Diff
+56 -12
View File
@@ -123,6 +123,7 @@ struct Observed {
endpoint: Option<String>,
spec_version: Option<u32>,
transaction_version: Option<u32>,
best_number: Option<u64>,
finalized_number: Option<u64>,
last_head: Option<Instant>,
last_error: Option<String>,
@@ -217,9 +218,9 @@ impl ChainManager {
})
}
/// A receiver that changes on every finalized head. Starts at 0 until
/// A receiver that changes on every best head. Starts at 0 until
/// the first head arrives.
pub fn finalized_heads(&self) -> tokio::sync::watch::Receiver<u64> {
pub fn best_heads(&self) -> tokio::sync::watch::Receiver<u64> {
self.shared.heads.subscribe()
}
@@ -231,6 +232,7 @@ impl ChainManager {
endpoint: o.endpoint,
spec_version: o.spec_version,
transaction_version: o.transaction_version,
best_number: o.best_number,
finalized_number: o.finalized_number,
head_age_seconds: o.last_head.map(|t| t.elapsed().as_secs()),
last_error: o.last_error,
@@ -259,14 +261,19 @@ impl Shared {
o.failures = o.failures.saturating_add(1);
}
fn note_head(&self, number: u64) {
fn note_best(&self, number: u64) {
let mut o = self.observed.write().expect("observed lock");
o.finalized_number = Some(number);
o.best_number = Some(number);
o.last_head = Some(Instant::now());
drop(o);
let _ = self.heads.send(number);
}
fn note_finalized(&self, number: u64) {
let mut o = self.observed.write().expect("observed lock");
o.finalized_number = Some(number);
}
fn note_runtime(&self, spec_version: u32, transaction_version: u32) {
let mut o = self.observed.write().expect("observed lock");
o.spec_version = Some(spec_version);
@@ -274,32 +281,66 @@ impl Shared {
}
}
/// Follow finalized heads and runtime upgrades until something breaks.
/// Follow best and finalized heads and runtime upgrades until something
/// breaks.
/// Returns why it stopped.
async fn follow(weak: &std::sync::Weak<Shared>, conn: &ChainConnection) -> String {
let mut best = match conn.client.blocks().subscribe_best().await {
Ok(s) => s,
Err(e) => return format!("subscribe best: {e}"),
};
let mut heads = match conn.client.blocks().subscribe_finalized().await {
Ok(s) => s,
Err(e) => return format!("subscribe finalized: {e}"),
};
// subxt's updater swaps metadata and runtime version inside the client
// when the chain upgrades; we only need to notice and record it.
// when the chain upgrades. Its subscription has been seen to die on a
// proof-of-work node with "header not found" after a reorg, so it is
// advisory: if it breaks, runtime versions are polled on each head
// instead, and a change forces a reconnect, which refetches metadata.
let updater = conn.client.updater();
let mut upgrades = match updater.runtime_updates().await {
Ok(s) => s,
Err(e) => return format!("subscribe runtime updates: {e}"),
Ok(s) => Some(s),
Err(e) => {
tracing::warn!(error = %e, "runtime update subscription unavailable; polling instead");
None
}
};
let connected_spec = conn.client.runtime_version().spec_version;
let mut heads_seen: u64 = 0;
let stale = Duration::from_secs(120);
loop {
tokio::select! {
head = best.next() => match head {
Some(Ok(block)) => {
let Some(shared) = weak.upgrade() else { return "manager dropped".into() };
shared.note_best(u64::from(block.number()));
heads_seen += 1;
if upgrades.is_none() && heads_seen % 10 == 0 {
if let Ok(rv) = conn.rpc.state_get_runtime_version(None).await {
if rv.spec_version != connected_spec {
return format!("runtime upgraded from spec {connected_spec} to {}; reconnecting", rv.spec_version);
}
}
}
}
Some(Err(e)) => return format!("best stream: {e}"),
None => return "best stream ended".into(),
},
head = heads.next() => match head {
Some(Ok(block)) => {
let Some(shared) = weak.upgrade() else { return "manager dropped".into() };
shared.note_head(u64::from(block.number()));
shared.note_finalized(u64::from(block.number()));
}
Some(Err(e)) => return format!("finalized stream: {e}"),
None => return "finalized stream ended".into(),
},
update = upgrades.next() => match update {
update = async {
match upgrades.as_mut() {
Some(u) => u.next().await,
None => std::future::pending().await,
}
} => match update {
Some(Ok(update)) => {
let version = *update.runtime_version();
match updater.apply_update(update) {
@@ -311,8 +352,11 @@ async fn follow(weak: &std::sync::Weak<Shared>, conn: &ChainConnection) -> Strin
Err(e) => tracing::warn!(error = ?e, "runtime update not applied"),
}
}
Some(Err(e)) => return format!("runtime updates stream: {e}"),
None => return "runtime updates stream ended".into(),
Some(Err(e)) => {
tracing::warn!(error = %e, "runtime update subscription broke; polling instead");
upgrades = None;
}
None => upgrades = None,
},
_ = tokio::time::sleep(stale) => return format!("no finalized head for {}s", stale.as_secs()),
}
+2
View File
@@ -4,11 +4,13 @@
//! the chain profile's context and never by subxt itself.
pub mod accounts;
pub mod adapter;
pub mod config;
pub mod connection;
pub mod offline;
pub use accounts::{AccountInfo, Balance, HighSecurity, account_info, next_nonce};
pub use adapter::SubstrateAdapter;
pub use config::{DilithiumSignature, QuantusConfig};
pub use connection::{ChainConnection, ChainManager};
pub use offline::{ChainState, SignedExtrinsic, sign_call};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,3 +15,8 @@ basket hold race lonely fit walk`), so nothing here is secret.
`keystore.rs` must open every one of them, and a wallet it writes must open in
the CLI. If the CLI's format changes, add the new file here rather than
editing an old one.
- `crystal_alice.json`, `crystal_bob.json`: the dev-chain accounts, written
by `quantus developer create-test-wallets` (CLI 2.2.2), empty password,
raw-seed ML-DSA-87 keys with no phrase. Funded on `quantus-node --dev`;
worthless anywhere else.
+150 -3
View File
@@ -68,8 +68,9 @@ pub struct ChainStatus {
pub endpoint: Option<String>,
pub spec_version: Option<u32>,
pub transaction_version: Option<u32>,
pub best_number: Option<u64>,
pub finalized_number: Option<u64>,
/// Seconds since the last finalized head arrived; `None` when never.
/// Seconds since the last best head arrived; `None` when never.
pub head_age_seconds: Option<u64>,
/// The last error, when disconnected.
pub last_error: Option<String>,
@@ -102,8 +103,92 @@ pub struct AccountBalance {
pub total: Amount,
pub nonce: u64,
pub high_security: Option<HighSecurityInfo>,
/// The finalized block the numbers were read at.
pub at_block: Option<u64>,
/// The best block the numbers were read at.
pub at_block: u64,
/// The chain's finalized height at the time, so the UI can say how
/// many confirmations deep `at_block` is.
pub finalized_block: Option<u64>,
}
/// A call decoded from its bytes for the review screen: the pallet, the
/// call, and each argument rendered for a person (addresses as SS58,
/// amounts in the asset's units, everything else as the chain names it).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CallSummary {
pub pallet: String,
pub call: String,
pub fields: Vec<CallField>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CallField {
pub name: String,
pub value: String,
}
/// A reversible transfer still waiting to execute.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PendingReversible {
pub tx_id: String,
pub from: String,
pub to: String,
pub guardian: String,
pub amount: Amount,
/// When it executes, as the scheduler records it: a block number, or a
/// timestamp in milliseconds, whichever the delay was given in.
pub execute_at_block: Option<u64>,
pub execute_at_ms: Option<u64>,
/// Whether the open wallet's account may cancel it (sender for a
/// one-time delay, guardian for a high-security account).
pub cancellable_by_me: bool,
}
/// A transaction prepared for review: what will be signed, decoded from
/// the bytes, and what it costs. `id` is handed back to submit it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PreparedTransferInfo {
pub id: String,
pub chain: ChainId,
pub from: String,
pub summary: CallSummary,
pub fee: Amount,
pub nonce: u64,
/// Blocks the transaction stays valid for from the block it was built at.
pub valid_for_blocks: u64,
}
/// One step in a submitted transaction's life, as emitted on `tx:status`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(tag = "stage", rename_all = "snake_case")]
#[ts(export)]
pub enum TxStage {
Submitted,
InBlock {
block: String,
},
Finalized {
block: String,
},
Failed {
block: Option<String>,
reason: String,
},
Dropped {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TxStatusInfo {
/// The prepared transaction's id.
pub id: String,
pub hash: String,
pub stage: TxStage,
}
/// What kind of thing an asset is on its chain.
@@ -155,6 +240,49 @@ impl Amount {
Self { raw, decimals }
}
/// Parse the human form exactly: `1.2345` at 12 decimals is
/// `1234500000000`. More fractional digits than the asset has, a sign,
/// an exponent, or anything that is not digits and one point is refused.
pub fn parse(text: &str, decimals: u8) -> Result<Self, String> {
let t = text.trim();
if t.is_empty() {
return Err("empty amount".into());
}
let (whole, frac) = match t.split_once('.') {
Some((w, f)) => (w, f),
None => (t, ""),
};
if whole.is_empty() && frac.is_empty() {
return Err("no digits".into());
}
if !whole.chars().all(|c| c.is_ascii_digit()) || !frac.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("{t:?} is not a plain decimal number"));
}
if frac.len() > usize::from(decimals) {
return Err(format!(
"at most {decimals} decimal places, {t:?} has {}",
frac.len()
));
}
let scale = 10u128.pow(u32::from(decimals));
let whole_n: u128 = if whole.is_empty() {
0
} else {
whole.parse().map_err(|_| "too large".to_string())?
};
let mut frac_n: u128 = if frac.is_empty() {
0
} else {
frac.parse().map_err(|_| "too large".to_string())?
};
frac_n *= 10u128.pow(u32::from(decimals) - frac.len() as u32);
let raw = whole_n
.checked_mul(scale)
.and_then(|w| w.checked_add(frac_n))
.ok_or("too large")?;
Ok(Self { raw, decimals })
}
/// The human form, exact: `1234500000000` at 12 decimals is `1.2345`.
pub fn to_decimal_string(&self) -> String {
let scale = 10u128.pow(u32::from(self.decimals));
@@ -314,6 +442,25 @@ mod tests {
assert_eq!(Amount::new(0, 12).to_decimal_string(), "0");
}
#[test]
fn amount_parses_exactly_and_refuses_what_it_cannot_represent() {
assert_eq!(Amount::parse("1.2345", 12).unwrap().raw, 1_234_500_000_000);
assert_eq!(Amount::parse("7", 12).unwrap().raw, 7_000_000_000_000);
assert_eq!(Amount::parse(".5", 12).unwrap().raw, 500_000_000_000);
assert_eq!(Amount::parse("0.000000000001", 12).unwrap().raw, 1);
assert!(Amount::parse("0.0000000000001", 12).is_err());
assert!(Amount::parse("-1", 12).is_err());
assert!(Amount::parse("1e3", 12).is_err());
assert!(Amount::parse("1,000", 12).is_err());
assert!(Amount::parse("", 12).is_err());
assert!(Amount::parse(".", 12).is_err());
let round = Amount::new(u128::MAX / 2, 12);
assert_eq!(
Amount::parse(&round.to_decimal_string(), 12).unwrap(),
round
);
}
#[test]
fn amount_survives_json_as_a_string() {
let a = Amount::new(u128::MAX, 12);
+7 -2
View File
@@ -16,6 +16,11 @@ spendable: Amount,
*/
total: Amount, nonce: bigint, high_security: HighSecurityInfo | null,
/**
* The finalized block the numbers were read at.
* The best block the numbers were read at.
*/
at_block: bigint | null, };
at_block: bigint,
/**
* The chain's finalized height at the time, so the UI can say how
* many confirmations deep `at_block` is.
*/
finalized_block: bigint | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CallField = { name: string, value: string, };
+9
View File
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CallField } from "./CallField";
/**
* A call decoded from its bytes for the review screen: the pallet, the
* call, and each argument rendered for a person (addresses as SS58,
* amounts in the asset's units, everything else as the chain names it).
*/
export type CallSummary = { pallet: string, call: string, fields: Array<CallField>, };
+2 -2
View File
@@ -9,9 +9,9 @@ export type ChainStatus = { chain: ChainId, connected: boolean,
/**
* The endpoint currently in use, or the last one tried.
*/
endpoint: string | null, spec_version: number | null, transaction_version: number | null, finalized_number: bigint | null,
endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: bigint | null, finalized_number: bigint | null,
/**
* Seconds since the last finalized head arrived; `None` when never.
* Seconds since the last best head arrived; `None` when never.
*/
head_age_seconds: bigint | null,
/**
+17
View File
@@ -0,0 +1,17 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
/**
* A reversible transfer still waiting to execute.
*/
export type PendingReversible = { tx_id: string, from: string, to: string, guardian: string, amount: Amount,
/**
* When it executes, as the scheduler records it: a block number, or a
* timestamp in milliseconds, whichever the delay was given in.
*/
execute_at_block: bigint | null, execute_at_ms: bigint | null,
/**
* Whether the open wallet's account may cancel it (sender for a
* one-time delay, guardian for a high-security account).
*/
cancellable_by_me: boolean, };
@@ -0,0 +1,14 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
import type { CallSummary } from "./CallSummary";
import type { ChainId } from "./ChainId";
/**
* A transaction prepared for review: what will be signed, decoded from
* the bytes, and what it costs. `id` is handed back to submit it.
*/
export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: bigint,
/**
* Blocks the transaction stays valid for from the block it was built at.
*/
valid_for_blocks: bigint, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One step in a submitted transaction's life, as emitted on `tx:status`.
*/
export type TxStage = { "stage": "submitted" } | { "stage": "in_block", block: string, } | { "stage": "finalized", block: string, } | { "stage": "failed", block: string | null, reason: string, } | { "stage": "dropped", reason: string, };
+8
View File
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TxStage } from "./TxStage";
export type TxStatusInfo = {
/**
* The prepared transaction's id.
*/
id: string, hash: string, stage: TxStage, };
+41
View File
@@ -9,6 +9,8 @@ import type { AppInfo } from './generated/AppInfo'
import type { ChainId } from './generated/ChainId'
import type { ChainProfile } from './generated/ChainProfile'
import type { ChainStatus } from './generated/ChainStatus'
import type { PendingReversible } from './generated/PendingReversible'
import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
import type { CreationStart } from './generated/CreationStart'
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
import type { SignatureScheme } from './generated/SignatureScheme'
@@ -128,3 +130,42 @@ export function balances(chain: ChainId): Promise<AccountBalance[]> {
export function balancesWatch(chain: ChainId): Promise<void> {
return command<void>('balances_watch', { chain })
}
// Sending is two steps so the review screen shows what Rust decoded from the
// bytes: prepare returns the summary and fee under an id, submit signs and
// sends that id. Progress arrives as `tx:status` events.
export function transferPrepare(
chain: ChainId,
from: string,
to: string,
amount: string,
reversibleFor?: number,
): Promise<PreparedTransferInfo> {
return command<PreparedTransferInfo>('transfer_prepare', {
chain,
from,
to,
amount,
reversibleFor: reversibleFor ?? null,
})
}
export function transferSubmit(id: string): Promise<string> {
return command<string>('transfer_submit', { id })
}
export function transferDiscard(id: string): Promise<void> {
return command<void>('transfer_discard', { id })
}
export function reversiblePending(chain: ChainId, address: string): Promise<PendingReversible[]> {
return command<PendingReversible[]>('reversible_pending', { chain, address })
}
export function reversibleCancelPrepare(
chain: ChainId,
from: string,
txId: string,
): Promise<PreparedTransferInfo> {
return command<PreparedTransferInfo>('reversible_cancel_prepare', { chain, from, txId })
}