feat(ui): pending reversible transfers with a measured countdown and a cancel
Some checks are pending
ci / gate (push) Waiting to run
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:
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
201
ui/src/components/PendingReversible.tsx
Normal file
201
ui/src/components/PendingReversible.tsx
Normal 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`
|
||||
}
|
||||
101
ui/src/components/ReviewAndSign.tsx
Normal file
101
ui/src/components/ReviewAndSign.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user