Files
observer/web/src/components/ReversibleIndex.tsx
T
grenadeandClaude Opus 5 c08d3c4e04
deploy / build (push) Successful in 7m44s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
feat: pending reversible transfers, with a countdown
Scheduled transfers that have not yet landed, counting down to the block they
execute at. `ReversibleTransfers` is the most distinctive thing this chain does
and the observer said nothing about it.

The only view here built from both halves, each authoritative about a different
thing. State — `PendingTransfers` — says what is still pending: a cancelled or
executed transfer is simply gone from the map, and that absence beats replaying
every event since genesis and reconstructing the set, which fails silently. The
event index says when each is due, because `TransactionScheduled` carries
`execute_at` and, contrary to what the issue assumed, the stored struct does
not. A transfer scheduled before the index reaches shows as pending with an
unknown deadline rather than being dropped for half a story.

Enumerating a map needs the prefix and `state_getKeysPaged` — there is no list,
only keys derived from the things in it — and recovering a key from a storage
key needs the hasher to have kept it. `Blake2_128Concat` and `Twox64Concat` do,
`Twox128` and friends do not, so `key_offset` is `None` there rather than a
guess. Verified against a populated map since the target one is empty:
`System::Account` enumerates, its keys decode back to accounts, and the balances
read.

`execute_at` is a `DispatchTime<BlockNumber, Moment>` — an enum, so reading the
wrong arm would show a millisecond timestamp as a height. Time remaining is an
estimate from a block interval that moves; the block count is the fact and the
page says so.

The page reads "nothing is in flight" today, correctly and deliberately: zero of
this pallet's six calls have been dispatched on mainnet. It exists before the
first one because catching the first one is the point.

CLAUDE.md gains the workflow this was the first of — issue first, closed by the
commit, and corrections recorded as comments on the issue rather than as
surprises in a diff.

Closes #1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 08:42:35 +03:00

211 lines
8.3 KiB
TypeScript

/**
* Money in flight, and still cancellable.
*
* `ReversibleTransfers` is the most distinctive thing this chain does: a
* transfer is scheduled rather than settled, waits out a delay, and can be
* called back by its sender or by a nominated guardian before it lands. No
* other explorer can show this, because no other chain has the pallet.
*
* It is also the only view here built from **both** halves of the observer,
* each authoritative about a different thing. State says what is still pending
* — a cancelled or executed transfer is simply gone from `PendingTransfers`,
* and that absence beats any replay of events. The event index says when each
* is due, because `TransactionScheduled` carries `execute_at` and the stored
* struct does not.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { ReversibleState } from '../api/generated/ReversibleState'
import { RequestFailed, fetchReversible } from '../api/rest'
import { exactTokens, height as fmtHeight, shortAddress, tokens, when } from '../lib/format'
import { href } from '../lib/routes'
/** Blocks as a rough span of time, said as roughly as it is meant. */
function span(blocks: number, secondsPerBlock: number | null): string | null {
if (secondsPerBlock === null || !Number.isFinite(secondsPerBlock)) return null
const s = Math.abs(blocks) * secondsPerBlock
if (s < 90) return `~${Math.round(s)}s`
if (s < 5400) return `~${Math.round(s / 60)}m`
if (s < 172800) return `~${Math.round(s / 3600)}h`
return `~${Math.round(s / 86400)}d`
}
export function ReversibleIndex({
chain,
decimals,
symbol,
}: {
chain: string
decimals: number
symbol: string
}) {
const [state, setState] = useState<ReversibleState | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setState(null)
setError(null)
const load = () =>
fetchReversible(chain, controller.signal)
.then(setState)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
load()
// A countdown that does not count is a screenshot. Re-read rather than
// decrementing locally: the deadline is in blocks, and blocks do not arrive
// on a timer.
const id = window.setInterval(load, 12_000)
return () => {
controller.abort()
window.clearInterval(id)
}
}, [chain])
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">In flight</h2>
{state && (
<span className="eyebrow">
{state.pending.length === 0 ? 'nothing pending' : `${state.pending.length} cancellable`}{' '}
· at #{fmtHeight(state.height)}
</span>
)}
</div>
{error && <p className="empty">{error}</p>}
{!error && state === null && <p className="empty">Reading the chain…</p>}
{state !== null && state.pending.length === 0 && (
/* Not an error and not a blank. Nothing is in flight, which is a real
answer, and the page exists before the first one because catching
the first one is the point. */
<p className="empty">
Nothing is in flight. A reversible transfer waits out a delay —{' '}
{state.default_delay_blocks
? fmtHeight(state.default_delay_blocks)
: 'a configured number of'}{' '}
blocks by default — during which its sender or its guardian can call it back. None is
pending on this chain right now.
</p>
)}
{state !== null && state.pending.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Scheduled transfers that have not yet executed, soonest first
</caption>
<thead>
<tr>
<th scope="col">Executes in</th>
<th scope="col" className="left">
From
</th>
<th scope="col" className="left">
To
</th>
<th scope="col">Amount</th>
<th scope="col" className="left">
Guardian
</th>
<th scope="col" className="event-when">
Scheduled
</th>
</tr>
</thead>
<tbody>
{state.pending.map((p) => (
<tr key={p.tx_id}>
<td className="numeral">
{p.blocks_remaining === null ? (
<span
className="runtime-modifier"
title="Its scheduling event is older than the index reaches, so the deadline is not known — but the transfer is pending."
>
unknown
</span>
) : p.blocks_remaining <= 0 ? (
<span className="chip chip-failed" title="Due, and not yet executed.">
overdue
</span>
) : (
<span className="rate-cell">
<span>{fmtHeight(p.blocks_remaining)} blocks</span>
{span(p.blocks_remaining, state.block_seconds) && (
<span
className="rate-error"
title="Estimated from the measured block interval, which moves. The block count is the fact."
>
{span(p.blocks_remaining, state.block_seconds)}
</span>
)}
</span>
)}
</td>
<td className="left">
{p.from_address ? (
<Link className="numeral" to={href({ chain, account: p.from_address })}>
{shortAddress(p.from_address)}
</Link>
) : (
'—'
)}
</td>
<td className="left">
{p.to_address ? (
<Link className="numeral" to={href({ chain, account: p.to_address })}>
{shortAddress(p.to_address)}
</Link>
) : (
'—'
)}
</td>
<td
className="numeral"
title={p.amount ? `${exactTokens(p.amount, decimals)} ${symbol}` : undefined}
>
{p.amount ? `${tokens(p.amount, decimals)} ${symbol}` : '—'}
</td>
<td className="left">
{p.guardian_address ? (
<Link className="numeral" to={href({ chain, account: p.guardian_address })}>
{shortAddress(p.guardian_address)}
</Link>
) : (
'—'
)}
</td>
<td className="numeral event-when" style={{ color: 'var(--text-muted)' }}>
{p.scheduled_at_height ? (
<Link to={href({ chain, block: String(p.scheduled_at_height) })}>
{when(p.scheduled_at)}
</Link>
) : (
'—'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="panel-note">
Read live from <code>ReversibleTransfers::PendingTransfers</code> in chain state, which is
authoritative about what is still pending — a cancelled or executed transfer is gone from
the map. The deadline comes from the <code>TransactionScheduled</code> event, because the
stored entry does not carry one; a transfer scheduled before the event index reaches shows
as pending with an unknown deadline rather than being dropped. Times are estimates from a
block interval that moves — the block count is the fact.
</p>
</section>
)
}