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
167 changes: 116 additions & 51 deletions packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ contract GoodDollarOFTMinterBurner is DAOUpgradeableContract {
event TokensBurned(address indexed from, uint256 amount, address indexed operator);

modifier onlyOperators() {
require(operators[msg.sender] || msg.sender == avatar, 'Not authorized');
// live avatar: the cached `avatar` can go stale after a DAO avatar change
require(operators[msg.sender] || msg.sender == address(dao.avatar()), 'Not authorized');
require(!paused, 'Contract is paused');
_;
}

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

/**
* @dev Initialize the MinterBurner contract
* @param _nameService The NameService contract for DAO integration
Expand Down Expand Up @@ -55,13 +61,16 @@ contract GoodDollarOFTMinterBurner is DAOUpgradeableContract {
}

/**
* @dev Burn tokens from an address
* @param _from The address to burn tokens from
* @dev Burn the caller's tokens; the caller must have approved this contract
* @param _from Must be msg.sender (kept for IMintableBurnable compatibility)
* @param _amount The amount of tokens to burn
* @return success True if the burn was successful
*/
function burn(address _from, uint256 _amount) external onlyOperators returns (bool) {
require(_from == msg.sender, 'can only burn own tokens');
Comment thread
blueogin marked this conversation as resolved.
Resolved
uint256 balanceBefore = token.balanceOf(address(this));
require(token.transferFrom(_from, address(this), _amount), 'TRANSFER_FROM_FAILED');
require(token.balanceOf(address(this)) - balanceBefore == _amount, 'received amount mismatch');
token.burn(_amount);

emit TokensBurned(_from, _amount, msg.sender);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@ interface ISuperGoodDollar {

function transferFrom(address from, address to, uint256 amount) external returns (bool);

function balanceOf(address account) external view returns (uint256);

function addMinter(address _minter) external;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ contract GoodDollarOFTAdapterHarness is GoodDollarOFTAdapter {
) external returns (uint256) {
return _credit(_to, _amountLD, _srcEid);
}

/// @dev Calls internal _debit to test the pull-and-burn path without a full LayerZero send
function exposed_debit(
address _from,
uint256 _amountLD,
uint256 _minAmountLD,
uint32 _dstEid
) external returns (uint256 amountSentLD, uint256 amountReceivedLD) {
return _debit(_from, _amountLD, _minAmountLD, _dstEid);
}
}
63 changes: 42 additions & 21 deletions packages/bridge-contracts/deploy/upgradeOFT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* - Reads proxy addresses from hardhat-deploy artifacts (`deployments/`) for the current network
* - Resolves token and LayerZero endpoint from GoodProtocol deployment (same as deployOFT)
* - Deploys new implementations (OFT adapter with constructor args, MinterBurner with none)
* - Calls upgradeTo(newImplementation) on each proxy (UUPS)
* - Upgrades each proxy (UUPS) through the DAO: Controller.genericCall when the deployer is a registered scheme,
* otherwise prints the genericCall payloads to submit as a DAO proposal
*
* Usage:
* npx hardhat deploy --tags OFT-Upgrade --network development-xdc
Expand Down Expand Up @@ -91,6 +92,25 @@ const func: DeployFunction = async function (hre) {
console.log("Controller:", controllerAddress);
console.log("Avatar:", avatarAddress);

const isScheme: boolean = await Controller.isSchemeRegistered(root.address, avatarAddress);
const pendingProposals: { target: string; data: string }[] = [];

// Avatar-owned proxies are upgraded via the DAO; a deployer-owned adapter (before handover) is upgraded directly
const upgradeProxy = async (name: string, proxy: string, newImpl: string, ownedByAvatar: boolean) => {
const contract = await ethers.getContractAt(name, proxy);
const data = contract.interface.encodeFunctionData("upgradeTo", [newImpl]);
if (!ownedByAvatar) {
await (await contract.upgradeTo(newImpl)).wait();
console.log(`✅ ${name} upgraded by owner`);
} else if (isScheme) {
await (await Controller.genericCall(proxy, data, avatarAddress, 0)).wait();
console.log(`✅ ${name} upgraded via DAO avatar`);
} else {
pendingProposals.push({ target: proxy, data });
console.log(`⏳ ${name} upgrade requires a DAO proposal`);
}
};

// CREATE2 salt for implementations:
// hardhat-deploy uses `deterministicDeployment` as the CREATE2 salt.
// We derive it from the contract's compiled bytecode.
Expand All @@ -111,14 +131,16 @@ const func: DeployFunction = async function (hre) {
});
console.log("GoodDollarOFTAdapter implementation", oftAdapterImpl.address);

console.log("Upgrading GoodDollarOFTAdapter proxy via upgradeTo...");
console.log("Upgrading GoodDollarOFTAdapter proxy...");
const oftAdapterProxyContract = await ethers.getContractAt(
"GoodDollarOFTAdapter",
oftAdapterProxy
);
const txOft = await oftAdapterProxyContract.upgradeTo(oftAdapterImpl.address);
await txOft.wait();
console.log("✅ GoodDollarOFTAdapter upgraded");
const adapterOwner: string = await oftAdapterProxyContract.owner();
if (adapterOwner !== avatarAddress && adapterOwner !== root.address) {
throw new Error(`GoodDollarOFTAdapter owner ${adapterOwner} is neither the Avatar nor the deployer`);
}
await upgradeProxy("GoodDollarOFTAdapter", oftAdapterProxy, oftAdapterImpl.address, adapterOwner === avatarAddress);

// --- GoodDollarOFTMinterBurner: deploy new implementation via hardhat-deploy, then upgrade proxy ---
console.log("\nDeploying new GoodDollarOFTMinterBurner implementation...");
Expand All @@ -133,22 +155,21 @@ const func: DeployFunction = async function (hre) {
);
console.log("GoodDollarOFTMinterBurner implementation", minterBurnerImpl.address);

console.log("Upgrading GoodDollarOFTMinterBurner proxy via Controller.genericCall (avatar)...");
const minterBurnerProxyContract = await ethers.getContractAt(
"GoodDollarOFTMinterBurner",
minterBurnerProxy
);
const upgradeData = minterBurnerProxyContract.interface.encodeFunctionData("upgradeTo", [
minterBurnerImpl.address,
]);
const txMb = await Controller.genericCall(
minterBurnerProxy,
upgradeData,
avatarAddress,
0
);
await txMb.wait();
console.log("✅ GoodDollarOFTMinterBurner upgraded via DAO avatar");
console.log("Upgrading GoodDollarOFTMinterBurner proxy via the DAO avatar...");
await upgradeProxy("GoodDollarOFTMinterBurner", minterBurnerProxy, minterBurnerImpl.address, true);

if (pendingProposals.length > 0) {
console.log("\n=== DAO proposal required (Controller.genericCall(target, data, avatar, 0)) ===");
console.log("Controller:", controllerAddress, "Avatar:", avatarAddress);
for (const p of pendingProposals) {
console.log(
JSON.stringify({
...p,
genericCall: Controller.interface.encodeFunctionData("genericCall", [p.target, p.data, avatarAddress, 0]),
})
);
}
}

const minterBurnerImplAddress = await getImplementationAddress(
ethers.provider,
Expand Down
7 changes: 5 additions & 2 deletions packages/bridge-contracts/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import '@layerzerolabs/toolbox-hardhat';
import { EndpointId } from '@layerzerolabs/lz-definitions';

import './scripts/oft/configure-oft';
import './scripts/oft/verify-invariants';

configDotenv();
envEnc.config();
Expand Down Expand Up @@ -144,12 +145,14 @@ const config: HardhatUserConfig = {
gas: 8000000,
gasPrice: 26e9,
chainId: 42220,
},
eid: EndpointId.CELO_V2_MAINNET,
} as any,
'production-xdc': {
accounts: accounts as HttpNetworkAccountsConfig,
chainId: 50,
url: 'https://rpc.xdc.org',
},
eid: EndpointId.XDC_V2_MAINNET,
} as any,
'development-xdc': {
accounts: accounts as HttpNetworkAccountsConfig,
chainId: 50,
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"test": "hardhat test",
"test:oft": "hardhat test ./test/GoodDollarOFT.test.ts",
"oft:configure": "hardhat oft:configure",
"oft:verify": "hardhat oft:verify",
"updateabi": "find ./artifacts/contracts/ -iregex '.*/[a-z]*.json' -exec cp \"{}\" ../bridge-app/src/abi \\;",
"start:dev": "hardhat node & yarn build && yarn updateabi && hardhat run scripts/deployDevEnv.ts --network localhost",
"mpb:deploy": "hardhat deploy --export-all ./release/mpb.json"
Expand Down
37 changes: 17 additions & 20 deletions packages/bridge-contracts/scripts/oft/bridge-oft-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,20 +99,18 @@ const main = async () => {
throw new Error(`Insufficient balance. Need ${ethers.utils.formatEther(amount)} G$, have ${ethers.utils.formatEther(balance)} G$`);
}

// Check and approve MinterBurner if needed (required for burning tokens)
// The OFT adapter calls minterBurner.burn(), which calls token.burnFrom() requiring approval
// Note: OFT adapter itself doesn't need approval since approvalRequired() returns false
const minterBurnerAllowance = await token.allowance(sender.address, minterBurnerAddress);
console.log("\nChecking MinterBurner allowance...");
console.log("Current MinterBurner allowance:", ethers.utils.formatEther(minterBurnerAllowance), "G$");

if (minterBurnerAllowance.lt(amount)) {
console.log("\nApproving MinterBurner to burn tokens...");
const approveMinterBurnerTx = await token.approve(minterBurnerAddress, amount);
await approveMinterBurnerTx.wait();
console.log("MinterBurner approval confirmed:", approveMinterBurnerTx.hash);
// The OFT adapter is the spender (approvalRequired() returns true): it pulls the tokens and burns them via the MinterBurner
const adapterAllowance = await token.allowance(sender.address, oftAdapterAddress);
console.log("\nChecking OFT adapter allowance...");
console.log("Current OFT adapter allowance:", ethers.utils.formatEther(adapterAllowance), "G$");

if (adapterAllowance.lt(amount)) {
console.log("\nApproving OFT adapter...");
const approveAdapterTx = await token.approve(oftAdapterAddress, amount);
await approveAdapterTx.wait();
console.log("OFT adapter approval confirmed:", approveAdapterTx.hash);
} else {
console.log("Sufficient MinterBurner allowance already set");
console.log("Sufficient OFT adapter allowance already set");
}

// Recipient address (same address on destination chain)
Expand Down Expand Up @@ -159,15 +157,14 @@ const main = async () => {

console.log(`✅ ${destNetwork} peer is configured correctly`);

await token.approve(minterBurnerAddress, amount);
// Double-check MinterBurner approval before calling quoteSend
console.log("\nVerifying MinterBurner approval before quoteSend...");
const finalMinterBurnerAllowance = await token.allowance(sender.address, minterBurnerAddress);
console.log("Final MinterBurner allowance:", ethers.utils.formatEther(finalMinterBurnerAllowance), "G$");
// Double-check adapter approval before calling quoteSend
console.log("\nVerifying OFT adapter approval before quoteSend...");
const finalAdapterAllowance = await token.allowance(sender.address, oftAdapterAddress);
console.log("Final OFT adapter allowance:", ethers.utils.formatEther(finalAdapterAllowance), "G$");

if (finalMinterBurnerAllowance.lt(amount)) {
if (finalAdapterAllowance.lt(amount)) {
throw new Error(
`MinterBurner allowance insufficient. Need ${ethers.utils.formatEther(amount)} G$, have ${ethers.utils.formatEther(finalMinterBurnerAllowance)} G$`
`OFT adapter allowance insufficient. Need ${ethers.utils.formatEther(amount)} G$, have ${ethers.utils.formatEther(finalAdapterAllowance)} G$`
);
}

Expand Down
3 changes: 2 additions & 1 deletion packages/bridge-contracts/scripts/oft/layerzero.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ const pathways: TwoWayConfig[] = [
[
celoContract,
xdcContract,
[["LayerZero Labs"], []],
// 2 required DVNs, both live on Celo and XDC (LayerZero metadata)
[["LayerZero Labs", "Nethermind"], []],
[20, 20],
[EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS],
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { BigNumber } from "ethers";
import { getOftDeploymentAddresses } from "../../../deploy/utils/getOftDeploymentAddresses";
import config from "../oft.config.json";

function parseLimit(value: string | undefined): BigNumber | null {
export function parseLimit(value: string | undefined): BigNumber | null {
if (!value) return null;
const numValue = value.trim();
if (numValue.includes(".") || numValue.length < 15) {
Expand Down
111 changes: 111 additions & 0 deletions packages/bridge-contracts/scripts/oft/verify-invariants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { task } from "hardhat/config";
import type { HardhatRuntimeEnvironment } from "hardhat/types";
import { EndpointId } from "@layerzerolabs/lz-definitions";
import Contracts from "@gooddollar/goodprotocol/releases/deployment.json";
import config from "./oft.config.json";
import { getOftDeploymentAddresses } from "../../deploy/utils/getOftDeploymentAddresses";

const REMOTE_NETWORK: Record<string, string> = {
"production-xdc": "production-celo",
"production-celo": "production-xdc",
"development-xdc": "development-celo",
"development-celo": "development-xdc",
};

const MIN_REQUIRED_DVNS = 2;
const ULN_CONFIG_TYPE = 2;
const ULN_CONFIG_ABI = [
"tuple(uint64 confirmations, uint8 requiredDVNCount, uint8 optionalDVNCount, uint8 optionalDVNThreshold, address[] requiredDVNs, address[] optionalDVNs)",
];
const ENDPOINT_ABI = [
"function delegates(address) view returns (address)",
"function getSendLibrary(address _sender, uint32 _dstEid) view returns (address)",
"function getReceiveLibrary(address _receiver, uint32 _srcEid) view returns (address lib, bool isDefault)",
"function getConfig(address _oapp, address _lib, uint32 _eid, uint32 _configType) view returns (bytes)",
];

const eidOf = (networkName: string) =>
networkName.includes("xdc") ? EndpointId.XDC_V2_MAINNET : EndpointId.CELO_V2_MAINNET;

/**
* Post-deploy invariants: Avatar owns/delegates the adapter, peers, limits, DVNs and minting rights are as expected.
* Collects every failure and throws once at the end.
*/
export async function verifyOftInvariants(hre: HardhatRuntimeEnvironment) {
const { ethers, network } = hre;
const { verifyOftDeployment } = await import("../../deploy/utils/verifyOftDeployment");
const { parseLimit } = await import("./steps/set-bridge-limits");

const networkName = network.name;
const remoteNetwork = REMOTE_NETWORK[networkName];
if (!remoteNetwork) throw new Error(`Unknown network pairing for "${networkName}"`);
const gp = (Contracts as any)[networkName];
const avatar: string = gp?.Avatar;
if (!avatar) throw new Error(`Avatar not found for ${networkName}`);

// implementation slots, token/minterBurner/feeRecipient wiring, operators(adapter); owner must be the Avatar
await verifyOftDeployment(networkName, [avatar]);

const failures: string[] = [];
const check = (ok: boolean, label: string) => {
console.log(`${ok ? "ok " : "FAIL"} ${label}`);
if (!ok) failures.push(label);
};
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();

const { GoodDollarOFTAdapter: adapterAddress, GoodDollarOFTMinterBurner: minterBurnerAddress } =
getOftDeploymentAddresses(networkName);
const { GoodDollarOFTAdapter: remoteAdapter } = getOftDeploymentAddresses(remoteNetwork);
const adapter = await ethers.getContractAt("GoodDollarOFTAdapter", adapterAddress);
const endpoint = await ethers.getContractAt(ENDPOINT_ABI, await adapter.endpoint());
const remoteEid = eidOf(remoteNetwork);

check(same(await adapter.owner(), avatar), `adapter.owner() == Avatar ${avatar}`);
check(same(await endpoint.delegates(adapterAddress), avatar), "endpoint.delegates(adapter) == Avatar");
check(
same(await adapter.peers(remoteEid), ethers.utils.hexZeroPad(remoteAdapter, 32)),
`adapter.peers(${remoteEid}) == ${remoteNetwork} adapter ${remoteAdapter}`
);

const token = await ethers.getContractAt(
"contracts/oft/interfaces/ISuperGoodDollar.sol:ISuperGoodDollar",
gp.GoodDollar
);
check(await token.isMinter(minterBurnerAddress), "G$.isMinter(minterBurner)");

const expected = (config as any)[networkName]?.limits;
if (!expected) {
check(false, `limits for ${networkName} in oft.config.json`);
} else {
const actual = await adapter.bridgeLimits();
for (const key of ["dailyLimit", "txLimit", "accountDailyLimit", "minAmount"]) {
check(parseLimit(expected[key])!.eq(actual[key]), `bridgeLimits.${key} == ${expected[key]}`);
}
check(actual.onlyWhitelisted === expected.onlyWhitelisted, `bridgeLimits.onlyWhitelisted == ${expected.onlyWhitelisted}`);
}

const sendLib: string = await endpoint.getSendLibrary(adapterAddress, remoteEid);
const [receiveLib] = await endpoint.getReceiveLibrary(adapterAddress, remoteEid);
for (const [side, lib] of [
["send", sendLib],
["receive", receiveLib],
]) {
const raw = await endpoint.getConfig(adapterAddress, lib, remoteEid, ULN_CONFIG_TYPE);
const [uln] = ethers.utils.defaultAbiCoder.decode(ULN_CONFIG_ABI, raw);
check(
uln.requiredDVNCount >= MIN_REQUIRED_DVNS && uln.requiredDVNCount !== 255,
`${side} ULN requiredDVNCount ${uln.requiredDVNCount} >= ${MIN_REQUIRED_DVNS} (${uln.requiredDVNs.join(", ")})`
);
Comment thread
blueogin marked this conversation as resolved.
Resolved
}

if (failures.length > 0) {
throw new Error(`OFT invariants failed on ${networkName}:\n- ${failures.join("\n- ")}`);
}
console.log(`OFT invariants hold on ${networkName}`);
}

task("oft:verify", "Check post-deploy OFT invariants (ownership, delegate, peers, limits, DVNs, minter)").setAction(
async (_, hre) => {
await verifyOftInvariants(hre);
}
);
Loading
Loading