feat(transaction)!: make max_epoch mandatory and bound the validity window - #2419
feat(transaction)!: make max_epoch mandatory and bound the validity window#2419sdbondi wants to merge 11 commits into
Conversation
…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>
353445c to
0228754
Compare
…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>
ReviewThe mechanical 1.
Scenario: the indexer is restarting when the 30s auto-claim tick fires; The enclosing loop already resolves 2. The new const was inserted directly under 3.
4. It is classified Scenario: a peer floods transactions stamped 5.
6.
|
- 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)
Re-review (through
|
* 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>
Motivation
max_epochwas optional with no upper bound, so a signed transaction stayed sequenceable forever. Two consequences:This is not about replay protection. Committed-id dedup is already permanent via the receipt-existence gate (#2381): the
TransactionReceiptsubstate 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) at12 * 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>→EpochonUnsignedTransactionV1, its pruned mirror, the signing-domain projection (TransactionSignatureFields) andTransactionPoolRecord. 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.
EpochRangeValidatorgains the upper-bound rule and is constructed with the constant. The newMaxEpochTooFarAheaderror 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 existingCurrentEpochLessThanMinimum/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, default3≈ 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.getexposescurrent_epochand the default window so callers that build transactions themselves — wallet CLI, traffic-sim — can pick a valid window without a new endpoint.Ledger.
SigningField::MaxEpochnow carries a bare little-endianu64rather than a borshOption, and the device always displays it. Field tag numbering is unchanged.Deployment notes
Consensus-breaking and a signing-domain break. The
max_epochfield encoding changes (borshOption<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
EpochRangeValidatorunit tests: inside-window, beforemin_epoch, expired, beyond the ceiling, inclusive boundary, and a saturating ceiling that admits anymax_epoch.preimage_field_tags_match_protocolplus the seal / add-signer / stealth recipe tests.OOTLE_REGEN_FIXTURES=1).bindings/package.jsonbumped to 1.49.0, dist rebuilt.cargo lints clippy --all-targetsclean;cargo +nightly-2025-12-05 fmt --allapplied.cargo nextest r -E "not package(integration_tests)" --no-fail-fast --release→ 1772 passed, 0 failed.