From c17e9070a7332de3a440b6e48e97b0dee224835f Mon Sep 17 00:00:00 2001 From: Francis Li Date: Thu, 8 Oct 2026 15:04:35 -0700 Subject: [PATCH] feat(base-time): expose the BaseTime predeploy read surface Denim installs the BaseTime predeploy at 0x4200...0030, which exposes the millisecond component of the current block timestamp. Add a read-only IBaseTime interface (timestampMillisPart, timestampMs, version) and a new StdPredeploys library with a typed BASE_TIME handle. Predeploys are EVM bytecode rather than native precompiles, so they get their own library instead of extending StdPrecompiles. The depositor-only setter and its errors are left out: only the protocol's tx[1] deposit can call it, so integrators would only ever see it revert. MockBaseTime stores its uint16 at slot 0, deliberately deviating from the ERC-7201 mock convention: storage parity with the real contract is the invariant, and the real predeploy uses the compiler's sequential layout. BaseTest etches the mock only when the address has no code, independent of the precompile live/reference probe, so live runs keep real bytecode. Adds unit tests for each function, selector pins, slot-0 layout tests, reference docs, and a Denim changelog entry. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a11ca1-0972-76cc-9a30-05db0799da5a --- AGENTS.md | 6 +- CHANGELOG.md | 13 +++- README.md | 6 +- ...03_Denim_BaseTime_millisecond_timestamp.md | 78 +++++++++++++++++++ changelog/README.md | 1 + docs/reference/constants.md | 10 ++- docs/reference/interfaces.md | 4 +- src/StdPredeploys.sol | 14 ++++ src/interfaces/IBaseTime.sol | 31 ++++++++ test/lib/BaseTest.sol | 11 +++ test/lib/BaseTimeTest.sol | 31 ++++++++ test/lib/mocks/MockBaseTime.sol | 33 ++++++++ test/lib/mocks/MockBaseTimeStorage.sol | 13 ++++ test/unit/BaseTime/selectors.t.sol | 24 ++++++ test/unit/BaseTime/timestampMillisPart.t.sol | 20 +++++ test/unit/BaseTime/timestampMs.t.sol | 16 ++++ test/unit/BaseTime/version.t.sol | 11 +++ test/unit/storage/MockBaseTimeStorage.t.sol | 37 +++++++++ 18 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 changelog/03_Denim_BaseTime_millisecond_timestamp.md create mode 100644 src/StdPredeploys.sol create mode 100644 src/interfaces/IBaseTime.sol create mode 100644 test/lib/BaseTimeTest.sol create mode 100644 test/lib/mocks/MockBaseTime.sol create mode 100644 test/lib/mocks/MockBaseTimeStorage.sol create mode 100644 test/unit/BaseTime/selectors.t.sol create mode 100644 test/unit/BaseTime/timestampMillisPart.t.sol create mode 100644 test/unit/BaseTime/timestampMs.t.sol create mode 100644 test/unit/BaseTime/version.t.sol create mode 100644 test/unit/storage/MockBaseTimeStorage.t.sol diff --git a/AGENTS.md b/AGENTS.md index 9122a522..57770100 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ Solidity interfaces, libraries, and reference mocks for Base's precompiles: **B20** (ERC-20 superset with roles, policies, pausing, permits, memos), **PolicyRegistry** (allowlist/blocklist singleton), -and **ActivationRegistry** (feature flags). The Solidity mocks must mirror the Rust implementations +and **ActivationRegistry** (feature flags), plus a read-only interface for the **BaseTime** predeploy +(millisecond block timestamp). The Solidity mocks must mirror the Rust implementations in base/base **slot-for-slot** — storage layout parity is the core invariant of this repo. ## Commands @@ -52,7 +53,8 @@ cross-validates layout/behavior) → **smoke** (real txs against a live chain). ``` src/StdPrecompiles.sol # canonical precompile addresses + typed handles -src/interfaces/ # IB20, IB20Asset, IB20Stablecoin, IB20Factory, IPolicyRegistry, IActivationRegistry +src/StdPredeploys.sol # canonical predeploy addresses + typed handles (BaseTime) +src/interfaces/ # IB20, IB20Asset, IB20Stablecoin, IB20Factory, IPolicyRegistry, IActivationRegistry, IBaseTime src/lib/ # B20Constants (role/policy ids), B20FactoryLib (createB20 encoders) src/impls/ # reserved for reference impls (currently empty; mocks fill that role) test/unit/ # one directory per feature; slot-level assertions diff --git a/CHANGELOG.md b/CHANGELOG.md index 1869de2c..b8b8e935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,10 @@ and selector introduced in this section takes effect only once Denim activates. ### Compatibility -Denim doesn't remove or rename any selector, event topic, or error selector, and adds one view -function. It is **not** additive-only: two B20 changes alter the outcome of existing calls. +Denim doesn't remove or rename any selector, event topic, or error selector. It adds one +PolicyRegistry view function and the read-only `IBaseTime` interface for the new BaseTime predeploy, +neither of which changes an existing selector. It is **not** additive-only: two B20 changes alter +the outcome of existing calls. - A transfer, mint, or seize whose recipient is the token's own address now reverts `InvalidReceiver(to)` where it previously succeeded. @@ -29,6 +31,7 @@ function. It is **not** additive-only: two B20 changes alter the outcome of exis | --- | --- | --- | --- | | B20 (Asset and Stablecoin) | Reject the token itself as a credit recipient (**breaking**) | `transfer`, `transferFrom`, their memo variants, `mint`, `mintWithMemo`, `batchMint`, and `seizeWithMemo` revert `InvalidReceiver(to)` when `to` is the token's own address. Self-transfers (`from == to`) still succeed, and `seizeWithMemo` from the token address still recovers balances already stuck there. | [03_Denim_B20_token_receiver](changelog/03_Denim_B20_token_receiver.md) | | B20 (Asset and Stablecoin) | Transfer executor policy on every transfer path (**breaking**) | `TRANSFER_EXECUTOR_POLICY` checks `msg.sender` on `transfer`, `transferFrom`, and their memo variants, including when `msg.sender == from`. Previously it ran only on delegated `transferFrom`. No new selectors, events, errors, or storage. | [03_Denim_B20_transfer_executor_enforcement](changelog/03_Denim_B20_transfer_executor_enforcement.md) | +| BaseTime | Millisecond block timestamp | New predeploy at `0x4200000000000000000000000000000000000030`, exposed as `StdPredeploys.BASE_TIME`. `IBaseTime` provides `timestampMs()` (`block.timestamp * 1000 + timestampMillisPart()`), `timestampMillisPart()`, and `version()`. Read-only; the protocol writes the value each block. | [03_Denim_BaseTime_millisecond_timestamp](changelog/03_Denim_BaseTime_millisecond_timestamp.md) | | PolicyRegistry | NOT / invert policies | Bit 63 of a policy ID becomes the invert bit: `isAuthorized` resolves the base policy and returns the opposite result, failing closed on an unknown base. Works for simple and composite policies and as a composite child. Adds the `invertedPolicyId(uint64)` view helper. | [03_Denim_PolicyRegistry_not_policy](changelog/03_Denim_PolicyRegistry_not_policy.md) | ### Migration guidance @@ -46,6 +49,12 @@ If holders should keep initiating their own transfers, add them, or a policy cov executor policy before Denim activates. Otherwise only accounts the policy authorizes, such as a transfer agent calling `transferFrom`, can move tokens. +#### BaseTime readers + +Read `StdPredeploys.BASE_TIME.timestampMs()` for the block timestamp in milliseconds. Until the +block's `tx[1]` deposit executes (for example, during the `tx[0]` L1-info deposit), it combines the +current block's seconds with the previous block's millisecond component. + #### PolicyRegistry integrators Use `invertedPolicyId(policyId)` (or set bit 63) instead of maintaining a mirrored allowlist and diff --git a/README.md b/README.md index 6e50c616..a95a8b33 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A collection of Solidity interfaces, libraries, and mock implementations for Bas - [**ActivationRegistry**](src/interfaces/IActivationRegistry.sol) — Feature flags controlled by Base team to activate/deactivate features. - [**PolicyRegistry**](docs/concepts/policies.md) — Membership sets controlled by custom admins, initially providing allow and block lists for B20 token operations. - [**B20**](docs/overview.md) — Standard ERC-20 implementation with extensions for roles, policies, memos, pausing, ERC-2612 permits, and a variant system. +- [**BaseTime**](src/interfaces/IBaseTime.sol) — Read-only interface for the predeploy exposing the current block timestamp in milliseconds. ## Documentation @@ -39,13 +40,15 @@ forge install base/base-std
 src
 ├── StdPrecompiles.sol: Precompile addresses with interface wrapper handles
+├── StdPredeploys.sol: Predeploy addresses with interface wrapper handles
 ├── interfaces
 │   ├── IB20.sol: Core token standard
 │   ├── IB20Asset.sol: Asset variant of B20
 │   ├── IB20Stablecoin.sol: Stablecoin variant of B20
 │   ├── IB20Factory.sol: B20 factory precompile
 │   ├── IPolicyRegistry.sol: Policy registry precompile
-│   └── IActivationRegistry.sol: Activation registry precompile
+│   ├── IActivationRegistry.sol: Activation registry precompile
+│   └── IBaseTime.sol: BaseTime predeploy (millisecond block timestamp)
 └── lib
     ├── B20Constants.sol: B20 role and policy-type identifier constants
     └── B20FactoryLib.sol: Pure encoders for B20 factory params and initCalls
@@ -59,6 +62,7 @@ These mock contracts replace the live precompiles in unit tests, allowing tests
 test/lib/mocks
 ├── MockActivationRegistry.sol: Mock implementation of the activation registry precompile
 ├── MockPolicyRegistry.sol: Mock implementation of the policy registry precompile
+├── MockBaseTime.sol: Mock implementation of the BaseTime predeploy
 └── MockB20Factory.sol: Mock implementation of the B20 factory precompile
 
diff --git a/changelog/03_Denim_BaseTime_millisecond_timestamp.md b/changelog/03_Denim_BaseTime_millisecond_timestamp.md new file mode 100644 index 00000000..73e77503 --- /dev/null +++ b/changelog/03_Denim_BaseTime_millisecond_timestamp.md @@ -0,0 +1,78 @@ +# BaseTime Millisecond Timestamp + +- **Feature Name**: millisecond_timestamp +- **Start Date**: 2026-10-08 +- **Authors**: Francis Li +- **Title**: BaseTime Millisecond Timestamp + +## Summary + +Denim installs the BaseTime predeploy at `0x4200000000000000000000000000000000000030`. It exposes the millisecond component of the current block timestamp, so contracts can read block time at millisecond precision. + +base-std adds the read-only `IBaseTime` interface and a typed handle, `StdPredeploys.BASE_TIME`. Read `StdPredeploys.BASE_TIME.timestampMs()` for the full millisecond timestamp. + +## Motivation + +`block.timestamp` has second precision, but from Denim a block's timestamp carries a sub-second component. Contracts that need the exact block time, such as auctions, rate limits, or time-weighted pricing, have no way to read the millisecond component from the EVM alone. + +## Background + +BaseTime is a proxied predeploy: Solidity bytecode at a `0x4200…` address, not a native precompile. That is why its handle lives in a new `StdPredeploys` library instead of `StdPrecompiles`. + +The protocol writes the millisecond component once per Denim block, through a depositor-only setter called by the block-scoped deposit at `tx[1]`. Before that deposit executes, the stored value is still the previous block's. + +## Specs + +### Interface Changes + +New interface `src/interfaces/IBaseTime.sol` and library `src/StdPredeploys.sol`: + +```solidity +interface IBaseTime { + function timestampMillisPart() external view returns (uint16); + function timestampMs() external view returns (uint64); + function version() external view returns (string memory); +} + +library StdPredeploys { + address internal constant BASE_TIME_ADDRESS = 0x4200000000000000000000000000000000000030; + IBaseTime internal constant BASE_TIME = IBaseTime(BASE_TIME_ADDRESS); +} +``` + +| Symbol | Selector / Topic0 | Status | Notes | +| ------ | ----------------- | ------ | ----- | +| `timestampMillisPart()` | `0x7b2fea99` | NEW (view) | Millisecond component: `0`, `200`, `400`, `600`, or `800`; never reverts | +| `timestampMs()` | `0x5745a677` | NEW (view) | `block.timestamp * 1000 + timestampMillisPart()`; never reverts | +| `version()` | `0x54fd4d50` | NEW (view) | Returns `"1.0.0"` | + +The depositor-only setter and its errors are protocol-internal and are not part of `IBaseTime`. + +### Behavioural Changes + +- No existing selector, event, error, or storage slot changes. The predeploy is new. +- Storage: `uint16 timestampMillisPart` in the low-order 2 bytes of slot 0. +- Until the block's `tx[1]` deposit executes (for example, during the `tx[0]` L1-info deposit), `timestampMs()` combines the current block's seconds with the previous block's millisecond component. + +### Examples + +```solidity +import {StdPredeploys} from "base-std/StdPredeploys.sol"; + +uint64 nowMs = StdPredeploys.BASE_TIME.timestampMs(); +// block.timestamp == 1_800_000_000, timestampMillisPart() == 400 +// nowMs == 1_800_000_000_400 +``` + +## Design Decisions & Alternatives Considered + +- **Read-only interface.** Only the protocol deposit can write the value, so exposing the setter would give integrators a selector that always reverts for them. +- **Separate `StdPredeploys` library.** Predeploys are EVM bytecode and precompiles are native code; keeping them in separate libraries keeps that distinction visible at the call site. +- **Slot-0 mock layout.** `MockBaseTime` stores the `uint16` at slot 0 instead of an ERC-7201 namespace, matching the real contract's layout so `vm.store`-based tests behave the same against either. + +## Migration Steps + +- Additive: no existing integration changes. +- After Denim activates, read `StdPredeploys.BASE_TIME.timestampMs()` for millisecond block time. +- Code that runs before `tx[1]` in a block, such as the L1-info deposit, sees the previous block's millisecond component. +- Before Denim, the predeploy has no implementation and calls to it revert; don't depend on it on a chain where Denim hasn't activated. diff --git a/changelog/README.md b/changelog/README.md index e7a9e837..95c8d5ba 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -29,6 +29,7 @@ Grouped by hardfork, one collapsible section per hardfork, newest first. | --- | --- | --- | --- | | B20 | Reject the token itself as a credit recipient | `src/interfaces/IB20.sol`, `src/interfaces/IB20Asset.sol` | [03_Denim_B20_token_receiver](03_Denim_B20_token_receiver.md) | | B20 | Transfer executor policy on every transfer path | `src/interfaces/IB20.sol` | [03_Denim_B20_transfer_executor_enforcement](03_Denim_B20_transfer_executor_enforcement.md) | +| BaseTime | Millisecond block timestamp predeploy (read-only) | `src/interfaces/IBaseTime.sol`, `src/StdPredeploys.sol` | [03_Denim_BaseTime_millisecond_timestamp](03_Denim_BaseTime_millisecond_timestamp.md) | | PolicyRegistry | NOT / invert policies | `src/interfaces/IPolicyRegistry.sol` | [03_Denim_PolicyRegistry_not_policy](03_Denim_PolicyRegistry_not_policy.md) | diff --git a/docs/reference/constants.md b/docs/reference/constants.md index 44f45e6a..0077d540 100644 --- a/docs/reference/constants.md +++ b/docs/reference/constants.md @@ -1,6 +1,6 @@ # Constants -*Role identifiers, policy-type identifiers, precompile addresses, and other fixed constants. See [`B20Constants`](../../src/lib/B20Constants.sol) and [`StdPrecompiles`](../../src/StdPrecompiles.sol).* +*Role identifiers, policy-type identifiers, precompile and predeploy addresses, and other fixed constants. See [`B20Constants`](../../src/lib/B20Constants.sol), [`StdPrecompiles`](../../src/StdPrecompiles.sol), and [`StdPredeploys`](../../src/StdPredeploys.sol).* ## Precompile addresses @@ -12,6 +12,14 @@ | `POLICY_REGISTRY_ADDRESS` | `0x8453000000000000000000000000000000000002` | Stores allowlist/blocklist/composite policies and answers `isAuthorized` checks consulted by every policy scope (see [Policies](../concepts/policies.md)). | | `ACTIVATION_REGISTRY_ADDRESS` | `0x8453000000000000000000000000000000000001` | Gates whether a B-20 variant or feature is live on a given chain; checked by the factory before it will create that variant. | +## Predeploy addresses + +*Fixed addresses of Base's predeploys (EVM bytecode at `0x4200…` addresses). See [`StdPredeploys`](../../src/StdPredeploys.sol).* + +| Name | Value | Purpose | +|---|---|---| +| `BASE_TIME_ADDRESS` | `0x4200000000000000000000000000000000000030` | Exposes the current block timestamp in milliseconds through [`IBaseTime`](../../src/interfaces/IBaseTime.sol) (`timestampMs`, `timestampMillisPart`); installed at Denim. | + ## Roles *Role identifiers checked via `hasRole`. See [`B20Constants`](../../src/lib/B20Constants.sol) and [`IB20`](../../src/interfaces/IB20.sol). Hex values are `keccak256` of the role name, verified with `cast keccak ""` and cross-checked in `chisel`.* diff --git a/docs/reference/interfaces.md b/docs/reference/interfaces.md index de911de2..2c45ca03 100644 --- a/docs/reference/interfaces.md +++ b/docs/reference/interfaces.md @@ -10,7 +10,9 @@ | [`IB20Factory`](../../src/interfaces/IB20Factory.sol) | B20 factory precompile | | [`IPolicyRegistry`](../../src/interfaces/IPolicyRegistry.sol) | Policy registry precompile | | [`IActivationRegistry`](../../src/interfaces/IActivationRegistry.sol) | Activation registry precompile | +| [`IBaseTime`](../../src/interfaces/IBaseTime.sol) | BaseTime predeploy (millisecond block timestamp, read-only) | | [`IERC8056`](../../src/interfaces/IERC8056.sol) | Scaled UI Amount standard (Asset variant multiplier) | | [`IERC165`](../../src/interfaces/IERC165.sol) | Interface detection | -See [`StdPrecompiles.sol`](../../src/StdPrecompiles.sol) for canonical precompile addresses. +See [`StdPrecompiles.sol`](../../src/StdPrecompiles.sol) for canonical precompile addresses and +[`StdPredeploys.sol`](../../src/StdPredeploys.sol) for predeploy addresses. diff --git a/src/StdPredeploys.sol b/src/StdPredeploys.sol new file mode 100644 index 00000000..ea618494 --- /dev/null +++ b/src/StdPredeploys.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.20 <0.9.0; + +import {IBaseTime} from "./interfaces/IBaseTime.sol"; + +/// @title StdPredeploys +/// @notice Address constants for Base's predeploys, each paired with an interface-typed handle +/// (e.g. `StdPredeploys.BASE_TIME.timestampMs()`). Predeploys are EVM bytecode at `0x4200…` +/// addresses, unlike the native precompiles in `StdPrecompiles`. +library StdPredeploys { + address internal constant BASE_TIME_ADDRESS = 0x4200000000000000000000000000000000000030; + + IBaseTime internal constant BASE_TIME = IBaseTime(BASE_TIME_ADDRESS); +} diff --git a/src/interfaces/IBaseTime.sol b/src/interfaces/IBaseTime.sol new file mode 100644 index 00000000..3ed142e3 --- /dev/null +++ b/src/interfaces/IBaseTime.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.20 <0.9.0; + +/// @title IBaseTime +/// @notice Read surface of the BaseTime predeploy, which exposes the millisecond component of the current +/// L2 block timestamp. +interface IBaseTime { + /*////////////////////////////////////////////////////////////// + TIMESTAMP QUERIES + //////////////////////////////////////////////////////////////*/ + + /// @notice Millisecond component (0, 200, 400, 600, or 800) of the current block timestamp. Never reverts. + /// + /// @dev Updated by the block's `tx[1]` deposit; earlier in the block it holds the previous block's value. + /// + /// @return Millisecond component of the current block timestamp. + function timestampMillisPart() external view returns (uint16); + + /// @notice Current block timestamp in milliseconds: `block.timestamp * 1000 + timestampMillisPart()`. + /// Never reverts. + /// + /// @dev Until the block's `tx[1]` deposit executes (e.g. during the `tx[0]` L1-info deposit), this + /// combines the current block's seconds with the previous block's millisecond component. + /// + /// @return Current block timestamp in milliseconds. + function timestampMs() external view returns (uint64); + + /// @notice Semantic version of the BaseTime implementation. Never reverts. + /// @return Semantic version string. + function version() external view returns (string memory); +} diff --git a/test/lib/BaseTest.sol b/test/lib/BaseTest.sol index eeb43b0d..563bf3b6 100644 --- a/test/lib/BaseTest.sol +++ b/test/lib/BaseTest.sol @@ -9,10 +9,12 @@ import {ActivationRegistryFeatureList} from "base-std-test/lib/mocks/ActivationR import {MockActivationRegistry} from "base-std-test/lib/mocks/MockActivationRegistry.sol"; import {MockPolicyRegistry} from "base-std-test/lib/mocks/MockPolicyRegistry.sol"; import {MockB20Factory} from "base-std-test/lib/mocks/MockB20Factory.sol"; +import {MockBaseTime} from "base-std-test/lib/mocks/MockBaseTime.sol"; import {IActivationRegistry} from "base-std/interfaces/IActivationRegistry.sol"; import {IPolicyRegistry} from "base-std/interfaces/IPolicyRegistry.sol"; import {StdPrecompiles} from "base-std/StdPrecompiles.sol"; +import {StdPredeploys} from "base-std/StdPredeploys.sol"; /// @notice Common base for every test contract in this suite. /// @@ -74,6 +76,9 @@ import {StdPrecompiles} from "base-std/StdPrecompiles.sol"; /// mutate a single ERC-7201-namespaced `features` map and emit the /// paired events. Only admin (resolved at setUp via /// `activationRegistry.admin()`) may flip features. +/// - `MockBaseTime` implements the `IBaseTime` read surface over a +/// slot-0 `uint16`. It is etched at the BaseTime predeploy only when +/// that address has no code, independently of the precompile probe. abstract contract BaseTest is Test { // -- Actors -- address internal admin = makeAddr("admin"); @@ -97,6 +102,12 @@ abstract contract BaseTest is Test { vm.label(StdPrecompiles.B20_FACTORY_ADDRESS, "B20Factory"); vm.label(StdPrecompiles.POLICY_REGISTRY_ADDRESS, "PolicyRegistry"); vm.label(StdPrecompiles.ACTIVATION_REGISTRY_ADDRESS, "ActivationRegistry"); + vm.label(StdPredeploys.BASE_TIME_ADDRESS, "BaseTime"); + + // BaseTime is EVM bytecode, so a code-size check is reliable here. + if (StdPredeploys.BASE_TIME_ADDRESS.code.length == 0) { + vm.etch(StdPredeploys.BASE_TIME_ADDRESS, type(MockBaseTime).runtimeCode); + } // Pick the world by detecting whether the live precompiles are // present (see contract NatSpec), then announce it so a run is never diff --git a/test/lib/BaseTimeTest.sol b/test/lib/BaseTimeTest.sol new file mode 100644 index 00000000..ba2fe651 --- /dev/null +++ b/test/lib/BaseTimeTest.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {BaseTest} from "base-std-test/lib/BaseTest.sol"; +import {MockBaseTimeStorage} from "base-std-test/lib/mocks/MockBaseTimeStorage.sol"; + +import {IBaseTime} from "base-std/interfaces/IBaseTime.sol"; +import {StdPredeploys} from "base-std/StdPredeploys.sol"; + +/// @notice Base test contract for `IBaseTime` unit tests. +/// +/// Inherits the BaseTime etch wiring from `BaseTest`; adds the predeploy +/// handle and a helper that writes the millisecond component directly, +/// standing in for the protocol's `tx[1]` deposit. +contract BaseTimeTest is BaseTest { + // -- Predeploy handle -- + IBaseTime internal baseTime = StdPredeploys.BASE_TIME; + + /// @notice Writes `part` into BaseTime's `timestampMillisPart` slot. + function _setTimestampMillisPart(uint16 part) internal { + vm.store( + StdPredeploys.BASE_TIME_ADDRESS, MockBaseTimeStorage.TIMESTAMP_MILLIS_PART_SLOT, bytes32(uint256(part)) + ); + } + + /// @notice Maps a fuzz seed to a valid millisecond component (0, 200, 400, 600, or 800). + function _validMillisPart(uint256 seed) internal pure returns (uint16) { + // forge-lint: disable-next-line(unsafe-typecast) + return uint16(bound(seed, 0, 4) * 200); + } +} diff --git a/test/lib/mocks/MockBaseTime.sol b/test/lib/mocks/MockBaseTime.sol new file mode 100644 index 00000000..1596a73e --- /dev/null +++ b/test/lib/mocks/MockBaseTime.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IBaseTime} from "base-std/interfaces/IBaseTime.sol"; + +/// @title MockBaseTime +/// @notice Reference implementation of the `IBaseTime` read surface. Etched at the canonical BaseTime +/// predeploy address via `vm.etch` from `BaseTest.setUp` when that address has no code. +/// +/// @dev No setter: the protocol's `tx[1]` deposit is the only writer on-chain, and tests set the +/// value with `vm.store` (see `BaseTimeTest`). +contract MockBaseTime is IBaseTime { + /// @notice Semantic version reported by the predeploy. + string internal constant VERSION = "1.0.0"; + + // Slot 0 to match the real predeploy's layout (see `MockBaseTimeStorage`), not ERC-7201. + uint16 internal _timestampMillisPart; + + /// @inheritdoc IBaseTime + function timestampMillisPart() external view returns (uint16) { + return _timestampMillisPart; + } + + /// @inheritdoc IBaseTime + function timestampMs() external view returns (uint64) { + return uint64(block.timestamp * 1000 + _timestampMillisPart); + } + + /// @inheritdoc IBaseTime + function version() external pure returns (string memory) { + return VERSION; + } +} diff --git a/test/lib/mocks/MockBaseTimeStorage.sol b/test/lib/mocks/MockBaseTimeStorage.sol new file mode 100644 index 00000000..f452a00e --- /dev/null +++ b/test/lib/mocks/MockBaseTimeStorage.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title MockBaseTimeStorage +/// @notice Slot-layout constants for the `MockBaseTime` reference implementation. +/// +/// @dev BaseTime is a Solidity predeploy, not a native precompile, so its layout is the +/// compiler's sequential layout rather than an ERC-7201 namespace: `uint16 +/// timestampMillisPart` occupies the low-order 2 bytes of slot 0. +library MockBaseTimeStorage { + /// @notice Slot holding `timestampMillisPart`. + bytes32 internal constant TIMESTAMP_MILLIS_PART_SLOT = bytes32(uint256(0)); +} diff --git a/test/unit/BaseTime/selectors.t.sol b/test/unit/BaseTime/selectors.t.sol new file mode 100644 index 00000000..690c7918 --- /dev/null +++ b/test/unit/BaseTime/selectors.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {IBaseTime} from "base-std/interfaces/IBaseTime.sol"; + +/// @notice Pins each `IBaseTime` selector to the value exposed by the BaseTime predeploy. +contract BaseTimeSelectorsTest is Test { + /// @notice `timestampMillisPart()` selector equals `0x7b2fea99`. + function test_timestampMillisPart_selector_pinned() public pure { + assertEq(IBaseTime.timestampMillisPart.selector, bytes4(0x7b2fea99), "timestampMillisPart selector"); + } + + /// @notice `timestampMs()` selector equals `0x5745a677`. + function test_timestampMs_selector_pinned() public pure { + assertEq(IBaseTime.timestampMs.selector, bytes4(0x5745a677), "timestampMs selector"); + } + + /// @notice `version()` selector equals `0x54fd4d50`. + function test_version_selector_pinned() public pure { + assertEq(IBaseTime.version.selector, bytes4(0x54fd4d50), "version selector"); + } +} diff --git a/test/unit/BaseTime/timestampMillisPart.t.sol b/test/unit/BaseTime/timestampMillisPart.t.sol new file mode 100644 index 00000000..15927e52 --- /dev/null +++ b/test/unit/BaseTime/timestampMillisPart.t.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {BaseTimeTest} from "base-std-test/lib/BaseTimeTest.sol"; + +contract BaseTimeTimestampMillisPartTest is BaseTimeTest { + /// @notice Verifies timestampMillisPart returns zero before any write + /// @dev Default state of slot 0 on a freshly etched predeploy. + function test_timestampMillisPart_success_zeroByDefault() public view { + assertEq(baseTime.timestampMillisPart(), 0, "timestampMillisPart must default to zero"); + } + + /// @notice Verifies timestampMillisPart returns the stored millisecond component + /// @dev Fuzzes over the valid set {0, 200, 400, 600, 800}. + function test_timestampMillisPart_success_returnsStored(uint256 seed) public { + uint16 part = _validMillisPart(seed); + _setTimestampMillisPart(part); + assertEq(baseTime.timestampMillisPart(), part, "timestampMillisPart must return the stored value"); + } +} diff --git a/test/unit/BaseTime/timestampMs.t.sol b/test/unit/BaseTime/timestampMs.t.sol new file mode 100644 index 00000000..d6e1e3be --- /dev/null +++ b/test/unit/BaseTime/timestampMs.t.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {BaseTimeTest} from "base-std-test/lib/BaseTimeTest.sol"; + +contract BaseTimeTimestampMsTest is BaseTimeTest { + /// @notice Verifies timestampMs combines block.timestamp seconds with the stored millisecond component + /// @dev Timestamp is bounded so `ts * 1000 + part` fits in uint64. + function test_timestampMs_success_combinesSecondsAndMillis(uint256 ts, uint256 seed) public { + ts = bound(ts, 0, type(uint64).max / 1000 - 1); + uint16 part = _validMillisPart(seed); + vm.warp(ts); + _setTimestampMillisPart(part); + assertEq(baseTime.timestampMs(), ts * 1000 + part, "timestampMs must equal block.timestamp * 1000 + part"); + } +} diff --git a/test/unit/BaseTime/version.t.sol b/test/unit/BaseTime/version.t.sol new file mode 100644 index 00000000..d9503275 --- /dev/null +++ b/test/unit/BaseTime/version.t.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {BaseTimeTest} from "base-std-test/lib/BaseTimeTest.sol"; + +contract BaseTimeVersionTest is BaseTimeTest { + /// @notice Verifies version returns the predeploy's semantic version + function test_version_success_returnsSemver() public view { + assertEq(baseTime.version(), "1.0.0", "version must equal 1.0.0"); + } +} diff --git a/test/unit/storage/MockBaseTimeStorage.t.sol b/test/unit/storage/MockBaseTimeStorage.t.sol new file mode 100644 index 00000000..32910a40 --- /dev/null +++ b/test/unit/storage/MockBaseTimeStorage.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {BaseTimeTest} from "base-std-test/lib/BaseTimeTest.sol"; +import {MockBaseTimeStorage} from "base-std-test/lib/mocks/MockBaseTimeStorage.sol"; + +/// @notice Pins `MockBaseTime`'s storage to the real predeploy's layout: `uint16 timestampMillisPart` +/// in the low-order 2 bytes of slot 0. +contract MockBaseTimeStorageTest is BaseTimeTest { + /// @notice Verifies `TIMESTAMP_MILLIS_PART_SLOT` is slot 0. + function test_TIMESTAMP_MILLIS_PART_SLOT_success_isSlotZero() public pure { + assertEq(MockBaseTimeStorage.TIMESTAMP_MILLIS_PART_SLOT, bytes32(0), "timestampMillisPart must live in slot 0"); + } + + /// @notice Verifies a value written to slot 0 is what `timestampMillisPart` reads. + function test_timestampMillisPart_success_readsSlotZero(uint16 part) public { + _setTimestampMillisPart(part); + assertEq( + vm.load(address(baseTime), MockBaseTimeStorage.TIMESTAMP_MILLIS_PART_SLOT), + bytes32(uint256(part)), + "slot 0 must hold the written value" + ); + assertEq(baseTime.timestampMillisPart(), part, "timestampMillisPart must read slot 0"); + } + + /// @notice Verifies `timestampMillisPart` reads only the low-order 2 bytes of slot 0. + /// @dev Fills the high 30 bytes with `high`; a uint16 at offset 0 must ignore them. + function test_timestampMillisPart_success_readsLowOrderBytes(uint16 part, uint240 high) public { + vm.assume(high != 0); + vm.store( + address(baseTime), + MockBaseTimeStorage.TIMESTAMP_MILLIS_PART_SLOT, + bytes32((uint256(high) << 16) | uint256(part)) + ); + assertEq(baseTime.timestampMillisPart(), part, "timestampMillisPart must read the low-order 2 bytes"); + } +}