Skip to content
Closed
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
299 changes: 299 additions & 0 deletions tips/tip-1040.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
---
id: TIP-1040
title: Epoch-Scoped Temporary Storage Precompile
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
protocolVersion: TBD
---

# TIP-1040: Epoch-Scoped Temporary Storage Precompile

## Abstract

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.

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

## Constants

| Name | Value | Description |
|------|-------|-------------|
| `PRECOMPILE_ADDRESS` | TBD | Reserved address for the temporary storage precompile |
| `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 `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, L) = block_number / L
```

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 tier's epoch accounts occupy a **separate address range**. Tier `t` with epoch `n` stores data in the account at:

```
epoch_account(t, n) = PRECOMPILE_ADDRESS + (t * 2^32) + n + 1
```

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:

```
storage_key = keccak256(sender || key)
```

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

**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(uint8 tier, bytes32 key, bytes32 value)`

Stores `value` at the derived slot in the current epoch account for the given tier.

**Selector**: `0x` + first 4 bytes of `keccak256("temporaryStore(uint8,bytes32,bytes32)")`

**Input**: ABI-encoded `(uint8 tier, bytes32 key, bytes32 value)` — 96 bytes after the 4-byte selector.

**Behavior**:

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.

**Gas**:

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.

- **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 or `tier > 3`.

### `temporaryLoad(uint8 tier, bytes32 key) returns (bytes32)`

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(uint8,bytes32)")`

**Input**: ABI-encoded `(uint8 tier, bytes32 key)` — 64 bytes after the 4-byte selector.

**Behavior**:

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

## 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 also marks the `(epoch_account, storage_key)` warm.

## Epoch Transition

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

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

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

Both functions use standard Solidity ABI encoding:

```
temporaryStore: 0x<selector> <tier:uint8> <key:bytes32> <value:bytes32> (4 + 96 = 100 bytes)
temporaryLoad: 0x<selector> <tier:uint8> <key:bytes32> (4 + 64 = 68 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 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;

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 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);
uint32 epoch = _currentEpoch(tier);
address account = _epochAccount(tier, epoch);

bytes32 epochedKey = keccak256(abi.encodePacked(account, storageKey));
assembly {
sstore(epochedKey, value)
}
}

/// @notice Load a value from temporary storage.
/// 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);

// Try current epoch account
bytes32 epochedKey = keccak256(abi.encodePacked(_epochAccount(tier, 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(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));
}

/// @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);
}

/// @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. **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. **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.

## Design Rationale

**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 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.
Loading