Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

stOLAS is an ERC4626 liquid staking vault for OLAS tokens. Users deposit OLAS on L1 (Ethereum) and receive stOLAS tokens. The deposited OLAS is bridged to L2 chains (Gnosis, Base, Mode) for active staking in the Autonolas service ecosystem. Rewards flow back from L2 to L1, increasing the stOLAS price-per-share.

## Build & Test Commands

```bash
make install # Install all dependencies (poetry, forge, yarn)
make build # forge build
make fmt # forge fmt && forge fmt --check
make lint # solhint on contracts/**/*.sol
make tests # forge test -vvv
make tests-hardhat # Hardhat JS tests (temporarily renames LzOracle.sol)
make tests-coverage # forge coverage -vvvv

# Run a single Forge test file
forge test --match-path "test/LiquidStaking.t.sol" -vv

# Run a single Forge test function
forge test --match-test "testDepositAndStake" -vvv

# Hardhat test variants
npm run test:hardhat # Full JS test suite
npm run test:fast # Optimized subset
npm run test:original # Original comprehensive suite
```

**Note:** Hardhat tests temporarily rename `LzOracle.sol` to avoid compilation conflicts. The npm scripts handle this automatically.

## Solidity Configuration

- **Compiler:** 0.8.30, optimizer enabled (1M runs), viaIR, EVM target: Prague
- **Source directory:** `contracts/`
- **Key remappings** (in `foundry.toml`): `@openzeppelin`, `@solmate`, `@registries`, `@layerzerolabs`, `@gnosis.pm`

## Architecture

### L1 Contracts (`contracts/l1/`)

| Contract | Role |
|---|---|
| **stOLAS** | ERC4626 vault. Tracks `stakedBalance`, `vaultBalance`, `reserveBalance`. PPS = totalReserves / totalSupply |
| **Depository** | Routes deposits to L2 staking models. Manages model lifecycle (Active/Inactive/Retired) and product types (Alpha/Beta/Final) |
| **Treasury** | Withdrawal requests via ERC6909 tokens. Triggers L2 unstaking if vault liquidity is insufficient |
| **Distributor** | Receives bridged rewards. Splits between veOLAS lock and stOLAS vault top-up |
| **Lock** | veOLAS management for governance voting power |
| **UnstakeRelayer** | Receives OLAS from retired L2 staking models, returns to stOLAS reserve |

### L2 Contracts (`contracts/l2/`)

| Contract | Role |
|---|---|
| **StakingManager** | Orchestrates service deployment/staking. Creates ActivityModule BeaconProxy instances per service |
| **ExternalStakingDistributor** | Manages external staking on third-party staking proxies. Deploys services (creates Safe multisigs with self as module), stakes/unstakes/re-stakes them, claims and distributes rewards (split between Collector, protocol, and curating agent per configurable factors). Supports V1 (rewards on multisig) and V2 (rewards on contract) staking types. Access-controlled via owner, whitelisted managing agents (for unstakes), and per-proxy curating agents guarded by staking guards. Receives OLAS deposits from L2 staking processor and handles withdraw/unstake requests back through Collector |
| **StakingTokenLocked** | Restricted StakingToken that only allows StakingManager as staker |
| **ActivityModule** | Per-service BeaconProxy. Verifies liveness, shields staking funds, triggers reward claims |
| **Collector** | Collects L2 rewards and bridges to L1 (REWARD→Distributor, UNSTAKE→Treasury, UNSTAKE_RETIRED→UnstakeRelayer) |

### Bridging (`contracts/l1/bridging/`, `contracts/l2/bridging/`)

- LayerZero V2 for cross-chain messaging
- Chain-specific processors: `GnosisDepositProcessorL1`/`L2`, `BaseDepositProcessorL1`/`L2`, `DefaultDepositProcessorL1`/`L2`
- `LzOracle` for LayerZero-driven staking model management

### Core Patterns

- **Proxy architecture:** UUPS-style via `Implementation.sol` (owner management + upgrade logic) and `Beacon.sol` (BeaconProxy for ActivityModules)
- **ERC standards:** ERC4626 (vault), ERC6909 (withdrawal tickets), ERC721 (service NFTs from Autonolas registry)
- **Libraries:** Solmate (ERC4626, ERC6909, ERC721), OpenZeppelin (security utilities), Autonolas Registries (staking infra)

### Cross-Chain Flow

```
Stake: Depository (L1) → DepositProcessor → Bridge → StakingProcessorL2 → StakingManager (L2)
Rewards: Collector (L2) → Bridge → Distributor (L1) → stOLAS
Unstake: Collector (L2) → Bridge → Treasury/UnstakeRelayer (L1) → stOLAS
```

## Deployment

- Scripts in `scripts/deployment/` follow numbered sequences: `deploy_l1_01_*` through `deploy_l1_15_*`, `deploy_l2_01_*` through `deploy_l2_09_*`
- Configuration scripts: `script_l1_*`, `script_l2_*` for post-deployment setup
- Contract addresses: `doc/configuration.json`
- Finalized ABIs: `abis/0.8.30/`
- Static audit: `./scripts/deployment/script_static_audit.sh eth_mainnet NETWORK_mainnet`

## ERC4626 Caveat

`deposit` is meant to be called only via **Depository**, and `redeem` only via **Treasury**. The `mint`/`withdraw` functions are non-standard and not for external use.

## Audit Findings & Resolutions

The project has undergone 9 internal audits (`audits/audit1` through `audits/audit9`) and 1 external audit (CODESPECT).

### audit8 (2026-03-08) — all informational, all fixed
- **INFO-1**: Treasury `requestToWithdraw` didn't forward `msg.value` to unstake calls → Fixed: validates and forwards ETH correctly
- **INFO-2**: Distributor `_increaseLock` left dangling OLAS approval on failure → Fixed: resets approval to 0
- **INFO-3**: ERC6909 withdrawal tokens are transferable → By design
- **INFO-4**: Permissionless trigger functions → By design

### audit9 (2026-03-18) — 5 Low, 5 Informational, all resolved
- **L-1**: `reStake` used dead `mapCuratingAgents` mapping → Fixed: uses `mapServiceIdCuratingAgents[serviceId]`
- **L-2**: `stOLAS.initialize()` no access control → By design (atomic deployment)
- **L-3**: `DefaultDepositProcessorL1.drain()` sends ETH to `address(0)` → Fixed: removed `drain()` entirely (ETH cannot get stuck)
- **L-4**: Distributor dangling approval → Fixed (same as audit8 INFO-2)
- **L-5**: `StakingTokenLocked.maxNumInactivityPeriods` unused → By design (backward compatibility)
- **INFO-1**: `setCuratingAgents` `stakingHash` in loop → Fixed: moved before loop
- **INFO-2**: `unstakeAndWithdraw` missing entry validation → Fixed: added zero-checks at entry
- **INFO-3**: `claim()` permissionless → By design
- **INFO-4**: `unstakeRetired()` permissionless → By design
- **INFO-5**: `LzOracle._lzReceive` trusts LZ Read → By design

## Modified Contracts (not yet re-deployed)

Contracts that have been modified but not yet re-deployed are tracked in
[`MODIFIED_CONTRACTS.md`](MODIFIED_CONTRACTS.md). Keep that file in sync when changing a
deployed contract, and clear entries once re-deployed.
23 changes: 23 additions & 0 deletions MODIFIED_CONTRACTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Modified Contracts — Pending Re-Deployment

This file tracks contracts whose source has been **modified** after their last on-chain
deployment but which have **not yet been re-deployed** ("modified but not updated").

Keep this list in sync whenever a deployed contract is changed, and clear an entry once
the corresponding contract has been re-deployed and its address updated in
`doc/configuration.json`.

## Pending audit8 / audit9 fixes

The following contracts were modified on the `post-audit` branch to address findings from
internal audits 8 and 9. The fixes were reviewed and verified correct in
[`audits/audit10/README.md`](audits/audit10/README.md) ("All 7 fixes verified correct.
No new vulnerabilities introduced."). They still require re-deployment.

| Contract | Layer | Changes |
|---|---|---|
| `contracts/l1/Depository.sol` | L1 | `_unstake` refund now sent to `sender` (original caller) instead of `msg.sender` |
| `contracts/l1/Treasury.sol` | L1 | `requestToWithdraw` now validates `msg.value`, forwards ETH to `unstakeExternal`/`unstake`; extracted `_processUnstakes` helper; added `WrongArrayLength` error |
| `contracts/l1/Distributor.sol` | L1 | `_increaseLock` resets dangling OLAS approval to 0 in the zero-lock branch |
| `contracts/l1/bridging/DefaultDepositProcessorL1.sol` | L1 | Removed `drain()` function and `Drained` event |
| `contracts/l2/ExternalStakingDistributor.sol` | L2 | `unstakeAndWithdraw` moves staking-proxy reads inside the service-unstake condition and validates `stakingProxy`/`serviceId` at entry; `reStake` uses `mapServiceIdCuratingAgents` instead of dead `mapCuratingAgents`; `setCuratingAgents` computes `stakingHash` outside the loop |
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ The test suite covers comprehensive E2E scenarios:
- **External Audits**: v0.1.0 code review is [completed](audits/README.md)
- **Open Source**: Full transparency with public repository
- **Bug Bounty**: Program under consideration post-audit
- **Pending Re-Deployment**: Contracts modified but not yet re-deployed are tracked in [MODIFIED_CONTRACTS.md](MODIFIED_CONTRACTS.md)

## Roadmap
> This roadmap reflects the current design and audit notes. Items and ordering may be updated by governance. *(Last updated: 2025-08-20 12:14 UTC)*
Expand Down
1 change: 1 addition & 0 deletions audits/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ An `audit6` with a focus `main` branch [audit6](https://github.com/kupermind/ola
An `audit7` with a focus `main` branch [audit7](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit7). <br>
An `audit8` with a focus `main` branch [audit8](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit8). <br>
An `audit9` — full project re-audit + delta review [audit9](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit9). <br>
An `audit10` — post-audit fix review of `post-audit` branch [audit10](https://github.com/LemonTreeTechnologies/olas-lst/blob/main/audits/audit10). <br>

### External audits
External audit reports are listed in their historical order:
Expand Down
128 changes: 128 additions & 0 deletions audits/audit10/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Audit 10 — Post-Audit Fix Review

**Audit Date:** March 24, 2026
**Commit Range:** `57d4094` (main) to `46f5737` (post-audit)
**Branch:** `post-audit`
**Repository:** `https://github.com/LemonTreeTechnologies/olas-lst`

## Objectives

Review of fixes addressing findings from internal audits 8 and 9, applied in the `post-audit` branch.
Verify that all fixes are correct, complete, and do not introduce new vulnerabilities.

## Scope

5 modified contracts, 2 commits (`40a349c`, `46f5737`):

```
contracts/l1/Depository.sol | 2 +-
contracts/l1/Distributor.sol | 3 +
contracts/l1/Treasury.sol | 109 ++++++++++++------
contracts/l1/bridging/DefaultDepositProcessorL1.sol| 24 -
contracts/l2/ExternalStakingDistributor.sol | 48 ++++-----
```

## Findings

### Fix 1. Depository: refund sent to `msg.sender` instead of `sender`
```
File: contracts/l1/Depository.sol:324
Before: (bool success,) = msg.sender.call{value: refund}("");
After: (bool success,) = sender.call{value: refund}("");
```
The `sender` parameter is the original caller passed through from Treasury.
When called via Treasury (which forwards `msg.sender` as `sender`),
the refund was incorrectly going to Treasury (`msg.sender`) instead of
the actual user (`sender`).

**Verdict: Fix correct. ✓**

### Fix 2. Distributor: dangling approval not reset
```
File: contracts/l1/Distributor.sol:89
Added: IToken(olas).approve(lock, 0);
```
In the `else` branch (when lock amount is zero and no lock is created),
a prior `approve(lock, olasAmount)` at line 79 leaves a non-zero allowance
on the Lock contract. The fix resets approval to zero.

**Verdict: Fix correct. ✓**

### Fix 3. Treasury: ETH value not forwarded to bridge calls during unstake
```
File: contracts/l1/Treasury.sol:142-213
Refactored into: _processUnstakes() internal function
```
The original `requestToWithdraw()` called `unstakeExternal()` and `unstake()`
without forwarding `msg.value` for bridge gas payments. Bridge calls on Gnosis
(AMB), Optimism (L1CrossDomainMessenger), etc. require native ETH for gas.
ETH sent by the user would remain stuck in Treasury.

The fix:
- Extracts `_processUnstakes()` helper (also resolves stack-too-deep)
- Tracks `remainingValue` across external and LST unstake calls
- Validates `externalAmounts.length == values[0].length`
- Forwards `{value: externalValue}` to `unstakeExternal()` and `{value: remainingValue}` to `unstake()`
- Rejects leftover ETH when no unstaking needed (`if (msg.value > 0) revert`)

**Verdict: Fix correct and complete. ✓**

### Fix 4. DefaultDepositProcessorL1: `drain()` function removed
```
File: contracts/l1/bridging/DefaultDepositProcessorL1.sol
Removed: function drain() external { ... } (24 lines)
```
The `drain()` function allowed the owner to withdraw all native balance
from the deposit processor. Removed to reduce attack surface — leftover
refunds are handled via the existing `LeftoversRefunded` event path.

**Verdict: Fix correct. ✓**

### Fix 5a. ExternalStakingDistributor: guard checks moved inside service unstake condition
```
File: contracts/l2/ExternalStakingDistributor.sol:684-710
```
Previously, `availableRewards` and `stakingState` were read from `stakingProxy`
even when no service unstake was requested (`stakingProxy == address(0)` or
`serviceId == 0`). This caused reverts on invalid proxy addresses.

The fix moves all staking proxy reads inside the
`if (stakingProxy != address(0) && serviceId > 0)` block.

**Verdict: Fix correct. ✓**

### Fix 5b. ExternalStakingDistributor: curating agent access control per-service
```
File: contracts/l2/ExternalStakingDistributor.sol:803
Before: mapCuratingAgents[msg.sender]
After: mapServiceIdCuratingAgents[serviceId] == msg.sender
```
Previously, any address in the generic `mapCuratingAgents` mapping could act
on ANY service. The fix restricts to per-service curating agents via
`mapServiceIdCuratingAgents[serviceId]`.

**Verdict: Fix correct. Addresses cross-service privilege escalation. ✓**

### Fix 5c. ExternalStakingDistributor: `stakingHash` computation moved outside loop
```
File: contracts/l2/ExternalStakingDistributor.sol:939
```
`keccak256(abi.encode(stakingGuard, stakingProxy))` was computed inside a loop
traversing curating agents. Since the inputs don't change per iteration,
this is a gas optimization only.

**Verdict: Fix correct (gas optimization). ✓**

## Summary

| # | Contract | Fix | Severity | Correct? |
|---|----------|-----|:--------:|:--------:|
| 1 | Depository | msg.sender → sender refund | Low | ✓ |
| 2 | Distributor | Reset dangling approval | Low | ✓ |
| 3 | Treasury | ETH value forwarding + validation | Medium | ✓ |
| 4 | DefaultDepositProcessorL1 | Remove drain() | Low | ✓ |
| 5a | ExternalStakingDistributor | Guards inside condition | Low | ✓ |
| 5b | ExternalStakingDistributor | Per-service curating agent access | Medium | ✓ |
| 5c | ExternalStakingDistributor | stakingHash outside loop | Gas | ✓ |

**All 7 fixes verified correct. No new vulnerabilities introduced.**
12 changes: 7 additions & 5 deletions audits/audit8/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,33 +88,35 @@ The protocol demonstrates mature defensive coding patterns:
### Medium: 0
### Low: 0

### Informational: 4
### Informational: 4 (all fixed)

#### INFO-1: Missing ETH Forwarding in Treasury.requestToWithdraw
#### INFO-1: Missing ETH Forwarding in Treasury.requestToWithdraw — FIXED
| | |
|---|---|
| **Location** | Treasury.sol:220-221, 227-228 |
| **Description** | `requestToWithdraw` is `payable` but doesn't forward `msg.value` to `Depository.unstake`/`unstakeExternal` calls |
| **Impact** | None currently — Optimism and Gnosis bridges don't require ETH for L1→L2 messages. If a bridge requiring ETH is added, unstaking from Treasury would fail. |
| **Recommendation** | Add `{value: ...}` forwarding or document that future bridges must not require ETH for L1→L2 UNSTAKE messages |
| **Resolution** | Fixed. `requestToWithdraw` now validates `msg.value`, tracks `remainingValue`, and forwards `{value: externalValue}` to `unstakeExternal` and `{value: remainingValue}` to `unstake`. Reverts if leftover ETH remains. |

#### INFO-2: Dangling OLAS Approval in Distributor
#### INFO-2: Dangling OLAS Approval in Distributor — FIXED
| | |
|---|---|
| **Location** | Distributor.sol:75 |
| **Description** | If `Lock.increaseLock()` fails via low-level call, OLAS approval to Lock remains until next `distribute()` call |
| **Impact** | None — Lock is immutable trusted address. Approval overwritten on next distribute(). |
| **Recommendation** | Consider resetting approval to 0 after failed lock call |
| **Resolution** | Fixed. Added `IToken(olas).approve(lock, 0)` in the failure branch of `_increaseLock()`. |

#### INFO-3: ERC6909 Withdrawal Tokens Are Transferable
#### INFO-3: ERC6909 Withdrawal Tokens Are Transferable — by design
| | |
|---|---|
| **Location** | Treasury.sol:190, solmate ERC6909 |
| **Description** | Withdrawal claim tokens can be transferred. New holder can finalize withdrawal after delay. |
| **Impact** | Feature, not bug. Enables secondary market for withdrawal claims. |
| **Recommendation** | Document this behavior for users |

#### INFO-4: Multiple Permissionless Trigger Functions
#### INFO-4: Multiple Permissionless Trigger Functions — by design
| | |
|---|---|
| **Location** | Depository.sol:763,829; Distributor.sol:125; UnstakeRelayer.sol:60; Collector.sol:317; ActivityModule.sol:303 |
Expand Down
Loading
Loading