Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 15 additions & 10 deletions contracts/src/CollectionsProtocolHandler.sol
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import "@openzeppelin/contracts/proxy/Clones.sol";
import "@openzeppelin/contracts/utils/Create2.sol";
import {LibString} from "solady/utils/LibString.sol";
import "./CollectionsERC721.sol";
import "./libraries/Proxy.sol";
import "./Ethscriptions.sol";
import "./libraries/Predeploys.sol";
import "./interfaces/IProtocolHandler.sol";

contract CollectionsProtocolHandler is IProtocolHandler {
using Clones for address;
using LibString for string;

// Standard NFT attribute structure
Expand Down Expand Up @@ -96,7 +96,7 @@ contract CollectionsProtocolHandler is IProtocolHandler {
// Note: Similar to ItemData but without ethscriptionId (can't change)
}

address public constant collectionsTemplate = Predeploys.COLLECTIONS_TEMPLATE_IMPLEMENTATION;
address public constant collectionsImplementation = Predeploys.COLLECTIONS_TEMPLATE_IMPLEMENTATION;
address public constant ethscriptions = Predeploys.ETHSCRIPTIONS;

// Track deployed collections by ID
Expand Down Expand Up @@ -156,19 +156,23 @@ contract CollectionsProtocolHandler is IProtocolHandler {
// Get totalSupply from metadata
uint256 totalSupply = metadata.totalSupply;

// Deploy ERC721 clone with CREATE2 using collectionId as salt for deterministic address
address collectionContract = collectionsTemplate.cloneDeterministic(collectionId);
// Deploy ERC721 proxy with CREATE2 using collectionId as salt for deterministic address
Proxy collectionProxy = new Proxy{salt: collectionId}(address(this));
Comment on lines +159 to +160

Copilot AI Oct 22, 2025

Copy link

Choose a reason for hiding this comment

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

The handler contract temporarily assumes admin privileges during initialization. If this function can be called multiple times or reentered before admin transfer, it creates a window where the handler has elevated privileges. Consider adding reentrancy protection or documenting this assumption explicitly.

Copilot uses AI. Check for mistakes.

// Initialize the clone with basic info
CollectionsERC721(collectionContract).initialize(
// Initialize implementation via proxy
bytes memory initCalldata = abi.encodeWithSelector(
CollectionsERC721.initialize.selector,
metadata.name,
metadata.symbol,
collectionId
);
collectionProxy.upgradeToAndCall(collectionsImplementation, initCalldata);
// Hand over admin to global ProxyAdmin
collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN);

// Store collection state
collectionState[collectionId] = CollectionState({
collectionContract: collectionContract,
collectionContract: address(collectionProxy),
createEthscriptionId: txHash,
currentSize: 0,
locked: false
Expand All @@ -179,7 +183,7 @@ contract CollectionsProtocolHandler is IProtocolHandler {

collectionIds.push(collectionId);

emit CollectionCreated(collectionId, collectionContract, metadata.name, metadata.symbol, totalSupply);
emit CollectionCreated(collectionId, address(collectionProxy), metadata.name, metadata.symbol, totalSupply);
emit ProtocolHandlerSuccess(txHash, protocolName());
}

Expand Down Expand Up @@ -459,7 +463,8 @@ contract CollectionsProtocolHandler is IProtocolHandler {
}

// Predict using CREATE2
return Clones.predictDeterministicAddress(collectionsTemplate, collectionId, address(this));
bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(this)));
return Create2.computeAddress(collectionId, keccak256(creationCode), address(this));
}

function getAllCollections() external view returns (bytes32[] memory) {
Expand Down
31 changes: 18 additions & 13 deletions contracts/src/FixedFungibleProtocolHandler.sol
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import "@openzeppelin/contracts/proxy/Clones.sol";
import "@openzeppelin/contracts/utils/Create2.sol";
import {LibString} from "solady/utils/LibString.sol";
import "./FixedFungibleERC20.sol";
import "./libraries/Proxy.sol";
import "./Ethscriptions.sol";
import "./libraries/Predeploys.sol";
import "./interfaces/IProtocolHandler.sol";
Expand All @@ -12,7 +13,6 @@ import "./interfaces/IProtocolHandler.sol";
/// @notice Implements the fixed-fungible token protocol enforced via Ethscriptions transfers
/// @dev Deploys and controls FixedFungible ERC-20 clones; callable only by the Ethscriptions contract
contract FixedFungibleProtocolHandler is IProtocolHandler {
using Clones for address;
using LibString for string;

// =============================================================
Expand Down Expand Up @@ -50,8 +50,8 @@ contract FixedFungibleProtocolHandler is IProtocolHandler {
// CONSTANTS
// =============================================================

/// @dev Deterministic template contract used for clone deployments
address public constant fixedFungibleTemplate = Predeploys.FIXED_FUNGIBLE_TEMPLATE_IMPLEMENTATION;
/// @dev Implementation contract used for proxy deployments
address public constant fixedFungibleImplementation = Predeploys.FIXED_FUNGIBLE_TEMPLATE_IMPLEMENTATION;
address public constant ethscriptions = Predeploys.ETHSCRIPTIONS;
string public constant CANONICAL_PROTOCOL = "fixed-fungible";
string public constant PRETTY_PROTOCOL = "Fixed-Fungible";
Expand Down Expand Up @@ -140,25 +140,29 @@ contract FixedFungibleProtocolHandler is IProtocolHandler {
if (deployOp.mintAmount == 0) revert InvalidMintAmount();
if (deployOp.maxSupply % deployOp.mintAmount != 0) revert MaxSupplyNotDivisibleByMintAmount();

// Deploy ERC20 clone with CREATE2 using tickKey as salt for deterministic address
address tokenAddress = fixedFungibleTemplate.cloneDeterministic(tickKey);
// Deploy a Proxy via CREATE2 with this handler as temporary admin for initialization
Proxy tokenProxy = new Proxy{salt: tickKey}(address(this));
Comment on lines +143 to +144

Copilot AI Oct 22, 2025

Copy link

Choose a reason for hiding this comment

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

The handler contract temporarily assumes admin privileges during initialization. If this function can be called multiple times or reentered before admin transfer, it creates a window where the handler has elevated privileges. Consider adding reentrancy protection or documenting this assumption explicitly.

Copilot uses AI. Check for mistakes.

// Initialize the clone
// Build name/symbol and initialization calldata
string memory name = string.concat(PRETTY_PROTOCOL, " ", deployOp.tick);
string memory symbol = LibString.upper(deployOp.tick);

// Initialize with max supply in 18 decimals
// User maxSupply "1000000" means 1000000 * 10^18 smallest units
FixedFungibleERC20(tokenAddress).initialize(
bytes memory initCalldata = abi.encodeWithSelector(
FixedFungibleERC20.initialize.selector,
name,
symbol,
deployOp.maxSupply * 10**18,
ethscriptionId
);
// Set implementation and run initialize as admin
tokenProxy.upgradeToAndCall(fixedFungibleImplementation, initCalldata);
// Hand off admin to global ProxyAdmin so upgrades require system governance
tokenProxy.changeAdmin(Predeploys.PROXY_ADMIN);

// Store token info
tokensByTick[tickKey] = TokenInfo({
tokenContract: tokenAddress,
tokenContract: address(tokenProxy),
deployEthscriptionId: ethscriptionId,
tick: deployOp.tick,
maxSupply: deployOp.maxSupply,
Expand All @@ -171,7 +175,7 @@ contract FixedFungibleProtocolHandler is IProtocolHandler {

emit FixedFungibleTokenDeployed(
ethscriptionId,
tokenAddress,
address(tokenProxy),
deployOp.tick,
deployOp.maxSupply,
deployOp.mintAmount
Expand Down Expand Up @@ -290,8 +294,9 @@ contract FixedFungibleProtocolHandler is IProtocolHandler {
return tokensByTick[tickKey].tokenContract;
}

// Predict using CREATE2
return Clones.predictDeterministicAddress(fixedFungibleTemplate, tickKey, address(this));
// Predict using CREATE2 for Proxy with constructor arg (admin = address(this))
bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(this)));
return Create2.computeAddress(tickKey, keccak256(creationCode), address(this));
}

/// @notice Check if an ethscription is a token item
Expand Down
18 changes: 7 additions & 11 deletions contracts/src/L2/ProxyAdmin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,21 @@ contract ProxyAdmin is Ownable {
/// @notice Returns the admin of the given proxy address.
/// @param _proxy Address of the proxy to get the admin of.
/// @return Address of the admin of the proxy.
function getProxyAdmin(address payable _proxy) external view returns (address) {
function getProxyAdmin(address _proxy) external view returns (address) {
return IStaticERC1967Proxy(_proxy).admin();
}

/// @notice Updates the admin of the given proxy address.
/// @param _proxy Address of the proxy to update.
/// @param _newAdmin Address of the new proxy admin.
function changeProxyAdmin(address payable _proxy, address _newAdmin) external onlyOwner {
function changeProxyAdmin(address _proxy, address _newAdmin) external onlyOwner {
Proxy(_proxy).changeAdmin(_newAdmin);
}

/// @notice Changes a proxy's implementation contract.
/// @param _proxy Address of the proxy to upgrade.
/// @param _implementation Address of the new implementation address.
function upgrade(address payable _proxy, address _implementation) public onlyOwner {
function upgrade(address _proxy, address _implementation) public onlyOwner {
Proxy(_proxy).upgradeTo(_implementation);
}

Expand All @@ -51,14 +51,10 @@ contract ProxyAdmin is Ownable {
/// @param _implementation Address of the new implementation address.
/// @param _data Data to trigger the new implementation with.
function upgradeAndCall(
address payable _proxy,
address _proxy,
address _implementation,
bytes memory _data
)
external
payable
onlyOwner
{
Proxy(_proxy).upgradeToAndCall{ value: msg.value }(_implementation, _data);
) external onlyOwner {
Proxy(_proxy).upgradeToAndCall(_implementation, _data);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Proxy Upgrades Fail to Forward ETH

The upgradeAndCall functions in ProxyAdmin and Proxy lost their payable modifier and msg.value forwarding. This prevents sending ETH to payable initialization functions during proxy upgrades, removing previously supported functionality.

Additional Locations (1)

Fix in Cursor Fix in Web

}
}
}
1 change: 0 additions & 1 deletion contracts/src/libraries/Proxy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ contract Proxy {
bytes calldata _data
)
public
payable
virtual
proxyCallIfNotAdmin
returns (bytes memory)
Expand Down
70 changes: 70 additions & 0 deletions contracts/test/AddressPrediction.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/libraries/Predeploys.sol";
import "../src/libraries/Proxy.sol";
import "../src/FixedFungibleProtocolHandler.sol";
import "../src/CollectionsProtocolHandler.sol";
import "../src/Ethscriptions.sol";
import "@openzeppelin/contracts/utils/Create2.sol";
import "./TestSetup.sol";

contract AddressPredictionTest is TestSetup {
// Test predictable address for FixedFungibleProtocolHandler token proxies
function testPredictFixedFungibleTokenAddress() public {
// Arrange
string memory tick = "eths";
bytes32 deployTxHash = keccak256("deploy-eths");

// Prepare deploy op data
FixedFungibleProtocolHandler.DeployOperation memory deployOp = FixedFungibleProtocolHandler.DeployOperation({
tick: tick,
maxSupply: 1_000_000,
mintAmount: 1_000
});
bytes memory data = abi.encode(deployOp);

// Prediction via contract helper
address predicted = fixedFungibleHandler.predictTokenAddressByTick(tick);

// Act: call deploy as Ethscriptions (authorized)
vm.prank(Predeploys.ETHSCRIPTIONS);
fixedFungibleHandler.op_deploy(deployTxHash, data);

// Assert actual matches predicted
address actual = fixedFungibleHandler.getTokenAddressByTick(tick);
assertEq(actual, predicted, "Predicted token address should match actual deployed proxy");
}

// Test predictable address for CollectionsProtocolHandler collection proxies
function testPredictCollectionsAddress() public {
// Arrange
bytes32 collectionId = keccak256("collection-1");

CollectionsProtocolHandler.CollectionMetadata memory metadata = CollectionsProtocolHandler.CollectionMetadata({
name: "My Collection",
symbol: "MYC",
totalSupply: 1000,
description: "A test collection",
logoImageUri: "data:,logo",
bannerImageUri: "data:,banner",
backgroundColor: "#000000",
websiteLink: "https://example.com",
twitterLink: "",
discordLink: ""
});

// Manually compute predicted proxy address
bytes memory creationCode = abi.encodePacked(type(Proxy).creationCode, abi.encode(address(collectionsHandler)));
address predicted = Create2.computeAddress(collectionId, keccak256(creationCode), address(collectionsHandler));

// Act: create collection as Ethscriptions (authorized)
vm.prank(Predeploys.ETHSCRIPTIONS);
collectionsHandler.op_create_collection(collectionId, abi.encode(metadata));

// Assert deployed matches predicted
(address actual,,,) = collectionsHandler.collectionState(collectionId);
assertEq(actual, predicted, "Predicted collection address should match actual deployed proxy");
}
}