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
8 changes: 4 additions & 4 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ BASE_RPC_URL=
ADMIN_ADDRESS=

# [REQUIRED] Operator address that can call executeDebtSwap() and other SafeDebtManager/LeveragedPosition functions
# This address is stored in ProtocolRegistry and can only be changed via TimelockController (2-day delay)
# This address is stored in ProtocolRegistry and can only be changed via TimelockController (8-hour delay by default)
SAFE_OPERATOR_ADDRESS=

# [REQUIRED] Pauser address that can pause/unpause SafeDebtManager and LeveragedPosition in emergencies
Expand All @@ -42,7 +42,7 @@ PAUSER_ADDRESS=
TIMELOCK_ADMIN=

# [OPTIONAL] Minimum delay (seconds) before queued timelock ops can execute.
# Defaults to 172800 (2 days). Lower on testnets only.
# Defaults to 28800 (8 hours).
TIMELOCK_DELAY=

# =============================================================================
Expand All @@ -54,7 +54,7 @@ TIMELOCK_DELAY=
RHP_TREASURY=

# [OPTIONAL] ProtocolRegistry to wire RHP to. If unset, falls back to
# PROTOCOL_REGISTRY_ADDRESS in contractAddresses.ts (auto-synced by deploy:1_core).
# PROTOCOL_REGISTRY_ADDRESS in contractAddresses.ts (auto-synced by registry/core deploys).
RHP_REGISTRY=

# [OPTIONAL] DEFAULT_ADMIN_ROLE holder on RatehopperUniV3Positions. Falls back to ADMIN_ADDRESS.
Expand Down Expand Up @@ -122,4 +122,4 @@ OPERATION_ID=
# REPORT_GAS=true

# [OPTIONAL] Override the chain id used by scripts/syncRegistryAddress.js. Default 8453 (Base).
# CHAIN_ID=8453
# CHAIN_ID=8453
87 changes: 66 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ The system consists of several key components:

1. **Governance & Access Control**:

- `TimelockController`: OpenZeppelin's timelock implementation with 2-day delay for critical operations
- `TimelockController`: OpenZeppelin's timelock implementation with 8-hour delay by default for critical operations
- `ProtocolRegistry.sol`: Central registry with hybrid access control:
- `DEFAULT_ADMIN_ROLE`: For routine operations (whitelist, token mappings) - immediate execution
- `CRITICAL_ROLE`: For critical operations (setParaswapV6, setOperator) - requires timelock
Expand Down Expand Up @@ -193,7 +193,7 @@ RHP_MAX_FEE_BPS=2000 # Hard upper bound on BOTH fees (bps). Default 200

# Optional — TimelockController inside 2_DeployUniV3Helper
TIMELOCK_ADMIN=0x... # Proposer + executor on the new timelock. Falls back to ADMIN_ADDRESS
TIMELOCK_DELAY=172800 # Min delay before queued ops execute (seconds). Default 172800 (2 days)
TIMELOCK_DELAY=28800 # Min delay before queued ops execute (seconds). Default 28800 (8 hours)
```
## Setup and Development

Expand Down Expand Up @@ -252,44 +252,68 @@ Hardhat Ignition is a declarative deployment framework. Instead of writing imper
- **Tracks state** in `ignition/deployments/chain-<chainId>/` so deployments can be resumed if interrupted
- **Records constructor args** in `journal.jsonl` for reproducibility and verification
- **Supports `--verify`** to automatically submit contracts to Etherscan after deployment
- **Supports `--reset`** to wipe previous state and redeploy from scratch
- **Supports per-future wipes** with `hardhat ignition wipe <deploymentId> <futureId>`

The core contracts are defined in a single module at `ignition/modules/1_DeployCore.ts`. Every step is chained sequentially via `after` dependencies to avoid nonce race conditions.

### Export ABIs

ABI files for the four core integration contracts are exported to `abis/` from Hardhat artifacts:

```bash
yarn abis
```

This compiles the contracts and writes:

- `abis/LeveragedPosition.json`
- `abis/RatehopperUniV3Positions.json`
- `abis/SafeDebtManager.json`
- `abis/SafeExecTransactionWrapper.json`

The deploy scripts below run the ABI exporter automatically after a successful deployment.

### Deploy Registry Only

Deploy only `ProtocolRegistry`:

```bash
yarn deploy:0_registry
```

This deploys and configures `ProtocolRegistry`, syncs the registry address into `contractAddresses.ts`, and refreshes the ABI files in `abis/`.

### Deploy All Contracts

Deploy everything in one command:
Deploy the core debt-management contracts:

```bash
yarn deploy:1_core
# or equivalently:
npx hardhat ignition deploy ignition/modules/1_DeployCore.ts --network base --verify --reset
```

This deploys `ignition/modules/1_DeployCore.ts` to Base with verification enabled, reusing the configured registry from `deploy:0_registry`, syncs the registry address into `contractAddresses.ts`, and refreshes the ABI files in `abis/`.

This deploys all contracts sequentially in a single transaction chain:

1. **TimelockController** (2-day delay)
2. **ProtocolRegistry** (with timelock, operator, paraswap configured)
3. **Handlers**: AaveV3 → Compound → Morpho → FluidSafe → Moonwell
4. **Registry config**: token mappings, Fluid resolver, whitelist
5. **Admin transfer**: grant `ADMIN_ADDRESS` admin role, revoke deployer
6. **SafeDebtManager** → `transferOwnership` to `ADMIN_ADDRESS`
7. **LeveragedPosition** → `transferOwnership` to `ADMIN_ADDRESS`
8. **SafeExecTransactionWrapper**
1. **Configured ProtocolRegistry** from `DeployRegistryOnly`
2. **Handlers**: AaveV3 → Compound → Morpho → FluidSafe → Moonwell
3. **SafeDebtManager** → `transferOwnership` to `ADMIN_ADDRESS`
4. **LeveragedPosition** → `transferOwnership` to `ADMIN_ADDRESS`
5. **SafeExecTransactionWrapper**

### Deploy RatehopperUniV3Positions (Standalone)

`RatehopperUniV3Positions` is deployed separately because it sits on top of an existing `ProtocolRegistry` and needs its own TimelockController for fund-impacting setters.

```bash
yarn deploy:2_univ3_helper
# or equivalently:
npx hardhat ignition deploy ignition/modules/2_DeployUniV3Helper.ts --network base --verify
```

This deploys `ignition/modules/2_DeployUniV3Helper.ts` to Base with verification enabled and refreshes the ABI files in `abis/`.

This module by default deploys:

1. **TimelockController** (proposer + executor = `TIMELOCK_ADMIN` ?? `ADMIN_ADDRESS`; delay = `TIMELOCK_DELAY` ?? 2 days)
1. **TimelockController** (proposer + executor = `TIMELOCK_ADMIN` ?? `ADMIN_ADDRESS`; delay = `TIMELOCK_DELAY` ?? 8 hours)
2. **RatehopperUniV3Positions** (wired to the registry from `RHP_REGISTRY` ?? `PROTOCOL_REGISTRY_ADDRESS`; CRITICAL_ROLE granted to the timelock from step 1)

To reuse an existing TimelockController instead of deploying a new one, set `RHP_TIMELOCK=0x...` — the module skips step 1 and points RHP at the supplied address. See `## Environment Variables` for the full list of optional knobs.
Expand All @@ -311,6 +335,27 @@ Inspect deployed addresses:
cat ignition/deployments/chain-8453/deployed_addresses.json
```

### Wiping Deployments

Use `wipe:all` when you want to clear all local Ignition state for Base and redeploy everything from scratch:

```bash
yarn wipe:all
```

This removes `ignition/deployments/chain-8453`. It does not delete on-chain contracts; it only resets this repo's local Ignition deployment journal and generated deployment artifacts for Base.

Shortcut scripts are available for common futures:

```bash
yarn wipe:leveraged-position
yarn wipe:univ3-positions
yarn wipe:safe-debt-manager
yarn wipe:safe-wrapper
```

In short: use `wipe:all` for a clean redeploy of the whole Base deployment, and use `wipe` or a `wipe:*` shortcut only when you intentionally want to rerun one named future.

### Contract Verification

The `--verify` flag on `yarn deploy:1_core` may fail due to a known `hardhat-verify` v2.x bug with Etherscan's V2 API (the plugin's GET requests strip the `chainid` parameter). Use the standalone verification script instead:
Expand All @@ -331,7 +376,7 @@ This script:

### Timelock Operations

Critical `ProtocolRegistry` setters (`setParaswapV6`, `setOperator`) carry `CRITICAL_ROLE` and revert unless `msg.sender` is the timelock, so they must be scheduled and executed through the `TimelockController` (2-day delay). Each script is a two-step flow: schedule, wait for the delay, then re-run with `EXECUTE=true` reusing the same `OPERATION_ID` printed during scheduling.
Critical `ProtocolRegistry` setters (`setParaswapV6`, `setOperator`) carry `CRITICAL_ROLE` and revert unless `msg.sender` is the timelock, so they must be scheduled and executed through the `TimelockController` (8-hour delay by default). Each script is a two-step flow: schedule, wait for the delay, then re-run with `EXECUTE=true` reusing the same `OPERATION_ID` printed during scheduling.

#### Finding the TimelockController address

Expand Down Expand Up @@ -365,7 +410,7 @@ Use the resulting address as `TIMELOCK_ADDRESS` in the commands below.
TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_PARASWAP_ADDRESS=0x... \
yarn hardhat run scripts/timelockUpdateParaswap.ts --network base

# Wait 2 days, then execute
# Wait for the timelock delay, then execute
EXECUTE=true OPERATION_ID="..." TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_PARASWAP_ADDRESS=0x... \
yarn hardhat run scripts/timelockUpdateParaswap.ts --network base
```
Expand All @@ -377,7 +422,7 @@ yarn hardhat run scripts/timelockUpdateParaswap.ts --network base
TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_OPERATOR_ADDRESS=0x... \
yarn hardhat run scripts/timelockUpdateOperator.ts --network base

# Wait 2 days, then execute (reuse the OPERATION_ID printed during scheduling)
# Wait for the timelock delay, then execute (reuse the OPERATION_ID printed during scheduling)
EXECUTE=true OPERATION_ID="..." TIMELOCK_ADDRESS=0x... PROTOCOL_REGISTRY_ADDRESS=0x... NEW_OPERATOR_ADDRESS=0x... \
yarn hardhat run scripts/timelockUpdateOperator.ts --network base
```
Expand All @@ -389,7 +434,7 @@ The contracts include several security features:

### Access Control & Governance

- **Timelock Controller**: 2-day delay for critical operations (Paraswap and operator updates)
- **Timelock Controller**: 8-hour delay by default for critical operations (Paraswap and operator updates)
- **Hybrid Access Control**:
- `DEFAULT_ADMIN_ROLE`: For routine operations (immediate execution)
- `CRITICAL_ROLE`: For critical operations (requires timelock)
Expand Down
6 changes: 3 additions & 3 deletions contractAddresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ export const ADMIN_ADDRESS = "0xc74fc973A0740Ca1ED6f8F31Ed56003A13D4F5F1";
// (e.g. 2_DeployUniV3Helper reads PROTOCOL_REGISTRY_ADDRESS to wire RHP
// to the existing registry).
//
// AUTO-MANAGED: `yarn deploy:1_core` runs scripts/syncRegistryAddress.js as its
// final step, rewriting the address below to the freshly deployed
// AUTO-MANAGED: registry/core deploy scripts run scripts/syncRegistryAddress.js,
// rewriting the address below to the configured
// ProtocolRegistry. Commit the resulting diff. Only edit by hand if pointing at
// a registry deployed outside this repo's Ignition flow.
export const PROTOCOL_REGISTRY_ADDRESS = "0x20b2003CEF180DBbcca9fAf33Ddb1D635fdD2F6c";
export const PROTOCOL_REGISTRY_ADDRESS = "0x2f1331Df43E2f63e01298f570F9e467375077d7d";

// Protocol enum
export enum Protocol {
Expand Down
102 changes: 94 additions & 8 deletions ignition/modules/0_DeployRegistryOnly.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import TimelockControllerModule from "./TimelockControllerModule";
import {
AERO_ADDRESS,
cbBTC_ADDRESS,
cbETH_ADDRESS,
DAI_ADDRESS,
eUSD_ADDRESS,
EURC_ADDRESS,
FLUID_VAULT_RESOLVER,
GHO_ADDRESS,
getCTokenMappingArrays,
getMTokenMappingArrays,
LBTC_ADDRESS,
MAI_ADDRESS,
PARASWAP_V6_CONTRACT_ADDRESS,
rETH_ADDRESS,
sUSDS_ADDRESS,
tBTC_ADDRESS,
UNISWAP_V3_FACTORY_ADDRESS,
USDbC_ADDRESS,
USDC_ADDRESS,
USDS_ADDRESS,
VIRTUAL_ADDRESS,
weETH_ADDRESS,
WELL_ADDRESS,
WETH_ADDRESS,
wrsETH_ADDRESS,
wstETH_ADDRESS,
} from "../../contractAddresses";

const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
Expand All @@ -18,52 +41,115 @@ function requireAddress(label: string, value: string): void {
}

/**
* Deploys only ProtocolRegistry.
* Deploys and configures ProtocolRegistry.
*
* If REGISTRY_TIMELOCK is set, that existing TimelockController address is used.
* If unset, this module deploys/reuses the shared TimelockControllerModule.
*
* Environment variables:
* - REGISTRY_INITIAL_ADMIN: DEFAULT_ADMIN_ROLE holder. Falls back to ADMIN_ADDRESS.
* - REGISTRY_INITIAL_ADMIN: Final DEFAULT_ADMIN_ROLE holder. Falls back to ADMIN_ADDRESS.
* - SAFE_OPERATOR_ADDRESS: Initial registry safeOperator.
* - REGISTRY_TIMELOCK: Optional existing timelock to use for CRITICAL_ROLE.
* - TIMELOCK_ADMIN: Used by TimelockControllerModule when REGISTRY_TIMELOCK is unset.
* Falls back to ADMIN_ADDRESS.
* - TIMELOCK_DELAY: Used by TimelockControllerModule when REGISTRY_TIMELOCK is unset.
* Defaults to 172800 seconds.
* Defaults to 28800 seconds.
* - DEPLOYER_PRIVATE_KEY: Deployer key (set in hardhat.config.ts).
*
* Usage:
* npx hardhat ignition deploy ignition/modules/0_DeployRegistryOnly.ts \
* --network base --verify
*/
export default buildModule("DeployRegistryOnly", (m) => {
const initialAdminAddr = process.env.REGISTRY_INITIAL_ADMIN ?? process.env.ADMIN_ADDRESS ?? "";
const finalAdminAddr = process.env.REGISTRY_INITIAL_ADMIN ?? process.env.ADMIN_ADDRESS ?? "";
const operatorAddr = process.env.SAFE_OPERATOR_ADDRESS ?? "";
const reuseTimelockAddr = process.env.REGISTRY_TIMELOCK ?? "";

requireAddress("initialAdmin (REGISTRY_INITIAL_ADMIN / ADMIN_ADDRESS)", initialAdminAddr);
requireAddress("finalAdmin (REGISTRY_INITIAL_ADMIN / ADMIN_ADDRESS)", finalAdminAddr);
requireAddress("operator (SAFE_OPERATOR_ADDRESS)", operatorAddr);
if (reuseTimelockAddr) requireAddress("timelock (REGISTRY_TIMELOCK)", reuseTimelockAddr);

const deployer = m.getAccount(0);
const DEFAULT_ADMIN_ROLE = "0x0000000000000000000000000000000000000000000000000000000000000000";

const timelock = reuseTimelockAddr ? undefined : m.useModule(TimelockControllerModule).timelock;
const timelockArg: any = timelock ?? reuseTimelockAddr;

const initialAdmin = m.getParameter<string>("initialAdmin", initialAdminAddr);
const finalAdmin = m.getParameter<string>("finalAdmin", finalAdminAddr);
const operator = m.getParameter<string>("operator", operatorAddr);

const registry = m.contract(
"ProtocolRegistry",
[
WETH_ADDRESS,
UNISWAP_V3_FACTORY_ADDRESS,
initialAdmin,
deployer,
timelockArg,
operator,
PARASWAP_V6_CONTRACT_ADDRESS,
],
timelock ? { after: [timelock] } : undefined,
);

return { registry, ...(timelock ? { timelock } : {}) };
const [mTokens, mContracts] = getMTokenMappingArrays();
const setMoonwellMappings = m.call(registry, "batchSetTokenMContracts", [mTokens, mContracts], {
id: "registry_setMoonwellMappings",
});

const [cTokens, cContracts] = getCTokenMappingArrays();
const setCompoundMappings = m.call(registry, "batchSetTokenCContracts", [cTokens, cContracts], {
id: "registry_setCompoundMappings",
after: [setMoonwellMappings],
});

const setFluidResolver = m.call(registry, "setFluidVaultResolver", [FLUID_VAULT_RESOLVER], {
id: "registry_setFluidVaultResolver",
after: [setCompoundMappings],
});

const whitelistTokens = [
USDC_ADDRESS,
cbETH_ADDRESS,
WETH_ADDRESS,
USDbC_ADDRESS,
cbBTC_ADDRESS,
eUSD_ADDRESS,
MAI_ADDRESS,
DAI_ADDRESS,
sUSDS_ADDRESS,
AERO_ADDRESS,
wstETH_ADDRESS,
rETH_ADDRESS,
weETH_ADDRESS,
EURC_ADDRESS,
GHO_ADDRESS,
wrsETH_ADDRESS,
WELL_ADDRESS,
USDS_ADDRESS,
tBTC_ADDRESS,
LBTC_ADDRESS,
VIRTUAL_ADDRESS,
];
const addToWhitelist = m.call(registry, "addToWhitelistBatch", [whitelistTokens], {
id: "registry_addToWhitelistBatch",
after: [setFluidResolver],
});

const grantAdminRole = m.call(registry, "grantRole", [DEFAULT_ADMIN_ROLE, finalAdmin], {
id: "registry_grantAdminToFinalAdmin",
after: [addToWhitelist],
});

const revokeDeployerRole = m.call(registry, "revokeRole", [DEFAULT_ADMIN_ROLE, deployer], {
id: "registry_revokeAdminFromDeployer",
after: [grantAdminRole],
});

// `registryConfigured` is the final config call (revoke), exposed so 1_DeployCore
// can gate handler deployment on registry configuration completing. Ignition's
// result type only models contract deployment futures, so cast the call future to
// satisfy the type while keeping the real future for `after` dependencies.
const registryConfigured = revokeDeployerRole as unknown as ReturnType<typeof m.contract>;

return { registry, registryConfigured, ...(timelock ? { timelock } : {}) };
});
Loading
Loading