Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions tips/tip-1040.md
Original file line number Diff line number Diff line change
@@ -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<selector> <key:bytes32> <value:bytes32> (4 + 32 + 32 = 68 bytes)
temporaryLoad: 0x<selector> <key:bytes32> (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`.
Loading