+
Skip to content

[CIP] "Slot-in" tx WIP #59

@rastislavcore

Description

@rastislavcore

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:

  1. local_time_ms >= timestamp_ms - MEMPOOL_PAST_DRIFT_MS
  2. local_time_ms <= valid_until_ms + MEMPOOL_FUTURE_DRIFT_MS (if valid_until_ms present)
  3. 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 existing tx, 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:

  1. Primary key: ascending timestamp_ms (earlier creation first), subject to validity checks above.
  2. Secondary key: higher effective fee (priority fee); producers MAY use pure fee as a tie-breaker among equal timestamps.
  3. 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

  1. Valid within drift
    timestamp_ms = now − 500ms, valid_until_ms = now + 3000ms.
    Admitted to pool; eligible for inclusion.

  2. Expired
    valid_until_ms = now − 1ms.
    Rejected at admission.

  3. Future-dated beyond drift
    timestamp_ms = now + 5000ms, drift = 1000ms.
    Rejected until within drift window.

  4. 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).

  5. 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.

  6. 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

Metadata

Metadata

Assignees

Labels

cipCore Improvement ProposaldraftCIP that is open for consideration

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions

    点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载