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");
+ }
+}