From 53ca4a507ac5ad70623233dbab28d94c0809fdb4 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Sun, 10 May 2026 16:58:55 +0000 Subject: [PATCH 1/3] docs(tip-1040): epoch-scoped temporary storage precompile Reopened from #3366. Epoch-based expiring storage with automatic pruning. Values live for 2^16 to 2^17 blocks, scoped per epoch account. Amp-Thread-ID: https://ampcode.com/threads/T-019e12a5-b71d-72db-afef-0fcd16f065d5 --- tips/tip-1040.md | 253 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 tips/tip-1040.md diff --git a/tips/tip-1040.md b/tips/tip-1040.md new file mode 100644 index 0000000000..2d492638e4 --- /dev/null +++ b/tips/tip-1040.md @@ -0,0 +1,253 @@ +--- +id: TIP-1040 +title: Epoch-Scoped Temporary Storage Precompile +description: A precompile providing cheap temporary key-value storage that automatically expires after one to two epochs. +authors: Dankrad +status: Draft +related: N/A +protocolVersion: TBD +--- + +# TIP-1040: Epoch-Scoped Temporary Storage Precompile + +## Abstract + +This TIP introduces a precompile that provides temporary key-value storage scoped to epochs. An epoch is 2^16 (65,536) blocks. Data written in a given epoch is readable during that epoch and the next, then automatically becomes inaccessible. This gives stored values a lifetime of between 2^16 and 2^17 blocks depending on when within the epoch the write occurs. Nodes may prune storage from epochs older than the previous one. + +## Motivation + +Certain use cases need on-chain data availability for a bounded time window rather than permanent storage — for example time-limited approvals, ephemeral session state, or temporary commitments. Today these patterns require permanent `SSTORE` writes and separate cleanup transactions, wasting gas and bloating state. + +Epoch-scoped temporary storage solves this by: + +1. **Eliminating cleanup cost** — stale data becomes inaccessible automatically, no explicit deletion needed. +2. **Reducing long-term state growth** — nodes can prune expired storage, keeping the state trie bounded. +3. **Cheaper writes** — 40,000 gas for new slots; overwrites within the same epoch follow standard SSTORE hot/cold pricing, much cheaper than a fresh write. + + +## Assumptions + +- The block number is always available to the precompile at execution time. +- Nodes maintain at least two epoch accounts (current and previous) at all times. A node that has pruned the previous epoch's account is non-conforming. +- The precompile address and the address range `[PRECOMPILE_ADDRESS + 1, ...]` are reserved and not used by any existing contract or EOA. +- Epoch boundaries are deterministic: `epoch = block_number >> 16`. +- Storage keys are collision-resistant due to the full `keccak256(sender, key)` derivation (256 bits); two different senders cannot write to the same slot. + +If the assumption that nodes keep two epoch accounts is violated (e.g., a node prunes the previous epoch account early), `temporaryLoad` may incorrectly return zero for data that should still be accessible. + +--- + +# Specification + +## Constants + +| Name | Value | Description | +|------|-------|-------------| +| `EPOCH_LENGTH` | 2^16 (65,536 blocks) | Number of blocks per epoch | +| `PRECOMPILE_ADDRESS` | TBD | Reserved address for the temporary storage precompile | +| `TEMPORARY_STORE_GAS_NEW` | 40,000 | Gas cost for `temporaryStore` when the slot is zero in the current epoch | +| `TEMPORARY_STORE_GAS_EXISTING_COLD` | 5,000 | Gas cost for `temporaryStore` when the slot is already nonzero in the current epoch and cold (same as `SSTORE_RESET_GAS`) | +| `TEMPORARY_STORE_GAS_EXISTING_WARM` | 200 | Gas cost for `temporaryStore` when the slot is already nonzero in the current epoch and warm (same as `WARM_STORAGE_READ_COST + SSTORE_WRITE_GAS_DELTA`) | +| `COLD_SLOAD_COST` | 2,100 | Additional cold access surcharge applied once per slot per transaction | +| `TEMPORARY_LOAD_GAS_COLD` | 2,100 | Gas cost for `temporaryLoad` on a cold slot (same as `COLD_SLOAD_COST`) | +| `TEMPORARY_LOAD_GAS_WARM` | 100 | Gas cost for `temporaryLoad` on a warm slot (same as `WARM_STORAGE_READ_COST`) | + +## Epoch Derivation + +``` +epoch(block_number) = block_number >> 16 +``` + +The current epoch is `epoch(block.number)`. The previous epoch is `epoch(block.number) - 1`. At genesis (epoch 0), there is no previous epoch to fall back to. + +## Storage Layout + +Each epoch's temporary storage lives in a **separate account**. Epoch `n` is stored in the account at address `PRECOMPILE_ADDRESS + n + 1`. The `+ 1` offset reserves `PRECOMPILE_ADDRESS` itself for the precompile dispatch logic. + +``` +epoch_account(n) = PRECOMPILE_ADDRESS + n + 1 +``` + +Within each epoch account, storage keys are derived from the caller and key: + +``` +storage_key = keccak256(sender || key) +``` + +This is the full 32-byte hash — no truncation is needed since the epoch is encoded in the account address rather than the storage key, giving 256 bits of collision resistance. + +**Pruning advantage**: Nodes can prune an entire expired epoch by dropping the account at `PRECOMPILE_ADDRESS + n + 1` and its storage trie in one operation, rather than scanning individual slots within a single large trie. Note that this dropping of storage is done out of consensus -- there is no actual deletion happening and the account trie does not change, the node can simply forget the storage for the account because it is guaranteed to not be needed again any time in the future. + +## Precompile Interface + +### `temporaryStore(bytes32 key, bytes32 value)` + +Stores `value` at the derived slot in the current epoch's storage tree. + +**Selector**: `0x` + first 4 bytes of `keccak256("temporaryStore(bytes32,bytes32)")` + +**Input**: ABI-encoded `(bytes32 key, bytes32 value)` — 64 bytes after the 4-byte selector. + +**Behavior**: + +1. Compute `storage_key = keccak256(msg.sender || key)`. +2. Compute `epoch_account = PRECOMPILE_ADDRESS + epoch(block.number) + 1`. +3. Read the existing value at `storage_key` in `epoch_account`'s storage. +4. Write `value` at `storage_key` in `epoch_account`'s storage. + +**Gas**: + +The gas cost depends on whether the slot already holds a nonzero value **in the current epoch's tree**. A nonzero value in the previous epoch's tree does not count — only the current epoch matters. + +- **New slot** (current epoch value is zero): `TEMPORARY_STORE_GAS_NEW` (40,000 gas). No cold/warm surcharge. +- **Existing slot** (current epoch value is nonzero): + - Cold (first access to this slot in the transaction): `COLD_SLOAD_COST` (2,100) + `TEMPORARY_STORE_GAS_EXISTING_COLD` (5,000) = 7,100 gas. + - Warm (slot already accessed in this transaction): `TEMPORARY_STORE_GAS_EXISTING_WARM` (200 gas). + +A `temporaryStore` to a new slot also marks it as warm for subsequent accesses. + +**Output**: Empty (0 bytes). Execution succeeds silently. + +**Error**: Reverts only if insufficient gas is provided. + +### `temporaryLoad(bytes32 key) returns (bytes32)` + +Loads the value for the derived slot, checking the current epoch first and falling back to the previous epoch. + +**Selector**: `0x` + first 4 bytes of `keccak256("temporaryLoad(bytes32)")` + +**Input**: ABI-encoded `(bytes32 key)` — 32 bytes after the 4-byte selector. + +**Behavior**: + +1. Compute `storage_key = keccak256(msg.sender || key)`. +2. Compute `current_account = PRECOMPILE_ADDRESS + epoch(block.number) + 1`. +3. Look up `storage_key` in `current_account`'s storage. If a non-zero value exists, return it. +4. If `epoch(block.number) == 0`, return `bytes32(0)` (no previous epoch exists). +5. Compute `previous_account = PRECOMPILE_ADDRESS + epoch(block.number)`. +6. Look up `storage_key` in `previous_account`'s storage. If a non-zero value exists, return it. +7. Return `bytes32(0)`. + +**Gas**: Follows the EIP-2929 access-list model. Each `(account, storage_key)` pair is tracked independently for hot/cold purposes: +- First access to a given `(account, storage_key)` within the transaction: `TEMPORARY_LOAD_GAS_COLD` (2,100 gas). +- Subsequent accesses to the same `(account, storage_key)`: `TEMPORARY_LOAD_GAS_WARM` (100 gas). +- If the fallback to the previous epoch is triggered, `(previous_account, storage_key)` is a different entry and charged independently. + +**Output**: ABI-encoded `bytes32` (32 bytes). + +## Warm/Cold Tracking + +Both `temporaryStore` and `temporaryLoad` participate in a shared per-transaction access set keyed by `(account, storage_key)`. Any access (store or load) to an `(account, storage_key)` pair marks it warm. The warm/cold distinction affects gas costs for both functions as described above. A new-slot store (40,000 gas) also marks the `(epoch_account, storage_key)` warm. + +## Epoch Transition + +When `block.number` crosses an epoch boundary (i.e., `epoch(block.number) != epoch(block.number - 1)`): + +1. The account at `PRECOMPILE_ADDRESS + epoch(block.number) - 1` (two epochs ago) becomes unreachable by the precompile and may be pruned by the node. +2. The account at `PRECOMPILE_ADDRESS + epoch(block.number) + 1` (current epoch) begins accepting writes. +3. The account at `PRECOMPILE_ADDRESS + epoch(block.number)` (previous epoch) remains accessible for read fallback. + +Nodes MUST retain the accounts for the current and previous epoch. Nodes MAY delete any older epoch account and its entire storage trie, however they MUST retain all information that is still needed for future operation, such as the Merkle root of the storage trie in order to be able to update the accounts trie in the future. + +## Node Storage Requirements + +Each epoch's data lives in its own account, so pruning is a simple account deletion — no key scanning required. At steady state, the precompile system maintains at most two epoch accounts (current + previous), bounding total storage to `2 × (number of unique slots written per epoch)` entries. This is a significant improvement over permanent storage, where state only grows. + +## Calldata Encoding + +Both functions use standard Solidity ABI encoding: + +``` +temporaryStore: 0x (4 + 32 + 32 = 68 bytes) +temporaryLoad: 0x (4 + 32 = 36 bytes) +``` + +## Reference Solidity Mock + +This mock demonstrates the exact storage key derivation and fallback logic. The real precompile is implemented natively in the node; this contract is for specification clarity and testing only. Gas costs are not modeled here — the precompile charges gas according to the rules above, not via Solidity's native SSTORE/SLOAD pricing. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title TemporaryStorageMock +/// @notice Reference mock for TIP-1040. NOT the real precompile — gas costs differ. +/// The real precompile uses separate accounts per epoch; this mock simulates +/// that by delegatecalling to epoch-derived addresses. In practice the node +/// implements this natively without any DELEGATECALL overhead. +contract TemporaryStorageMock { + /// @dev Base address for epoch accounts. Epoch n stores at PRECOMPILE + n + 1. + /// In the real precompile this is the precompile's own address. + address public immutable PRECOMPILE; + + constructor() { + PRECOMPILE = address(this); + } + + /// @notice Store a value in the current epoch's temporary storage. + /// @param key Caller-chosen 32-byte key. + /// @param value The value to store. + function temporaryStore(bytes32 key, bytes32 value) external { + bytes32 storageKey = _storageKey(msg.sender, key); + // In the real precompile, this writes to the epoch account's storage. + // Here we simulate it by writing to this contract's storage with a + // key that incorporates the epoch, since we can't create real accounts. + bytes32 epochedKey = keccak256(abi.encodePacked(_epochAccount(_currentEpoch()), storageKey)); + assembly { + sstore(epochedKey, value) + } + } + + /// @notice Load a value from temporary storage. + /// Checks the current epoch first, falls back to the previous epoch. + /// @param key Caller-chosen 32-byte key. + /// @return result The stored value, or bytes32(0) if not found in either epoch. + function temporaryLoad(bytes32 key) external view returns (bytes32 result) { + uint32 currentEpoch = _currentEpoch(); + bytes32 storageKey = _storageKey(msg.sender, key); + + // Try current epoch account + bytes32 epochedKey = keccak256(abi.encodePacked(_epochAccount(currentEpoch), storageKey)); + assembly { + result := sload(epochedKey) + } + if (result != bytes32(0)) { + return result; + } + + // Fall back to previous epoch account + if (currentEpoch == 0) { + return bytes32(0); + } + bytes32 prevEpochedKey = keccak256(abi.encodePacked(_epochAccount(currentEpoch - 1), storageKey)); + assembly { + result := sload(prevEpochedKey) + } + } + + /// @dev Compute the storage key within an epoch account: keccak256(sender || key). + function _storageKey(address sender, bytes32 key) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(sender, key)); + } + + /// @dev Compute the epoch account address: PRECOMPILE + epoch + 1. + function _epochAccount(uint32 epoch) internal view returns (address) { + return address(uint160(PRECOMPILE) + uint160(epoch) + 1); + } + + /// @dev Current epoch index: block.number >> 16, truncated to uint32. + function _currentEpoch() internal view returns (uint32) { + return uint32(block.number >> 16); + } +} +``` + +# Invariants + +1. **Bounded lifetime**: A value written in epoch `E` MUST be readable in epochs `E` and `E+1`, and MUST NOT be readable from epoch `E+2` onward. +2. **Sender isolation**: `keccak256(sender_a || key) != keccak256(sender_b || key)` for `sender_a != sender_b` — different senders cannot read or overwrite each other's storage for the same key. +3. **Write-read consistency**: A `temporaryStore(key, value)` followed by `temporaryLoad(key)` in the same transaction MUST return `value`. +4. **Zero default**: `temporaryLoad` for a key that has never been written (in the current or previous epoch) MUST return `bytes32(0)`. +5. **Epoch account pruning safety**: Deleting any epoch account older than `PRECOMPILE_ADDRESS + epoch(block.number)` MUST NOT affect the result of any `temporaryLoad` call. +6. **Deterministic gas**: `temporaryStore` costs 40,000 gas for new slots (zero in current epoch) and follows SSTORE-equivalent hot/cold pricing for existing slots (nonzero in current epoch). A nonzero value in the previous epoch alone does not make the slot "existing". `temporaryLoad` cost depends only on the cold/warm state of the accessed slots within the transaction. +7. **Overwrite semantics**: A second `temporaryStore(key, value2)` in the same epoch overwrites the previous value. `temporaryLoad(key)` MUST return `value2`. From 714b990ea0a3481c9a417caba2ec4dd1bf6c39e2 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Sun, 10 May 2026 17:37:46 +0000 Subject: [PATCH 2/3] docs(tip-1040): extend to N-epoch lifetimes with tiered pricing Key changes from original: - temporaryStore now takes numEpochs (1-512) for configurable lifetime - Tiered gas pricing: 20k (1 epoch) -> 250k (181-512 epochs) - temporaryLoad scans backwards from current epoch to find live entries - New temporaryLoadExpiry returns value + remaining lifetime - MAX_EXPIRY_EPOCHS=512 covers ~1 year at 1s blocks - Pruning remains deterministic: epoch account E droppable when current_epoch > E + MAX_EXPIRY_EPOCHS Motivated by MPP session duration flexibility. Amp-Thread-ID: https://ampcode.com/threads/T-019e12a5-b71d-72db-afef-0fcd16f065d5 --- tips/tip-1040.md | 282 +++++++++++++++++++++++++++++++---------------- 1 file changed, 184 insertions(+), 98 deletions(-) diff --git a/tips/tip-1040.md b/tips/tip-1040.md index 2d492638e4..edb31735ee 100644 --- a/tips/tip-1040.md +++ b/tips/tip-1040.md @@ -1,7 +1,7 @@ --- id: TIP-1040 title: Epoch-Scoped Temporary Storage Precompile -description: A precompile providing cheap temporary key-value storage that automatically expires after one to two epochs. +description: A precompile providing cheap temporary key-value storage that expires after a caller-chosen number of epochs, with tiered gas pricing. authors: Dankrad status: Draft related: N/A @@ -12,29 +12,28 @@ protocolVersion: TBD ## Abstract -This TIP introduces a precompile that provides temporary key-value storage scoped to epochs. An epoch is 2^16 (65,536) blocks. Data written in a given epoch is readable during that epoch and the next, then automatically becomes inaccessible. This gives stored values a lifetime of between 2^16 and 2^17 blocks depending on when within the epoch the write occurs. Nodes may prune storage from epochs older than the previous one. +This TIP introduces a precompile that provides temporary key-value storage scoped to epochs. An epoch is 2^16 (65,536) blocks. The caller chooses how many epochs the data should live for (1 to `MAX_EXPIRY_EPOCHS`). Data written in epoch `E` with duration `N` epochs is readable from epoch `E` through epoch `E + N`, then automatically becomes inaccessible. Gas pricing scales with the requested lifetime — short-lived entries are cheap, while long-lived entries approach permanent storage cost. ## Motivation -Certain use cases need on-chain data availability for a bounded time window rather than permanent storage — for example time-limited approvals, ephemeral session state, or temporary commitments. Today these patterns require permanent `SSTORE` writes and separate cleanup transactions, wasting gas and bloating state. +Certain use cases need on-chain data availability for a bounded time window rather than permanent storage — for example time-limited approvals, ephemeral session state, MPP session keys, or temporary commitments. Today these patterns require permanent `SSTORE` writes and separate cleanup transactions, wasting gas and bloating state. -Epoch-scoped temporary storage solves this by: +The original single-epoch design (values live for 1–2 epochs) was too rigid for use cases like MPP sessions that need configurable durations. This revision extends the model to support N-epoch lifetimes while preserving the core benefits: 1. **Eliminating cleanup cost** — stale data becomes inaccessible automatically, no explicit deletion needed. 2. **Reducing long-term state growth** — nodes can prune expired storage, keeping the state trie bounded. -3. **Cheaper writes** — 40,000 gas for new slots; overwrites within the same epoch follow standard SSTORE hot/cold pricing, much cheaper than a fresh write. +3. **Configurable duration** — callers choose how long data lives, paying proportionally. +4. **Cheaper writes** — short-lived storage starts at 20,000 gas, well below permanent storage cost. ## Assumptions - The block number is always available to the precompile at execution time. -- Nodes maintain at least two epoch accounts (current and previous) at all times. A node that has pruned the previous epoch's account is non-conforming. +- Nodes maintain epoch accounts for all non-expired epochs. A node that has pruned a non-expired epoch account is non-conforming. - The precompile address and the address range `[PRECOMPILE_ADDRESS + 1, ...]` are reserved and not used by any existing contract or EOA. - Epoch boundaries are deterministic: `epoch = block_number >> 16`. - Storage keys are collision-resistant due to the full `keccak256(sender, key)` derivation (256 bits); two different senders cannot write to the same slot. -If the assumption that nodes keep two epoch accounts is violated (e.g., a node prunes the previous epoch account early), `temporaryLoad` may incorrectly return zero for data that should still be accessible. - --- # Specification @@ -44,13 +43,13 @@ If the assumption that nodes keep two epoch accounts is violated (e.g., a node p | Name | Value | Description | |------|-------|-------------| | `EPOCH_LENGTH` | 2^16 (65,536 blocks) | Number of blocks per epoch | +| `MAX_EXPIRY_EPOCHS` | 512 | Maximum number of additional epochs a value can live (~1 year at 1s blocks) | | `PRECOMPILE_ADDRESS` | TBD | Reserved address for the temporary storage precompile | -| `TEMPORARY_STORE_GAS_NEW` | 40,000 | Gas cost for `temporaryStore` when the slot is zero in the current epoch | -| `TEMPORARY_STORE_GAS_EXISTING_COLD` | 5,000 | Gas cost for `temporaryStore` when the slot is already nonzero in the current epoch and cold (same as `SSTORE_RESET_GAS`) | -| `TEMPORARY_STORE_GAS_EXISTING_WARM` | 200 | Gas cost for `temporaryStore` when the slot is already nonzero in the current epoch and warm (same as `WARM_STORAGE_READ_COST + SSTORE_WRITE_GAS_DELTA`) | -| `COLD_SLOAD_COST` | 2,100 | Additional cold access surcharge applied once per slot per transaction | -| `TEMPORARY_LOAD_GAS_COLD` | 2,100 | Gas cost for `temporaryLoad` on a cold slot (same as `COLD_SLOAD_COST`) | -| `TEMPORARY_LOAD_GAS_WARM` | 100 | Gas cost for `temporaryLoad` on a warm slot (same as `WARM_STORAGE_READ_COST`) | +| `COLD_SLOAD_COST` | 2,100 | Cold access surcharge per slot per transaction | +| `TEMPORARY_LOAD_GAS_COLD` | 2,100 | Gas cost for `temporaryLoad` on a cold slot | +| `TEMPORARY_LOAD_GAS_WARM` | 100 | Gas cost for `temporaryLoad` on a warm slot | +| `TEMPORARY_STORE_GAS_EXISTING_COLD` | 5,000 | Gas cost for overwriting an existing slot (cold) | +| `TEMPORARY_STORE_GAS_EXISTING_WARM` | 200 | Gas cost for overwriting an existing slot (warm) | ## Epoch Derivation @@ -58,7 +57,7 @@ If the assumption that nodes keep two epoch accounts is violated (e.g., a node p epoch(block_number) = block_number >> 16 ``` -The current epoch is `epoch(block.number)`. The previous epoch is `epoch(block.number) - 1`. At genesis (epoch 0), there is no previous epoch to fall back to. +The current epoch is `epoch(block.number)`. At genesis (epoch 0), there are no previous epochs. ## Storage Layout @@ -68,51 +67,69 @@ Each epoch's temporary storage lives in a **separate account**. Epoch `n` is sto epoch_account(n) = PRECOMPILE_ADDRESS + n + 1 ``` -Within each epoch account, storage keys are derived from the caller and key: +Within each epoch account, storage uses **two slots per entry**: ``` -storage_key = keccak256(sender || key) +value_key = keccak256(sender || key) +metadata_key = keccak256(sender || key || 0x01) ``` -This is the full 32-byte hash — no truncation is needed since the epoch is encoded in the account address rather than the storage key, giving 256 bits of collision resistance. +The value slot stores the caller's value. The metadata slot stores the expiry epoch as a `uint32` in the low 32 bits. + +**Why store in the write-epoch account**: The entry is always stored in the epoch account corresponding to when it was written. The metadata records the expiry epoch so that `temporaryLoad` can check liveness, and so that nodes know when to prune. -**Pruning advantage**: Nodes can prune an entire expired epoch by dropping the account at `PRECOMPILE_ADDRESS + n + 1` and its storage trie in one operation, rather than scanning individual slots within a single large trie. Note that this dropping of storage is done out of consensus -- there is no actual deletion happening and the account trie does not change, the node can simply forget the storage for the account because it is guaranteed to not be needed again any time in the future. +**Pruning advantage**: Nodes can prune an entire expired epoch account once all entries in it have expired. Since entries in epoch `E` can live at most until epoch `E + MAX_EXPIRY_EPOCHS`, the account at `PRECOMPILE_ADDRESS + E + 1` can always be dropped once `current_epoch > E + MAX_EXPIRY_EPOCHS`. This is a simple, deterministic check — no per-slot scanning required. ## Precompile Interface -### `temporaryStore(bytes32 key, bytes32 value)` +### `temporaryStore(bytes32 key, bytes32 value, uint16 numEpochs)` -Stores `value` at the derived slot in the current epoch's storage tree. +Stores `value` at the derived slot in the current epoch's storage tree, with an expiry of `numEpochs` additional epochs. -**Selector**: `0x` + first 4 bytes of `keccak256("temporaryStore(bytes32,bytes32)")` +**Selector**: `0x` + first 4 bytes of `keccak256("temporaryStore(bytes32,bytes32,uint16)")` -**Input**: ABI-encoded `(bytes32 key, bytes32 value)` — 64 bytes after the 4-byte selector. +**Input**: ABI-encoded `(bytes32 key, bytes32 value, uint16 numEpochs)` — 96 bytes after the 4-byte selector. **Behavior**: -1. Compute `storage_key = keccak256(msg.sender || key)`. -2. Compute `epoch_account = PRECOMPILE_ADDRESS + epoch(block.number) + 1`. -3. Read the existing value at `storage_key` in `epoch_account`'s storage. -4. Write `value` at `storage_key` in `epoch_account`'s storage. +1. Validate `numEpochs >= 1` and `numEpochs <= MAX_EXPIRY_EPOCHS`. Revert if out of range. +2. Compute `storage_key = keccak256(msg.sender || key)`. +3. Compute `metadata_key = keccak256(msg.sender || key || 0x01)`. +4. Compute `epoch_account = PRECOMPILE_ADDRESS + epoch(block.number) + 1`. +5. Compute `expiry_epoch = epoch(block.number) + numEpochs`. +6. Write `value` at `storage_key` in `epoch_account`'s storage. +7. Write `expiry_epoch` at `metadata_key` in `epoch_account`'s storage. + +If the caller previously wrote to the same `(sender, key)` in an earlier epoch that is still live, the old entry becomes shadowed. `temporaryLoad` will find the new entry first (see load behavior below). The old entry will be pruned when its epoch account expires. **Gas**: -The gas cost depends on whether the slot already holds a nonzero value **in the current epoch's tree**. A nonzero value in the previous epoch's tree does not count — only the current epoch matters. +Gas cost is determined by the requested `numEpochs` lifetime: + +| Lifetime (numEpochs) | Approximate Duration (1s blocks) | Gas | +|---|---|---| +| 1 | ~18 hours | 20,000 | +| 2–4 | ~1–3 days | 40,000 | +| 5–10 | ~4–7 days | 80,000 | +| 11–45 | ~1–4 weeks | 120,000 | +| 46–180 | ~1–4 months | 200,000 | +| 181–512 | ~4–12 months | 250,000 | -- **New slot** (current epoch value is zero): `TEMPORARY_STORE_GAS_NEW` (40,000 gas). No cold/warm surcharge. -- **Existing slot** (current epoch value is nonzero): - - Cold (first access to this slot in the transaction): `COLD_SLOAD_COST` (2,100) + `TEMPORARY_STORE_GAS_EXISTING_COLD` (5,000) = 7,100 gas. - - Warm (slot already accessed in this transaction): `TEMPORARY_STORE_GAS_EXISTING_WARM` (200 gas). +These tiers ensure short-lived storage is cheap while long-lived entries approach the [TIP-1000](./tip-1000.md) benchmark of 250,000 gas for permanent state. + +For **overwriting** an existing entry in the same epoch (current epoch value already nonzero): +- Cold (first access): `COLD_SLOAD_COST` (2,100) + `TEMPORARY_STORE_GAS_EXISTING_COLD` (5,000) = 7,100 gas. +- Warm (already accessed): `TEMPORARY_STORE_GAS_EXISTING_WARM` (200 gas). A `temporaryStore` to a new slot also marks it as warm for subsequent accesses. **Output**: Empty (0 bytes). Execution succeeds silently. -**Error**: Reverts only if insufficient gas is provided. +**Error**: Reverts if insufficient gas, `numEpochs < 1`, or `numEpochs > MAX_EXPIRY_EPOCHS`. ### `temporaryLoad(bytes32 key) returns (bytes32)` -Loads the value for the derived slot, checking the current epoch first and falling back to the previous epoch. +Loads the value for the derived slot by scanning epoch accounts from the current epoch backwards, returning the first live entry found. **Selector**: `0x` + first 4 bytes of `keccak256("temporaryLoad(bytes32)")` @@ -121,50 +138,63 @@ Loads the value for the derived slot, checking the current epoch first and falli **Behavior**: 1. Compute `storage_key = keccak256(msg.sender || key)`. -2. Compute `current_account = PRECOMPILE_ADDRESS + epoch(block.number) + 1`. -3. Look up `storage_key` in `current_account`'s storage. If a non-zero value exists, return it. -4. If `epoch(block.number) == 0`, return `bytes32(0)` (no previous epoch exists). -5. Compute `previous_account = PRECOMPILE_ADDRESS + epoch(block.number)`. -6. Look up `storage_key` in `previous_account`'s storage. If a non-zero value exists, return it. -7. Return `bytes32(0)`. - -**Gas**: Follows the EIP-2929 access-list model. Each `(account, storage_key)` pair is tracked independently for hot/cold purposes: -- First access to a given `(account, storage_key)` within the transaction: `TEMPORARY_LOAD_GAS_COLD` (2,100 gas). -- Subsequent accesses to the same `(account, storage_key)`: `TEMPORARY_LOAD_GAS_WARM` (100 gas). -- If the fallback to the previous epoch is triggered, `(previous_account, storage_key)` is a different entry and charged independently. +2. Compute `metadata_key = keccak256(msg.sender || key || 0x01)`. +3. Let `current = epoch(block.number)`. +4. Let `oldest = max(0, current - MAX_EXPIRY_EPOCHS)`. +5. For `e` from `current` down to `oldest`: + a. Compute `account = PRECOMPILE_ADDRESS + e + 1`. + b. Read `expiry_epoch` from `metadata_key` in `account`'s storage. + c. If `expiry_epoch >= current` (entry is still live): + - Read `value` from `storage_key` in `account`'s storage. + - If `value` is nonzero, return it. + d. If `expiry_epoch != 0` and `expiry_epoch < current` (entry existed but expired), continue to next epoch. +6. Return `bytes32(0)`. + +**Gas**: The precompile charges for each epoch account actually accessed during the scan. Each `(account, metadata_key)` read is charged at cold/warm rates. When a live entry is found, the `(account, storage_key)` read is also charged. The scan stops at the first live entry. + +In the common case (entry written in current or recent epoch), the load touches only 1–2 epoch accounts. Worst case, it scans `MAX_EXPIRY_EPOCHS + 1` accounts, but this only occurs for entries written many epochs ago with a long expiry — and the gas cost reflects the work done. **Output**: ABI-encoded `bytes32` (32 bytes). +### `temporaryLoadExpiry(bytes32 key) returns (bytes32 value, uint32 expiryEpoch)` + +Same scan behavior as `temporaryLoad`, but also returns the expiry epoch of the entry found. Useful for applications (e.g., MPP) that need to check remaining lifetime. + +**Selector**: `0x` + first 4 bytes of `keccak256("temporaryLoadExpiry(bytes32)")` + +**Output**: ABI-encoded `(bytes32, uint32)` (64 bytes). + ## Warm/Cold Tracking -Both `temporaryStore` and `temporaryLoad` participate in a shared per-transaction access set keyed by `(account, storage_key)`. Any access (store or load) to an `(account, storage_key)` pair marks it warm. The warm/cold distinction affects gas costs for both functions as described above. A new-slot store (40,000 gas) also marks the `(epoch_account, storage_key)` warm. +Both `temporaryStore` and `temporaryLoad` participate in a shared per-transaction access set keyed by `(account, storage_key)`. Any access to an `(account, storage_key)` pair marks it warm. The warm/cold distinction affects gas costs as described above. -## Epoch Transition +## Epoch Transition and Pruning When `block.number` crosses an epoch boundary (i.e., `epoch(block.number) != epoch(block.number - 1)`): -1. The account at `PRECOMPILE_ADDRESS + epoch(block.number) - 1` (two epochs ago) becomes unreachable by the precompile and may be pruned by the node. -2. The account at `PRECOMPILE_ADDRESS + epoch(block.number) + 1` (current epoch) begins accepting writes. -3. The account at `PRECOMPILE_ADDRESS + epoch(block.number)` (previous epoch) remains accessible for read fallback. +1. The current epoch account at `PRECOMPILE_ADDRESS + epoch(block.number) + 1` begins accepting writes. +2. All epoch accounts from `epoch(block.number) - 1` back to `epoch(block.number) - MAX_EXPIRY_EPOCHS` remain accessible for reads (entries there may still be live depending on their individual `expiry_epoch`). +3. Any epoch account `E` where `epoch(block.number) > E + MAX_EXPIRY_EPOCHS` is guaranteed to have no live entries and MAY be pruned. -Nodes MUST retain the accounts for the current and previous epoch. Nodes MAY delete any older epoch account and its entire storage trie, however they MUST retain all information that is still needed for future operation, such as the Merkle root of the storage trie in order to be able to update the accounts trie in the future. +Nodes MUST retain all epoch accounts that could contain live entries. Nodes MAY delete any epoch account guaranteed to be fully expired. When deleting an epoch account, nodes MUST retain the Merkle root needed for future state trie updates. ## Node Storage Requirements -Each epoch's data lives in its own account, so pruning is a simple account deletion — no key scanning required. At steady state, the precompile system maintains at most two epoch accounts (current + previous), bounding total storage to `2 × (number of unique slots written per epoch)` entries. This is a significant improvement over permanent storage, where state only grows. +At steady state, the precompile system maintains at most `MAX_EXPIRY_EPOCHS + 1` epoch accounts. In practice, most entries will use short lifetimes, so many epoch accounts will be empty or sparse. Each epoch account can be pruned as a unit — no per-slot scanning required. ## Calldata Encoding -Both functions use standard Solidity ABI encoding: +Functions use standard Solidity ABI encoding: ``` -temporaryStore: 0x (4 + 32 + 32 = 68 bytes) -temporaryLoad: 0x (4 + 32 = 36 bytes) +temporaryStore: 0x (4 + 96 = 100 bytes) +temporaryLoad: 0x (4 + 32 = 36 bytes) +temporaryLoadExpiry: 0x (4 + 32 = 36 bytes) ``` ## Reference Solidity Mock -This mock demonstrates the exact storage key derivation and fallback logic. The real precompile is implemented natively in the node; this contract is for specification clarity and testing only. Gas costs are not modeled here — the precompile charges gas according to the rules above, not via Solidity's native SSTORE/SLOAD pricing. +This mock demonstrates the storage key derivation, metadata layout, and backward-scan logic. The real precompile is implemented natively in the node; this contract is for specification clarity and testing only. Gas costs are not modeled here. ```solidity // SPDX-License-Identifier: MIT @@ -172,70 +202,112 @@ pragma solidity ^0.8.0; /// @title TemporaryStorageMock /// @notice Reference mock for TIP-1040. NOT the real precompile — gas costs differ. -/// The real precompile uses separate accounts per epoch; this mock simulates -/// that by delegatecalling to epoch-derived addresses. In practice the node -/// implements this natively without any DELEGATECALL overhead. contract TemporaryStorageMock { - /// @dev Base address for epoch accounts. Epoch n stores at PRECOMPILE + n + 1. - /// In the real precompile this is the precompile's own address. address public immutable PRECOMPILE; + uint16 public constant MAX_EXPIRY_EPOCHS = 512; + + error InvalidNumEpochs(); constructor() { PRECOMPILE = address(this); } - /// @notice Store a value in the current epoch's temporary storage. - /// @param key Caller-chosen 32-byte key. - /// @param value The value to store. - function temporaryStore(bytes32 key, bytes32 value) external { + /// @notice Store a value with a caller-chosen epoch lifetime. + /// @param key Caller-chosen 32-byte key. + /// @param value The value to store. + /// @param numEpochs Number of additional epochs the value should live (1–512). + function temporaryStore(bytes32 key, bytes32 value, uint16 numEpochs) external { + if (numEpochs < 1 || numEpochs > MAX_EXPIRY_EPOCHS) revert InvalidNumEpochs(); + + uint32 currentEpoch = _currentEpoch(); + uint32 expiryEpoch = currentEpoch + numEpochs; + bytes32 storageKey = _storageKey(msg.sender, key); - // In the real precompile, this writes to the epoch account's storage. - // Here we simulate it by writing to this contract's storage with a - // key that incorporates the epoch, since we can't create real accounts. - bytes32 epochedKey = keccak256(abi.encodePacked(_epochAccount(_currentEpoch()), storageKey)); - assembly { - sstore(epochedKey, value) - } + bytes32 metadataKey = _metadataKey(msg.sender, key); + address account = _epochAccount(currentEpoch); + + // Write value + bytes32 epochedValueKey = keccak256(abi.encodePacked(account, storageKey)); + assembly { sstore(epochedValueKey, value) } + + // Write expiry metadata + bytes32 epochedMetaKey = keccak256(abi.encodePacked(account, metadataKey)); + assembly { sstore(epochedMetaKey, expiryEpoch) } } /// @notice Load a value from temporary storage. - /// Checks the current epoch first, falls back to the previous epoch. - /// @param key Caller-chosen 32-byte key. - /// @return result The stored value, or bytes32(0) if not found in either epoch. + /// Scans from current epoch backwards, returns first live entry. + /// @param key Caller-chosen 32-byte key. + /// @return result The stored value, or bytes32(0) if no live entry found. function temporaryLoad(bytes32 key) external view returns (bytes32 result) { uint32 currentEpoch = _currentEpoch(); bytes32 storageKey = _storageKey(msg.sender, key); + bytes32 metadataKey = _metadataKey(msg.sender, key); - // Try current epoch account - bytes32 epochedKey = keccak256(abi.encodePacked(_epochAccount(currentEpoch), storageKey)); - assembly { - result := sload(epochedKey) - } - if (result != bytes32(0)) { - return result; - } + uint32 oldest = currentEpoch > MAX_EXPIRY_EPOCHS + ? currentEpoch - MAX_EXPIRY_EPOCHS + : 0; - // Fall back to previous epoch account - if (currentEpoch == 0) { - return bytes32(0); + for (uint32 e = currentEpoch; e >= oldest; e--) { + address account = _epochAccount(e); + + // Check expiry metadata + bytes32 epochedMetaKey = keccak256(abi.encodePacked(account, metadataKey)); + uint32 expiryEpoch; + assembly { expiryEpoch := sload(epochedMetaKey) } + + if (expiryEpoch >= currentEpoch) { + // Entry is still live — read value + bytes32 epochedValueKey = keccak256(abi.encodePacked(account, storageKey)); + assembly { result := sload(epochedValueKey) } + if (result != bytes32(0)) return result; + } + + if (e == 0) break; } - bytes32 prevEpochedKey = keccak256(abi.encodePacked(_epochAccount(currentEpoch - 1), storageKey)); - assembly { - result := sload(prevEpochedKey) + + return bytes32(0); + } + + /// @notice Load value + expiry epoch for a key. + function temporaryLoadExpiry(bytes32 key) external view returns (bytes32 result, uint32 expiryEpoch) { + uint32 currentEpoch = _currentEpoch(); + bytes32 storageKey = _storageKey(msg.sender, key); + bytes32 metadataKey = _metadataKey(msg.sender, key); + + uint32 oldest = currentEpoch > MAX_EXPIRY_EPOCHS + ? currentEpoch - MAX_EXPIRY_EPOCHS + : 0; + + for (uint32 e = currentEpoch; e >= oldest; e--) { + address account = _epochAccount(e); + bytes32 epochedMetaKey = keccak256(abi.encodePacked(account, metadataKey)); + assembly { expiryEpoch := sload(epochedMetaKey) } + + if (expiryEpoch >= currentEpoch) { + bytes32 epochedValueKey = keccak256(abi.encodePacked(account, storageKey)); + assembly { result := sload(epochedValueKey) } + if (result != bytes32(0)) return (result, expiryEpoch); + } + + if (e == 0) break; } + + return (bytes32(0), 0); } - /// @dev Compute the storage key within an epoch account: keccak256(sender || key). function _storageKey(address sender, bytes32 key) internal pure returns (bytes32) { return keccak256(abi.encodePacked(sender, key)); } - /// @dev Compute the epoch account address: PRECOMPILE + epoch + 1. + function _metadataKey(address sender, bytes32 key) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(sender, key, uint8(0x01))); + } + function _epochAccount(uint32 epoch) internal view returns (address) { return address(uint160(PRECOMPILE) + uint160(epoch) + 1); } - /// @dev Current epoch index: block.number >> 16, truncated to uint32. function _currentEpoch() internal view returns (uint32) { return uint32(block.number >> 16); } @@ -244,10 +316,24 @@ contract TemporaryStorageMock { # Invariants -1. **Bounded lifetime**: A value written in epoch `E` MUST be readable in epochs `E` and `E+1`, and MUST NOT be readable from epoch `E+2` onward. +1. **Configurable lifetime**: A value written in epoch `E` with `numEpochs = N` MUST be readable in epochs `E` through `E + N`, and MUST NOT be readable from epoch `E + N + 1` onward. 2. **Sender isolation**: `keccak256(sender_a || key) != keccak256(sender_b || key)` for `sender_a != sender_b` — different senders cannot read or overwrite each other's storage for the same key. -3. **Write-read consistency**: A `temporaryStore(key, value)` followed by `temporaryLoad(key)` in the same transaction MUST return `value`. -4. **Zero default**: `temporaryLoad` for a key that has never been written (in the current or previous epoch) MUST return `bytes32(0)`. -5. **Epoch account pruning safety**: Deleting any epoch account older than `PRECOMPILE_ADDRESS + epoch(block.number)` MUST NOT affect the result of any `temporaryLoad` call. -6. **Deterministic gas**: `temporaryStore` costs 40,000 gas for new slots (zero in current epoch) and follows SSTORE-equivalent hot/cold pricing for existing slots (nonzero in current epoch). A nonzero value in the previous epoch alone does not make the slot "existing". `temporaryLoad` cost depends only on the cold/warm state of the accessed slots within the transaction. -7. **Overwrite semantics**: A second `temporaryStore(key, value2)` in the same epoch overwrites the previous value. `temporaryLoad(key)` MUST return `value2`. +3. **Write-read consistency**: A `temporaryStore(key, value, N)` followed by `temporaryLoad(key)` in the same transaction MUST return `value`. +4. **Zero default**: `temporaryLoad` for a key that has never been written (or has expired) MUST return `bytes32(0)`. +5. **Shadow semantics**: A new `temporaryStore` for the same key in a later epoch shadows any earlier live entry. `temporaryLoad` MUST return the most recently written live value. +6. **Epoch account pruning safety**: Deleting any epoch account `E` where `current_epoch > E + MAX_EXPIRY_EPOCHS` MUST NOT affect the result of any `temporaryLoad` call. +7. **Tiered pricing**: `temporaryStore` gas MUST follow the lifetime bucket table. Short-lived entries MUST be cheaper than permanent storage. Entries at `MAX_EXPIRY_EPOCHS` MUST cost 250,000 gas, matching the TIP-1000 permanent storage benchmark. +8. **Bounded numEpochs**: `temporaryStore` MUST revert if `numEpochs < 1` or `numEpochs > MAX_EXPIRY_EPOCHS`. +9. **Overwrite semantics**: A second `temporaryStore(key, value2, N2)` in the same epoch overwrites both value and expiry. `temporaryLoad(key)` MUST return `value2`. +10. **Deterministic gas for loads**: `temporaryLoad` gas cost MUST reflect the number of epoch accounts actually accessed during the backward scan, charged per cold/warm access rules. + +## Design Rationale: Epoch-Based vs. Timestamp-Based Expiry + +This TIP uses epoch-based expiry (`numEpochs`) rather than timestamp-based expiry (`block.timestamp`) for several reasons: + +1. **Deterministic pruning**: Nodes can determine prunability from block number alone, without tracking per-slot timestamps. +2. **Batch pruning**: All entries in an epoch account share the same worst-case expiry bound, so the entire account can be dropped as a unit. +3. **Block-deterministic**: Epoch boundaries are a pure function of block number, avoiding timestamp manipulation concerns. +4. **Sufficient granularity**: At 1-second blocks, one epoch ≈ 18.2 hours. `MAX_EXPIRY_EPOCHS = 512` covers ~1 year. For use cases like MPP sessions (hours to weeks), this provides ample range. + +The tradeoff is that expiry granularity is limited to epoch boundaries. An entry requested for "exactly 2 days" will live for 2–3 epochs depending on when within the epoch it is written. We consider this acceptable for the target use cases. From c6e3624073ebe9afa315f7cfb0bbd770747e2956 Mon Sep 17 00:00:00 2001 From: Centaur AI Date: Wed, 13 May 2026 16:33:08 +0000 Subject: [PATCH 3/3] docs(tip-1040): replace MAX_EXPIRY_EPOCHS with tiered epoch durations (day/week/month/year) Replaces the variable numEpochs + backward-scanning design with four fixed epoch tiers. Each tier uses the original simple 2-epoch model (current + previous), eliminating: - Backward scan across up to 512 epoch accounts on load - Per-entry metadata slot for individual expiry tracking - Unpredictable load gas costs Tiers: day (~18h, 20k gas), week (~6d, 80k), month (~24d, 160k), year (~388d, 250k). At most 8 live epoch accounts total (2 per tier). Amp-Thread-ID: https://ampcode.com/threads/T-019e2218-8522-7664-9b02-c1f731c33f19 --- tips/tip-1040.md | 344 +++++++++++++++++++++-------------------------- 1 file changed, 152 insertions(+), 192 deletions(-) diff --git a/tips/tip-1040.md b/tips/tip-1040.md index edb31735ee..174ab9edff 100644 --- a/tips/tip-1040.md +++ b/tips/tip-1040.md @@ -1,7 +1,7 @@ --- id: TIP-1040 title: Epoch-Scoped Temporary Storage Precompile -description: A precompile providing cheap temporary key-value storage that expires after a caller-chosen number of epochs, with tiered gas pricing. +description: A precompile providing cheap temporary key-value storage with multiple epoch tiers (day, week, month, year) that automatically expires after one to two epochs per tier. authors: Dankrad status: Draft related: N/A @@ -12,28 +12,31 @@ protocolVersion: TBD ## Abstract -This TIP introduces a precompile that provides temporary key-value storage scoped to epochs. An epoch is 2^16 (65,536) blocks. The caller chooses how many epochs the data should live for (1 to `MAX_EXPIRY_EPOCHS`). Data written in epoch `E` with duration `N` epochs is readable from epoch `E` through epoch `E + N`, then automatically becomes inaccessible. Gas pricing scales with the requested lifetime — short-lived entries are cheap, while long-lived entries approach permanent storage cost. +This TIP introduces a precompile that provides temporary key-value storage scoped to epochs. The precompile offers **four epoch tiers** — day, week, month, and year — each with a different epoch duration. Within every tier, data written in a given epoch is readable during that epoch and the next, then automatically becomes inaccessible. Callers choose the tier that matches their desired storage lifetime and pay gas proportionally — short-lived tiers are cheap, long-lived tiers approach permanent storage cost. ## Motivation Certain use cases need on-chain data availability for a bounded time window rather than permanent storage — for example time-limited approvals, ephemeral session state, MPP session keys, or temporary commitments. Today these patterns require permanent `SSTORE` writes and separate cleanup transactions, wasting gas and bloating state. -The original single-epoch design (values live for 1–2 epochs) was too rigid for use cases like MPP sessions that need configurable durations. This revision extends the model to support N-epoch lifetimes while preserving the core benefits: +Multiple tiers allow contracts to choose the cheapest tier that covers their required duration. A short-lived approval uses the day tier (cheap), while an MPP session key lasting weeks uses the month tier (more expensive but still cheaper than permanent storage). + +Epoch-scoped temporary storage provides: 1. **Eliminating cleanup cost** — stale data becomes inaccessible automatically, no explicit deletion needed. 2. **Reducing long-term state growth** — nodes can prune expired storage, keeping the state trie bounded. -3. **Configurable duration** — callers choose how long data lives, paying proportionally. -4. **Cheaper writes** — short-lived storage starts at 20,000 gas, well below permanent storage cost. - +3. **Tiered pricing** — callers choose their expiry tier and pay proportionally. +4. **Constant-time loads** — each tier checks at most 2 epoch accounts (current + previous). ## Assumptions - The block number is always available to the precompile at execution time. -- Nodes maintain epoch accounts for all non-expired epochs. A node that has pruned a non-expired epoch account is non-conforming. -- The precompile address and the address range `[PRECOMPILE_ADDRESS + 1, ...]` are reserved and not used by any existing contract or EOA. -- Epoch boundaries are deterministic: `epoch = block_number >> 16`. +- Nodes maintain at least two epoch accounts per tier (current and previous) at all times. A node that has pruned a non-expired epoch account is non-conforming. +- The precompile address and the address ranges reserved for each tier are not used by any existing contract or EOA. +- Epoch boundaries are deterministic: `epoch = block_number / epoch_length` for each tier. - Storage keys are collision-resistant due to the full `keccak256(sender, key)` derivation (256 bits); two different senders cannot write to the same slot. +If the assumption that nodes keep two epoch accounts per tier is violated (e.g., a node prunes the previous epoch account early), `temporaryLoad` may incorrectly return zero for data that should still be accessible. + --- # Specification @@ -42,159 +45,149 @@ The original single-epoch design (values live for 1–2 epochs) was too rigid fo | Name | Value | Description | |------|-------|-------------| -| `EPOCH_LENGTH` | 2^16 (65,536 blocks) | Number of blocks per epoch | -| `MAX_EXPIRY_EPOCHS` | 512 | Maximum number of additional epochs a value can live (~1 year at 1s blocks) | | `PRECOMPILE_ADDRESS` | TBD | Reserved address for the temporary storage precompile | -| `COLD_SLOAD_COST` | 2,100 | Cold access surcharge per slot per transaction | +| `COLD_SLOAD_COST` | 2,100 | Additional cold access surcharge applied once per slot per transaction | | `TEMPORARY_LOAD_GAS_COLD` | 2,100 | Gas cost for `temporaryLoad` on a cold slot | | `TEMPORARY_LOAD_GAS_WARM` | 100 | Gas cost for `temporaryLoad` on a warm slot | -| `TEMPORARY_STORE_GAS_EXISTING_COLD` | 5,000 | Gas cost for overwriting an existing slot (cold) | -| `TEMPORARY_STORE_GAS_EXISTING_WARM` | 200 | Gas cost for overwriting an existing slot (warm) | +| `TEMPORARY_STORE_GAS_EXISTING_COLD` | 5,000 | Gas cost for `temporaryStore` when the slot is already nonzero in the current epoch and cold | +| `TEMPORARY_STORE_GAS_EXISTING_WARM` | 200 | Gas cost for `temporaryStore` when the slot is already nonzero in the current epoch and warm | + +## Epoch Tiers + +The precompile defines four epoch tiers. Each tier has a fixed epoch length, a gas cost for new-slot writes, and a dedicated address range for its epoch accounts. + +| Tier ID | Name | Epoch Length (blocks) | Approx Duration (1s blocks) | Value Lifetime | New-Slot Gas | +|---------|------|----------------------|----------------------------|----------------|-------------| +| 0 | Day | 2^16 (65,536) | ~18 hours | ~18h – 36h | 20,000 | +| 1 | Week | 2^19 (524,288) | ~6 days | ~6d – 12d | 80,000 | +| 2 | Month | 2^21 (2,097,152) | ~24 days | ~24d – 48d | 160,000 | +| 3 | Year | 2^25 (33,554,432) | ~388 days | ~388d – 776d | 250,000 | + +The year tier's gas cost of 250,000 matches the [TIP-1000](./tip-1000.md) benchmark for permanent state, reflecting that values living for ~1–2 years impose comparable state bloat. ## Epoch Derivation +For a given tier with epoch length `L`: + ``` -epoch(block_number) = block_number >> 16 +epoch(block_number, L) = block_number / L ``` -The current epoch is `epoch(block.number)`. At genesis (epoch 0), there are no previous epochs. +The current epoch is `epoch(block.number, L)`. The previous epoch is `epoch(block.number, L) - 1`. At genesis (epoch 0), there is no previous epoch to fall back to. ## Storage Layout -Each epoch's temporary storage lives in a **separate account**. Epoch `n` is stored in the account at address `PRECOMPILE_ADDRESS + n + 1`. The `+ 1` offset reserves `PRECOMPILE_ADDRESS` itself for the precompile dispatch logic. +Each tier's epoch accounts occupy a **separate address range**. Tier `t` with epoch `n` stores data in the account at: ``` -epoch_account(n) = PRECOMPILE_ADDRESS + n + 1 +epoch_account(t, n) = PRECOMPILE_ADDRESS + (t * 2^32) + n + 1 ``` -Within each epoch account, storage uses **two slots per entry**: +The `2^32` stride per tier ensures the address ranges for different tiers never overlap, even at very high epoch numbers. The `+ 1` offset reserves each tier's base address. + +Within each epoch account, storage keys are derived from the caller and key: ``` -value_key = keccak256(sender || key) -metadata_key = keccak256(sender || key || 0x01) +storage_key = keccak256(sender || key) ``` -The value slot stores the caller's value. The metadata slot stores the expiry epoch as a `uint32` in the low 32 bits. +This is the full 32-byte hash — no truncation is needed since the epoch and tier are encoded in the account address rather than the storage key, giving 256 bits of collision resistance. Each entry uses **one slot** — there is no metadata slot, since all entries in an epoch account share the same expiry (the end of the next epoch for that tier). -**Why store in the write-epoch account**: The entry is always stored in the epoch account corresponding to when it was written. The metadata records the expiry epoch so that `temporaryLoad` can check liveness, and so that nodes know when to prune. - -**Pruning advantage**: Nodes can prune an entire expired epoch account once all entries in it have expired. Since entries in epoch `E` can live at most until epoch `E + MAX_EXPIRY_EPOCHS`, the account at `PRECOMPILE_ADDRESS + E + 1` can always be dropped once `current_epoch > E + MAX_EXPIRY_EPOCHS`. This is a simple, deterministic check — no per-slot scanning required. +**Pruning advantage**: Nodes can prune an entire expired epoch by dropping the account at `epoch_account(t, n)` and its storage trie in one operation, rather than scanning individual slots within a single large trie. This is done out of consensus — there is no actual deletion happening and the account trie does not change; the node can simply forget the storage for the account because it is guaranteed to not be needed again. ## Precompile Interface -### `temporaryStore(bytes32 key, bytes32 value, uint16 numEpochs)` +### `temporaryStore(uint8 tier, bytes32 key, bytes32 value)` -Stores `value` at the derived slot in the current epoch's storage tree, with an expiry of `numEpochs` additional epochs. +Stores `value` at the derived slot in the current epoch account for the given tier. -**Selector**: `0x` + first 4 bytes of `keccak256("temporaryStore(bytes32,bytes32,uint16)")` +**Selector**: `0x` + first 4 bytes of `keccak256("temporaryStore(uint8,bytes32,bytes32)")` -**Input**: ABI-encoded `(bytes32 key, bytes32 value, uint16 numEpochs)` — 96 bytes after the 4-byte selector. +**Input**: ABI-encoded `(uint8 tier, bytes32 key, bytes32 value)` — 96 bytes after the 4-byte selector. **Behavior**: -1. Validate `numEpochs >= 1` and `numEpochs <= MAX_EXPIRY_EPOCHS`. Revert if out of range. -2. Compute `storage_key = keccak256(msg.sender || key)`. -3. Compute `metadata_key = keccak256(msg.sender || key || 0x01)`. -4. Compute `epoch_account = PRECOMPILE_ADDRESS + epoch(block.number) + 1`. -5. Compute `expiry_epoch = epoch(block.number) + numEpochs`. +1. Validate `tier <= 3`. Revert if out of range. +2. Look up the epoch length `L` for the given tier. +3. Compute `storage_key = keccak256(msg.sender || key)`. +4. Compute `epoch_account = PRECOMPILE_ADDRESS + (tier * 2^32) + epoch(block.number, L) + 1`. +5. Read the existing value at `storage_key` in `epoch_account`'s storage. 6. Write `value` at `storage_key` in `epoch_account`'s storage. -7. Write `expiry_epoch` at `metadata_key` in `epoch_account`'s storage. - -If the caller previously wrote to the same `(sender, key)` in an earlier epoch that is still live, the old entry becomes shadowed. `temporaryLoad` will find the new entry first (see load behavior below). The old entry will be pruned when its epoch account expires. **Gas**: -Gas cost is determined by the requested `numEpochs` lifetime: +The gas cost depends on the tier (for new slots) and whether the slot already holds a nonzero value **in the current epoch's tree**. A nonzero value in the previous epoch's tree does not count — only the current epoch matters. -| Lifetime (numEpochs) | Approximate Duration (1s blocks) | Gas | -|---|---|---| -| 1 | ~18 hours | 20,000 | -| 2–4 | ~1–3 days | 40,000 | -| 5–10 | ~4–7 days | 80,000 | -| 11–45 | ~1–4 weeks | 120,000 | -| 46–180 | ~1–4 months | 200,000 | -| 181–512 | ~4–12 months | 250,000 | - -These tiers ensure short-lived storage is cheap while long-lived entries approach the [TIP-1000](./tip-1000.md) benchmark of 250,000 gas for permanent state. - -For **overwriting** an existing entry in the same epoch (current epoch value already nonzero): -- Cold (first access): `COLD_SLOAD_COST` (2,100) + `TEMPORARY_STORE_GAS_EXISTING_COLD` (5,000) = 7,100 gas. -- Warm (already accessed): `TEMPORARY_STORE_GAS_EXISTING_WARM` (200 gas). +- **New slot** (current epoch value is zero): tier-specific gas from the table above (20,000 / 80,000 / 160,000 / 250,000). No cold/warm surcharge. +- **Existing slot** (current epoch value is nonzero): + - Cold (first access to this slot in the transaction): `COLD_SLOAD_COST` (2,100) + `TEMPORARY_STORE_GAS_EXISTING_COLD` (5,000) = 7,100 gas. + - Warm (slot already accessed in this transaction): `TEMPORARY_STORE_GAS_EXISTING_WARM` (200 gas). A `temporaryStore` to a new slot also marks it as warm for subsequent accesses. **Output**: Empty (0 bytes). Execution succeeds silently. -**Error**: Reverts if insufficient gas, `numEpochs < 1`, or `numEpochs > MAX_EXPIRY_EPOCHS`. +**Error**: Reverts if insufficient gas or `tier > 3`. -### `temporaryLoad(bytes32 key) returns (bytes32)` +### `temporaryLoad(uint8 tier, bytes32 key) returns (bytes32)` -Loads the value for the derived slot by scanning epoch accounts from the current epoch backwards, returning the first live entry found. +Loads the value for the derived slot, checking the current epoch first and falling back to the previous epoch within the specified tier. -**Selector**: `0x` + first 4 bytes of `keccak256("temporaryLoad(bytes32)")` +**Selector**: `0x` + first 4 bytes of `keccak256("temporaryLoad(uint8,bytes32)")` -**Input**: ABI-encoded `(bytes32 key)` — 32 bytes after the 4-byte selector. +**Input**: ABI-encoded `(uint8 tier, bytes32 key)` — 64 bytes after the 4-byte selector. **Behavior**: -1. Compute `storage_key = keccak256(msg.sender || key)`. -2. Compute `metadata_key = keccak256(msg.sender || key || 0x01)`. -3. Let `current = epoch(block.number)`. -4. Let `oldest = max(0, current - MAX_EXPIRY_EPOCHS)`. -5. For `e` from `current` down to `oldest`: - a. Compute `account = PRECOMPILE_ADDRESS + e + 1`. - b. Read `expiry_epoch` from `metadata_key` in `account`'s storage. - c. If `expiry_epoch >= current` (entry is still live): - - Read `value` from `storage_key` in `account`'s storage. - - If `value` is nonzero, return it. - d. If `expiry_epoch != 0` and `expiry_epoch < current` (entry existed but expired), continue to next epoch. -6. Return `bytes32(0)`. - -**Gas**: The precompile charges for each epoch account actually accessed during the scan. Each `(account, metadata_key)` read is charged at cold/warm rates. When a live entry is found, the `(account, storage_key)` read is also charged. The scan stops at the first live entry. - -In the common case (entry written in current or recent epoch), the load touches only 1–2 epoch accounts. Worst case, it scans `MAX_EXPIRY_EPOCHS + 1` accounts, but this only occurs for entries written many epochs ago with a long expiry — and the gas cost reflects the work done. +1. Validate `tier <= 3`. Revert if out of range. +2. Look up the epoch length `L` for the given tier. +3. Compute `storage_key = keccak256(msg.sender || key)`. +4. Compute `current_account = PRECOMPILE_ADDRESS + (tier * 2^32) + epoch(block.number, L) + 1`. +5. Look up `storage_key` in `current_account`'s storage. If a non-zero value exists, return it. +6. If `epoch(block.number, L) == 0`, return `bytes32(0)` (no previous epoch exists). +7. Compute `previous_account = PRECOMPILE_ADDRESS + (tier * 2^32) + epoch(block.number, L)`. +8. Look up `storage_key` in `previous_account`'s storage. If a non-zero value exists, return it. +9. Return `bytes32(0)`. + +**Gas**: Follows the EIP-2929 access-list model. Each `(account, storage_key)` pair is tracked independently for hot/cold purposes: +- First access to a given `(account, storage_key)` within the transaction: `TEMPORARY_LOAD_GAS_COLD` (2,100 gas). +- Subsequent accesses to the same `(account, storage_key)`: `TEMPORARY_LOAD_GAS_WARM` (100 gas). +- If the fallback to the previous epoch is triggered, `(previous_account, storage_key)` is a different entry and charged independently. **Output**: ABI-encoded `bytes32` (32 bytes). -### `temporaryLoadExpiry(bytes32 key) returns (bytes32 value, uint32 expiryEpoch)` - -Same scan behavior as `temporaryLoad`, but also returns the expiry epoch of the entry found. Useful for applications (e.g., MPP) that need to check remaining lifetime. - -**Selector**: `0x` + first 4 bytes of `keccak256("temporaryLoadExpiry(bytes32)")` - -**Output**: ABI-encoded `(bytes32, uint32)` (64 bytes). - ## Warm/Cold Tracking -Both `temporaryStore` and `temporaryLoad` participate in a shared per-transaction access set keyed by `(account, storage_key)`. Any access to an `(account, storage_key)` pair marks it warm. The warm/cold distinction affects gas costs as described above. +Both `temporaryStore` and `temporaryLoad` participate in a shared per-transaction access set keyed by `(account, storage_key)`. Any access (store or load) to an `(account, storage_key)` pair marks it warm. The warm/cold distinction affects gas costs for both functions as described above. A new-slot store also marks the `(epoch_account, storage_key)` warm. -## Epoch Transition and Pruning +## Epoch Transition -When `block.number` crosses an epoch boundary (i.e., `epoch(block.number) != epoch(block.number - 1)`): +When `block.number` crosses an epoch boundary for tier `t` (i.e., `epoch(block.number, L_t) != epoch(block.number - 1, L_t)`): -1. The current epoch account at `PRECOMPILE_ADDRESS + epoch(block.number) + 1` begins accepting writes. -2. All epoch accounts from `epoch(block.number) - 1` back to `epoch(block.number) - MAX_EXPIRY_EPOCHS` remain accessible for reads (entries there may still be live depending on their individual `expiry_epoch`). -3. Any epoch account `E` where `epoch(block.number) > E + MAX_EXPIRY_EPOCHS` is guaranteed to have no live entries and MAY be pruned. +1. The account at `epoch_account(t, epoch(block.number, L_t) - 2)` (two epochs ago for tier `t`) becomes unreachable by the precompile and may be pruned by the node. +2. The account at `epoch_account(t, epoch(block.number, L_t))` (current epoch for tier `t`) begins accepting writes. +3. The account at `epoch_account(t, epoch(block.number, L_t) - 1)` (previous epoch for tier `t`) remains accessible for read fallback. -Nodes MUST retain all epoch accounts that could contain live entries. Nodes MAY delete any epoch account guaranteed to be fully expired. When deleting an epoch account, nodes MUST retain the Merkle root needed for future state trie updates. +Epoch transitions happen independently per tier — a day-tier epoch boundary does not affect the week tier's epochs. + +Nodes MUST retain the accounts for the current and previous epoch of each tier. Nodes MAY delete any older epoch account and its entire storage trie, however they MUST retain all information that is still needed for future operation, such as the Merkle root of the storage trie in order to be able to update the accounts trie in the future. ## Node Storage Requirements -At steady state, the precompile system maintains at most `MAX_EXPIRY_EPOCHS + 1` epoch accounts. In practice, most entries will use short lifetimes, so many epoch accounts will be empty or sparse. Each epoch account can be pruned as a unit — no per-slot scanning required. +Each tier maintains at most two live epoch accounts (current + previous), so the total number of live epoch accounts across all tiers is at most `2 × NUM_TIERS = 8`. Each epoch account can be pruned as a unit — no per-slot scanning required. This is a significant improvement over permanent storage, where state only grows. ## Calldata Encoding -Functions use standard Solidity ABI encoding: +Both functions use standard Solidity ABI encoding: ``` -temporaryStore: 0x (4 + 96 = 100 bytes) -temporaryLoad: 0x (4 + 32 = 36 bytes) -temporaryLoadExpiry: 0x (4 + 32 = 36 bytes) +temporaryStore: 0x (4 + 96 = 100 bytes) +temporaryLoad: 0x (4 + 64 = 68 bytes) ``` ## Reference Solidity Mock -This mock demonstrates the storage key derivation, metadata layout, and backward-scan logic. The real precompile is implemented natively in the node; this contract is for specification clarity and testing only. Gas costs are not modeled here. +This mock demonstrates the exact storage key derivation and fallback logic. The real precompile is implemented natively in the node; this contract is for specification clarity and testing only. Gas costs are not modeled here — the precompile charges gas according to the rules above, not via Solidity's native SSTORE/SLOAD pricing. ```solidity // SPDX-License-Identifier: MIT @@ -202,138 +195,105 @@ pragma solidity ^0.8.0; /// @title TemporaryStorageMock /// @notice Reference mock for TIP-1040. NOT the real precompile — gas costs differ. +/// The real precompile uses separate accounts per epoch per tier; this mock +/// simulates that by writing to this contract's storage with keys that +/// incorporate the tier and epoch. In practice the node implements this +/// natively without any overhead. contract TemporaryStorageMock { address public immutable PRECOMPILE; - uint16 public constant MAX_EXPIRY_EPOCHS = 512; - error InvalidNumEpochs(); + uint256[4] public EPOCH_LENGTHS = [ + 2**16, // Tier 0: Day (~18h) + 2**19, // Tier 1: Week (~6d) + 2**21, // Tier 2: Month (~24d) + 2**25 // Tier 3: Year (~388d) + ]; + + error InvalidTier(); constructor() { PRECOMPILE = address(this); } - /// @notice Store a value with a caller-chosen epoch lifetime. - /// @param key Caller-chosen 32-byte key. - /// @param value The value to store. - /// @param numEpochs Number of additional epochs the value should live (1–512). - function temporaryStore(bytes32 key, bytes32 value, uint16 numEpochs) external { - if (numEpochs < 1 || numEpochs > MAX_EXPIRY_EPOCHS) revert InvalidNumEpochs(); - - uint32 currentEpoch = _currentEpoch(); - uint32 expiryEpoch = currentEpoch + numEpochs; + /// @notice Store a value in the current epoch's temporary storage for a given tier. + /// @param tier Epoch tier (0=day, 1=week, 2=month, 3=year). + /// @param key Caller-chosen 32-byte key. + /// @param value The value to store. + function temporaryStore(uint8 tier, bytes32 key, bytes32 value) external { + if (tier > 3) revert InvalidTier(); bytes32 storageKey = _storageKey(msg.sender, key); - bytes32 metadataKey = _metadataKey(msg.sender, key); - address account = _epochAccount(currentEpoch); - - // Write value - bytes32 epochedValueKey = keccak256(abi.encodePacked(account, storageKey)); - assembly { sstore(epochedValueKey, value) } + uint32 epoch = _currentEpoch(tier); + address account = _epochAccount(tier, epoch); - // Write expiry metadata - bytes32 epochedMetaKey = keccak256(abi.encodePacked(account, metadataKey)); - assembly { sstore(epochedMetaKey, expiryEpoch) } + bytes32 epochedKey = keccak256(abi.encodePacked(account, storageKey)); + assembly { + sstore(epochedKey, value) + } } /// @notice Load a value from temporary storage. - /// Scans from current epoch backwards, returns first live entry. - /// @param key Caller-chosen 32-byte key. - /// @return result The stored value, or bytes32(0) if no live entry found. - function temporaryLoad(bytes32 key) external view returns (bytes32 result) { - uint32 currentEpoch = _currentEpoch(); + /// Checks the current epoch first, falls back to the previous epoch. + /// @param tier Epoch tier (0=day, 1=week, 2=month, 3=year). + /// @param key Caller-chosen 32-byte key. + /// @return result The stored value, or bytes32(0) if not found in either epoch. + function temporaryLoad(uint8 tier, bytes32 key) external view returns (bytes32 result) { + if (tier > 3) revert InvalidTier(); + + uint32 currentEpoch = _currentEpoch(tier); bytes32 storageKey = _storageKey(msg.sender, key); - bytes32 metadataKey = _metadataKey(msg.sender, key); - - uint32 oldest = currentEpoch > MAX_EXPIRY_EPOCHS - ? currentEpoch - MAX_EXPIRY_EPOCHS - : 0; - - for (uint32 e = currentEpoch; e >= oldest; e--) { - address account = _epochAccount(e); - // Check expiry metadata - bytes32 epochedMetaKey = keccak256(abi.encodePacked(account, metadataKey)); - uint32 expiryEpoch; - assembly { expiryEpoch := sload(epochedMetaKey) } - - if (expiryEpoch >= currentEpoch) { - // Entry is still live — read value - bytes32 epochedValueKey = keccak256(abi.encodePacked(account, storageKey)); - assembly { result := sload(epochedValueKey) } - if (result != bytes32(0)) return result; - } - - if (e == 0) break; + // Try current epoch account + bytes32 epochedKey = keccak256(abi.encodePacked(_epochAccount(tier, currentEpoch), storageKey)); + assembly { + result := sload(epochedKey) } - - return bytes32(0); - } - - /// @notice Load value + expiry epoch for a key. - function temporaryLoadExpiry(bytes32 key) external view returns (bytes32 result, uint32 expiryEpoch) { - uint32 currentEpoch = _currentEpoch(); - bytes32 storageKey = _storageKey(msg.sender, key); - bytes32 metadataKey = _metadataKey(msg.sender, key); - - uint32 oldest = currentEpoch > MAX_EXPIRY_EPOCHS - ? currentEpoch - MAX_EXPIRY_EPOCHS - : 0; - - for (uint32 e = currentEpoch; e >= oldest; e--) { - address account = _epochAccount(e); - bytes32 epochedMetaKey = keccak256(abi.encodePacked(account, metadataKey)); - assembly { expiryEpoch := sload(epochedMetaKey) } - - if (expiryEpoch >= currentEpoch) { - bytes32 epochedValueKey = keccak256(abi.encodePacked(account, storageKey)); - assembly { result := sload(epochedValueKey) } - if (result != bytes32(0)) return (result, expiryEpoch); - } - - if (e == 0) break; + if (result != bytes32(0)) { + return result; } - return (bytes32(0), 0); + // Fall back to previous epoch account + if (currentEpoch == 0) { + return bytes32(0); + } + bytes32 prevEpochedKey = keccak256(abi.encodePacked(_epochAccount(tier, currentEpoch - 1), storageKey)); + assembly { + result := sload(prevEpochedKey) + } } + /// @dev Compute the storage key within an epoch account: keccak256(sender || key). function _storageKey(address sender, bytes32 key) internal pure returns (bytes32) { return keccak256(abi.encodePacked(sender, key)); } - function _metadataKey(address sender, bytes32 key) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(sender, key, uint8(0x01))); + /// @dev Compute the epoch account address: PRECOMPILE + (tier * 2^32) + epoch + 1. + function _epochAccount(uint8 tier, uint32 epoch) internal view returns (address) { + return address(uint160(PRECOMPILE) + uint160(uint256(tier) * (2**32)) + uint160(epoch) + 1); } - function _epochAccount(uint32 epoch) internal view returns (address) { - return address(uint160(PRECOMPILE) + uint160(epoch) + 1); - } - - function _currentEpoch() internal view returns (uint32) { - return uint32(block.number >> 16); + /// @dev Current epoch index for a given tier. + function _currentEpoch(uint8 tier) internal view returns (uint32) { + return uint32(block.number / EPOCH_LENGTHS[tier]); } } ``` # Invariants -1. **Configurable lifetime**: A value written in epoch `E` with `numEpochs = N` MUST be readable in epochs `E` through `E + N`, and MUST NOT be readable from epoch `E + N + 1` onward. +1. **Bounded lifetime**: A value written in tier-epoch `E` MUST be readable in epochs `E` and `E+1` of that tier, and MUST NOT be readable from epoch `E+2` onward. 2. **Sender isolation**: `keccak256(sender_a || key) != keccak256(sender_b || key)` for `sender_a != sender_b` — different senders cannot read or overwrite each other's storage for the same key. -3. **Write-read consistency**: A `temporaryStore(key, value, N)` followed by `temporaryLoad(key)` in the same transaction MUST return `value`. -4. **Zero default**: `temporaryLoad` for a key that has never been written (or has expired) MUST return `bytes32(0)`. -5. **Shadow semantics**: A new `temporaryStore` for the same key in a later epoch shadows any earlier live entry. `temporaryLoad` MUST return the most recently written live value. -6. **Epoch account pruning safety**: Deleting any epoch account `E` where `current_epoch > E + MAX_EXPIRY_EPOCHS` MUST NOT affect the result of any `temporaryLoad` call. -7. **Tiered pricing**: `temporaryStore` gas MUST follow the lifetime bucket table. Short-lived entries MUST be cheaper than permanent storage. Entries at `MAX_EXPIRY_EPOCHS` MUST cost 250,000 gas, matching the TIP-1000 permanent storage benchmark. -8. **Bounded numEpochs**: `temporaryStore` MUST revert if `numEpochs < 1` or `numEpochs > MAX_EXPIRY_EPOCHS`. -9. **Overwrite semantics**: A second `temporaryStore(key, value2, N2)` in the same epoch overwrites both value and expiry. `temporaryLoad(key)` MUST return `value2`. -10. **Deterministic gas for loads**: `temporaryLoad` gas cost MUST reflect the number of epoch accounts actually accessed during the backward scan, charged per cold/warm access rules. - -## Design Rationale: Epoch-Based vs. Timestamp-Based Expiry +3. **Tier isolation**: Storage in different tiers is completely independent. Writing to key `K` in tier 0 does not affect key `K` in tier 1. +4. **Write-read consistency**: A `temporaryStore(tier, key, value)` followed by `temporaryLoad(tier, key)` in the same transaction MUST return `value`. +5. **Zero default**: `temporaryLoad` for a key that has never been written (in the current or previous epoch of that tier) MUST return `bytes32(0)`. +6. **Epoch account pruning safety**: Deleting any epoch account older than `epoch_account(t, epoch(block.number, L_t) - 1)` MUST NOT affect the result of any `temporaryLoad` call. +7. **Tiered pricing**: `temporaryStore` new-slot gas MUST follow the per-tier gas table. Short-lived tiers MUST be cheaper than long-lived tiers. The year tier MUST cost 250,000 gas, matching the TIP-1000 permanent storage benchmark. +8. **Valid tier**: `temporaryStore` and `temporaryLoad` MUST revert if `tier > 3`. +9. **Overwrite semantics**: A second `temporaryStore(tier, key, value2)` in the same epoch overwrites the previous value. `temporaryLoad(tier, key)` MUST return `value2`. +10. **Deterministic gas**: `temporaryLoad` cost depends only on the cold/warm state of the accessed slots within the transaction. At most two epoch accounts are checked per load. -This TIP uses epoch-based expiry (`numEpochs`) rather than timestamp-based expiry (`block.timestamp`) for several reasons: +## Design Rationale -1. **Deterministic pruning**: Nodes can determine prunability from block number alone, without tracking per-slot timestamps. -2. **Batch pruning**: All entries in an epoch account share the same worst-case expiry bound, so the entire account can be dropped as a unit. -3. **Block-deterministic**: Epoch boundaries are a pure function of block number, avoiding timestamp manipulation concerns. -4. **Sufficient granularity**: At 1-second blocks, one epoch ≈ 18.2 hours. `MAX_EXPIRY_EPOCHS = 512` covers ~1 year. For use cases like MPP sessions (hours to weeks), this provides ample range. +**Why four tiers instead of arbitrary durations?** Fixed tiers keep the design simple: each tier checks exactly 2 epoch accounts (current + previous), giving constant-time loads and single-slot entries with no per-entry metadata. Each tier prunes independently by dropping its oldest epoch account. -The tradeoff is that expiry granularity is limited to epoch boundaries. An entry requested for "exactly 2 days" will live for 2–3 epochs depending on when within the epoch it is written. We consider this acceptable for the target use cases. +The four tiers cover the practical range from hours to ~1 year. Contracts that need finer-grained expiry can store a timestamp alongside their value and enforce it in application logic, using the tier as a guaranteed upper bound for node-level pruning.