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
7 changes: 3 additions & 4 deletions .github/workflows/contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,10 @@ jobs:
- name: Check that bindings are up-to-date
run: just bindings-check

# The deployment promotion gate runs on pull requests into an environment branch, where the recorded
# deployments and the source are meant to agree. Elsewhere the gated tests skip and no chain is forked.
- name: Check that the recorded deployments library is up-to-date
run: just contracts-deployments-check

- name: Run Forge tests
run: just contracts-test
env:
ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }}
VERIFY_STAGING_DEPLOYMENTS: ${{ github.base_ref == 'staging' }}
VERIFY_PRODUCTION_DEPLOYMENTS: ${{ github.base_ref == 'main' }}
12 changes: 10 additions & 2 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Changes flow one way, `next` → `staging` → `main`, and the promotion pull re
- **`staging`** receives `next`. A pull request into it requires every entry in the staging section to run the source version, checked with `VERIFY_STAGING_DEPLOYMENTS`.
- **`main`** receives `staging`. A pull request into it requires every entry in the production section to run the source version, carry no prerelease suffix, and be owned by a Safe, checked with `VERIFY_PRODUCTION_DEPLOYMENTS`.

The flags gate the deployment tests in the contracts and the crates suites alike; unset, the gated tests skip and no chain is forked.
The flags gate the deployment tests, which live in the bindings crate beside the record they check; unset, they skip and no chain is forked.

Deploy or upgrade **every** chain of an environment before opening its promotion pull request — one chain left behind blocks the promotion for all of them.

Expand Down Expand Up @@ -176,7 +176,7 @@ For each chain in the `staging` section of the record:
- [ ] After the last chain, confirm the promotion gate locally by running

```sh
VERIFY_STAGING_DEPLOYMENTS=true just contracts-test bindings-test
VERIFY_STAGING_DEPLOYMENTS=true just bindings-test
```

the same checks the promotion pull request runs.
Expand Down Expand Up @@ -359,6 +359,14 @@ For **both**:

The genesis fields pin how the address was derived and cannot be recovered from the chain once the proxy is upgraded. They are written once and never edited.

- [ ] Regenerate the library the deploy script reads the record through with

```sh
just contracts-gen-deployments
```

and commit it alongside the record. The contracts package ships without `deployments.json`, so the deploy script reads the records from the generated [`./contracts/generated/RecordedDeployments.sol`](./contracts/generated/RecordedDeployments.sol); leaving it stale lets a genesis deploy run twice on the same chain. CI reruns the generator and fails on any diff.

- [ ] Bump the `bindings` package version in [`./crates/bindings/Cargo.toml`](./crates/bindings/Cargo.toml) to `A.B.0`, where `A` is the last `MAJOR` version and `B` is the last `MINOR` version number incremented by 1.

- [ ] Run `just bindings-build` and check that the `Cargo.lock` file reflects the version number change, then run the tests with `just bindings-test`.
Expand Down
9 changes: 3 additions & 6 deletions contracts/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,9 @@ via_ir = true
optimizer_runs = 10_000
allow_internal_expect_revert = true

# Let tests read build artifacts (needed by `openzeppelin-foundry-upgrades`) and the
# recorded deployments. Setting `fs_permissions` replaces the implicit artifact access.
fs_permissions = [
{ access = "read", path = "out" },
{ access = "read", path = "../crates/bindings/deployments.json" },
]
# Let tests read build artifacts, which `openzeppelin-foundry-upgrades` needs. Setting
# `fs_permissions` replaces the implicit artifact access.
fs_permissions = [{ access = "read", path = "out" }]

# Settings required by `openzeppelin-foundry-upgrades` for upgrade-safety validation
# (https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades#before-running).
Expand Down
73 changes: 73 additions & 0 deletions contracts/generated/RecordedDeployments.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

/// @title RecordedDeployments
/// @author Anoma Foundation, 2026
/// @notice The protocol adapter deployments each environment records.
/// @dev Generated from `crates/bindings/deployments.json`, the single source of truth, which the bindings crate
/// embeds and checks against the chains. Do not edit by hand: run `just contracts-gen-deployments`, which CI reruns
/// and fails on any diff. The records live with the bindings because that crate publishes them; this library carries
/// them into Solidity so the contracts package reads nothing outside itself.
/// @custom:security-contact security@anoma.foundation
library RecordedDeployments {
/// @notice A recorded protocol adapter proxy. The field `addr` holds the address, which is a reserved word.
/// @dev The genesis fields pin how the address was derived: the creation code and the constructor arguments
/// determine it together with the environment salt, and none of them can be read from the chain once the proxy is
/// upgraded.
struct Proxy {
address addr;
address initialImplementation;
bytes initializerData;
bytes creationCode;
}

/// @notice A recorded protocol adapter deployment.
struct Deployment {
uint256 chainId;
Proxy proxy;
}

/// @notice Returns whether the environment records a deployment for the chain.
/// @param isProduction Whether to check the production or the staging environment.
/// @param chainId The chain ID to look for.
/// @return recorded Whether the environment records a deployment for the chain.
function isRecorded(bool isProduction, uint256 chainId) internal pure returns (bool recorded) {
Deployment[] memory deployments = isProduction ? production() : staging();

for (uint256 i = 0; i < deployments.length; ++i) {
if (deployments[i].chainId == chainId) {
return true;
}
}
}

/// @notice Returns the deployments the staging environment records.
/// @return deployments The recorded staging deployments.
function staging() internal pure returns (Deployment[] memory deployments) {
deployments = new Deployment[](2);
deployments[0] = Deployment({
chainId: 11155111,
proxy: Proxy({
addr: 0xe23d3b3FC0944cB0c1184C6e04b9d26Ed50CeC51,
initialImplementation: 0x4d68d7A738FBAF0E81aB508F573B1C6A45a376d7,
initializerData: hex"c4d66de800000000000000000000000061462be56782568376f9cb069382efa72764a407",
creationCode: hex"6080604052610284803803806100148161016e565b9283398101604082820312610156578151916001600160a01b03831690818403610156576020810151906001600160401b038211610156570182601f82011215610156578051906001600160401b03821161015a5761007c601f8301601f191660200161016e565b938285526020838301011161015657815f9260208093018387015e8401015281511561014757823b15610135577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b031916821790557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b5f80a280511561011e5761010e91610193565b505b604051606490816102208239f35b505034156101105763b398979f60e01b5f5260045ffd5b634c9c8ce360e01b5f5260045260245ffd5b6330a289cf60e21b5f5260045ffd5b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b6040519190601f01601f191682016001600160401b0381118382101761015a57604052565b905f8091602081519101845af4808061020c575b156101c75750506040513d81523d5f602083013e60203d82010160405290565b156101ec57639996b31560e01b5f9081526001600160a01b0391909116600452602490fd5b3d156101fd576040513d5f823e3d90fd5b63d6bda27560e01b5f5260045ffd5b503d1515806101a75750813b15156101a756fe60806040525f8073ffffffffffffffffffffffffffffffffffffffff7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5416368280378136915af43d5f803e156053573d5ff35b3d5ffdfea164736f6c6343000824000a"
})
});
deployments[1] = Deployment({
chainId: 84532,
proxy: Proxy({
addr: 0xb5A5a52Af29dA0c8801D9caf4D75a4d6C3895f0A,
initialImplementation: 0xd29F13e08C3DAe49Dc99F65Bc0C51EBF6310682f,
initializerData: hex"c4d66de800000000000000000000000061462be56782568376f9cb069382efa72764a407",
creationCode: hex"6080604052610284803803806100148161016e565b9283398101604082820312610156578151916001600160a01b03831690818403610156576020810151906001600160401b038211610156570182601f82011215610156578051906001600160401b03821161015a5761007c601f8301601f191660200161016e565b938285526020838301011161015657815f9260208093018387015e8401015281511561014757823b15610135577f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b031916821790557fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b5f80a280511561011e5761010e91610193565b505b604051606490816102208239f35b505034156101105763b398979f60e01b5f5260045ffd5b634c9c8ce360e01b5f5260045260245ffd5b6330a289cf60e21b5f5260045ffd5b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b6040519190601f01601f191682016001600160401b0381118382101761015a57604052565b905f8091602081519101845af4808061020c575b156101c75750506040513d81523d5f602083013e60203d82010160405290565b156101ec57639996b31560e01b5f9081526001600160a01b0391909116600452602490fd5b3d156101fd576040513d5f823e3d90fd5b63d6bda27560e01b5f5260045ffd5b503d1515806101a75750813b15156101a756fe60806040525f8073ffffffffffffffffffffffffffffffffffffffff7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc5416368280378136915af43d5f803e156053573d5ff35b3d5ffdfea164736f6c6343000824000a"
})
});
}

/// @notice Returns the deployments the production environment records.
/// @return deployments The recorded production deployments.
function production() internal pure returns (Deployment[] memory deployments) {
deployments = new Deployment[](0);
}
}
3 changes: 2 additions & 1 deletion contracts/script/DeployProtocolAdapterImplementation.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {Options} from "openzeppelin-foundry-upgrades-0.4.2/src/Options.sol";
import {Upgrades} from "openzeppelin-foundry-upgrades-0.4.2/src/Upgrades.sol";

import {ProtocolAdapter} from "../src/ProtocolAdapter.sol";
import {Parameters} from "./Parameters.sol";

/// @title DeployProtocolAdapterImplementation
/// @author Anoma Foundation, 2026
Expand All @@ -17,7 +18,7 @@ import {ProtocolAdapter} from "../src/ProtocolAdapter.sol";
/// @custom:security-contact security@anoma.foundation
contract DeployProtocolAdapterImplementation is SupportedNetworks, Script {
/// @notice The CREATE2 salt for the implementation deployment, shared by the staging and production environments.
bytes32 public constant IMPLEMENTATION_SALT = "ProtocolAdapterImpl";
bytes32 public constant IMPLEMENTATION_SALT = Parameters.IMPLEMENTATION_SALT;

/// @notice The initialization data to pass to `upgradeToAndCall` when upgrading a proxy to this implementation —
/// empty because the current version requires no reinitialization.
Expand Down
40 changes: 10 additions & 30 deletions contracts/script/DeployProtocolAdapterProxy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ pragma solidity ^0.8.30;
import {ERC1967Proxy} from "@openzeppelin-contracts-5.7.0/proxy/ERC1967/ERC1967Proxy.sol";
import {Script} from "forge-std-1.16.2/src/Script.sol";

import {RecordedDeployments} from "../generated/RecordedDeployments.sol";
import {ProtocolAdapter} from "../src/ProtocolAdapter.sol";
import {DeployProtocolAdapterImplementation} from "./DeployProtocolAdapterImplementation.s.sol";
import {Parameters} from "./Parameters.sol";

/// @title DeployProtocolAdapterProxy
/// @author Anoma Foundation, 2026
Expand All @@ -14,19 +16,16 @@ import {DeployProtocolAdapterImplementation} from "./DeployProtocolAdapterImplem
/// @custom:security-contact security@anoma.foundation
contract DeployProtocolAdapterProxy is Script {
/// @notice The CREATE2 salt for the staging environment proxy deployment.
bytes32 public constant PROXY_SALT_STAGING = "ProtocolAdapterProxyStaging";
bytes32 public constant PROXY_SALT_STAGING = Parameters.PROXY_SALT_STAGING;

/// @notice The CREATE2 salt for the production environment proxy deployment.
bytes32 public constant PROXY_SALT_PRODUCTION = "ProtocolAdapterProxyProduction";
bytes32 public constant PROXY_SALT_PRODUCTION = Parameters.PROXY_SALT_PRODUCTION;

/// @notice The staging environment proxy owner — the deployment wallet, upgrading instantly.
address public constant PROXY_OWNER_STAGING = 0x61462bE56782568376f9cB069382EFa72764a407;
address public constant PROXY_OWNER_STAGING = Parameters.PROXY_OWNER_STAGING;

/// @notice The production environment proxy owner — the Safe multisig queueing upgrades.
address public constant PROXY_OWNER_PRODUCTION = 0xE9082Ac8Aa2Fb27DEfDBAC604921C196b884Da10;

/// @notice The deployments recorded per environment, relative to the Foundry root.
string internal constant _DEPLOYMENTS_PATH = "../crates/bindings/deployments.json";
address public constant PROXY_OWNER_PRODUCTION = Parameters.PROXY_OWNER_PRODUCTION;

/// @notice Thrown if the environment already has a deployment recorded for this chain.
error DeploymentAlreadyRecorded(string environment, uint256 chainId);
Expand Down Expand Up @@ -54,7 +53,10 @@ contract DeployProtocolAdapterProxy is Script {

// Checks
{
_requireUnrecorded(isProduction);
require(
!RecordedDeployments.isRecorded({isProduction: isProduction, chainId: block.chainid}),
DeploymentAlreadyRecorded(environmentName(isProduction), block.chainid)
);

// forge-lint: disable-next-line(unused-return)
(implementation,) = implementationDeployScript.predict();
Expand Down Expand Up @@ -94,28 +96,6 @@ contract DeployProtocolAdapterProxy is Script {
name = isProduction ? "production" : "staging";
}

/// @notice Checks that the environment has no deployment recorded for this chain yet.
/// @param isProduction Whether to check the production or the staging environment.
function _requireUnrecorded(bool isProduction) internal view {
// `fs_permissions` scopes the read to the recorded deployments.
// forge-lint: disable-next-line(unsafe-cheatcode)
string memory json = vm.readFile(_DEPLOYMENTS_PATH);
string memory environment = environmentName(isProduction);

for (uint256 i = 0;; ++i) {
// solhint-disable-next-line func-named-parameters
string memory entry = string.concat(".", environment, "[", vm.toString(i), "]");
if (!vm.keyExistsJson(json, entry)) {
return;
}

require(
vm.parseJsonUint(json, string.concat(entry, ".chainId")) != block.chainid,
DeploymentAlreadyRecorded(environment, block.chainid)
);
}
}

/// @notice Derives the deterministic proxy address and the constructor arguments it commits to.
/// @param salt The CREATE2 salt of the environment.
/// @param implementation The implementation contract the proxy delegates to.
Expand Down
62 changes: 62 additions & 0 deletions contracts/script/Parameters.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

/// @title Parameters
/// @author Anoma Foundation, 2026
/// @notice The deterministic deployment parameters — the CREATE2 salts and the environment proxy owners. They fix
/// where a deployment lands and who may upgrade it, so they are held once here and read by the deploy scripts, their
/// tests, and the bindings crate through `DeploymentParameters`.
/// @custom:security-contact security@anoma.foundation
library Parameters {
/// @notice The CREATE2 salt for the staging environment proxy deployment.
bytes32 internal constant PROXY_SALT_STAGING = "ProtocolAdapterProxyStaging";

/// @notice The CREATE2 salt for the production environment proxy deployment.
bytes32 internal constant PROXY_SALT_PRODUCTION = "ProtocolAdapterProxyProduction";

/// @notice The CREATE2 salt for the implementation deployment, shared by the staging and production environments.
bytes32 internal constant IMPLEMENTATION_SALT = "ProtocolAdapterImpl";

/// @notice The staging environment proxy owner — the deployment wallet, upgrading instantly.
address internal constant PROXY_OWNER_STAGING = 0x61462bE56782568376f9cB069382EFa72764a407;

/// @notice The production environment proxy owner — the Safe multisig queueing upgrades.
address internal constant PROXY_OWNER_PRODUCTION = 0xE9082Ac8Aa2Fb27DEfDBAC604921C196b884Da10;
}

/// @title DeploymentParameters
/// @author Anoma Foundation, 2026
/// @notice Exposes the deployment parameters through getters, so consumers outside Solidity — the bindings crate and
/// its tests — read the values this source holds instead of restating them.
/// @custom:security-contact security@anoma.foundation
contract DeploymentParameters {
/// @notice Returns the CREATE2 salt for the staging environment proxy deployment.
/// @return salt The staging proxy salt.
function PROXY_SALT_STAGING() external pure returns (bytes32 salt) {
salt = Parameters.PROXY_SALT_STAGING;
}

/// @notice Returns the CREATE2 salt for the production environment proxy deployment.
/// @return salt The production proxy salt.
function PROXY_SALT_PRODUCTION() external pure returns (bytes32 salt) {
salt = Parameters.PROXY_SALT_PRODUCTION;
}

/// @notice Returns the CREATE2 salt for the implementation deployment.
/// @return salt The implementation salt, shared by both environments.
function IMPLEMENTATION_SALT() external pure returns (bytes32 salt) {
salt = Parameters.IMPLEMENTATION_SALT;
}

/// @notice Returns the staging environment proxy owner.
/// @return owner The deployment wallet upgrading the staging proxies.
function PROXY_OWNER_STAGING() external pure returns (address owner) {
owner = Parameters.PROXY_OWNER_STAGING;
}

/// @notice Returns the production environment proxy owner.
/// @return owner The Safe multisig queueing production upgrades.
function PROXY_OWNER_PRODUCTION() external pure returns (address owner) {
owner = Parameters.PROXY_OWNER_PRODUCTION;
}
}
Loading
Loading