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
- 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);

Choose a reason for hiding this comment

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

P2 Badge Mint premine to a recoverable address on delayed initialize

On chain 10, initialize mints the full 10M supply to msg.sender, but this function can now be executed post-deploy only via proxy-admin-driven upgrade calls; in that path, msg.sender inside delegatecall is the ProxyAdmin contract. That means delayed initialization mints the premine to ProxyAdmin, and those tokens are effectively stuck because ProxyAdmin has no generic ERC20 transfer method.

Useful? React with 👍 / 👎.

}

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_);
Comment on lines +32 to +38

Choose a reason for hiding this comment

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

P1 Badge Initialize access control in the default EXA deployment flow

DEFAULT_ADMIN_ROLE is only granted inside initialize2, but the repository’s deploy flow still initializes EXA with only initialize (see deploy/EXA.ts), so fresh deployments/upgrades end up with no admin account able to grant BRIDGE_ROLE. In that state, all new bridge mint/burn entry points guarded by onlyRole(BRIDGE_ROLE) are unusable until an extra manual upgradeAndCall(..., initialize2(...)) step is performed.

Useful? React with 👍 / 👎.

}

/// @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();
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