Skip to content

Conversation

@clement-ux
Copy link
Contributor

@clement-ux clement-ux commented Jan 23, 2026

Overview

Major refactoring of the deployment framework to improve speed, scalability, and developer experience. This transforms the deployment system from a basic script-based approach to a sophisticated, maintainable framework.

71 files changed | +3,733 lines | -2,687 lines

Motivation

The old deployment process had several limitations that made it difficult to maintain as the number of deployment scripts grew:

1. Manual Script Registration

Every new deployment script required:

  • Manual import at the top of DeployManager.sol
  • Manual instantiation in the run() function
  • Risk of forgetting to add it (there was even a TODO: "Use vm.readDir to recursively build this?")
// OLD: 15+ manual imports and instantiations
import {UpgradeLidoARMMainnetScript} from "./mainnet/003_UpgradeLidoARMScript.sol";
// ... more imports

function run() external {
    _runDeployFile(new UpgradeLidoARMMainnetScript());
    // ... more manual calls
}

2. Inefficient JSON Serialization

The old approach read and wrote JSON line-by-line, which was:

  • Slow (multiple file reads per execution)
  • Error-prone (had a warning comment about "EOF while parsing" bugs)
  • Data lossy (each vm.serializeUint call overwrote previous data)

3. AddressResolver Recreated Per Script

A new AddressResolver was instantiated in every script's run() function, wasting gas and setup time.

4. Address Passing via Constructor

Scripts had to receive addresses as constructor parameters, creating tight coupling:

// OLD: Complex constructor dependencies
_runDeployFile(new UpgradeOriginARMScript(
    getDeployedAddressInBuild("HARVESTER"),
    getDeployedAddressInBuild("ORIGIN_ARM"),
    getDeployedAddressInBuild("SILO_VARLAMORE_S_MARKET")
));

Key Changes

1. Dynamic Script Discovery

Scripts are now auto-discovered from chain-specific folders (mainnet/, sonic/). No more manual registration.

// NEW: Automatic discovery
VmSafe.DirEntry[] memory files = vm.readDir(path);
for (uint256 i; i < resultSize; i++) {
    _runDeployFile(address(vm.deployCode(contractName)));
}

2. Struct-Based JSON with Arrays

JSON is now parsed once using struct deserialization, and written once at the end:

// NEW: Fast struct parsing
Root memory root = abi.decode(vm.parseJson(deployment), (Root));

New JSON format:

{
  "contracts": [{"name": "LIDO_ARM", "implementation": "0x..."}],
  "executions": [{"name": "003_UpgradeLidoARMScript", "timestamp": 123}]
}

3. Persistent Resolver at Deterministic Address

A single Resolver contract is deployed via vm.etch to a deterministic address. All scripts access the same instance:

// Deterministic address: keccak256("Resolver")
Resolver internal resolver = Resolver(address(uint160(uint256(keccak256("Resolver")))));

4. Resolver-Based Address Lookups

Scripts now query the Resolver directly - no constructor params needed:

// NEW: Simple lookups
address proxy = resolver.implementations("LIDO_ARM");

5. Standardized Deployment Lifecycle

New AbstractDeployScript provides a clean lifecycle with override hooks:

  • _execute() - Deploy contracts, register them
  • _buildGovernanceProposal() - Define governance actions
  • _fork() - Post-deployment verification

6. Multi-Chain Support

Built-in support for multiple chains via folder-based organization:

  • Chain ID 1 (Mainnet) → script/deploy/mainnet/
  • Chain ID 146 (Sonic) → script/deploy/sonic/

Benefits

Aspect Before After
Adding new script Manual import + instantiation Just add file to folder
JSON operations Multiple reads per script Single read at start, single write at end
Address resolution Constructor params or file read O(1) Resolver lookup
Resolver creation New instance per script Single persistent instance
Script organization All imports in one file Folder-based, auto-discovered
Governance support Ad-hoc Built-in with simulation

CI Improvements

New Workflow Triggers

Before: CI only ran on pull_request and push to main.

After: Added two new triggers:

  • Scheduled runs (cron: '0 6 * * *') – Daily at 6:00 AM UTC for comprehensive testing
  • Manual dispatch (workflow_dispatch) – Trigger full test suite from GitHub UI on demand

Profile-Based Invariant Testing

Invariant tests now dynamically select their Foundry profile based on the trigger:

Trigger Profile Runs Depth
pull_request lite 50 100
push (feature branch) lite 50 100
push (main) ci 1,000 1,000
schedule ci 1,000 1,000
workflow_dispatch ci 1,000 1,000

This provides fast feedback on PRs while ensuring thorough testing on main and scheduled runs.

Reusable Setup Action

Created .github/actions/setup/action.yml to DRY up the workflow:

  • Foundry installation with built-in caching
  • Soldeer dependencies with cache (key: soldeer-${{ hashFiles('soldeer.lock') }})
  • Optional Yarn dependencies with cache

Smart Job Skipping

Jobs that don't need to run on scheduled builds are skipped:

  • lint, build, unit-tests → Skip on schedule (no code changes to validate)
  • smoke-tests, fork-tests → Always run (validate live chain state)
  • Medusa fuzzing → Only runs in ci profile (too slow for PR feedback)

Time Reduction

Scenario Before After Reduction
PR checks ~25 min ~3 min ~88%
Full suite (main/scheduled) ~25 min ~25 min Same (but more thorough)

The PR workflow is now 8× faster by using the lite profile, while scheduled runs maintain the same rigor with 20× more invariant test iterations.

New Architecture

DeployManager.setUp()
  ├── Determine state (FORK_TEST, FORK_DEPLOYING, REAL_DEPLOYING)
  ├── Load/create deployment JSON
  └── Deploy Resolver to deterministic address

DeployManager.run()
  ├── _preDeployment() → Load JSON into Resolver
  ├── Read scripts from chain folder
  ├── For each script: _runDeployFile()
  │   ├── Check skip() / proposalExecuted()
  │   ├── Run script.run() if not in history
  │   └── Or call handleGovernanceProposal() if already deployed
  └── _postDeployment() → Save Resolver data to JSON

AbstractDeployScript.run()
  ├── Get state from Resolver
  ├── Start broadcast/prank
  ├── Execute _execute()
  ├── Stop broadcast/prank
  ├── Store contracts in Resolver
  ├── Build & handle governance proposal
  └── Run _fork() for verification

How to Write a New Deployment Script

  1. Create file: script/deploy/mainnet/NNN_YourScript.s.sol
  2. Follow the naming convention:
    • File: NNN_DescriptiveName.s.sol
    • Contract: $NNN_DescriptiveName
    • Constructor: "NNN_DescriptiveName"
contract $017_MyUpgrade is AbstractDeployScript("017_MyUpgrade") {
    using GovHelper for GovProposal;

    bool public constant override skip = false;
    bool public constant override proposalExecuted = false;

    MyContract public newImpl;

    function _execute() internal override {
        // Look up existing contracts
        address proxy = resolver.implementations("MY_CONTRACT");

        // Deploy new contracts
        newImpl = new MyContract();
        _recordDeployment("MY_CONTRACT_IMPL", address(newImpl));
    }

    function _buildGovernanceProposal() internal override {
        govProposal.setDescription("Upgrade MyContract");
        govProposal.action(
            resolver.implementations("MY_CONTRACT"),
            "upgradeTo(address)",
            abi.encode(address(newImpl))
        );
    }

    function _fork() internal override {
        // Verify deployment
    }
}

See script/deploy/mainnet/000_Example.s.sol for a complete template.

Note

  • 017_UpgradeLidoARMScript is an example and should be removed before this PR is merged.

@clement-ux clement-ux marked this pull request as ready for review January 23, 2026 17:18
@clement-ux
Copy link
Contributor Author

After benchmark the process is not faster.
I'll try to improve it.

@clement-ux clement-ux closed this Jan 26, 2026
@clement-ux clement-ux deleted the clement/refactor-deployment branch January 26, 2026 08:01
@clement-ux clement-ux restored the clement/refactor-deployment branch January 26, 2026 10:27
@clement-ux clement-ux reopened this Jan 26, 2026
@clement-ux
Copy link
Contributor Author

clement-ux commented Jan 26, 2026

Benchmark

After more rigorous testing, here are the results

No deployment file

Test Before After Improvement
Setup Only (cold run) 1.39s 0.83s 40.3% ⬆️
Setup Only (warm run) 0.64s 0.87s 35.9% ⬇️
All tests (cold run) 4.78s 4.54s 5.0% ⬆️
All tests (warm run) 1.12s 1.02s 8.9% ⬆️

One deployment file

Test Before After Improvement
Setup Only (cold run) 1.70s 0.78s 54.1% ⬆️
Setup Only (warm run) 0.98s 0.83s 15.3% ⬆️
All tests (cold run) 5.63s 5.54s 1.6% ⬆️
All tests (warm run) 1.26s 0.66s 47.6% ⬆️

@clement-ux clement-ux self-assigned this Jan 26, 2026
@clement-ux clement-ux added Feature Introduces new functionality. Tooling Scripts, automation, and utilities. Deployment script Deployment script for smart contract. labels Jan 26, 2026
Copy link
Member

@sparrowDom sparrowDom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome stuff! Left some comments

"PENDLE_ORIGIN_ARM_SY": "0xbcae2Eb1cc47F137D8B2D351B0E0ea8DdA4C6184"
}
}
"contracts": [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, the JSON seems less human readable with the new format. Is it more easily machine readable?

# ╚══════════════════════════════════════════════════════════════════════════════╝

# [Optional] Deployer address (corresponding to your private key)
# DEPLOYER_ADDRESS=
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably rather set in console with a whitespace (to prevent storing to console history) rather than file? If it is in the file one can more easily forget to unset it.

// Production chains
chainNames[1] = "Ethereum Mainnet";
chainNames[146] = "Sonic Mainnet";
chainNames[8453] = "Base Mainnet";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a deployments-17000.json in the build folder. Should we add holesky here?

if (state == State.REAL_DEPLOYING) {
vm.startBroadcast(deployer);
}
if (state == State.FORK_TEST || state == State.FORK_DEPLOYING) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can be simplified to if (isSimulation)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: else if

if (state == State.REAL_DEPLOYING) {
vm.stopBroadcast();
}
if (state == State.FORK_TEST || state == State.FORK_DEPLOYING) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also can be simplified to if (isSimulation)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit else if fits better

///
/// @param log Whether logging is enabled
/// @param prop The proposal to simulate
function simulate(bool log, GovProposal memory prop) internal {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like how proposal can be in any stage of lifecycle and is then pushed to completion


// ===== Step 7: Build & Handle Governance Proposal =====
// Call the child contract's _buildGovernanceProposal() if implemented
_buildGovernanceProposal();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we validate governance proposal after it has been built?

  • e.g. that the description has been set
  • or that there is at least one action or one transaction being broadcasted?

// avoiding unnecessary compilation and execution of historical deployment files.
// Scripts are numbered (e.g., 001_, 002_...) and sorted alphabetically,
// so only the last N scripts (most recent) will be considered for deployment.
uint256 public maxDeploymentFiles = 2;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be reasoned from the deploy times marked in the build/deployments-X.json file? If working off of non latest block, the chain's current time can be compared to the deployments.json time. To figure out if deployment needs to be ran or not.

Though then again things aren't so obvious when the governance proposal still needs to be executed.


// Skip if the governance proposal for this script was already executed
// This means the script's purpose has been fully accomplished
if (deployFile.proposalExecuted()) return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a similar issue with these proposal executions on origin-dollar repo. I think we would really benefit if there is a process that is able to fetch if and when a proposal has been executed on chain and add it to deployments file:

"executions": [
    {
      "name": "001_CoreMainnet",
      "timestamp": 1723685111,
      "gov_proposa_timestamp": 1723785111
    },

This way we don't need to go back into source files and set proposalExecuted=true once the proposal is executed. And it will also be correct when a fork is ran on a past BLOCK_NUMBER. Which currently isn't true, as picking a very old block number would mean we need to set proposalExecuted=false in some of the deployment files

string memory deployFileName = deployFile.name();

// Check deployment history to see if this script was already run
bool alreadyDeployed = resolver.executionExists(deployFileName);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should consider that the fork might not have been initialized on the latest block.

Though supporting this would increase complexity quite a lot (similar to above comment regarding proposal executions)

Maybe it would be good to decide if deployments support forking off of older block number or no

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Deployment script Deployment script for smart contract. Feature Introduces new functionality. Tooling Scripts, automation, and utilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants