Skip to content

feat(transaction)!: make max_epoch mandatory and bound the validity window - #2419

Open
sdbondi wants to merge 11 commits into
tari-project:developmentfrom
sdbondi:transaction-mandatory-epoch-range
Open

feat(transaction)!: make max_epoch mandatory and bound the validity window#2419
sdbondi wants to merge 11 commits into
tari-project:developmentfrom
sdbondi:transaction-mandatory-epoch-range

Conversation

@sdbondi

@sdbondi sdbondi commented Aug 13, 2026

Copy link
Copy Markdown
Member

Motivation

max_epoch was optional with no upper bound, so a signed transaction stayed sequenceable forever. Two consequences:

  • a wallet could never declare a transaction dead — it might still land at any point in the future;
  • an aborted attempt, which consensus deliberately allows to be re-sequenced, could be retried indefinitely.

This is not about replay protection. Committed-id dedup is already permanent via the receipt-existence gate (#2381): the TransactionReceipt substate is chain state, retained regardless of epoch GC, so a committed id can never be re-sequenced. What was missing is a bounded, deterministic lifetime for every transaction.

Radix sizes the equivalent knob (max_epoch_range) at 12 * 24 * 30 — ~30 days at 5-minute epochs — for the same reason. This PR takes the same window.

Changes

Type change. max_epoch: Option<Epoch>Epoch on UnsignedTransactionV1, its pruned mirror, the signing-domain projection (TransactionSignatureFields) and TransactionPoolRecord. The builder takes it at construction — Transaction::builder(network, max_epoch) — so every construction site chooses a window at compile time rather than silently omitting one.

Consensus constant. New ConsensusConstants::max_transaction_validity_epochs = 2160 — ~30 days at the ~20 minute epoch target — uniform across mainnet/devnet/esmeralda/testnet. It is a ceiling, not a default: wallets stamp a much shorter window for ordinary traffic, leaving the long window to callers that explicitly need it (offline or multi-party signing). A test asserts the value is identical on every network, since a divergent ceiling would mean nodes disagree on which transactions may be sequenced.

Validation. EpochRangeValidator gains the upper-bound rule and is constructed with the constant. The new MaxEpochTooFarAhead error is classified node-local, not sender fault: a node lagging by even one epoch computes a lower ceiling, so a transaction stamped at the ceiling would look too-far-ahead and graylist an honest peer. Same class as the existing CurrentEpochLessThanMinimum / CurrentEpochGreaterThanMaximum.

Enforcement. Mempool ingress now runs the epoch rules alongside the structural ones and before the gossip-acceptance verdict, so an out-of-window transaction is refused before it is admitted or re-gossiped. Consensus sequencing already routed through the same validator via TariBlockTransactionValidator.

Wallet. The daemon stamps max_epoch = current_epoch + default_transaction_validity_epochs (new config, default 3 ≈ 1 hour) when the caller does not supply one, backed by a 30-second epoch cache so a burst of builds does not make an indexer round-trip each. Building now fails if the indexer is unreachable rather than guessing a window the network may not accept. settings.get exposes current_epoch and the default window so callers that build transactions themselves — wallet CLI, traffic-sim — can pick a valid window without a new endpoint.

Ledger. SigningField::MaxEpoch now carries a bare little-endian u64 rather than a borsh Option, and the device always displays it. Field tag numbering is unchanged.

Deployment notes

Consensus-breaking and a signing-domain break. The max_epoch field encoding changes (borsh Option<Epoch>Epoch), so pre-signed transactions do not carry over and the Ledger app + ootle-go golden vectors need the coordinated update. Belongs in the same breaking batch as the other consensus changes in this release.

The Ledger app crate (ootle-ledger-app) is out-of-workspace and needs the device toolchain — it is not built by CI here. Its changes match the type change and the in-workspace lock-step test guards the contract, but it needs a device build before release.

Verification

  • 6 new EpochRangeValidator unit tests: inside-window, before min_epoch, expired, beyond the ceiling, inclusive boundary, and a saturating ceiling that admits any max_epoch.
  • Consensus-constant uniformity test.
  • Ledger lock-step tests green: preimage_field_tags_match_protocol plus the seal / add-signer / stealth recipe tests.
  • SDK golden vectors regenerated (OOTLE_REGEN_FIXTURES=1).
  • TS bindings hand-edited, bindings/package.json bumped to 1.49.0, dist rebuilt.
  • cargo lints clippy --all-targets clean; cargo +nightly-2025-12-05 fmt --all applied.
  • cargo nextest r -E "not package(integration_tests)" --no-fail-fast --release1772 passed, 0 failed.

sdbondi and others added 2 commits August 13, 2026 13:06
…indow

## Motivation
`max_epoch` was optional with no upper bound, so a signed transaction stayed
sequenceable forever. Two consequences: a wallet could never declare a
transaction dead, and an aborted attempt — which consensus deliberately allows
to be re-sequenced — could be retried indefinitely.

Committed-id replay is already handled permanently by the receipt-existence
gate, so this is not about replay protection. It is about giving every
transaction a bounded, deterministic lifetime.

## Changes
- `max_epoch: Option<Epoch>` → `Epoch` on `UnsignedTransactionV1`, its pruned
  mirror, the signing-domain projection and the transaction pool record. The
  builder takes it at construction (`Transaction::builder(network, max_epoch)`),
  so every construction site chooses a window at compile time.
- New consensus constant `max_transaction_validity_epochs = 2160` — ~30 days at
  the ~20 minute epoch target, uniform across networks. A ceiling, not a
  default. Covered by a test asserting network uniformity.
- `EpochRangeValidator` gains the upper bound and is constructed with the
  constant. `MaxEpochTooFarAhead` is classified node-local, not sender fault:
  a node lagging by an epoch would otherwise penalise honest senders whose
  window sits at the ceiling.
- Mempool ingress runs the epoch rules alongside the structural ones, before
  the gossip-acceptance verdict, so an out-of-window transaction is refused
  before it is admitted or re-gossiped. Consensus sequencing already routed
  through the same validator.
- Wallet daemon stamps `max_epoch = current_epoch + default_transaction_validity_epochs`
  (new config, default 3 ≈ 1h) when the caller does not supply one, backed by a
  30s epoch cache. Building now fails if the indexer is unreachable rather than
  guessing a window. `settings.get` exposes the current epoch and the default
  window so callers that build transactions themselves (wallet CLI, traffic-sim)
  can pick a window the network accepts.
- Ledger: `SigningField::MaxEpoch` now carries a bare little-endian u64 rather
  than a borsh `Option`, and the device always displays it.

`epoch_history_length` is deliberately left alone: committed-id dedup reads the
receipt substate, which is chain state and retained regardless, so retention
length has no bearing on the window.

## Deployment notes
Consensus-breaking and a signing-domain break — the `max_epoch` field encoding
changes, so pre-signed transactions and Ledger golden vectors do not carry over.
Belongs in the same breaking batch as the other consensus changes.

## Verification
- 6 new `EpochRangeValidator` unit tests (min, expiry, ceiling, inclusive
  boundary, saturating ceiling).
- Ledger lock-step tests green (`preimage_field_tags_match_protocol` and the
  three recipe tests).
- SDK golden vectors regenerated.
- `cargo lints clippy --all-targets` clean; `cargo nextest r -E "not
  package(integration_tests)" --release` → 1772 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ledger-feature signer test, the ledger client's preimage fixture and the
crate-level doc example construct transactions on paths that a default-feature
build never compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sdbondi
sdbondi force-pushed the transaction-mandatory-epoch-range branch from 353445c to 0228754 Compare August 13, 2026 09:18
…liveness pill

The transaction detail views in the wallet, indexer and validator node UIs now
show the validity window alongside the other transaction facts, so the epoch a
transaction dies in is visible where it is inspected.

The wallet gains a liveness pill in the app bar, probing the configured indexer
on the same endpoint the indexer settings tab uses and reading the URL from
settings so a change takes effect without a reload. One failed probe reads as
the intermediate state rather than an outage.

settings.get no longer fails when the indexer is unreachable: it reports a null
current_epoch instead. Settings is where the indexer URL is corrected, so it has
to stay readable while the network is down. Callers that need the epoch to pick
a validity window (wallet CLI, traffic-sim) now say so explicitly rather than
guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sdbondi

sdbondi commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Review

The mechanical Option<Epoch>Epoch conversion looks consistent — no leftover Option handling, all TransactionBuilder::new/Transaction::builder call sites updated, EpochRangeValidator wired at both mempool ingress and consensus sequencing, and the ledger read_u64 matches borsh's bare-LE-u64 encoding of Epoch. Findings below.


1. applications/tari_walletd/src/services/auto_claim_burn_service.rs:465 — network error classified as permanent

self.claim_max_epoch().await.map_err(ClaimError::Permanent)? misclassifies a network failure. claim_max_epochquery_current_epochsdk.get_network_interface().get_current_epoch() is an indexer round-trip, and ClaimError::Transient's own doc says "network unavailable, indexer unreachable" is transient (that's what MAX_RETRIES_NETWORK exists for).

Scenario: the indexer is restarting when the 30s auto-claim tick fires; get_current_epoch errors, the claim is classified Permanent, pending_claims.remove(&file_name) drops it from the queue, and the burn is never auto-claimed — manual claim required even though the next tick would have succeeded. Same at line 508 for the non-dry-run submission.

The enclosing loop already resolves current_epoch for the readiness filter; reuse it, or map to ClaimError::transient(e, MAX_RETRIES_NETWORK).

2. applications/tari_walletd/src/services/auto_claim_burn_service.rs:34 — doc comment now attached to the wrong const

The new const was inserted directly under MAX_RETRIES_NETWORK's doc comment, so /// Maximum retries for network/submission errors (indexer unreachable, tx service down). now documents CLAIM_TRANSACTION_VALIDITY_EPOCHS (line 37), and MAX_RETRIES_NETWORK (line 39) is left undocumented.

3. crates/storage/src/consensus_models/transaction_pool.rs:417 — stale #[serde(default)] inverts meaning

#[serde(default)] was left on max_epoch when its type changed from Option<Epoch> to Epoch. Epoch: Default is Epoch(0), so a payload missing the field used to mean "no expiry" and now means "expired before epoch 1". Any TransactionPoolRecord deserialized from a payload omitting max_epoch yields a record that every execution_epoch > max_epoch check treats as expired. Either drop the attribute so a missing field errors, or make the default explicit.

4. crates/transaction_validation/src/error.rs:121MaxEpochTooFarAhead gives spam a free pass

It is classified is_sender_fault() == false, so MempoolService::handle_new_transaction returns MessageAcceptance::Ignore, which carries no peer-score penalty. The stated justification (a node lagging one epoch computes a lower ceiling) only holds at the boundary.

Scenario: a peer floods transactions stamped max_epoch = u64::MAX; every node validates, drops, and reports Ignore, so the sender is never penalised or graylisted for traffic that is unambiguously invalid on any honest view of the epoch. A graduated rule (node-local within some slack of the ceiling, sender-fault beyond it) keeps the anti-graylist property without giving up the spam defence.

5. applications/tari_walletd/src/config.rs:107default_transaction_validity_epochs unvalidated

0 produces max_epoch == current_epoch, so every transaction the daemon builds must be sequenced inside the current epoch and will usually expire near a boundary. A value above max_transaction_validity_epochs (2160) makes every built transaction fail validation network-wide with MaxEpochTooFarAhead — and because that error is node-local (finding 4), it is silently Ignored rather than reported, so the operator sees transactions vanish with no diagnostic. A startup check (1..=2160) turns both into a config error.

6. utilities/traffic-sim/src/sim.rs:428 — validity window hoisted out of the loop

let max_epoch = exchange_wallet.max_epoch().await?; sits outside the for (wallet, account) in self.wallet_and_account_iter() loop, so one 10-epoch window is stamped on every transaction the loop builds. Each iteration makes several wallet-daemon round-trips; with epoch_time: 120 (the commented preset) 10 epochs is ~20 minutes, so a run over enough wallets has later transactions rejected as CurrentEpochGreaterThanMaximum. Resolving it per iteration (or per wallet) costs one cached settings call.

sdbondi and others added 3 commits August 14, 2026 13:46
- Auto-claim burn no longer re-queries the epoch to stamp `max_epoch`. It takes
  the epoch `check_and_submit_pending` already resolved, so a momentary indexer
  outage can no longer be classified `Permanent` and drop a burn from the queue
  for good. The round-trip goes away with it.
- Restore `MAX_RETRIES_NETWORK`'s doc comment, which the new validity-window
  constant had been inserted underneath.
- Drop the now-stale `#[serde(default)]` on `TransactionPoolRecord::max_epoch`.
  With the field mandatory, `Epoch: Default` is `Epoch(0)`, so a payload missing
  it went from meaning "no expiry" to "expired before epoch 1" — the attribute
  inverts the meaning it was added for. A missing field is now an error.
- Validate `default_transaction_validity_epochs` at startup. Zero is refused: it
  stamps `max_epoch == current_epoch`, so a transaction built near a boundary
  expires before it can land. An oversized value is only warned about — the
  network's ceiling decides, and the wallet does not depend on the consensus
  crate, so it has no authority to refuse a window a larger network would accept.
- traffic-sim resolves its validity window per wallet rather than once per run.
  Each loop iteration makes several daemon round-trips, so a single window taken
  up front could lapse before the later wallets were funded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mandatory-epoch-range

* upstream/development:
  feat(ootle-wasm): expose script-path witness construction for spending PayTo::Conditions outputs (tari-project#2426)
  fix(engine)!: make the ElGamal value proof sound (tari-project#2425)
  feat(mempool): validate blob references at ingress (tari-project#2424)
  fix(ci): deploy the indexer web UI with pnpm dlx instead of wrangler-action (tari-project#2423)
  ci: deploy the indexer web UI to Cloudflare on release (tari-project#2422)
@sdbondi

sdbondi commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Re-review (through 8d4f7563)

All five walletd/storage/traffic-sim findings from the previous round are resolved. Finding 4 (MaxEpochTooFarAhead not sender-fault) stands and is restated below. Six findings on the current head:


1. crates/transaction_validation/src/epoch_range.rs:55 — high: the ceiling splits the verdict across nodes at an epoch boundary

The new ceiling is a consensus rule evaluated against each node's local epoch (epoch_state.epoch() at the validate_epoch/validate_full call sites in on_receive_new_transaction.rs:205-206, current_view().get_epoch() in the mempool), so the constant being uniform network-wide is not sufficient for nodes to agree.

At a boundary, node A is at E while replica B is still at E-1. A transaction stamped max_epoch = E + 2160 — exactly the ceiling, which is precisely what the long-window offline/multi-party signing case the constant exists for will use — is accepted by A and rejected by B with MaxEpochTooFarAhead.

Unlike CurrentEpochGreaterThanMaximum, whose error direction makes a lagging node more permissive, this rule makes the lagging node stricter, which is the dangerous direction: validate_new_transaction returns Ok(None), the transaction is silently never added to B's pool with no retry, and a block parked on it stays parked. Either evaluate the ceiling against a deterministic epoch, or add boundary slack (current + max_validity + epoch_end_spread-ish) so a one-epoch view difference cannot split the verdict. Classifying the error node-local addresses peer scoring, but not this.

2. applications/tari_walletd/src/handlers/transaction.rs:191 — medium: submit now needs the indexer even when it has nothing to resolve

submit_inner calls context.transaction_builder().await? and then immediately with_unsigned_transaction(req.transaction), which replaces the whole unsigned transaction and discards the max_epoch just fetched. The fetch is an indexer round-trip that can fail, so transactions.submit now errors when the indexer is unreachable even though the caller supplied a complete (possibly already-signed) transaction carrying its own max_epoch. Same pattern at lines 387 (handle_detect_inputs) and 439 (submit_dry_run_inner). These three want a builder that does not resolve an epoch.

3. applications/tari_walletd/web_ui/src/components/IndexerLivenessPill.tsx:76 — medium: pill probes while logged out

The pill is mounted in LayoutMain, the parent route element in App.tsx:238, so it renders while GuardedRoute is still showing the auth dialog. settingsGet() is an authenticated call, so on the login screen every probe throws; after two polls (30s) the pill reads "Disconnected" and the tooltip shows the auth error as if the indexer were down — and it keeps issuing failing authenticated RPCs every 15s. Gate the probe on the auth store's loggedIn.

4. crates/transaction_validation/src/error.rs:126 — low: unbounded overshoot is unpunished (carried over)

MaxEpochTooFarAhead => false makes the mempool report MessageAcceptance::Ignore, so the peer is never scored down. The one-epoch-lag argument justifies leniency for values near the ceiling; a peer gossiping a stream stamped max_epoch = u64::MAX is unambiguously at fault and gets no penalty, after every node has already paid full structural + signature validation (structural runs first, service.rs:274). Treating an overshoot larger than any plausible lag as sender fault keeps the anti-graylist property.

5. applications/tari_walletd/src/handlers/context.rs:299 — low: epoch cache survives an indexer switch

The 30s epoch cache is never invalidated when settings.set repoints the daemon at a different indexer. After switching networks (or to an indexer on a different chain), for up to 30s transaction_max_epoch() stamps a max_epoch derived from the previous network's epoch; if the two chains differ by more than max_transaction_validity_epochs, the transactions are rejected as out of range with no obvious cause. Clearing cached_epoch in the settings-set handler closes it.

6. applications/tari_walletd/src/config.rs:129 — low: boundary is off by one against the ceiling

The guard is > against a hard-copied IMPLAUSIBLE_TRANSACTION_VALIDITY_EPOCHS = 2160, so exactly 2160 passes silently — yet that value stamps max_epoch at exactly the network ceiling, i.e. the case any validator lagging one epoch refuses (finding 1). The constant also has no compile-time link to ConsensusConstants::max_transaction_validity_epochs; if the consensus ceiling is ever lowered, the wallet keeps accepting a config under which every transaction it builds is unsequenceable.


Checked and cleared: the MutexGuard in current_epoch() is dropped before the .await (the scrutinee temporary ends with the if statement), so no deadlock at line 307 and no !Send future; min_epoch > max_epoch is unreachable-but-harmless (rejected at every epoch, so never admitted); the ledger SigningField::MaxEpoch 8-byte read matches Epoch's borsh encoding and the field stays in both ADD_SIGNER_SEQ/SEAL_SEQ; with_unsigned_transaction's fee-builder max_epoch is never read, so the divergence with with_max_epoch is inert.

sdbondi and others added 5 commits August 14, 2026 14:33
* development:
  fix(engine)!: account for confidential commitments in total_supply (tari-project#2421)
  fix(wallet): report the fee actually paid as a transaction's final_fee (tari-project#2427)
…mandatory-epoch-range

* upstream/development:
  feat(wallet-webui): show indexer liveness in the app bar (tari-project#2429)

# Conflicts:
#	applications/tari_walletd/web_ui/src/components/IndexerLivenessPill.tsx
## Motivation
The ceiling on how far ahead `max_epoch` may sit was checked at the consensus
new-transaction gate against each node's own epoch view. Unlike the expiry and
min-epoch rules — which fail permissively for a node whose view lags — this rule
fails *strictly*: a node an epoch behind computes a lower ceiling and refuses a
window a node ahead of it accepts.

Shard groups routinely lag one another by an epoch, so a lagging group would
reject a transaction another group had already sequenced. `validate_new_transaction`
returns `Ok(None)`, so the transaction was never pooled and never retried, and
the sequencing group waited on foreign pledges that could not arrive until the
lagging group caught up. It bit exactly the case the ceiling exists for: a
long-window transaction stamped at precisely `current + max_transaction_validity_epochs`.

## Changes
- Split the ceiling out of `EpochRangeValidator` into
  `TransactionValidityWindowValidator`. `EpochRangeValidator` keeps only the
  rules that are safe wherever a transaction is admitted, and is what the
  consensus sequencing path runs.
- Mempool ingress composes both: a drop there is harmless because nothing has
  been sequenced, and it is what keeps out-of-window traffic out of the network.
- The binding enforcement moves to execution, against the pinned `LockedEpoch`
  that shard groups agree on before executing. Every node evaluates the same
  number and reaches the same verdict, so an out-of-window transaction is
  sequenced as an abort — which all groups adopt — instead of being dropped by
  whichever group was behind. New `AbortReason::ValidityWindowTooLong`.
- `MaxEpochTooFarAhead` is now sender fault beyond twice the ceiling. Near the
  ceiling it stays node-local so a lagging node cannot graylist an honest peer;
  past twice it no epoch view reconciles the value, and leaving it unpunished let
  a flood of unbounded windows extract full structural and signature validation
  from every node for free.
- Wallet daemon: submitting a caller-supplied transaction no longer needs the
  indexer. `transaction_builder_from_unsigned` (backed by
  `TransactionBuilder::from_unsigned`) takes network and `max_epoch` from the
  transaction itself, so `transactions.submit`, `detect_inputs` and the dry run
  no longer fetch an epoch they immediately discard.
- Wallet daemon: `settings.set` invalidates the cached epoch when the indexer
  changes, so a window is never derived from the previous chain's epoch.
- The config warning threshold is pinned to
  `ConsensusConstants::max_transaction_validity_epochs` by a test, via a
  dev-dependency so the wallet binary does not carry the consensus crate.

## Deployment notes
Consensus-breaking: `AbortReason` gains a variant (proto `VALIDITY_WINDOW_TOO_LONG = 10`)
and an out-of-window transaction now aborts at execution rather than being
refused at admission.

## Verification
`cargo nextest --release --all-features` → 1813 passed, 0 failed. clippy and
`fmt --check` clean on the CI-pinned nightly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ation sites

Mempool ingress ran two validators side by side — a structural chain and an
epoch chain — which the caller had to remember to pair correctly. It now takes a
single `Context = Epoch` validator: the structural rules lifted via
`map_context`, then the epoch window, then the admission ceiling.

The consensus path keeps its structural/epoch separation, for two reasons. It
must run a *different* rule set — no validity ceiling, since a lagging shard
group would otherwise refuse to admit a transaction another group has already
sequenced — and separating the halves lets it re-check only the epoch rules for
mempool-originated transactions instead of verifying their signatures a second
time.

Both sites now build on one `create_structural_transaction_validator`, so the
structural rules have a single definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants