feat(substrate): transfers prepared for review, priced, signed by the session, submitted and watched
ci / gate (push) Waiting to run

`SubstrateAdapter` implements the chain-adapter port for Quantus. `prepare`
builds the call from a chain-neutral intent, decodes it back from its own
bytes through the runtime metadata into a summary a person can check (a
32-byte account renders as the chain's SS58 address, an amount in the
token's units), prices it by assembling a full extrinsic with a zeroed
signature of the right size and asking `TransactionPaymentApi_query_info`,
and returns the signer payload for the session to sign. `submit` refuses
if the runtime spec moved or the payload no longer matches, assembles with
the session's signature, and streams statuses; a dispatch error is reported
at inclusion, named `Pallet::Variant` where the chain names it, so nobody
waits for finality to learn a transfer failed.

Reversible transfers: `reversible_for` routes through
`ReversibleTransfers::schedule_transfer_with_delay`; a high-security
account is routed through the pallet's default-delay call whether it asked
or not, since the chain's extension refuses a plain transfer from one.
`pending_reversible` lists what is waiting with the execution block from
the scheduler's lookup, whose task name is the tx id itself, and
`CancelReversible` cancels.

Three things the dev node taught, each now written into the code:

- Mortality must be anchored at the *best* block. Finality on this
  proof-of-work chain trails the tip by a hundred blocks, and an era born
  at the finalized head names a birth block whose hash the runtime resolves
  differently: "Transaction has a bad signature", for both schemes, while
  immortal transactions from the same keys passed. Probed all four
  scheme-by-era combinations to isolate it.
- Everything the UI shows is read at the best block, with the block number
  and the finalized height alongside so confirmations can be shown.
  subxt's `at_latest()` is the finalized block; the pending list read there
  showed the state of a hundred blocks ago while the chain's events showed
  every schedule landing.
- subxt's runtime-update subscription died on this node with "header not
  found" after a reorg and took the connection with it. It is advisory
  now: on error the runtime version is polled every ten heads and a change
  forces a reconnect, which refetches metadata.

Live, against `quantus-node --dev` with the funded dev accounts: a 3 DEV
transfer from crystal_alice to crystal_bob is included and moves bob's
balance; a reversible transfer with a 200-block window is scheduled, shows
as pending with its execution block, and is cancelled; an overspend fails
as `Token(FundsUnavailable)`. Fee for a plain transfer: 0.010157927 DEV.

In the app: `transfer_prepare` (amount parsed from its decimal form in
Rust), `transfer_submit` (signs through the session, emits `tx:status`),
`transfer_discard`, `reversible_pending`, `reversible_cancel_prepare`.

Closes #22

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-15 22:58:39 +03:00
co-authored by Claude Fable 5.1
parent 26559ee148
commit 95bcfe9ca4
26 changed files with 54969 additions and 51 deletions
+7 -2
View File
@@ -16,6 +16,11 @@ spendable: Amount,
*/
total: Amount, nonce: bigint, high_security: HighSecurityInfo | null,
/**
* The finalized block the numbers were read at.
* The best block the numbers were read at.
*/
at_block: bigint | null, };
at_block: bigint,
/**
* The chain's finalized height at the time, so the UI can say how
* many confirmations deep `at_block` is.
*/
finalized_block: bigint | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CallField = { name: string, value: string, };
+9
View File
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CallField } from "./CallField";
/**
* A call decoded from its bytes for the review screen: the pallet, the
* call, and each argument rendered for a person (addresses as SS58,
* amounts in the asset's units, everything else as the chain names it).
*/
export type CallSummary = { pallet: string, call: string, fields: Array<CallField>, };
+2 -2
View File
@@ -9,9 +9,9 @@ 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, finalized_number: bigint | null,
endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: bigint | null, finalized_number: bigint | null,
/**
* Seconds since the last finalized head arrived; `None` when never.
* Seconds since the last best head arrived; `None` when never.
*/
head_age_seconds: bigint | null,
/**
+17
View File
@@ -0,0 +1,17 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
/**
* A reversible transfer still waiting to execute.
*/
export type PendingReversible = { tx_id: string, from: string, to: string, guardian: string, amount: Amount,
/**
* When it executes, as the scheduler records it: a block number, or a
* timestamp in milliseconds, whichever the delay was given in.
*/
execute_at_block: bigint | null, execute_at_ms: bigint | null,
/**
* Whether the open wallet's account may cancel it (sender for a
* one-time delay, guardian for a high-security account).
*/
cancellable_by_me: boolean, };
@@ -0,0 +1,14 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
import type { CallSummary } from "./CallSummary";
import type { ChainId } from "./ChainId";
/**
* A transaction prepared for review: what will be signed, decoded from
* the bytes, and what it costs. `id` is handed back to submit it.
*/
export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: bigint,
/**
* Blocks the transaction stays valid for from the block it was built at.
*/
valid_for_blocks: bigint, };
+6
View File
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One step in a submitted transaction's life, as emitted on `tx:status`.
*/
export type TxStage = { "stage": "submitted" } | { "stage": "in_block", block: string, } | { "stage": "finalized", block: string, } | { "stage": "failed", block: string | null, reason: string, } | { "stage": "dropped", reason: string, };
+8
View File
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TxStage } from "./TxStage";
export type TxStatusInfo = {
/**
* The prepared transaction's id.
*/
id: string, hash: string, stage: TxStage, };
+41
View File
@@ -9,6 +9,8 @@ import type { AppInfo } from './generated/AppInfo'
import type { ChainId } from './generated/ChainId'
import type { ChainProfile } from './generated/ChainProfile'
import type { ChainStatus } from './generated/ChainStatus'
import type { PendingReversible } from './generated/PendingReversible'
import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
import type { CreationStart } from './generated/CreationStart'
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
import type { SignatureScheme } from './generated/SignatureScheme'
@@ -128,3 +130,42 @@ export function balances(chain: ChainId): Promise<AccountBalance[]> {
export function balancesWatch(chain: ChainId): Promise<void> {
return command<void>('balances_watch', { chain })
}
// Sending is two steps so the review screen shows what Rust decoded from the
// bytes: prepare returns the summary and fee under an id, submit signs and
// sends that id. Progress arrives as `tx:status` events.
export function transferPrepare(
chain: ChainId,
from: string,
to: string,
amount: string,
reversibleFor?: number,
): Promise<PreparedTransferInfo> {
return command<PreparedTransferInfo>('transfer_prepare', {
chain,
from,
to,
amount,
reversibleFor: reversibleFor ?? null,
})
}
export function transferSubmit(id: string): Promise<string> {
return command<string>('transfer_submit', { id })
}
export function transferDiscard(id: string): Promise<void> {
return command<void>('transfer_discard', { id })
}
export function reversiblePending(chain: ChainId, address: string): Promise<PendingReversible[]> {
return command<PendingReversible[]>('reversible_pending', { chain, address })
}
export function reversibleCancelPrepare(
chain: ChainId,
from: string,
txId: string,
): Promise<PreparedTransferInfo> {
return command<PreparedTransferInfo>('reversible_cancel_prepare', { chain, from, txId })
}