Skip to content
Closed
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
62 changes: 37 additions & 25 deletions packages/bridge-contracts/deploy/deployOFT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,24 @@
*
* Steps:
* 1. Deploy both implementations (deterministic, salt = bytecode hash)
* 2. Predict both proxy addresses via GoodProtocol ProxyFactory1967.getDeploymentAddress(salt, deployer)
* 3. Deploy + initialize each proxy atomically via ProxyFactory1967.deployProxy(salt, impl, initData)
* 2. Predict both proxy addresses via CreateX.computeCreate3Address(guardedSalt)
* 3. Deploy + initialize each proxy atomically via CreateX.deployCreate3AndInit(salt, proxyBytecode, initCallData, values)
* - MinterBurner.initialize(nameService, adapter)
* - Adapter.initialize(token, minterBurner, deployer, Avatar as feeRecipient)
* 4. verifyOftDeployment: impl slots, owner, token, minterBurner, feeRecipient, operators(adapter)
*
* Note: proxy addresses depend on the per-chain ProxyFactory address, so they differ between chains.
* Note: CreateX is deployed at 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed across all chains,
* so sender-guarded CREATE3 salts produce identical deterministic proxy addresses on every chain.
*/

import { DeployFunction } from 'hardhat-deploy/types';
import { ethers } from 'hardhat';
import { BigNumber } from 'ethers';
import Contracts from '@gooddollar/goodprotocol/releases/deployment.json';
import { getImplementationAddress } from '@openzeppelin/upgrades-core';
import { verifyContract } from './utils/verifyContract';
import { getLzEndpoint } from './utils/getLzEndpoint';
import { verifyOftDeployment } from './utils/verifyOftDeployment';
import { CREATEX_ABI, CREATEX_ADDRESS, buildCreateXSalt, guardCreateXSalt } from './utils/createX';

const func: DeployFunction = async function (hre) {
const { deployments, network } = hre;
Expand Down Expand Up @@ -125,37 +126,48 @@ const func: DeployFunction = async function (hre) {
});
console.log('GoodDollarOFTAdapter implementation', oftAdapterImpl.address);

// ProxyFactory1967 deploys and initializes in one tx, with a CREATE2 salt bound to the deployer (no init front-running)
const proxyFactoryAddress = goodProtocolContracts.ProxyFactory;
if (!proxyFactoryAddress) {
throw new Error(`ProxyFactory address not found in GoodProtocol deployment for network ${networkName}`);
// CreateX deploys and initializes ERC1967Proxy in one tx via CREATE3, with a salt bound to the deployer
// (no init front-running) and cross-chain protection disabled (0x00 flag) so proxy addresses match across chains.
if ((await ethers.provider.getCode(CREATEX_ADDRESS)) === '0x') {
throw new Error(`CreateX not deployed at ${CREATEX_ADDRESS} on network ${networkName}`);
}
const proxyFactory = await ethers.getContractAt(
[
'function deployProxy(uint256 _salt, address _logic, bytes _data) returns (address)',
'function getDeploymentAddress(uint256 _salt, address _sender) view returns (address)',
],
proxyFactoryAddress,
const createX = await ethers.getContractAt(CREATEX_ABI, CREATEX_ADDRESS);
const proxyArtifact = await hre.artifacts.readArtifact(
'@gooddollar/goodprotocol/contracts/utils/ProxyFactory1967.sol:ERC1967Proxy',
);
const proxyInterface = new ethers.utils.Interface(proxyArtifact.abi);

const envPrefix = isDevelopment ? 'Development' : 'Production';
const minterBurnerProxySalt = ethers.BigNumber.from(
ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`${envPrefix}-GoodDollarOFTMinterBurnerV1`)),
const minterBurnerProxySalt = buildCreateXSalt(root.address, `${envPrefix}-GoodDollarOFTMinterBurnerV1`);
const oftAdapterProxySalt = buildCreateXSalt(root.address, `${envPrefix}-GoodDollarOFTAdapterV1`);
// Both proxy addresses are known before deployment, which breaks the MinterBurner <-> adapter init cycle.
// Note: CreateX.computeCreate3Address(bytes32) does NOT run _guard(salt) internally (unlike deployCreate3AndInit),
// so it requires the post-guard salt from guardCreateXSalt(root.address, rawSalt).
const minterBurnerAddress: string = await createX['computeCreate3Address(bytes32)'](
guardCreateXSalt(root.address, minterBurnerProxySalt),
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
);
const oftAdapterProxySalt = ethers.BigNumber.from(
ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`${envPrefix}-GoodDollarOFTAdapterV1`)),
const oftAdapterAddress: string = await createX['computeCreate3Address(bytes32)'](
guardCreateXSalt(root.address, oftAdapterProxySalt),
);
// Both proxy addresses are known before deployment, which breaks the MinterBurner <-> adapter init cycle
const minterBurnerAddress: string = await proxyFactory.getDeploymentAddress(minterBurnerProxySalt, root.address);
const oftAdapterAddress: string = await proxyFactory.getDeploymentAddress(oftAdapterProxySalt, root.address);

const deployProxyIfMissing = async (name: string, address: string, salt: BigNumber, logic: string, initData: string) => {
const deployProxyIfMissing = async (name: string, address: string, salt: string, logic: string, initData: string) => {
if ((await ethers.provider.getCode(address)) !== '0x') {
console.log(`${name} proxy already deployed`, address);
} else {
const tx = await proxyFactory.deployProxy(salt, logic, initData);
await tx.wait();
console.log(`${name} proxy deployed and initialized`, address, tx.hash);
const proxyInitCall = proxyInterface.encodeFunctionData('initialize', [logic, initData]);
try {
const tx = await createX.deployCreate3AndInit(salt, proxyArtifact.bytecode, proxyInitCall, {
constructorAmount: 0,
initCallAmount: 0,
});
await tx.wait();
console.log(`${name} proxy deployed and initialized`, address, tx.hash);
} catch (err) {
if ((await ethers.provider.getCode(address)) === '0x') {
throw err;
}
console.log(`${name} proxy was deployed concurrently at ${address}; verifying state via verifyOftDeployment`);
}
}
await deployments.save(name, { address, abi: (await hre.artifacts.readArtifact(name)).abi });
};
Expand Down
67 changes: 67 additions & 0 deletions packages/bridge-contracts/deploy/utils/createX.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ethers } from 'hardhat';

/**
* Canonical CreateX universal factory address deployed across EVM chains (Celo, XDC, Ethereum, Fuse, etc.).
* @see https://github.com/pcaversaccio/createx
*/
export const CREATEX_ADDRESS = '0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed';

/**
* Keccak-256 hash of CreateX's 16-byte CREATE3 child proxy initcode (`0x67363d3d37363d34f03d5260086018f3`).
*/
export const CREATE3_PROXY_INITCODE_HASH = '0x21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f';

export const CREATEX_ABI = [
'function deployCreate3AndInit(bytes32 salt, bytes initCode, bytes data, tuple(uint256 constructorAmount, uint256 initCallAmount) values) payable returns (address newContract)',
// Note: CreateX's on-chain computeCreate3Address(bytes32) does NOT run _guard(salt); it expects the post-guard salt
// produced by guardCreateXSalt(deployer, rawSalt), whereas deployCreate3AndInit takes the raw salt and calls _guard(salt).
'function computeCreate3Address(bytes32 salt) view returns (address computedAddress)',
'function computeCreate3Address(bytes32 salt, address deployer) pure returns (address computedAddress)',
];

/**
* Builds a 32-byte CreateX salt with sender-permissioned deploy protection and cross-chain address consistency:
* - bytes 0..19 (20 bytes): `deployer` address (`SenderBytes.MsgSender` — only `deployer` can produce the guarded salt)
* - byte 20 (1 byte): `0x00` (`RedeployProtectionFlag.False` — omits `block.chainid` so the address is identical across all chains)
* - bytes 21..31 (11 bytes): first 11 bytes of `keccak256(utf8(label))`
*/
export function buildCreateXSalt(deployer: string, label: string): string {
const entropy = ethers.utils.hexDataSlice(ethers.utils.id(label), 0, 11);
return ethers.utils.solidityPack(['address', 'uint8', 'bytes11'], [ethers.utils.getAddress(deployer), 0, entropy]);
}

/**
* Computes the post-`_guard(salt)` value that CreateX derives internally in `deployCreate3*` when
* `msg.sender == deployer` and `salt[20] == 0x00`:
* `_efficientHash(bytes32(uint256(uint160(msg.sender))), salt)` == `keccak256(abi.encode(deployer, salt))`.
* Note: CreateX's view function `computeCreate3Address(bytes32)` does NOT apply `_guard`, so callers of
* `computeCreate3Address(bytes32)` must pass this guarded salt, while `deployCreate3AndInit` takes the raw salt.
*/
export function guardCreateXSalt(deployer: string, salt: string): string {
const normalizedDeployer = ethers.utils.getAddress(deployer);
const saltPrefix = ethers.utils.getAddress(ethers.utils.hexDataSlice(salt, 0, 20));
if (saltPrefix !== normalizedDeployer) {
throw new Error(`CreateX salt sender prefix mismatch: expected ${normalizedDeployer}, got ${saltPrefix}`);
}
const redeployFlag = ethers.utils.hexDataSlice(salt, 20, 21);
if (redeployFlag !== '0x00') {
throw new Error(`CreateX cross-chain redeploy protection must be 0x00 for cross-chain deterministic addresses, got ${redeployFlag}`);
}
return ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(['address', 'bytes32'], [normalizedDeployer, salt]),
);
}

/**
* Deterministically computes the CREATE3 deployment address for a sender-guarded CreateX salt.
* Depends only on `createXAddress`, `deployer`, and `salt` (independent of chain ID and contract bytecode).
*/
export function computeCreateXCreate3Address(
deployer: string,
salt: string,
createXAddress: string = CREATEX_ADDRESS,
): string {
const guardedSalt = guardCreateXSalt(deployer, salt);
const proxyChild = ethers.utils.getCreate2Address(createXAddress, guardedSalt, CREATE3_PROXY_INITCODE_HASH);
return ethers.utils.getContractAddress({ from: proxyChild, nonce: 1 });
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Use `development-xdc` / `development-celo` for staging. Deploy **both** chains b
|-----------|--------|--------|
| Token / NameService / Controller | `@gooddollar/goodprotocol` `deployment.json` for the Hardhat network | Required |
| LayerZero endpoint | `@layerzerolabs/lz-evm-sdk-v2` `EndpointV2.json` via `deploy/utils/getLzEndpoint.ts` (fallback: `LAYERZERO_ENDPOINT` if network unmapped) | Celo/XDC mainnet deployments |
| Proxy CREATE2 salt | `Development-…V1` or `Production-…V1` string hash | Fixed proxy address per env |
| Proxy CREATE3 salt | CreateX (`0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed`) sender-guarded salt (`<deployer>00<11-byte hash>` of `Development-…V1` or `Production-…V1`) | Identical deterministic proxy address across all chains per env |
| Implementation CREATE2 salt | `keccak256(bytecode)` | Changes when code changes |

## Configure
Expand Down
66 changes: 45 additions & 21 deletions packages/bridge-contracts/test/GoodDollarOFT.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers';
import contracts from '@gooddollar/goodprotocol/releases/deployment.json';
import * as networkHelpers from "@nomicfoundation/hardhat-network-helpers";
import { deployMinterBurnerProxy } from './helpers/deployMinterBurnerProxy';
import {
CREATEX_ABI,
CREATEX_ADDRESS,
buildCreateXSalt,
computeCreateXCreate3Address,
guardCreateXSalt,
} from '../deploy/utils/createX';

const CELO_CHAIN_URL = 'https://forno.celo.org';
/**
Expand Down Expand Up @@ -430,17 +437,11 @@ describe('GoodDollarOFTAdapter Fork Tests (Celo Production)', () => {
});
});

// Mirrors deploy/deployOFT.ts + scripts/oft/steps/adapter-ownership.ts against the real Celo ProxyFactory/endpoint
describe('Atomic deploy via ProxyFactory1967 and Avatar handover (H-01/H-02)', () => {
it('deploys both proxies initialized in one tx each and hands owner + LZ delegate to the Avatar', async function () {
// Mirrors deploy/deployOFT.ts + scripts/oft/steps/adapter-ownership.ts against the real Celo CreateX/endpoint
describe('Atomic deploy via CreateX and Avatar handover (H-01/H-02)', () => {
it('deploys both proxies initialized in one tx each via CreateX and hands owner + LZ delegate to the Avatar', async function () {
const avatar = celoContracts.Avatar;
const proxyFactory = await ethers.getContractAt(
[
'function deployProxy(uint256 _salt, address _logic, bytes _data) returns (address)',
'function getDeploymentAddress(uint256 _salt, address _sender) view returns (address)',
],
celoContracts.ProxyFactory,
);
const createX = await ethers.getContractAt(CREATEX_ABI, CREATEX_ADDRESS);
const endpoint = await ethers.getContractAt(
['function delegates(address) view returns (address)'],
CELO_LZ_ENDPOINT,
Expand All @@ -449,29 +450,52 @@ describe('GoodDollarOFTAdapter Fork Tests (Celo Production)', () => {
const minterBurnerImpl = await (await ethers.getContractFactory('GoodDollarOFTMinterBurner')).deploy();
const AdapterFactory = await ethers.getContractFactory('GoodDollarOFTAdapter');
const adapterImpl = await AdapterFactory.deploy(celoContracts.GoodDollar, CELO_LZ_ENDPOINT);
const proxyFactory = await ethers.getContractFactory(
'@gooddollar/goodprotocol/contracts/utils/ProxyFactory1967.sol:ERC1967Proxy',
);

const mbSalt = ethers.BigNumber.from(ethers.utils.id('Test-GoodDollarOFTMinterBurnerV1'));
const adapterSalt = ethers.BigNumber.from(ethers.utils.id('Test-GoodDollarOFTAdapterV1'));
const mbAddress = await proxyFactory.getDeploymentAddress(mbSalt, owner.address);
const adapterAddress = await proxyFactory.getDeploymentAddress(adapterSalt, owner.address);
const mbSalt = buildCreateXSalt(owner.address, 'Test-GoodDollarOFTMinterBurnerV1');
const adapterSalt = buildCreateXSalt(owner.address, 'Test-GoodDollarOFTAdapterV1');
const mbAddress = await createX['computeCreate3Address(bytes32)'](guardCreateXSalt(owner.address, mbSalt));
const adapterAddress = await createX['computeCreate3Address(bytes32)'](
guardCreateXSalt(owner.address, adapterSalt),
);
expect(mbAddress).to.equal(computeCreateXCreate3Address(owner.address, mbSalt));
expect(adapterAddress).to.equal(computeCreateXCreate3Address(owner.address, adapterSalt));
// salt is bound to the deployer, so another sender gets a different address
expect(await proxyFactory.getDeploymentAddress(adapterSalt, user.address)).to.not.equal(adapterAddress);
const otherSenderSalt = buildCreateXSalt(user.address, 'Test-GoodDollarOFTAdapterV1');
expect(await createX['computeCreate3Address(bytes32)'](guardCreateXSalt(user.address, otherSenderSalt))).to.not.equal(
adapterAddress,
);

await proxyFactory.deployProxy(
mbSalt,
// Even if another caller passes the deployer's raw salt, CreateX guards it to a different address
const mbInitCall = proxyFactory.interface.encodeFunctionData('initialize', [
minterBurnerImpl.address,
minterBurnerImpl.interface.encodeFunctionData('initialize', [celoContracts.NameService, adapterAddress]),
);
await proxyFactory.deployProxy(
adapterSalt,
]);
await createX.connect(user).deployCreate3AndInit(mbSalt, proxyFactory.bytecode, mbInitCall, {
constructorAmount: 0,
initCallAmount: 0,
});
expect(await ethers.provider.getCode(mbAddress)).to.equal('0x');

await createX.deployCreate3AndInit(mbSalt, proxyFactory.bytecode, mbInitCall, {
constructorAmount: 0,
initCallAmount: 0,
});
const adapterInitCall = proxyFactory.interface.encodeFunctionData('initialize', [
adapterImpl.address,
AdapterFactory.interface.encodeFunctionData('initialize', [
celoContracts.GoodDollar,
mbAddress,
owner.address,
avatar,
]),
);
]);
await createX.deployCreate3AndInit(adapterSalt, proxyFactory.bytecode, adapterInitCall, {
constructorAmount: 0,
initCallAmount: 0,
});

const implSlot = '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc';
const implOf = async (proxy: string) =>
Expand Down
60 changes: 60 additions & 0 deletions packages/bridge-contracts/test/GoodDollarOFT.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { ethers, upgrades } from "hardhat";
import { expect } from "chai";
import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers";
import { deployMinterBurnerProxy } from "./helpers/deployMinterBurnerProxy";
import {
CREATEX_ADDRESS,
buildCreateXSalt,
computeCreateXCreate3Address,
guardCreateXSalt,
} from "../deploy/utils/createX";

// OFT message encoding helpers (mirrors OFTMsgCodec library)
// message = abi.encodePacked(bytes32(to), uint64(amountSD))
Expand Down Expand Up @@ -1154,4 +1160,58 @@ describe("OFT (unit, no fork)", () => {
expect(await token.balanceOf(user.address)).to.equal(amount);
});
});

describe("CreateX deterministic address helpers", () => {
it("encodes deployer prefix, 0x00 cross-chain flag, and 11-byte label hash in buildCreateXSalt", async () => {
const [deployer] = await ethers.getSigners();
const label = "GoodDollarOFTAdapter_v1";
const salt = buildCreateXSalt(deployer.address, label);

expect(ethers.utils.hexDataLength(salt)).to.equal(32);
expect(ethers.utils.getAddress(ethers.utils.hexDataSlice(salt, 0, 20))).to.equal(deployer.address);
expect(ethers.utils.hexDataSlice(salt, 20, 21)).to.equal("0x00");
expect(ethers.utils.hexDataSlice(salt, 21, 32)).to.equal(
ethers.utils.hexDataSlice(ethers.utils.id(label), 0, 11)
);
});

it("guards sender-bound salts identically to CreateX._guard and rejects mismatched callers or chainId flags", async () => {
const [deployer, other] = await ethers.getSigners();
const salt = buildCreateXSalt(deployer.address, "GoodDollarOFTAdapter_v1");

const guarded = guardCreateXSalt(deployer.address, salt);
expect(guarded).to.equal(
ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["address", "bytes32"], [deployer.address, salt]))
);

expect(() => guardCreateXSalt(other.address, salt)).to.throw("CreateX salt sender prefix mismatch");

const chainGuardedSalt = ethers.utils.hexConcat([
ethers.utils.hexDataSlice(salt, 0, 20),
"0x01",
ethers.utils.hexDataSlice(salt, 21, 32),
]);
expect(() => guardCreateXSalt(deployer.address, chainGuardedSalt)).to.throw(
"CreateX cross-chain redeploy protection must be 0x00"
);
});

it("computes deterministic CREATE3 addresses that match across chains for the same deployer and differ per label/deployer", async () => {
const [deployer, other] = await ethers.getSigners();
const adapterSalt = buildCreateXSalt(deployer.address, "GoodDollarOFTAdapter_v1");
const mbSalt = buildCreateXSalt(deployer.address, "GoodDollarOFTMinterBurner_v1");
const otherAdapterSalt = buildCreateXSalt(other.address, "GoodDollarOFTAdapter_v1");

const adapterAddr = computeCreateXCreate3Address(deployer.address, adapterSalt);
const mbAddr = computeCreateXCreate3Address(deployer.address, mbSalt);
const otherAdapterAddr = computeCreateXCreate3Address(other.address, otherAdapterSalt);

expect(ethers.utils.isAddress(adapterAddr)).to.equal(true);
expect(adapterAddr).to.not.equal(mbAddr);
expect(adapterAddr).to.not.equal(otherAdapterAddr);
// Repeating with explicit CREATEX_ADDRESS yields identical address regardless of chain
expect(computeCreateXCreate3Address(deployer.address, adapterSalt, CREATEX_ADDRESS)).to.equal(adapterAddr);
});
});
});