-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Language
en-US
Category
core
Abstract
Introduces two time fields (timestamp_ms, valid_until_ms) and a timestamp-keyed replace-by-fee policy. Transactions remain protected by the classic sequential nonce, while intra-block ordering prefers signer-declared transaction timestamps.
Motivation
Sequential nonces provide replay protection but force strict in-order execution and create UX friction around stuck or out-of-order transactions. Adding a signed creation timestamp and a validity window allows clients to:
- express intended execution time bounds,
- replace transactions deterministically without juggling nonces,
- and achieve fairer, less ad-hoc ordering across accounts.
This proposal specifically targets systems that prefer time-first ordering while preserving nonce-based safety and compatibility.
Specification
Transaction fields
The following two optional consensus fields are added to the signed transaction:
timestamp_ms
(uint64
): milliseconds since Unix epoch; signer-declared creation time.valid_until_ms
(uint64
): milliseconds since Unix epoch; exclusive upper bound for inclusion.
These fields are covered by the transaction signature and hash. Nodes and tooling MUST expose them over RPC if present.
Rationale for
uint64
: Covers ~584k years at millisecond resolution; future-proof and compact.
Consensus validation
At inclusion time for transaction tx
in block
:
// Drift parameters (recommendation)
const (
MAX_PAST_DRIFT_MS int64 = 60_000
MAX_FUTURE_DRIFT_MS int64 = 60_000
)
type Tx struct {
From Address
Nonce BigInt // classic sequential nonce (e.g., uint256)
TimestampMS *uint64 // optional
ValidUntilMS *uint64 // optional (exclusive bound)
// ... fee fields, energy, to, value, data, chainId, signature, etc.
}
type Block struct {
TimeMS int64
// ... other fields (slot/epoch if applicable)
}
type State interface {
ExpectedNonce(addr Address) BigInt
}
func ValidateTx(tx Tx, blk Block, st State) error {
// Use tx.timestamp_ms if present, else fallback to block time for ordering (not consensus).
if tx.TimestampMS != nil {
t := int64(*tx.TimestampMS)
if blk.TimeMS < t-MAX_PAST_DRIFT_MS {
return fmt.Errorf("too early for timestamp_ms")
}
if tx.ValidUntilMS != nil {
if blk.TimeMS >= int64(*tx.ValidUntilMS)+MAX_FUTURE_DRIFT_MS {
return fmt.Errorf("expired by valid_until_ms")
}
}
} else if tx.ValidUntilMS != nil {
// If only expiry is set, enforce it.
if blk.TimeMS >= int64(*tx.ValidUntilMS)+MAX_FUTURE_DRIFT_MS {
return fmt.Errorf("expired by valid_until_ms")
}
}
// Classic nonce equality (sequential)
if tx.Nonce.Cmp(st.ExpectedNonce(tx.From)) != 0 {
return fmt.Errorf("nonce mismatch")
}
// signature, balance, energy limit/price checks...
return nil
}
If the underlying chain uses fixed-duration slots/epochs, clients SHOULD perform the comparisons in slot units to avoid wall-clock disputes and proposer manipulation.
Mempool admission
A node admits a transaction to the mempool iff:
local_time_ms >= timestamp_ms - MEMPOOL_PAST_DRIFT_MS
local_time_ms <= valid_until_ms + MEMPOOL_FUTURE_DRIFT_MS
(ifvalid_until_ms
present)- Signature, fee, and basic sanity checks pass.
Transactions are retained until min(valid_until_ms, local_time_ms + MEMPOOL_MAX_RETENTION_MS)
and then dropped if not mined. Suggested MEMPOOL_MAX_RETENTION_MS = 24h
for transactions without an explicit valid_until_ms
.
const (
MEMPOOL_PAST_DRIFT_MS int64 = 60_000
MEMPOOL_FUTURE_DRIFT_MS int64 = 60_000
MEMPOOL_MAX_RETENTION_MS int64 = 86_400_000 // 24h
)
func AdmitToMempool(tx Tx, nowMS int64) bool {
if tx.TimestampMS != nil {
t := int64(*tx.TimestampMS)
if nowMS < t-MEMPOOL_PAST_DRIFT_MS {
return false
}
}
if tx.ValidUntilMS != nil && nowMS > int64(*tx.ValidUntilMS)+MEMPOOL_FUTURE_DRIFT_MS {
return false
}
// signature/fee sanity omitted for brevity
return true
}
Replace-by-fee keyed by timestamp
Define the RBF key as (from, timestamp_ms)
.
- When a new candidate
tx'
arrives with the same(from, timestamp_ms)
as an existingtx
, the mempool keeps only the higher effective fee (per current fee market rules). Ties are broken by higher energy limit, then lexicographic hash. - RBF does not require the same calldata; only the key and higher effective fee.
- Nodes MUST reject replacement candidates that are already expired at evaluation time.
type RBFKey struct {
From Address
TimestampMS uint64 // must be present for timestamp-keyed RBF
}
type PoolEntry struct {
Tx Tx
EffPriorityFee BigInt
EnergyLimit uint64
Hash [32]byte
}
type Mempool struct {
byRBFKey map[RBFKey]PoolEntry
}
func (mp *Mempool) Upsert(tx Tx, nowMS int64) {
if tx.TimestampMS == nil {
// Without timestamp_ms, treat as non-RBF-keyed; handle via legacy policy.
return
}
t := *tx.TimestampMS
// Drop if expired at arrival
if tx.ValidUntilMS != nil && nowMS > int64(*tx.ValidUntilMS)+MEMPOOL_FUTURE_DRIFT_MS {
return
}
key := RBFKey{From: tx.From, TimestampMS: t}
cand := PoolEntry{
Tx: tx,
EffPriorityFee: ComputeEffectivePriorityFee(tx),
EnergyLimit: txEnergyLimit(tx),
Hash: txHash(tx),
}
if prev, ok := mp.byRBFKey[key]; ok {
if better(cand, prev) {
mp.byRBFKey[key] = cand
}
} else {
mp.byRBFKey[key] = cand
}
}
func better(a, b PoolEntry) bool {
if a.EffPriorityFee.Cmp(b.EffPriorityFee) > 0 {
return true
}
if a.EffPriorityFee.Cmp(b.EffPriorityFee) < 0 {
return false
}
if a.EnergyLimit > b.EnergyLimit {
return true
}
if a.EnergyLimit < b.EnergyLimit {
return false
}
return bytes.Compare(a.Hash[:], b.Hash[:]) < 0
}
Why timestamp-keyed RBF?
Users can deterministically “bump” a transaction by resubmitting with the same millisecond timestamp and a higher tip, without touching the nonce. Wallets can keep the nonce equal as well, but the RBF key is the timestamp, not the nonce.
Ordering rules
Within each block:
- Primary key: ascending
timestamp_ms
(earlier creation first), subject to validity checks above. - Secondary key: higher effective fee (priority fee); producers MAY use pure fee as a tie-breaker among equal timestamps.
- Tertiary key: lexicographic transaction hash.
Per-account execution still respects the classic sequential nonce rule: if tx.nonce > expected
, later nonces from that account cannot execute in that block.
func CompareForBlock(a, b PoolEntry) int {
// Primary: timestamp_ms (missing treated as block time; implementer’s choice)
var ta, tb uint64
if a.Tx.TimestampMS != nil { ta = *a.Tx.TimestampMS }
if b.Tx.TimestampMS != nil { tb = *b.Tx.TimestampMS }
if ta < tb { return -1 }
if ta > tb { return 1 }
// Secondary: effective priority fee (descending)
cmp := a.EffPriorityFee.Cmp(b.EffPriorityFee)
if cmp != 0 { return -cmp } // higher first
// Tertiary: tx hash (ascending)
return bytes.Compare(a.Hash[:], b.Hash[:])
}
Since
timestamp_ms
is signer-provided, drift bounds prevent pathological backdating to “jump the queue.”
Replay protection & classic nonce
- The nonce semantics are unchanged:
tx.nonce
MUST equal the sender’s next expected nonce at inclusion. - The new time fields introduce no additional replay surface because they are signed and feed into the transaction hash. Standard
(chainId, nonce, from)
replay safety remains intact.
Handling “big nonces” (including nonces larger than any millisecond timestamp)
Some deployments use very large nonces (e.g., uint128/uint256 counters or values deliberately larger than timestamp_ms
). This proposal keeps nonce semantics independent of timestamp_ms
, so size and ordering of nonces do not interact with timestamp-based RBF.
Two compatibility options:
Option A (Recommended, zero risk): Independent fields
- Keep using any-size sequential nonces.
- RBF is keyed by
(from, timestamp_ms)
only—no comparison between nonce and timestamp. - Wallets SHOULD re-use the same nonce when replacing (to avoid inclusion races), but mempools match purely on timestamp.
Option B (Optional wallet/UI): Nonce normalization base
- Compute
nonce_base = current_nonce_at_activation
per account. - Display a normalized nonce
n' = nonce - nonce_base
for UX without changing consensus.
Operator guidance: Index mempool entries by (from, timestamp_ms)
and store the expected nonce alongside. If a replacement candidate with the same timestamp arrives with a different (e.g., much larger) nonce than currently expected, keep it in a shadow slot and promote it when the account’s expected nonce reaches that value.
type ShadowQueue struct {
Ready map[RBFKey]PoolEntry
Shadow map[RBFKey][]PoolEntry // queued by future nonces
}
func (sq *ShadowQueue) PromoteIfReady(key RBFKey, expected BigInt) {
// Move any shadow entries whose nonce == expected into Ready.
entries := sq.Shadow[key]
kept := entries[:0]
for _, pe := range entries {
if pe.Tx.Nonce.Cmp(expected) == 0 {
cur, ok := sq.Ready[key]
if !ok || better(pe, cur) {
sq.Ready[key] = pe
}
} else {
kept = append(kept, pe)
}
}
sq.Shadow[key] = kept
}
Encoding
If the protocol uses RLP or SSZ for transactions, append the two fields as optional tails:
// Conceptual layout (when present):
// timestamp_ms : uint64
// valid_until_ms : uint64
//
// Both MUST be included in the signing payload when present.
Reference validation (concise, Go)
func Validate(tx Tx, blk Block, st State) error {
// Enforce timestamp and expiry if provided
if tx.TimestampMS != nil {
t := int64(*tx.TimestampMS)
if blk.TimeMS < t-MAX_PAST_DRIFT_MS {
return fmt.Errorf("too early")
}
}
if tx.ValidUntilMS != nil {
if blk.TimeMS >= int64(*tx.ValidUntilMS)+MAX_FUTURE_DRIFT_MS {
return fmt.Errorf("expired")
}
}
// Classic sequential nonce
if tx.Nonce.Cmp(st.ExpectedNonce(tx.From)) != 0 {
return fmt.Errorf("nonce mismatch")
}
// ... signature, balance, energy checks
return nil
}
Mempool replacement policy (concise, Go)
- Key:
(from, timestamp_ms)
- Keep: only the candidate with the highest effective priority fee
- Drop: expired candidates; those outside admission drift; those failing minimal economic constraints
func InsertOrReplace(mp *Mempool, tx Tx, nowMS int64) {
if !AdmitToMempool(tx, nowMS) {
return
}
if tx.TimestampMS == nil {
// handle separately (legacy path)
return
}
mp.Upsert(tx, nowMS)
}
Parameters (suggested starting points)
MAX_PAST_DRIFT_MS = 60_000
MAX_FUTURE_DRIFT_MS = 60_000
MEMPOOL_PAST_DRIFT_MS = 60_000
MEMPOOL_FUTURE_DRIFT_MS = 60_000
MEMPOOL_MAX_RETENTION_MS = 86_400_000
(24h)
Wallet & UI guidance
- Show “Valid until” and warn on imminent expiry.
- For fee bump: re-use identical
timestamp_ms
and increase the tip. - Keep the same nonce on replacements to avoid premature invalidation at inclusion time.
- If scheduling transactions, set
valid_until_ms
conservatively (e.g., +5–15 minutes).
Examples
Example 1: simple transfer
{
"from": "cb…",
"to": "cb…",
"value": "1000000000000000000",
"nonce": 42,
"energy": 21000,
"maxFeePerEnergy": 50,
"maxPriorityFeePerEnergy": 2,
"timestamp_ms": 1724467200123,
"valid_until_ms": 1724467500123,
"signature": "…"
}
Example 2: fee-bump (RBF) using the same timestamp
{
"from": "cb…",
"to": "cb…",
"value": "1000000000000000000",
"nonce": 42,
"energy": 21000,
"maxFeePerEnergy": 80,
"maxPriorityFeePerEnergy": 4,
"timestamp_ms": 1724467200123, // identical
"valid_until_ms": 1724467500123,
"signature": "…"
}
Rationale
The proposal keeps the classic sequential nonce for replay protection and linearity, while adding timestamp_ms
and valid_until_ms
for time-bounded execution. Replace-by-fee is keyed by (from, timestamp_ms)
, so users bump fees by resubmitting the same timestamp with a higher tip-no nonce juggling. All fee amounts and pricing surfaces are expressed in Nucle (ꞥ), Core’s main fee unit (10⁻⁹ Core), aligning the transaction pool and wallet UX with Core’s denomination standard. Core-value displays stay in Core (₡), while low-level accounting can use Ore when needed.
Backwards Compatibility
Nodes that ignore the new fields continue to process transactions in fee-first order with classic nonces. Networks can introduce the fields behind a fork-activated version bit. Tooling changes are incremental: wire types, RPC reflection, and mempool indexing by (from, timestamp_ms)
.
Transactions that omit the new fields continue to function as today. If timestamp_ms
is absent, nodes behave as if timestamp_ms = block_time_ms
for ordering only (non-consensus). If valid_until_ms
is absent, nodes treat it as infinite (no expiry).
Test Cases
-
Valid within drift
timestamp_ms = now − 500ms
,valid_until_ms = now + 3000ms
.
Admitted to pool; eligible for inclusion. -
Expired
valid_until_ms = now − 1ms
.
Rejected at admission. -
Future-dated beyond drift
timestamp_ms = now + 5000ms
, drift = 1000ms.
Rejected until within drift window. -
RBF with same timestamp (fee bump in Nucle)
Tx₁:timestamp_ms = T
,priority_fee = 200 ꞥ
.
Tx₂:timestamp_ms = T
,priority_fee = 400 ꞥ
.
Pool retains Tx₂ (higher effective priority fee). -
Different timestamp, same nonce
Tx₁:nonce = 42
,timestamp_ms = T
.
Tx₂:nonce = 42
,timestamp_ms = T + 2ms
.
Both can sit in pool (distinct RBF keys), but only one executes at nonce 42. -
Big nonce (e.g., 2⁶⁴ + 5)
Valid: RBF keying uses(from, timestamp_ms)
; nonce size is orthogonal. (Nonce equality still enforced at inclusion.)
Implementation
Consensus
- Extend tx encoding with optional
timestamp_ms
,valid_until_ms
. - Validate: drift-bounded
timestamp_ms
/valid_until_ms
; nonce equality; signature/balance/energy as usual.
Core Transaction Pool
- Index by RBF key
(from, timestamp_ms)
. - Replacement rule: keep only the candidate with the highest effective priority fee in Nucle (ꞥ); break ties by higher energy limit, then hash.
- Admission/retention honor drift and expiry; evict after
valid_until_ms
.
Wallets & SDKs
- Include
timestamp_ms
,valid_until_ms
in the signing payload. - Fee bump UX: resubmit with identical
timestamp_ms
and higher priority fee in Nucle (ꞥ). - Display values in Core (₡); show fees in Nucle (ꞥ); allow advanced views in Ore for precision.
Security Considerations
- Timestamp manipulation: bounded drift prevents severe backdating/future-dating. Producers remain free to order within equal timestamps by fee.
- Spam near expiry: near-expiring RBF storms are mitigated by standard DoS protections: per-peer rate limits, minimum tip requirements, and surge pricing.
- Privacy:
timestamp_ms
leaks intended creation time; wallets MAY round to coarse buckets (e.g., nearest 250ms) if needed.
Rationale & alternatives
- Keeps classic nonce for maximum compatibility and replay safety.
- Chooses timestamp-keyed RBF to simplify user mental model: “same timestamp = same intent, higher fee wins.”
- Milliseconds provide enough granularity to avoid accidental collisions while staying compact.
- Alternatives considered: lane-based parallelism; pure fee ordering; validity windows without timestamp-keyed RBF.
Requires
No response
Replaces
No response
Superseded by
No response