From dc3a87c0e6fbe98edcc6244b923ffaaeb76807ed Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 08:32:04 +0300 Subject: [PATCH] feat(ui): pending reversible transfers with a measured countdown and a cancel The accounts page lists PendingTransfersBySender for every account of the open wallet, refreshed every five seconds, with when each executes: at a block, counted down at the block interval the chain manager now measures over its last 32 best heads (exposed as ChainStatus.block_interval_ms, with the profile's nominal time as the fallback and labelled as such), or at a timestamp, counted down by the clock. Cancel prepares ReversibleTransfers.cancel and goes through the same review pane as a send, which moves out of the send route into ReviewAndSign. The call decoder now renders a 32-byte argument by its metadata type: an AccountId32 as SS58, anything else as hex. Before, a cancel's tx_id was dressed up as an address. Unit tests cover both the interval and the rendering; the remaining Option fields are typed `number | null`. On the dev node: a 2 DEV transfer with a 60-block window listed at block 314 with "49 blocks, about 52 s" against a measured 1.1 s interval; a 3 DEV transfer with a 1000-block window cancelled through review, the tx_id shown as hex, and the balance back up by 3 less the fee. Block 314 arrived 14 s after the estimate: the dev chain's interval swung between 0.6 and 1.7 s per ten blocks over that minute. See the issue for what that means for the acceptance criterion. Closes #29 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW --- crates/wallet-app/src/commands.rs | 1 + crates/wallet-data/src/substrate/adapter.rs | 57 ++++- .../wallet-data/src/substrate/connection.rs | 48 ++++- crates/wallet-entities/src/lib.rs | 15 +- ui/src/api/generated/ChainStatus.ts | 10 +- ui/src/api/generated/HighSecurityInfo.ts | 2 +- ui/src/api/hooks.ts | 17 ++ ui/src/components/PendingReversible.tsx | 201 ++++++++++++++++++ ui/src/components/ReviewAndSign.tsx | 101 +++++++++ ui/src/routes/Accounts.tsx | 10 +- ui/src/routes/Send.tsx | 92 ++------ 11 files changed, 458 insertions(+), 96 deletions(-) create mode 100644 ui/src/components/PendingReversible.tsx create mode 100644 ui/src/components/ReviewAndSign.tsx diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index 0697b0e..3aa874a 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -278,6 +278,7 @@ pub fn chain_status( best_number: None, finalized_number: None, head_age_seconds: None, + block_interval_ms: None, last_error: None, failures: 0, }), diff --git a/crates/wallet-data/src/substrate/adapter.rs b/crates/wallet-data/src/substrate/adapter.rs index bebf28e..c33ca6c 100644 --- a/crates/wallet-data/src/substrate/adapter.rs +++ b/crates/wallet-data/src/substrate/adapter.rs @@ -100,7 +100,7 @@ impl SubstrateAdapter { Composite::Named(named) => named .into_iter() .map(|(name, v)| CallField { - value: self.render(&name, &v), + value: self.render(metadata, &name, &v), name, }) .collect(), @@ -108,7 +108,7 @@ impl SubstrateAdapter { .into_iter() .enumerate() .map(|(i, v)| CallField { - value: self.render("", &v), + value: self.render(metadata, "", &v), name: i.to_string(), }) .collect(), @@ -120,17 +120,28 @@ impl SubstrateAdapter { }) } - /// Render one argument: a 32-byte account (bare or inside `Id`) as SS58, - /// an amount-named integer in the token's units, anything else as the - /// chain names it. - fn render(&self, name: &str, v: &Value) -> String { + /// Render one argument: an `AccountId32` (bare or inside a + /// `MultiAddress::Id`) as SS58, any other 32 bytes (a hash, a + /// transaction id) as hex, an amount-named integer in the token's + /// units, anything else as the chain names it. The decision is by the + /// field's type in metadata, not by its shape: a transaction id is + /// also 32 bytes and must not be dressed up as an address. + fn render(&self, metadata: &subxt::Metadata, name: &str, v: &Value) -> String { + let type_name = metadata + .types() + .resolve(v.context) + .and_then(|t| t.path.segments.last().cloned()); if let Some(bytes) = as_account_bytes(v) { - return ss58::encode(self.profile().ss58_prefix, &bytes); + return if type_name.as_deref() == Some("AccountId32") { + ss58::encode(self.profile().ss58_prefix, &bytes) + } else { + format!("0x{}", hex::encode(bytes)) + }; } if let ValueDef::Variant(var) = &v.value { if var.name == "Id" { - if let Some(bytes) = var.values.values().next().and_then(as_account_bytes) { - return ss58::encode(self.profile().ss58_prefix, &bytes); + if let Some(inner) = var.values.values().next() { + return self.render(metadata, name, inner); } } } @@ -688,6 +699,34 @@ mod tests { SubstrateAdapter::new(ChainManager::start(p)) } + #[tokio::test] + async fn a_transaction_id_renders_as_hex_not_as_an_address() { + let a = adapter_for("quantus"); + let state = mainnet_state(); + let call = subxt::dynamic::tx( + "ReversibleTransfers", + "cancel", + vec![Value::from_bytes([7u8; 32])], + ); + let client = subxt::OfflineClient::::new( + state.genesis_hash, + subxt::client::RuntimeVersion { + spec_version: 152, + transaction_version: 6, + }, + state.metadata.clone(), + ); + let bytes = client.tx().call_data(&call).unwrap(); + let summary = a.decode_call(&state, &bytes).unwrap(); + assert_eq!(summary.call, "cancel"); + assert_eq!(summary.fields[0].name, "tx_id"); + assert_eq!( + summary.fields[0].value, + format!("0x{}", "07".repeat(32)), + "a tx id is not an account" + ); + } + #[tokio::test] async fn decodes_a_transfer_call_into_a_summary_a_person_can_check() { let a = adapter_for("quantus"); diff --git a/crates/wallet-data/src/substrate/connection.rs b/crates/wallet-data/src/substrate/connection.rs index b41b391..49c494e 100644 --- a/crates/wallet-data/src/substrate/connection.rs +++ b/crates/wallet-data/src/substrate/connection.rs @@ -126,10 +126,29 @@ struct Observed { best_number: Option, finalized_number: Option, last_head: Option, + /// Recent best heads and when they arrived, oldest first, for the + /// measured block interval. Bounded by `HEAD_SAMPLES`. + head_times: std::collections::VecDeque<(u64, Instant)>, last_error: Option, failures: u32, } +/// How many best heads the block interval is measured over. +const HEAD_SAMPLES: usize = 32; + +impl Observed { + /// Milliseconds per block over the sampled heads: the span between the + /// oldest and newest arrival divided by the blocks between them. Needs + /// two heads with different numbers. + fn block_interval_ms(&self) -> Option { + let (first_n, first_t) = *self.head_times.front()?; + let (last_n, last_t) = *self.head_times.back()?; + let blocks = last_n.checked_sub(first_n).filter(|b| *b > 0)?; + let ms = last_t.duration_since(first_t).as_millis() as u64; + Some(ms / blocks) + } +} + struct Shared { profile: ChainProfile, connection: RwLock>, @@ -226,6 +245,7 @@ impl ChainManager { pub fn status(&self) -> ChainStatus { let o = self.shared.observed.read().expect("observed lock").clone(); + let block_interval_ms = o.block_interval_ms(); ChainStatus { chain: self.shared.profile.id.clone(), connected: o.connected, @@ -235,6 +255,7 @@ impl ChainManager { best_number: o.best_number, finalized_number: o.finalized_number, head_age_seconds: o.last_head.map(|t| t.elapsed().as_secs()), + block_interval_ms, last_error: o.last_error, failures: o.failures, } @@ -263,8 +284,16 @@ impl Shared { fn note_best(&self, number: u64) { let mut o = self.observed.write().expect("observed lock"); + let now = Instant::now(); o.best_number = Some(number); - o.last_head = Some(Instant::now()); + o.last_head = Some(now); + // A head repeated or out of order (a reorg) says nothing about pace. + if o.head_times.back().is_none_or(|(n, _)| number > *n) { + o.head_times.push_back((number, now)); + while o.head_times.len() > HEAD_SAMPLES { + o.head_times.pop_front(); + } + } drop(o); let _ = self.heads.send(number); } @@ -367,6 +396,23 @@ async fn follow(weak: &std::sync::Weak, conn: &ChainConnection) -> Strin mod tests { use super::*; + #[test] + fn block_interval_is_the_span_over_the_blocks_it_covers() { + let mut o = Observed::default(); + assert_eq!(o.block_interval_ms(), None); + let t0 = Instant::now(); + o.head_times.push_back((10, t0)); + assert_eq!(o.block_interval_ms(), None, "one head is no interval"); + o.head_times.push_back((10, t0 + Duration::from_secs(5))); + assert_eq!( + o.block_interval_ms(), + None, + "the same head twice is no interval" + ); + o.head_times.push_back((14, t0 + Duration::from_secs(20))); + assert_eq!(o.block_interval_ms(), Some(5_000)); + } + #[test] fn backoff_doubles_caps_and_jitters_within_half_to_full() { let mut b = Backoff::new(Duration::from_secs(1), Duration::from_secs(30)); diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index d0f949c..2fce92a 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -74,13 +74,18 @@ pub struct ChainStatus { pub endpoint: Option, pub spec_version: Option, pub transaction_version: Option, - #[ts(type = "number")] + #[ts(type = "number | null")] pub best_number: Option, - #[ts(type = "number")] + #[ts(type = "number | null")] pub finalized_number: Option, /// Seconds since the last best head arrived; `None` when never. - #[ts(type = "number")] + #[ts(type = "number | null")] pub head_age_seconds: Option, + /// The block interval as measured from recent best heads, for + /// countdowns; `None` until enough heads have arrived. The profile's + /// nominal block time is the fallback, not the answer. + #[ts(type = "number | null")] + pub block_interval_ms: Option, /// The last error, when disconnected. pub last_error: Option, /// How many consecutive attempts have failed. @@ -92,9 +97,9 @@ pub struct ChainStatus { #[ts(export)] pub struct HighSecurityInfo { pub guardian: String, - #[ts(type = "number")] + #[ts(type = "number | null")] pub delay_blocks: Option, - #[ts(type = "number")] + #[ts(type = "number | null")] pub delay_ms: Option, } diff --git a/ui/src/api/generated/ChainStatus.ts b/ui/src/api/generated/ChainStatus.ts index 031c69d..1993f90 100644 --- a/ui/src/api/generated/ChainStatus.ts +++ b/ui/src/api/generated/ChainStatus.ts @@ -9,11 +9,17 @@ 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, best_number: number, finalized_number: number, +endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: number | null, finalized_number: number | null, /** * Seconds since the last best head arrived; `None` when never. */ -head_age_seconds: number, +head_age_seconds: number | null, +/** + * The block interval as measured from recent best heads, for + * countdowns; `None` until enough heads have arrived. The profile's + * nominal block time is the fallback, not the answer. + */ +block_interval_ms: number | null, /** * The last error, when disconnected. */ diff --git a/ui/src/api/generated/HighSecurityInfo.ts b/ui/src/api/generated/HighSecurityInfo.ts index 7d5930f..7bc378c 100644 --- a/ui/src/api/generated/HighSecurityInfo.ts +++ b/ui/src/api/generated/HighSecurityInfo.ts @@ -3,4 +3,4 @@ /** * A high-security account's guardian and cancellation delay. */ -export type HighSecurityInfo = { guardian: string, delay_blocks: number, delay_ms: number, }; +export type HighSecurityInfo = { guardian: string, delay_blocks: number | null, delay_ms: number | null, }; diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts index 65b8b91..0c5feb5 100644 --- a/ui/src/api/hooks.ts +++ b/ui/src/api/hooks.ts @@ -14,6 +14,7 @@ import { chainStatus, chainsList, lock, + reversiblePending, sessionStatus, settingsGet, settingsSet, @@ -29,6 +30,7 @@ export const queryKeys = { wallets: ['wallets'] as const, chainStatus: (chain: ChainId) => ['chain-status', chain] as const, balances: (chain: ChainId) => ['balances', chain] as const, + pendingReversible: (chain: ChainId) => ['pending-reversible', chain] as const, } export function useAppInfo() { @@ -113,3 +115,18 @@ export function useSessionLockedListener() { return subscribe(onSessionLocked(() => qc.invalidateQueries({ queryKey: queryKeys.session }))) }, [qc]) } + +/// Reversible transfers still pending from any of `addresses` on `chain`. +export function usePendingReversible(chain: ChainId | undefined, addresses: string[]) { + return useQuery({ + queryKey: chain + ? [...queryKeys.pendingReversible(chain), addresses] + : ['pending-reversible', 'none'], + queryFn: async () => { + const lists = await Promise.all(addresses.map((a) => reversiblePending(chain!, a))) + return lists.flat() + }, + enabled: !!chain && addresses.length > 0, + refetchInterval: 5_000, + }) +} diff --git a/ui/src/components/PendingReversible.tsx b/ui/src/components/PendingReversible.tsx new file mode 100644 index 0000000..894b48b --- /dev/null +++ b/ui/src/components/PendingReversible.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import type { ChainId } from '../api/generated/ChainId' +import type { ChainProfile } from '../api/generated/ChainProfile' +import type { PendingReversible as Pending } from '../api/generated/PendingReversible' +import type { PreparedTransferInfo } from '../api/generated/PreparedTransferInfo' +import { queryKeys, useChainStatus, usePendingReversible, useSettings } from '../api/hooks' +import { reversibleCancelPrepare } from '../api/wallet' +import { formatAmount } from '../lib/format' +import { ReviewAndSign } from './ReviewAndSign' +import { TxProgress } from './TxProgress' + +/** + * Reversible transfers still waiting to execute, from the open wallet's + * accounts, with a countdown from the chain's measured block interval and + * a cancel that goes through the same review as a send. + */ +export function PendingReversibleList({ + chain, + profile, + addresses, +}: { + chain: ChainId + profile: ChainProfile + addresses: string[] +}) { + const qc = useQueryClient() + const settings = useSettings() + const status = useChainStatus(chain) + const pending = usePendingReversible(chain, addresses) + const [prepared, setPrepared] = useState(null) + const [submitted, setSubmitted] = useState<{ id: string; hash: string } | null>(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(null) + + async function cancel(p: Pending) { + setError(null) + setBusy(p.tx_id) + try { + // The sender cancels its own; a guardian cancels for its ward. Both + // are accounts of this wallet, or the button is not offered. + const from = addresses.includes(p.guardian) ? p.guardian : p.from + setPrepared(await reversibleCancelPrepare(chain, from, p.tx_id)) + } catch (err) { + setError((err as Error).message) + } finally { + setBusy(null) + } + } + + if (pending.isPending || (pending.isSuccess && pending.data.length === 0 && !submitted)) { + return null + } + return ( +
+
+

Pending reversible transfers

+ {status.data?.block_interval_ms != null && ( + + measured block interval{' '} + {(status.data.block_interval_ms / 1000).toFixed(1)} s + + )} +
+ {prepared ? ( +
+ { + setPrepared(null) + setSubmitted(s) + }} + onBack={() => setPrepared(null)} + /> +
+ ) : submitted ? ( +
+ +
+ +
+
+ ) : ( + <> + {pending.isError &&

{pending.error.message}

} + {error &&

{error}

} + {pending.data && pending.data.length > 0 && ( + + + + + + + + + + + + {pending.data.map((p) => ( + + + + + + + + ))} + +
FromToAmountExecutes
{settings.data?.account_names[p.from] ?? p.from}{settings.data?.account_names[p.to] ?? p.to} + {formatAmount(p.amount)} {profile.token_symbol} + + + + {p.cancellable_by_me && ( + + )} +
+ )} + + )} +
+ ) +} + +/** + * When a pending transfer executes: at a block, counted down at the + * measured block interval, or at a timestamp, counted down by the clock. + */ +function Countdown({ + pending, + best, + intervalMs, + measured, +}: { + pending: Pending + best: number | null + intervalMs: number + measured: boolean +}) { + const [now, setNow] = useState(Date.now()) + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(t) + }, []) + if (pending.execute_at_ms != null) { + const left = Math.max(0, pending.execute_at_ms - now) + return ( + + in {describeMs(left)} + + ) + } + if (pending.execute_at_block == null) return unknown + if (best == null) { + return ( + + at block {pending.execute_at_block} + + ) + } + const blocks = Math.max(0, pending.execute_at_block - best) + const left = blocks * intervalMs + return ( + + block {pending.execute_at_block},{' '} + {blocks} blocks, {measured ? 'about' : 'nominally'}{' '} + {describeMs(left)} + + ) +} + +function describeMs(ms: number): string { + const s = Math.round(ms / 1000) + if (s < 60) return `${s} s` + if (s < 3600) return `${Math.floor(s / 60)} min ${s % 60} s` + if (s < 86400) return `${(s / 3600).toFixed(1)} h` + return `${(s / 86400).toFixed(1)} days` +} diff --git a/ui/src/components/ReviewAndSign.tsx b/ui/src/components/ReviewAndSign.tsx new file mode 100644 index 0000000..8cd2ef3 --- /dev/null +++ b/ui/src/components/ReviewAndSign.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react' +import type { ChainProfile } from '../api/generated/ChainProfile' +import type { PreparedTransferInfo } from '../api/generated/PreparedTransferInfo' +import { useSettings } from '../api/hooks' +import { transferDiscard, transferSubmit } from '../api/wallet' +import { describeBlocks } from '../lib/blocks' +import { formatAmount } from '../lib/format' + +/** + * The review pane every signed action goes through. Shows what Rust + * decoded from the bytes it will sign, from `PreparedTransferInfo`, never + * from whatever form produced it. Submit hands back only the prepared id. + */ +export function ReviewAndSign({ + prepared, + profile, + onSubmitted, + onBack, +}: { + prepared: PreparedTransferInfo + profile: ChainProfile + onSubmitted: (submitted: { id: string; hash: string }) => void + onBack: () => void +}) { + const settings = useSettings() + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function confirm() { + setError(null) + setBusy(true) + try { + const hash = await transferSubmit(prepared.id) + onSubmitted({ id: prepared.id, hash }) + } catch (err) { + setError((err as Error).message) + // A refused submit dropped the prepared transaction on the Rust side. + onBack() + } finally { + setBusy(false) + } + } + + async function back() { + await transferDiscard(prepared.id).catch(() => undefined) + onBack() + } + + return ( +
+

+ Decoded from the bytes that will be signed. Check every value against what you meant. +

+ + + + + + + + + + + {prepared.summary.fields.map((f) => ( + + + + + ))} + + + + + + + + + +
From + {settings.data?.account_names[prepared.from] ?? ''} {prepared.from} +
Call + {prepared.summary.pallet}.{prepared.summary.call} +
{f.name}{f.value}
Fee + {formatAmount(prepared.fee)} {profile.token_symbol} +
Valid for + {prepared.valid_for_blocks} blocks ( + {describeBlocks(prepared.valid_for_blocks, profile.block_time_ms)}), nonce{' '} + {prepared.nonce} +
+ {error &&

{error}

} +
+ + +
+
+ ) +} diff --git a/ui/src/routes/Accounts.tsx b/ui/src/routes/Accounts.tsx index 7b45983..ec3eb22 100644 --- a/ui/src/routes/Accounts.tsx +++ b/ui/src/routes/Accounts.tsx @@ -1,6 +1,7 @@ -import { useBalances, useSession, useSettings } from '../api/hooks' +import { useBalances, useChains, useSession, useSettings } from '../api/hooks' import { AccountName } from '../components/AccountName' import { ChainCaveat } from '../components/ChainCaveat' +import { PendingReversibleList } from '../components/PendingReversible' import { formatAmount } from '../lib/format' /** @@ -13,6 +14,10 @@ export function Accounts() { const session = useSession() const unlocked = !!session.data && !session.data.locked const balances = useBalances(settings.data?.network, unlocked) + const chains = useChains() + const chain = settings.data?.network + const profile = chains.data?.find((c) => c.id === chain) + const addresses = session.data?.accounts.map((a) => a.address) ?? [] return ( <> @@ -72,6 +77,9 @@ export function Accounts() { )} + {chain && profile && unlocked && ( + + )} ) } diff --git a/ui/src/routes/Send.tsx b/ui/src/routes/Send.tsx index 669db49..2fd9b45 100644 --- a/ui/src/routes/Send.tsx +++ b/ui/src/routes/Send.tsx @@ -1,8 +1,9 @@ import { useState, type FormEvent } from 'react' import { useBalances, useChains, useSession, useSettings } from '../api/hooks' import type { PreparedTransferInfo } from '../api/generated/PreparedTransferInfo' -import { transferDiscard, transferPrepare, transferSubmit } from '../api/wallet' +import { transferPrepare } from '../api/wallet' import { ChainCaveat } from '../components/ChainCaveat' +import { ReviewAndSign } from '../components/ReviewAndSign' import { TxProgress } from '../components/TxProgress' import { describeBlocks } from '../lib/blocks' import { formatAmount } from '../lib/format' @@ -10,10 +11,9 @@ import { formatAmount } from '../lib/format' const WINDOW_PRESETS = [100, 1000, 10000] /** - * Three panes. The form collects text. Review shows what Rust decoded - * from the bytes it will sign: recipient, amount, fee and window come from - * `PreparedTransferInfo`, never from the form. Submit hands back only the - * prepared id, so nothing the form holds can reach the signature. + * Three panes. The form collects text; the review pane shows what Rust + * decoded from the bytes it will sign and submits by prepared id alone, so + * nothing the form holds can reach the signature; then the progress. */ export function Send() { const settings = useSettings() @@ -59,28 +59,6 @@ export function Send() { } } - async function confirm() { - if (!prepared) return - setError(null) - setBusy(true) - try { - const hash = await transferSubmit(prepared.id) - setSubmitted({ id: prepared.id, hash }) - setPrepared(null) - } catch (err) { - setError((err as Error).message) - // A refused submit drops the prepared transaction on the Rust side. - setPrepared(null) - } finally { - setBusy(false) - } - } - - async function back() { - if (prepared) await transferDiscard(prepared.id).catch(() => undefined) - setPrepared(null) - } - function reset() { setSubmitted(null) setTo('') @@ -116,56 +94,16 @@ export function Send() { ) : prepared ? ( -
-

- Decoded from the bytes that will be signed. Check the recipient against the address - you were given. -

- - - - - - - - - - - {prepared.summary.fields.map((f) => ( - - - - - ))} - - - - - - - - - -
From - {settings.data?.account_names[prepared.from] ?? ''} {prepared.from} -
Call - {prepared.summary.pallet}.{prepared.summary.call} -
{f.name}{f.value}
Fee - {formatAmount(prepared.fee)} {profile.token_symbol} -
Valid for - {prepared.valid_for_blocks} blocks ( - {describeBlocks(prepared.valid_for_blocks, profile.block_time_ms)}), nonce{' '} - {prepared.nonce} -
- {error &&

{error}

} -
- - -
+
+ { + setPrepared(null) + setSubmitted(s) + }} + onBack={() => setPrepared(null)} + />
) : (