feat: pending reversible transfers, with a countdown
All checks were successful
deploy / build (push) Successful in 7m44s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s

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
This commit is contained in:
2026-09-10 08:42:35 +03:00
parent 6b0e02e059
commit c08d3c4e04
13 changed files with 849 additions and 4 deletions

View File

@@ -11,6 +11,25 @@ Conventions: `~/git/architecture` — `generic.md` for the workspace shape,
`deployment-gitea-actions.md` for the deploy, `port-allocations.md` for the port,
`reverse-proxies.md` and `external-tls.md`/`internal-tls.md` for the vhosts.
## Work is filed before it is done
Most fixes and features start as a Gitea issue on
`quantus/blackbeard.observer`, and the commit that implements one closes it:
```
Closes #12
```
The point is an auditable history — why a thing was built, what was believed at
the time, and what turned out to be wrong on the way. That last part is the
valuable half, so **when an investigation contradicts the issue, comment on the
issue rather than quietly building the right thing**. Issue #1 asserted that
`execute_at` came from `PendingTransfers`; it does not, and the correction is a
comment there, dated, rather than a surprise in a diff.
An issue is not a ceremony for a typo. It is for anything where somebody later
would reasonably ask "why is it like this".
## The things that cost an afternoon
**substrate-telemetry sends its JSON in *binary* WebSocket frames, not text.** A

View File

@@ -18,9 +18,9 @@ use axum::{Json, Router};
use blackbeard_entities::{
AccountDetail, AccountEvent, AccountRow, ActivitySource, ApiError, BigUintDec, BlockDetail,
CallIndex, CallSummary, ChainInfo, ChainSeries, ChainState, ChainSummary, EventSummary,
LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, RewardSummary,
RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension,
RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, Window,
LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, PendingTransfer, RecentBlock,
ReversibleState, RewardSummary, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet,
RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, Window,
};
use serde::{Deserialize, Serialize};
use tower_http::compression::CompressionLayer;
@@ -62,6 +62,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
get(event_feed),
)
.route("/v1/chains/{chain}/state", get(chain_state))
.route("/v1/chains/{chain}/reversible", get(reversible))
.route("/v1/chains/{chain}/runtimes", get(runtimes))
.route("/v1/chains/{chain}/runtimes/{spec}", get(runtime))
.route("/v1/ws", get(crate::ws::handler))
@@ -737,6 +738,177 @@ async fn accounts(
))
}
/// How many pending transfers one request will enumerate.
///
/// `MaxPendingPerAccount` is 16, so this is a hundred accounts' worth. A cap
/// exists because enumeration is one RPC round trip per entry and an unbounded
/// map would be an unbounded page.
const PENDING_LIMIT: u32 = 1_600;
/// Money in flight, and still cancellable.
///
/// A join of both halves of the observer, each authoritative about a different
/// thing. `PendingTransfers` in **state** says what is still pending — a
/// cancelled or executed transfer is simply gone from the map, and that absence
/// is more trustworthy than replaying every event since genesis. The **event
/// index** supplies `execute_at`, because `TransactionScheduled` carries it and
/// the stored struct does not.
async fn reversible(
State(state): State<AppState>,
Path(chain): Path<String>,
) -> Result<Json<ReversibleState>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
let id = runtime.id();
let (parsed, height) = {
let inner = runtime.read();
(inner.current_runtime(), inner.height)
};
let Some(parsed) = parsed else {
return Err(Failure(
StatusCode::SERVICE_UNAVAILABLE,
ApiError::new(
"runtime_unknown",
"no runtime metadata cached for this chain yet",
),
));
};
let summary = runtime.summary();
let described = parsed.describe();
// The configured delay, read from the runtime rather than assumed: it is
// what makes a countdown of "6,912 blocks" mean anything.
let default_delay_blocks = described
.pallets
.iter()
.find(|p| p.name == "ReversibleTransfers")
.and_then(|p| p.constants.iter().find(|c| c.name == "DefaultDelay"))
.and_then(|c| c.value.as_ref())
.and_then(|v| v.get("BlockNumber").and_then(serde_json::Value::as_u64));
let map = parsed
.storage_map("ReversibleTransfers", "PendingTransfers")
.map_err(|e| {
// A runtime without the pallet is not an error the caller caused.
Failure(
StatusCode::NOT_FOUND,
ApiError::new("no_reversible_transfers", e.to_string()),
)
})?;
// When each one is due. Fetched before the state walk so a slow enumeration
// does not leave the join half-done.
let scheduled = state
.store
.events_of_kind(
&id,
"ReversibleTransfers",
"TransactionScheduled",
None,
1_000,
)
.await
.map_err(database_unavailable)?;
let due: std::collections::HashMap<String, &blackbeard_data::store::StoredEvent> = scheduled
.iter()
.filter_map(|e| Some((e.fields.get("tx_id")?.as_str()?.to_owned(), e)))
.collect();
let prefix = format!("0x{}", hex::encode(&map.prefix));
let keys = runtime
.rpc
.storage_keys_paged(&prefix, PENDING_LIMIT, None)
.await
.unwrap_or_default();
let mut pending = Vec::new();
for key in keys {
let Ok(raw_key) = hex::decode(key.trim_start_matches("0x")) else {
continue;
};
let Ok(Some(raw)) = runtime.rpc.storage(&key, None).await else {
continue;
};
let Ok(entry) = parsed.decode_map_value(&map, &raw) else {
continue;
};
let tx_id = parsed
.decode_map_key(&map, &raw_key)
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_else(|| key.clone());
// The three accounts and the amount, by the names the pallet gives
// them. The whole entry rides alongside, so this convenience is
// checkable and a field added later is still visible.
let account = |name: &str| entry.get(name).and_then(|v| v.as_str()).map(str::to_owned);
let (from, to, guardian) = (account("from"), account("to"), account("guardian"));
let event = due.get(&tx_id);
let execute_at = event.and_then(|e| execute_at_block(&e.fields));
pending.push(PendingTransfer {
from_address: from.as_deref().and_then(blackbeard_core::wormhole::ss58_of),
to_address: to.as_deref().and_then(blackbeard_core::wormhole::ss58_of),
guardian_address: guardian
.as_deref()
.and_then(blackbeard_core::wormhole::ss58_of),
from,
to,
guardian,
amount: entry.get("amount").and_then(|v| match v {
serde_json::Value::String(s) => Some(BigUintDec(s.clone())),
serde_json::Value::Number(n) => Some(BigUintDec(n.to_string())),
_ => None,
}),
// Signed: the scheduler can run behind, and an overdue transfer is
// a real state worth showing rather than clamping to zero.
blocks_remaining: match (execute_at, height) {
(Some(at), Some(now)) => Some(at as i64 - now as i64),
_ => None,
},
execute_at,
scheduled_at_height: event.map(|e| e.height),
scheduled_at: event.and_then(|e| e.at),
entry,
tx_id,
});
}
// Soonest first, and the ones with no known deadline last: an unknown
// countdown sorted among the known ones would imply an ordering it has not
// earned.
pending.sort_by_key(|p| {
(
p.blocks_remaining.is_none(),
p.blocks_remaining.unwrap_or(i64::MAX),
)
});
Ok(Json(ReversibleState {
chain: id,
height,
block_seconds: summary
.block_interval_seconds
.or(Some(summary.target_block_time_seconds)),
default_delay_blocks,
pending,
}))
}
/// The block a `DispatchTime` names, when it names one.
///
/// `execute_at` is `DispatchTime<BlockNumber, Moment>` — an enum, so it is a
/// block on one arm and a timestamp on another, and assuming the block arm
/// would silently read a millisecond timestamp as a height.
fn execute_at_block(fields: &serde_json::Value) -> Option<u64> {
let at = fields.get("execute_at")?;
// `normalise` renders a single-field variant as `{"At": value}`.
for arm in ["At", "Block", "BlockNumber"] {
if let Some(v) = at.get(arm) {
return v.as_u64().or_else(|| v.as_str()?.parse().ok());
}
}
at.as_u64()
}
/// Every storage entry readable without a key, decoded.
///
/// The half of a chain that history cannot reach. This chain's treasury address

View File

@@ -244,6 +244,36 @@ pub struct StorageTarget {
pub default: Option<Vec<u8>>,
}
/// A storage map: where its entries live and what they decode as.
#[derive(Debug, Clone, PartialEq)]
pub struct StorageMap {
/// `twox128(pallet prefix) ++ twox128(item)`. Every key in the map starts
/// with this, and it is what enumeration asks for.
pub prefix: Vec<u8>,
/// The registry type a key decodes as.
pub key_ty: u32,
/// The registry type a value decodes as.
pub value_ty: u32,
/// Bytes of hash sitting before the key inside each storage key, when the
/// hasher kept the key at all. `None` when it did not, or when the map
/// takes more than one key.
pub key_offset: Option<usize>,
}
/// How many bytes of hash a hasher puts before the key it keeps.
///
/// `None` for the hashers that discard the key — a map using one can be
/// enumerated and counted but its entries cannot be named.
fn hash_prefix_len(hasher: &frame_metadata::v14::StorageHasher) -> Option<usize> {
use frame_metadata::v14::StorageHasher as H;
match hasher {
H::Blake2_128Concat => Some(16),
H::Twox64Concat => Some(8),
H::Identity => Some(0),
H::Blake2_128 | H::Blake2_256 | H::Twox128 | H::Twox256 => None,
}
}
/// `twox128`, as Substrate builds storage prefixes: two little-endian xxhash64
/// digests, seeded 0 and 1, concatenated.
fn twox_128(input: &[u8]) -> [u8; 16] {
@@ -699,6 +729,103 @@ impl Runtime {
})
}
/// A storage map's prefix, and what its keys and values decode as.
///
/// The prefix alone is what `state_getKeysPaged` enumerates on, which is
/// the only way to ask "what is in this map" from outside the runtime —
/// there is no list, only keys derived from the things in it.
///
/// Whether the key is *recoverable* from a storage key depends on the
/// hasher. The `Concat` variants keep the key after its hash, so it can be
/// read back; the plain ones do not, and such a map can be enumerated but
/// not attributed. `key_offset` says which, and is `None` for the second
/// case rather than a guess.
pub fn storage_map(&self, pallet: &str, item: &str) -> Result<StorageMap, RuntimeError> {
let pallet_meta = self
.metadata
.pallets
.iter()
.find(|p| p.name == pallet)
.ok_or_else(|| RuntimeError::NoStorageEntry(format!("no pallet {pallet}")))?;
let storage = pallet_meta
.storage
.as_ref()
.ok_or_else(|| RuntimeError::NoStorageEntry(format!("{pallet} declares no storage")))?;
let entry = storage
.entries
.iter()
.find(|e| e.name == item)
.ok_or_else(|| RuntimeError::NoStorageEntry(format!("no {pallet}::{item}")))?;
let frame_metadata::v14::StorageEntryType::Map {
hashers,
key: key_ty,
value,
} = &entry.ty
else {
return Err(RuntimeError::NoStorageEntry(format!(
"{pallet}::{item} is a plain entry, not a map"
)));
};
let mut prefix = Vec::with_capacity(32);
prefix.extend_from_slice(&twox_128(storage.prefix.as_bytes()));
prefix.extend_from_slice(&twox_128(item.as_bytes()));
// Only a single-key map yields a recoverable key here. A double map's
// storage key interleaves two hashes and two keys, and picking that
// apart needs the key type's own encoded length rather than an offset.
let key_offset = match (hashers.len(), hashers.first()) {
(1, Some(h)) => hash_prefix_len(h),
_ => None,
};
Ok(StorageMap {
prefix,
key_ty: key_ty.id,
value_ty: value.id,
key_offset,
})
}
/// Decode a map's key back out of one of its storage keys.
///
/// `None` when the hasher discarded it, which is a property of the map
/// rather than a failure.
pub fn decode_map_key(
&self,
map: &StorageMap,
storage_key: &[u8],
) -> Option<serde_json::Value> {
let offset = 32 + map.key_offset?;
let mut cursor = storage_key.get(offset..)?;
let value =
scale_value::scale::decode_as_type(&mut cursor, map.key_ty, &self.metadata.types)
.ok()?;
let mut accounts = BTreeSet::new();
Some(self.normalise(&value, &mut accounts))
}
/// Decode a map's value against the type its entry declares.
pub fn decode_map_value(
&self,
map: &StorageMap,
raw: &[u8],
) -> Result<serde_json::Value, RuntimeError> {
let mut cursor = raw;
let value =
scale_value::scale::decode_as_type(&mut cursor, map.value_ty, &self.metadata.types)
.map_err(|e| RuntimeError::Decode(e.to_string()))?;
if !cursor.is_empty() {
return Err(RuntimeError::Decode(format!(
"{} trailing bytes in storage value",
cursor.len()
)));
}
let mut accounts = BTreeSet::new();
Ok(self.normalise(&value, &mut accounts))
}
/// Decode a storage value against the type its entry declares.
///
/// Normalised the same way event fields are — accounts as `0x` hex, big
@@ -1594,6 +1721,63 @@ mod tests {
assert!(a.default.is_some());
}
/// A map's prefix is the first 32 bytes of every key in it — pinned here
/// against the `System::Account` key the test above builds, so the two
/// cannot drift apart.
#[test]
fn a_map_prefix_is_the_head_of_its_keys() {
let rt = Runtime::from_metadata(&metadata()).expect("parses");
let map = rt.storage_map("System", "Account").expect("declared");
assert_eq!(
hex::encode(&map.prefix),
"26aa394eea5630e07c48ae0c9558cef7b99d880ec681799c0cf30e8886371da9"
);
// `Blake2_128Concat`, so the account sits sixteen bytes after the
// prefix and can be read back.
assert_eq!(map.key_offset, Some(16));
let account =
hex::decode("c6801725b054b06a3d5030f25caa3f2e9094d7722efe4f9eddbce1227798ec7c")
.unwrap();
let full = rt
.storage_key(
"System",
"Account",
&[scale_value::Value::from_bytes(&account)],
)
.unwrap();
assert!(full.key.starts_with(&map.prefix));
// And the round trip: the key comes back out of the storage key.
let recovered = rt.decode_map_key(&map, &full.key).expect("recoverable");
assert_eq!(
recovered.as_str(),
Some(format!("0x{}", hex::encode(&account)).as_str()),
"{recovered:?}"
);
}
#[test]
fn a_plain_entry_is_not_a_map() {
let rt = Runtime::from_metadata(&metadata()).expect("parses");
assert!(matches!(
rt.storage_map("TreasuryPallet", "TreasuryAccount"),
Err(RuntimeError::NoStorageEntry(_))
));
}
/// The map this was built for. Its key is an `H256` transaction id, and it
/// must be recoverable — a pending transfer nobody can name is not much use.
#[test]
fn pending_transfers_is_an_enumerable_map() {
let rt = Runtime::from_metadata(&metadata()).expect("parses");
let map = rt
.storage_map("ReversibleTransfers", "PendingTransfers")
.expect("declared");
assert_eq!(map.prefix.len(), 32);
assert!(map.key_offset.is_some(), "keys must be recoverable");
}
#[test]
fn a_key_of_the_wrong_shape_is_refused() {
let rt = Runtime::from_metadata(&metadata()).expect("parses");

View File

@@ -205,6 +205,34 @@ impl RpcClient {
Ok(self.body(hash).await?.and_then(|b| b.timestamp_ms))
}
/// Every storage key under a prefix, a page at a time.
///
/// The only way to ask a node "what is in this map": there is no list, just
/// keys derived from the things in it, so enumeration walks the prefix.
///
/// `start` is the last key of the previous page, exclusive. A page shorter
/// than `count` is the end.
pub async fn storage_keys_paged(
&self,
prefix: &str,
count: u32,
start: Option<&str>,
) -> Result<Vec<String>, DataError> {
let params = match start {
Some(s) => json!([prefix, count, s]),
None => json!([prefix, count]),
};
let v = self.call("state_getKeysPaged", params).await?;
Ok(v.as_array()
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default())
}
/// The runtime's own description of itself, as of `hash`.
///
/// The node executes `Metadata_metadata` against the runtime code in that

View File

@@ -21,6 +21,7 @@ mod call;
mod chain;
mod error;
mod miner;
mod reversible;
mod runtime;
mod series;
mod state;
@@ -32,6 +33,7 @@ pub use call::{CallIndex, CallSummary, EventSummary};
pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking};
pub use error::{ApiError, EntityError};
pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint};
pub use reversible::{PendingTransfer, ReversibleState};
pub use runtime::{
RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension,
RuntimeStorage, RuntimeSummary, RuntimeVariant,

View File

@@ -0,0 +1,84 @@
//! Money in flight, and still cancellable.
//!
//! `ReversibleTransfers` is the most distinctive thing this chain does: a
//! transfer is scheduled rather than settled, sits for a delay, and can be
//! cancelled 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 one view here that **has to be a join of state and history**,
//! and each half is authoritative about a different thing:
//!
//! - **State** says what is still pending. A transfer that was cancelled or
//! executed is gone from `PendingTransfers`, and its absence is more
//! trustworthy than any replay of events could be.
//! - **The event index** says when each one is due, because
//! `TransactionScheduled` carries `execute_at` and the stored struct does not.
//!
//! A pending transfer whose scheduling event predates the index shows as
//! pending with an unknown deadline, rather than being dropped for lacking half
//! its story.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{BigUintDec, ChainId};
/// One scheduled transfer that has not yet landed.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "PendingTransfer.ts")]
pub struct PendingTransfer {
/// The transaction id, which is the map's own key and how the sender or a
/// guardian would refer to it when cancelling.
pub tx_id: String,
/// Who scheduled it, as `0x` hex.
pub from: Option<String>,
/// And as an address.
pub from_address: Option<String>,
/// Where it is going.
pub to: Option<String>,
/// And as an address.
pub to_address: Option<String>,
/// Who may cancel it besides the sender.
pub guardian: Option<String>,
/// And as an address.
pub guardian_address: Option<String>,
/// How much is frozen for it, in the chain's smallest unit.
pub amount: Option<BigUintDec>,
/// The whole decoded entry, so the named fields above are checkable and a
/// field this does not know about is still visible.
#[ts(type = "Record<string, unknown>")]
pub entry: serde_json::Value,
/// The block it executes at, when the scheduling event is in the index.
#[ts(type = "number")]
pub execute_at: Option<u64>,
/// Blocks still to go. Negative would mean overdue, which is possible while
/// the scheduler catches up, so it is signed.
#[ts(type = "number")]
pub blocks_remaining: Option<i64>,
/// The block it was scheduled in.
#[ts(type = "number")]
pub scheduled_at_height: Option<u64>,
/// When that was, by the chain's clock.
pub scheduled_at: Option<DateTime<Utc>>,
}
/// Everything currently in flight on a chain.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "ReversibleState.ts")]
pub struct ReversibleState {
/// Which chain.
pub chain: ChainId,
/// The block the state was read at, which is what the countdown counts from.
#[ts(type = "number")]
pub height: Option<u64>,
/// The chain's measured or target block interval, for turning blocks into
/// an estimate of time. Derived and moving — the block count is the fact.
pub block_seconds: Option<f64>,
/// The default delay a scheduled transfer gets, in blocks, as the runtime
/// is configured. Context for a countdown that would otherwise mean nothing.
#[ts(type = "number")]
pub default_delay_blocks: Option<u64>,
/// What is pending, soonest first.
pub pending: Vec<PendingTransfer>,
}

View File

@@ -190,6 +190,39 @@ become one decimal rather than four or eight little-endian limbs. Difficulty as
`[1189189, 0, 0, 0, 0, 0, 0, 0]` is a correct description of the bytes and tells
a reader nothing.
## Money in flight
`/quantus/reversible` shows scheduled transfers that have not yet landed,
counting down to the block they execute at. `ReversibleTransfers` is the most
distinctive thing this chain does — a transfer waits out a delay, default 7,200
blocks, during which its sender or a nominated guardian can call it back — and
no other explorer can show it, because no other chain has the pallet.
It is the only view here built from **both** halves of the observer, 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 is more
trustworthy than replaying every event since genesis and reconstructing the
set, which fails silently when it fails.
- **The event index** says when each is due, because `TransactionScheduled`
carries `execute_at` and the stored struct does not. A transfer scheduled
before the index reaches shows as pending with an unknown deadline rather
than being dropped for lacking half its story.
`execute_at` is a `DispatchTime<BlockNumber, Moment>` — an enum, so it names a
block on one arm and a timestamp on another, and reading the wrong arm would
present 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
which is which.
Enumerating a map needs `state_getKeysPaged` on the entry's prefix — there is no
list, only keys derived from the things in it — and recovering each key from its
storage key needs the hasher to have kept it. `Blake2_128Concat` and
`Twox64Concat` do; `Twox128` and the rest do not, so `StorageMap::key_offset` is
`None` there rather than a guess, and such a map can be counted but not
attributed.
## State: what is true now
Everything else here is history — headers, events, extrinsics, a record of what

View File

@@ -24,6 +24,7 @@ import { Leaderboard } from './components/Leaderboard'
import { MinerPanel } from './components/MinerPanel'
import { RuntimePanel } from './components/RuntimePanel'
import { RuntimesIndex } from './components/RuntimesIndex'
import { ReversibleIndex } from './components/ReversibleIndex'
import { SectionNav } from './components/SectionNav'
import { StateIndex } from './components/StateIndex'
import { StatBar } from './components/StatBar'
@@ -244,6 +245,13 @@ export default function App() {
symbol={info?.token_symbol ?? ''}
/>
)}
{route.index === 'reversible' && chain && (
<ReversibleIndex
chain={chain}
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>
)}
{route.index === 'state' && chain && (
<StateIndex
chain={chain}

View File

@@ -0,0 +1,62 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BigUintDec } from "./BigUintDec";
/**
* One scheduled transfer that has not yet landed.
*/
export type PendingTransfer = {
/**
* The transaction id, which is the map's own key and how the sender or a
* guardian would refer to it when cancelling.
*/
tx_id: string,
/**
* Who scheduled it, as `0x` hex.
*/
from: string | null,
/**
* And as an address.
*/
from_address: string | null,
/**
* Where it is going.
*/
to: string | null,
/**
* And as an address.
*/
to_address: string | null,
/**
* Who may cancel it besides the sender.
*/
guardian: string | null,
/**
* And as an address.
*/
guardian_address: string | null,
/**
* How much is frozen for it, in the chain's smallest unit.
*/
amount: BigUintDec | null,
/**
* The whole decoded entry, so the named fields above are checkable and a
* field this does not know about is still visible.
*/
entry: Record<string, unknown>,
/**
* The block it executes at, when the scheduling event is in the index.
*/
execute_at: number,
/**
* Blocks still to go. Negative would mean overdue, which is possible while
* the scheduler catches up, so it is signed.
*/
blocks_remaining: number,
/**
* The block it was scheduled in.
*/
scheduled_at_height: number,
/**
* When that was, by the chain's clock.
*/
scheduled_at: string | null, };

View File

@@ -0,0 +1,30 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ChainId } from "./ChainId";
import type { PendingTransfer } from "./PendingTransfer";
/**
* Everything currently in flight on a chain.
*/
export type ReversibleState = {
/**
* Which chain.
*/
chain: ChainId,
/**
* The block the state was read at, which is what the countdown counts from.
*/
height: number,
/**
* The chain's measured or target block interval, for turning blocks into
* an estimate of time. Derived and moving — the block count is the fact.
*/
block_seconds: number | null,
/**
* The default delay a scheduled transfer gets, in blocks, as the runtime
* is configured. Context for a countdown that would otherwise mean nothing.
*/
default_delay_blocks: number,
/**
* What is pending, soonest first.
*/
pending: Array<PendingTransfer>, };

View File

@@ -15,6 +15,7 @@ import type { BlockExtrinsic } from './generated/BlockExtrinsic'
import type { CallIndex } from './generated/CallIndex'
import type { ChainSeries } from './generated/ChainSeries'
import type { ChainState } from './generated/ChainState'
import type { ReversibleState } from './generated/ReversibleState'
import type { RecentBlock } from './generated/RecentBlock'
import type { MinerDetail } from './generated/MinerDetail'
import type { RuntimeDetail } from './generated/RuntimeDetail'
@@ -158,6 +159,16 @@ export function fetchState(chain: string, signal?: AbortSignal): Promise<ChainSt
return get<ChainState>(`/chains/${encodeURIComponent(chain)}/state`, signal)
}
/**
* Scheduled transfers that have not yet executed.
*
* Live, and re-read rather than counted down locally: the deadline is a block
* height, and blocks do not arrive on a timer.
*/
export function fetchReversible(chain: string, signal?: AbortSignal): Promise<ReversibleState> {
return get<ReversibleState>(`/chains/${encodeURIComponent(chain)}/reversible`, signal)
}
/** Accounts ranked by what they have been paid, most first. */
export function fetchAccounts(chain: string, signal?: AbortSignal): Promise<AccountRow[]> {
return get<AccountRow[]>(`/chains/${encodeURIComponent(chain)}/accounts`, signal)

View File

@@ -0,0 +1,210 @@
/**
* 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>
)
}

View File

@@ -56,7 +56,8 @@ export interface Route {
}
/** The kinds that have an index. */
export type SectionName = 'block' | 'call' | 'event' | 'account' | 'state' | 'runtime'
export type SectionName =
'block' | 'call' | 'event' | 'account' | 'reversible' | 'state' | 'runtime'
/**
* The sections, in the order the nav shows them.
@@ -70,6 +71,7 @@ export const SECTIONS: { id: SectionName; label: string }[] = [
{ id: 'call', label: 'Calls' },
{ id: 'event', label: 'Events' },
{ id: 'account', label: 'Accounts' },
{ id: 'reversible', label: 'In flight' },
{ id: 'state', label: 'State' },
{ id: 'runtime', label: 'Runtimes' },
]