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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silent-chefs-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/protocol": patch
---

✨ exa: add crosschain mint and burn support
5 changes: 5 additions & 0 deletions .changeset/slick-feet-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/protocol": patch
---

🔒 exa: restrict initialize to proxy admin or construction
931 changes: 477 additions & 454 deletions .gas-snapshot

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ jobs:
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.3.6
version: v1.5.1
- run: pnpm install --frozen-lockfile
- uses: changesets/action@v1
with:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ jobs:
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- uses: foundry-rs/foundry-toolchain@v1
with:
version: v1.3.6
version: v1.5.1
Comment on lines 21 to +27
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🌐 Web query:

What is the latest version of Foundry toolchain?

💡 Result:

The latest Foundry toolchain release is Foundry v1.6.0 (released January 22, 2026). [1]

A pre-release is also listed: v1.6.0-rc1. [1]

Source(s)
[1] (getfoundry.sh)

Citations:


LGTM - Version upgrades look appropriate, though a newer version exists.

Node.js 22 is the current LTS version. The Foundry toolchain upgrade to v1.5.1 is a solid improvement. However, Foundry v1.6.0 (released January 2026) is now the latest stable version and could be considered for future updates if you want to stay on the cutting edge.

- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: rm -rf cache/fuzz cache/invariant
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
/lcov.info
/types/

.claude/settings.local.json
.openzeppelin/unknown-*
.vscode/
cache/
Expand Down
59 changes: 57 additions & 2 deletions contracts/periphery/EXA.sol
Original file line number Diff line number Diff line change
@@ -1,16 +1,61 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.17;

import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable-v4/access/AccessControlUpgradeable.sol";
import {
ERC20VotesUpgradeable
} from "@openzeppelin/contracts-upgradeable-v4/token/ERC20/extensions/ERC20VotesUpgradeable.sol";
import { StorageSlotUpgradeable } from "@openzeppelin/contracts-upgradeable-v4/utils/StorageSlotUpgradeable.sol";
import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import { IERC7802 } from "@openzeppelin/contracts/interfaces/draft-IERC7802.sol";

contract EXA is ERC20VotesUpgradeable, AccessControlUpgradeable, IERC7802 {
bytes32 public constant BRIDGE_ROLE = keccak256("BRIDGE_ROLE");
bytes32 internal constant ADMIN_SLOT = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

contract EXA is ERC20VotesUpgradeable {
function initialize() external initializer {
if (address(this).code.length > 0 && msg.sender != StorageSlotUpgradeable.getAddressSlot(ADMIN_SLOT).value) {
revert NotProxyAdmin();
}

__ERC20_init("exactly", "EXA");
__ERC20Permit_init("exactly");
__ERC20Votes_init();
_mint(msg.sender, 10_000_000e18);
if (block.chainid == 10) _mint(msg.sender, 10_000_000e18);
}

function initialize2(address admin_) external reinitializer(2) {
if (msg.sender != StorageSlotUpgradeable.getAddressSlot(ADMIN_SLOT).value) revert NotProxyAdmin();
if (admin_ == address(0)) revert ZeroAddress();
if (bytes(symbol()).length == 0) revert NotInitialized();

__AccessControl_init();
_grantRole(DEFAULT_ADMIN_ROLE, admin_);
}

/// @inheritdoc IERC7802
function crosschainMint(address to, uint256 amount) public onlyRole(BRIDGE_ROLE) {
_mint(to, amount);
emit CrosschainMint(to, amount, msg.sender);
}

/// @inheritdoc IERC7802
function crosschainBurn(address from, uint256 amount) public onlyRole(BRIDGE_ROLE) {
_burn(from, amount);
emit CrosschainBurn(from, amount, msg.sender);
}

function mint(address to, uint256 amount) external {
crosschainMint(to, amount);
}

function burn(address from, uint256 amount) external {
crosschainBurn(from, amount);
}

function clock() public view override returns (uint48) {
Expand All @@ -21,4 +66,14 @@ contract EXA is ERC20VotesUpgradeable {
function CLOCK_MODE() public pure override returns (string memory) {
return "mode=timestamp";
}

function supportsInterface(
bytes4 interfaceId
) public view override(AccessControlUpgradeable, IERC165) returns (bool) {
return interfaceId == type(IERC7802).interfaceId || super.supportsInterface(interfaceId);
}
}

error NotInitialized();
error NotProxyAdmin();
error ZeroAddress();
18 changes: 16 additions & 2 deletions deploy/EXA.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { MerkleTree } from "merkletreejs";
import type { DeployFunction } from "hardhat-deploy/types";
import type { EXA, EscrowedEXA } from "../types";
import type { EXA, EscrowedEXA, ProxyAdmin } from "../types";
import transferOwnership from "./.utils/transferOwnership";
import executeOrPropose from "./.utils/executeOrPropose";
import timelockPropose from "./.utils/timelockPropose";
import validateUpgrade from "./.utils/validateUpgrade";
import grantRole from "./.utils/grantRole";
import airdrop from "../scripts/airdrop.json";
Expand Down Expand Up @@ -33,14 +34,27 @@ const func: DeployFunction = async ({
owner: timelock,
viaAdminContract: { name: "ProxyAdmin" },
proxyContract: "TransparentUpgradeableProxy",
execute: { init: { methodName: "initialize", args: [] } },
execute: {
init: { methodName: "initialize", args: [] },
onUpgrade: { methodName: "initialize2", args: [timelock] },
},
},
from: deployer,
log: true,
}),
);

const exa = await getContract<EXA>("EXA", await getSigner(deployer));

if (!(await exa.hasRole(await exa.DEFAULT_ADMIN_ROLE(), timelock))) {

Choose a reason for hiding this comment

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

P1 Badge Avoid probing AccessControl before EXA upgrade executes

This hasRole check assumes the proxy is already running the new EXA implementation, but in the timelock/multisig flow validateUpgrade often only schedules/proposes the upgrade and returns before execution; in that state the proxy still points to the old EXA, which does not implement hasRole/DEFAULT_ADMIN_ROLE, so this call reverts and aborts the deploy script. This can break the first rollout of this commit on live networks unless the upgrade is executed manually before rerunning.

Useful? React with 👍 / 👎.

const proxyAdmin = await getContract<ProxyAdmin>("ProxyAdmin", await getSigner(deployer));
await timelockPropose(proxyAdmin, "upgradeAndCall", [
exa.target,
(await get("EXA_Implementation")).address,
exa.interface.encodeFunctionData("initialize2", [timelock]),
]);
}
Comment on lines +49 to +56
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for any BRIDGE_ROLE grants in deployment or scripts
rg -n 'BRIDGE_ROLE|grantRole.*bridge' --type ts --type js -C2

Repository: exactly/protocol

Length of output: 42


🏁 Script executed:

# First, find the EXA contract file
find . -type f -name "*EXA*" | grep -E "\.(sol|ts|js)$" | head -20

Repository: exactly/protocol

Length of output: 257


🏁 Script executed:

# Search for mint and burn function definitions
rg -n "function mint|function burn" --type ts --type js --type sol -A3

Repository: exactly/protocol

Length of output: 87


🏁 Script executed:

# Search for role definitions (including different naming patterns)
rg -n "ROLE|keccak256.*role|bytes32.*=" --type ts --type js --type sol | grep -i "admin\|bridge\|role" | head -30

Repository: exactly/protocol

Length of output: 87


🏁 Script executed:

# Read the EXA.sol file to understand role structure
cat ./contracts/periphery/EXA.sol | head -100

Repository: exactly/protocol

Length of output: 2845


🏁 Script executed:

# Search for BRIDGE_ROLE, mint, burn in EXA.sol
grep -n "BRIDGE_ROLE\|function mint\|function burn\|bytes32.*ROLE\|initialize2" ./contracts/periphery/EXA.sol

Repository: exactly/protocol

Length of output: 494


🏁 Script executed:

# Check the deploy/EXA.ts file structure
cat ./deploy/EXA.ts

Repository: exactly/protocol

Length of output: 4163


Add BRIDGE_ROLE grant to timelock during EXA deployment or confirm it's granted via a separate governance proposal.

The mint() and burn() functions require BRIDGE_ROLE to be callable (they delegate to crosschainMint and crosschainBurn which are guarded by onlyRole(BRIDGE_ROLE)), but the deployment script only grants DEFAULT_ADMIN_ROLE to timelock via initialize2. Without BRIDGE_ROLE being granted—either in this deployment or via a subsequent governance proposal—the mint/burn functions remain non-functional. Ensure the role grant path is explicitly documented and implemented.


const deployerBalance = await exa.balanceOf(deployer);
if (deployerBalance !== 0n) await (await exa.transfer(treasury ?? multisig, deployerBalance)).wait();

Expand Down
2 changes: 1 addition & 1 deletion deploy/Markets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const func: DeployFunction = async ({
contract: verified ? "VerifiedMarket" : "Market",
args: [asset.target, auditor.target],
envKey: "MARKETS",
unsafeAllow: ["constructor", "state-variable-immutable"],
unsafeAllow: ["constructor", "delegatecall", "state-variable-immutable"],
},
async (name, opts) =>
deploy(name, {
Expand Down
3 changes: 1 addition & 2 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ optimizer = true
optimizer_runs = 1111
revert_strings = "strip"
isolate = true
deny_warnings = true
ignored_error_codes = ["unused-param", "code-size", "init-code-size"]

ffi = true
Expand All @@ -15,7 +14,7 @@ script = "scripts"
cache_path = "cache/foundry"
fs_permissions = [{ access = "read", path = "./deployments" }]
verbosity = 3
gas_limit = 2_500_000_000
gas_limit = 3_000_000_000

[invariant]
fail_on_revert = true
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"test:hardhat": "hardhat test --deploy-fixture",
"coverage": "pnpm coverage:snapshot && pnpm coverage:foundry && pnpm coverage:hardhat && pnpm coverage:fuzzer",
"coverage:foundry": "forge coverage --report lcov --no-match-contract Protocol",
"coverage:hardhat": "hardhat coverage",
"coverage:hardhat": "SOLIDITY_COVERAGE=true hardhat coverage",
"coverage:snapshot": "FOUNDRY_PROFILE=snapshot forge snapshot --check --no-match-contract Protocol",
"coverage:fuzzer": "FOUNDRY_PROFILE=production forge test --no-match-contract Protocol",
"deploy:ethereum": "hardhat --network ethereum deploy",
Expand All @@ -36,8 +36,8 @@
"node": ">=18"
},
"dependencies": {
"@openzeppelin/contracts": "^5.0.2",
"@openzeppelin/contracts-upgradeable": "^5.0.2",
"@openzeppelin/contracts": "^5.4.0",
"@openzeppelin/contracts-upgradeable": "^5.4.0",
"@openzeppelin/contracts-upgradeable-v4": "npm:@openzeppelin/contracts-upgradeable@^4.9.6",
"@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@^4.9.6",
"solady": "^0.1.26",
Expand Down
24 changes: 12 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading