Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
94 changes: 94 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,51 @@ pnpm i

Key `.env` variables: `PROVIDER_URL`, `SONIC_PROVIDER_URL`, `BASE_PROVIDER_URL`, `BLOCK_NUMBER`, `ACCOUNTS_TO_FUND`.

### `@oplabs/talos-client` (private, optional)

`pnpm i` deliberately does **not** install `@oplabs/talos-client`. It is declared
as an *optional peer dependency* so that CI and external contributors can install
this repo without GitHub Packages credentials.

You need it to **run** anything that talks to a chain — `tsx tasks/run.ts`
(`tasks/lib/network.ts` resolves the RPC env var through it) and
`tasks/lib/signer.ts` (KMS signer + Postgres nonce queue).

You do **not** need it merely to load code. `tasks/lib/network.ts` requires it
lazily, inside `rpcUrlFor()`, precisely so that importers further up the chain —
`utils/resolvers.js` → `utils/morpho.js` → `tasks/tasks.js` →
`hardhat.config.js` — keep working without it. Do not hoist that back to a
top-level import: it breaks `hardhat deploy` in the ABI publish workflow, which
installs without GitHub Packages auth.

```bash
pnpm install:talos
```

The script sources a token from the `gh` CLI (or an explicit `NODE_AUTH_TOKEN`),
verifies it can actually read the package, installs it via a throwaway npm config,
and then **restores `package.json` and `pnpm-lock.yaml`**. That restore is
deliberate: committing the package as a normal dependency breaks CI, which has no
GitHub Packages auth.

Do not put the auth token in `contracts/.npmrc`. pnpm prints a
`Failed to replace env in config` warning on *every* command when an `.npmrc`
references an unset variable, so that noise would hit CI and every engineer
without a token.

Two things to know:

- `gh auth login` does **not** request the `read:packages` scope. If the script
reports a scope error, run `gh auth refresh -s read:packages` once.
- Because the install is not persisted, any later `pnpm install` prunes it.
Re-run the script afterwards.

Neither CI nor the Talos runner image uses this script. CI installs without the
package (`pnpm install --frozen-lockfile`, no GitHub Packages auth) and never
needs it; `dockerfile-actions` installs it explicitly with the
`talos_package_token` build secret, reading the same pinned version from
`peerDependencies`.

## Commands (run from `contracts/`)

### Build
Expand Down Expand Up @@ -160,6 +205,55 @@ Located in `deploy/` and numbered sequentially (e.g., `001_ousd.js`, `002_vault.

When adding a new deployment script, increment the number and follow existing patterns in `utils/deploy.js` (especially `deployWithConfirmation` and `withConfirmation`).

## Storage Layout Checks

**The baseline is the descriptor.** `deployments/<network>/<Name>.json` holds
`{address, abi, storageLayout}` — the layout is what is deployed at that address
right now, written by `scripts/create-hardhat-format-descriptors.js` in the same
step that records the address, so the write side cannot silently stop.

**The gate runs per deployed contract, before broadcast.** Call
`_assertStorageSafe(type(X).name)` in a deploy script before `new X()`. It shells
out to `scripts/check-storage-upgrade.js` over `vm.ffi`; a non-zero exit aborts
the whole forge script, so nothing is broadcast and the ledger is untouched.
Checking the deployed contract is sufficient — its layout is the flattened layout
of every source file it inherits (e.g. `OUSDVault` = `VaultStorage` 37 slots +
`Initializable` 3).

For a deliberate break — retiring a slot behind a `uint256` placeholder and
abandoning the data — use `_allowStorageBreak(type(X).name, "<reason>")`. The
reason is required and lands in the PR diff next to the deployment.

Comparison is `@openzeppelin/upgrades-core`, which supplies the `__gap` shrink
arithmetic. Two policies are ours: a rename is ignored only when the new label is
derived from the old (`assets` -> `_deprecated_assets`); an unrelated rename is
not, because two same-typed variables trading labels also reports as two renames.
Enum member data is absent from solc layouts and is ignored — an enum is one byte
regardless of variant count, so no slot moves; what is given up is noticing that a
stored value now decodes to a different variant.

Prefer `__gap` for new gaps, but any number of leading underscores works — labels
matching `/^_+[a-z]*gap$/i` are normalised to `__gap` **in memory** at comparison
time, because OZ's `isGap()` matches only `__gap`/`__gap_*` while contracts
deployed before the rename still carry `______gap` on chain. Stored layouts stay
faithful to their source, so a bug in that normalisation never costs a re-fetch.

Storage-slot checks cover **Ethereum mainnet and Base** only. ArbitrumOne, Sonic,
Plume, Hoodi and HyperEVM are out of scope; the gate is a no-op there.

**Re-supporting a chain means re-fetching every layout.** The legacy
`storageLayout/` tree has been deleted; baselines now live only in descriptors.
To bring a chain back, run
`scripts/fetch-onchain-storage-layouts.js --network <net>` (reads each deployed
implementation's layout from its verified source) then
`scripts/seed-descriptor-storage-layouts.js --network <net>`, and add the chain to
`NETWORK_BY_CHAIN` in both that script and `check-storage-upgrade.js`.

`scripts/check-storage-layout.js` remains for ad-hoc "what did this PR change"
investigation between two git refs. It is **not** the gate: it compares type
identifier strings, which embed AST node ids that shift between compilations, so
it false-positives on any contract with an enum or struct in storage.

## Roles & Access Control

Four key roles used across all contracts:
Expand Down
30 changes: 27 additions & 3 deletions contracts/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ DEPLOY_SCRIPT := scripts/deploy/DeployManager.s.sol
DEPLOY_BASE := --account deployerKey --sender $(DEPLOYER_ADDRESS) --broadcast --slow --no-isolate
DEPLOY_BUILD := contracts/ scripts/deploy/

# Rewrites deployments/<network>/<Name>.json for whatever the broadcast just
# deployed, so Talos crons and the hardhat task CLI keep resolving live
# addresses. Deliberately NOT wired into deploy-local: that target also
# broadcasts, but against a throwaway node.
#
# It runs even when `forge script` exits non-zero, with forge's exit code
# preserved afterwards. That matters because --verify runs AFTER the broadcast:
# a successful deploy whose Etherscan verification failed would otherwise skip
# the descriptor update and leave live crons pointing at the old contract — the
# exact silent drift this step exists to prevent. A descriptor failure still
# fails the target (`|| exit 1`); only forge's own exit code is deferred.
DESCRIPTORS := node scripts/create-hardhat-format-descriptors.js

# Clearing the chain's broadcast dir first is what makes "a run file exists"
# mean "this run deployed something". forge leaves the PREVIOUS run-latest.json
# byte-identical when a broadcast produces zero transactions, and `make
# deploy-local` broadcasts against `pnpm node:anvil` — which forks mainnet and
# so reports chain id 1. Without this, a local rehearsal followed by a
# no-op deploy-mainnet would replay anvil addresses into live descriptors.
CLEAR_BROADCAST := rm -rf broadcast/DeployManager.s.sol

# ╔══════════════════════════════════════════════════════════════════════════════╗
# ║ DEFAULT ║
# ╚══════════════════════════════════════════════════════════════════════════════╝
Expand Down Expand Up @@ -135,15 +156,18 @@ snapshot:
# make deploy-local -> deploy to local node (no verification)
deploy-mainnet:
forge build $(DEPLOY_BUILD)
@forge script $(DEPLOY_SCRIPT) --rpc-url $(MAINNET_PROVIDER_URL) $(DEPLOY_BASE) --verify -vvvv
$(CLEAR_BROADCAST)/1
@forge script $(DEPLOY_SCRIPT) --rpc-url $(MAINNET_PROVIDER_URL) $(DEPLOY_BASE) --verify -vvvv; rc=$$?; $(DESCRIPTORS) --chain-id 1 || exit 1; exit $$rc

deploy-base:
forge build $(DEPLOY_BUILD)
@forge script $(DEPLOY_SCRIPT) --rpc-url $(BASE_PROVIDER_URL) $(DEPLOY_BASE) --verify -vvvv
$(CLEAR_BROADCAST)/8453
@forge script $(DEPLOY_SCRIPT) --rpc-url $(BASE_PROVIDER_URL) $(DEPLOY_BASE) --verify -vvvv; rc=$$?; $(DESCRIPTORS) --chain-id 8453 || exit 1; exit $$rc

deploy-hyperevm:
forge build $(DEPLOY_BUILD)
@forge script $(DEPLOY_SCRIPT) --rpc-url $(HYPEREVM_PROVIDER_URL) $(DEPLOY_BASE) --verify -vvvv
$(CLEAR_BROADCAST)/999
@forge script $(DEPLOY_SCRIPT) --rpc-url $(HYPEREVM_PROVIDER_URL) $(DEPLOY_BASE) --verify -vvvv; rc=$$?; $(DESCRIPTORS) --chain-id 999 || exit 1; exit $$rc

deploy-local:
forge build $(DEPLOY_BUILD)
Expand Down
2 changes: 1 addition & 1 deletion contracts/contracts/harvest/OETHHarvesterSimple.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ contract OETHHarvesterSimple is Initializable, Strategizable {
mapping(address => bool) public supportedStrategies;

/// @notice Gap for upgrade safety
uint256[48] private ___gap;
uint256[48] private __gap;

////////////////////////////////////////////////////
/// --- EVENTS
Expand Down
2 changes: 1 addition & 1 deletion contracts/contracts/utils/Initializable.sol
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,5 @@ abstract contract Initializable {
}
}

uint256[50] private ______gap;
uint256[50] private __gap;
}
2 changes: 1 addition & 1 deletion contracts/contracts/utils/InitializableERC20Detailed.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
*/
abstract contract InitializableERC20Detailed is IERC20 {
// Storage gap to skip storage from prior to OUSD reset
uint256[100] private _____gap;
uint256[100] private __gap;

string private _name;
string private _symbol;
Expand Down
Loading