feat(substrate): transfers prepared for review, priced, signed by the session, submitted and watched
ci / gate (push) Waiting to run
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:
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,4 @@ blake2.workspace = true
|
||||
bs58.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user