feat(ui): pending reversible transfers with a measured countdown and a cancel
Some checks are pending
ci / gate (push) Waiting to run

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<u64> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
2026-09-16 08:32:04 +03:00
parent 52774f1d88
commit dc3a87c0e6
11 changed files with 458 additions and 96 deletions

View File

@@ -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,
}),

View File

@@ -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<u32>) -> 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<u32>) -> 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::<super::super::config::QuantusConfig>::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");

View File

@@ -126,10 +126,29 @@ struct Observed {
best_number: Option<u64>,
finalized_number: Option<u64>,
last_head: Option<Instant>,
/// 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<String>,
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<u64> {
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<Option<ChainConnection>>,
@@ -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<Shared>, 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));

View File

@@ -74,13 +74,18 @@ pub struct ChainStatus {
pub endpoint: Option<String>,
pub spec_version: Option<u32>,
pub transaction_version: Option<u32>,
#[ts(type = "number")]
#[ts(type = "number | null")]
pub best_number: Option<u64>,
#[ts(type = "number")]
#[ts(type = "number | null")]
pub finalized_number: Option<u64>,
/// Seconds since the last best head arrived; `None` when never.
#[ts(type = "number")]
#[ts(type = "number | null")]
pub head_age_seconds: Option<u64>,
/// 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<u64>,
/// The last error, when disconnected.
pub last_error: Option<String>,
/// 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<u64>,
#[ts(type = "number")]
#[ts(type = "number | null")]
pub delay_ms: Option<u64>,
}

View File

@@ -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.
*/

View File

@@ -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, };

View File

@@ -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,
})
}

View File

@@ -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<PreparedTransferInfo | null>(null)
const [submitted, setSubmitted] = useState<{ id: string; hash: string } | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(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 (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Pending reversible transfers</h2>
{status.data?.block_interval_ms != null && (
<span className="muted" style={{ fontSize: 12 }}>
measured block interval{' '}
<span className="numeral">{(status.data.block_interval_ms / 1000).toFixed(1)} s</span>
</span>
)}
</div>
{prepared ? (
<div className="panel-body">
<ReviewAndSign
prepared={prepared}
profile={profile}
onSubmitted={(s) => {
setPrepared(null)
setSubmitted(s)
}}
onBack={() => setPrepared(null)}
/>
</div>
) : submitted ? (
<div className="panel-body">
<TxProgress id={submitted.id} hash={submitted.hash} chain={chain} />
<div className="row">
<button
className="button"
type="button"
onClick={() => {
setSubmitted(null)
qc.invalidateQueries({ queryKey: queryKeys.pendingReversible(chain) })
qc.invalidateQueries({ queryKey: queryKeys.balances(chain) })
}}
>
Done
</button>
</div>
</div>
) : (
<>
{pending.isError && <p className="error">{pending.error.message}</p>}
{error && <p className="error">{error}</p>}
{pending.data && pending.data.length > 0 && (
<table className="table">
<thead>
<tr>
<th>From</th>
<th>To</th>
<th className="num">Amount</th>
<th>Executes</th>
<th></th>
</tr>
</thead>
<tbody>
{pending.data.map((p) => (
<tr key={p.tx_id}>
<td className="mono">{settings.data?.account_names[p.from] ?? p.from}</td>
<td className="mono">{settings.data?.account_names[p.to] ?? p.to}</td>
<td className="num numeral">
{formatAmount(p.amount)} {profile.token_symbol}
</td>
<td>
<Countdown
pending={p}
best={status.data?.best_number ?? null}
intervalMs={status.data?.block_interval_ms ?? profile.block_time_ms}
measured={status.data?.block_interval_ms != null}
/>
</td>
<td>
{p.cancellable_by_me && (
<button
className="button danger"
type="button"
disabled={busy === p.tx_id}
onClick={() => cancel(p)}
>
{busy === p.tx_id ? 'Preparing…' : 'Cancel'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</section>
)
}
/**
* 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 (
<span data-testid="countdown" data-left-ms={left}>
in {describeMs(left)}
</span>
)
}
if (pending.execute_at_block == null) return <span className="muted">unknown</span>
if (best == null) {
return (
<span>
at block <span className="numeral">{pending.execute_at_block}</span>
</span>
)
}
const blocks = Math.max(0, pending.execute_at_block - best)
const left = blocks * intervalMs
return (
<span data-testid="countdown" data-blocks-left={blocks} data-left-ms={left}>
block <span className="numeral">{pending.execute_at_block}</span>,{' '}
<span className="numeral">{blocks}</span> blocks, {measured ? 'about' : 'nominally'}{' '}
{describeMs(left)}
</span>
)
}
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`
}

View File

@@ -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<string | null>(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 (
<div data-testid="review">
<p className="muted">
Decoded from the bytes that will be signed. Check every value against what you meant.
</p>
<table className="table review">
<tbody>
<tr>
<th>From</th>
<td className="mono">
{settings.data?.account_names[prepared.from] ?? ''} {prepared.from}
</td>
</tr>
<tr>
<th>Call</th>
<td className="mono">
{prepared.summary.pallet}.{prepared.summary.call}
</td>
</tr>
{prepared.summary.fields.map((f) => (
<tr key={f.name}>
<th>{f.name}</th>
<td className="mono">{f.value}</td>
</tr>
))}
<tr>
<th>Fee</th>
<td className="numeral">
{formatAmount(prepared.fee)} {profile.token_symbol}
</td>
</tr>
<tr>
<th>Valid for</th>
<td>
<span className="numeral">{prepared.valid_for_blocks}</span> blocks (
{describeBlocks(prepared.valid_for_blocks, profile.block_time_ms)}), nonce{' '}
<span className="numeral">{prepared.nonce}</span>
</td>
</tr>
</tbody>
</table>
{error && <p className="error">{error}</p>}
<div className="row">
<button className="button primary" type="button" onClick={confirm} disabled={busy}>
{busy ? 'Signing…' : 'Sign and send'}
</button>
<button className="button" type="button" onClick={back} disabled={busy}>
Back
</button>
</div>
</div>
)
}

View File

@@ -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 (
<>
<ChainCaveat />
@@ -72,6 +77,9 @@ export function Accounts() {
</table>
)}
</section>
{chain && profile && unlocked && (
<PendingReversibleList chain={chain} profile={profile} addresses={addresses} />
)}
</>
)
}

View File

@@ -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() {
</div>
</div>
) : prepared ? (
<div className="panel-body" data-testid="review">
<p className="muted">
Decoded from the bytes that will be signed. Check the recipient against the address
you were given.
</p>
<table className="table review">
<tbody>
<tr>
<th>From</th>
<td className="mono">
{settings.data?.account_names[prepared.from] ?? ''} {prepared.from}
</td>
</tr>
<tr>
<th>Call</th>
<td className="mono">
{prepared.summary.pallet}.{prepared.summary.call}
</td>
</tr>
{prepared.summary.fields.map((f) => (
<tr key={f.name}>
<th>{f.name}</th>
<td className="mono">{f.value}</td>
</tr>
))}
<tr>
<th>Fee</th>
<td className="numeral">
{formatAmount(prepared.fee)} {profile.token_symbol}
</td>
</tr>
<tr>
<th>Valid for</th>
<td>
<span className="numeral">{prepared.valid_for_blocks}</span> blocks (
{describeBlocks(prepared.valid_for_blocks, profile.block_time_ms)}), nonce{' '}
<span className="numeral">{prepared.nonce}</span>
</td>
</tr>
</tbody>
</table>
{error && <p className="error">{error}</p>}
<div className="row">
<button className="button primary" type="button" onClick={confirm} disabled={busy}>
{busy ? 'Signing…' : 'Sign and send'}
</button>
<button className="button" type="button" onClick={back} disabled={busy}>
Back
</button>
</div>
<div className="panel-body">
<ReviewAndSign
prepared={prepared}
profile={profile}
onSubmitted={(s) => {
setPrepared(null)
setSubmitted(s)
}}
onBack={() => setPrepared(null)}
/>
</div>
) : (
<form className="panel-body" onSubmit={prepare}>