Skip to content

fix(oft): audit fixes C-01, H-01, H-02, H-03, M-01a, L-05 - #14

Open
sirpy wants to merge 1 commit into
masterfrom
fix/oft-audit-critical-high
Open

sirpy wants to merge 1 commit into
masterfrom
fix/oft-audit-critical-high

Conversation

@sirpy

@sirpy sirpy commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the critical and high findings from the OFT security audit (GoodDollarOFTAdapter, GoodDollarOFTMinterBurner, deploy/config scripts). These must be fixed before mainnet. The medium, low and informational findings come in a follow-up PR stacked on this branch.

Contract changes (GoodDollarOFTAdapter)

  • C-01: reentrancy in approveFailedRequest. SuperGoodDollar mint calls the recipient's ERC777 tokensReceived hook. A request could therefore be re-approved from inside _credit and minted N times. The fix clears failed before _credit and adds nonReentrant (via ReentrancyGuardUpgradeable).
  • H-03: approvals while the bridge is paused. approveFailedRequest now does require(!isClosed). A new owner-only rejectFailedRequest(guid) (event FailedReceiveRequestRejected) cancels forged or suspicious parked requests.
  • M-01a: _credit return value. _credit now returns recipientAmount, so OFTReceived and the receipt report the net amount after fees. Previously it returned the gross amount.

⚠️ Storage layout changed: ReentrancyGuardUpgradeable was added to the inheritance. There are no OFT deployments yet, so this is safe.

Deploy / config scripts

  • H-02: deploy and initialize atomically. deployOFT.ts now uses GoodProtocol ProxyFactory1967 instead of Arachnid CREATE2 plus a separate initialize tx.

    • Both proxy addresses are predicted with getDeploymentAddress(salt, deployer). This breaks the MinterBurner ↔ adapter init cycle.
    • Each proxy is deployed and initialized in one tx (deployProxy). The salt is bound to the deployer, so the proxies can't be front-run or hijacked.
    • Proxy addresses now depend on the per-chain ProxyFactory address, so they differ between Celo and XDC. Peers are set per address by lz:oapp:wire, so this is fine.
  • New deploy/utils/verifyOftDeployment.ts. It checks:

    • the EIP-1967 implementation slot of both proxies against the *_Implementation deployments;
    • the adapter's owner() (deployer or Avatar), token(), minterBurner() and feeRecipient() == Avatar;
    • the MinterBurner's token() and operators(adapter).

    It runs at the end of the deploy, at the start of oft:configure (before wiring peers) and in grant-minter-role (before addMinter).

  • L-05: fee recipient. The Avatar is the fee recipient at initialize. Previously it was the deployer.

  • H-01: LZ delegate handover. adapter-ownership.ts now calls setDelegate(Avatar) before transferOwnership(Avatar) and asserts that both owner and endpoint.delegates(adapter) are the Avatar.

    • If the owner is already the Avatar but the delegate isn't, the script fails loudly, because that fix needs a DAO proposal.

Tests

  • test/GoodDollarOFT.reentrancy.test.ts replaces the PoC and keeps it as a regression test.
    • A re-entrant approval reverts with ReentrancyGuard: reentrant call, and a normal approval mints exactly once.
    • It runs against a hook-enabled mock and on a Celo fork with the real SuperGoodDollar + ERC1820 registry.
  • test/GoodDollarOFT.test.ts:
    • H-03: approve while closed (owner and anyone after the window); reject by owner, non-owner, nonexistent request, and after approval.
    • M-01a: _credit return value and OFTReceived net amount with a 1% fee.
  • test/GoodDollarOFT.e2e.test.ts: a Celo-fork test that mirrors the new deploy flow against the real ProxyFactory1967 and LZ EndpointV2.
    • It checks deployer-bound addresses, impl slots, initialized state and that re-initialization reverts.
    • It then checks the owner + delegate handover to the Avatar.

Full suite: 98 passing (34 pending, already skipped before this PR).

Needs manual verification

  • Do a dry run of hardhat deploy --tags OFT on development-celo / development-xdc. Confirm that goodprotocol deployment.json has ProxyFactory for both networks: dev and prod Celo and XDC have it.
  • The audit's "single-step ownership" item still applies: transferOwnership(Avatar) is irreversible. adapter-ownership.ts now asserts the Avatar address from deployment.json.

Summary by Sourcery

Harden the OFT contracts and deployment flow against audited security findings before mainnet.

New Features:

  • Add owner-controlled rejection of parked failed receive requests.
  • Add deployment verification for OFT proxy implementations and initialization wiring.

Bug Fixes:

  • Prevent reentrant failed-request approvals and block approvals while the bridge is paused.
  • Deploy and initialize OFT proxies atomically, bind proxy addresses to the deployer, and securely hand over the LayerZero delegate with ownership.
  • Report net credited amounts after fees through _credit and OFTReceived.

Enhancements:

  • Route OFT fees to the Avatar and strengthen deployment and ownership handover checks.

Deployment:

  • Use GoodProtocol ProxyFactory1967 for deployer-bound, atomic OFT proxy deployment across networks.

Tests:

  • Add regression and fork coverage for ERC777 reentrancy, failed-request controls, net fee reporting, atomic deployment, and Avatar handover.

- C-01: approveFailedRequest clears the failed flag before _credit and is nonReentrant (G$ ERC777 mint hook re-entry)
- H-03: approveFailedRequest reverts while the bridge is closed; owner-only rejectFailedRequest
- M-01a: _credit returns the net amount credited (OFTReceived/OFTReceipt)
- H-02: deploy proxies via ProxyFactory1967 with atomic initialize; verifyOftDeployment before wiring/minter grant
- L-05: Avatar is the fee recipient at initialize
- H-01: setDelegate(Avatar) before transferOwnership and assert owner + delegate
- tests: reentrancy regression (mock + Celo fork with real SuperGoodDollar), H-03/M-01a regressions, fork deploy/handover

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol" line_range="20" />
<code_context>
  * @dev Inherits from OFTCoreUpgradeable (which already includes OwnableUpgradeable) and implements mint/burn logic similar to MintBurnOFTAdapter
  */
-contract GoodDollarOFTAdapter is UUPSUpgradeable, OFTCoreUpgradeable {
+contract GoodDollarOFTAdapter is UUPSUpgradeable, ReentrancyGuardUpgradeable, OFTCoreUpgradeable {
     using OFTMsgCodec for bytes;
     using OFTMsgCodec for bytes32;
</code_context>
<issue_to_address>
**Legacy adapter storage shifts on upgrade**

When a proxy deployed with the pre-PR layout is upgraded through `upgradeOFT.ts`, `ReentrancyGuardUpgradeable` adds storage before `OFTCoreUpgradeable` and the adapter’s fields, so `upgradeOFT.ts` upgrades a pre-PR proxy with a shifted layout; its token wiring, limits, and failed-request records are then read from the wrong slots, breaking the bridge and making the old state inaccessible.

Keep the old storage layout for in-place upgrades or use a fresh proxy and explicitly migrate state instead of upgrading a pre-PR proxy to this implementation.
</issue_to_address>

### Comment 2
<location path="packages/bridge-contracts/scripts/oft/steps/adapter-ownership.ts" line_range="39-45" />
<code_context>
-    console.log("adapter ownership: already Avatar");
+  if (isAvatar(currentOwner)) {
+    // The LZ delegate can rewrite DVN/executor config, so it must be the Avatar too
+    if (!isAvatar(currentDelegate)) {
+      throw new Error(
+        `Adapter owner is Avatar but LZ delegate is ${currentDelegate}; setDelegate(Avatar) must go through a DAO proposal`
</code_context>
<issue_to_address>
**Anyone can replace the LZ delegate**

When an untrusted account calls the inherited `setDelegate` before or after the ownership handover, the inherited `setDelegate` entry point is permissionless, so an attacker can set themselves as the endpoint delegate after this script sets the Avatar. They can then change the adapter’s LayerZero configuration despite the ownership handover, and can race the separate `transferOwnership` transaction so the script transfers ownership before detecting the wrong delegate.

Override `setDelegate` with an owner or governance authorization check, or otherwise prevent callers other than the intended governance from changing the endpoint delegate.
</issue_to_address>

### Comment 3
<location path="packages/bridge-contracts/deploy/deployOFT.ts" line_range="183" />
<code_context>
-  } else {
-    console.log('GoodDollarOFTAdapter already initialized');
-  }
+      avatarAddress,
+    ]),
+  );
</code_context>
<issue_to_address>
**Fee-bearing receives revert**

When the Avatar has no ERC777 recipient implementer registered and bridge fees are positive, `_credit` sends the fee to the Avatar through `minterBurner.mint`; SuperGoodDollar rejects the mint, so the entire receive reverts and incoming transfers remain undelivered.

Register the Avatar as an ERC777 recipient or route fees to a recipient that can receive ERC777 tokens.
</issue_to_address>

### Comment 4
<location path="packages/bridge-contracts/deploy/deployOFT.ts" line_range="149-150" />
<code_context>
+    ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`${envPrefix}-GoodDollarOFTAdapterV1`)),
+  );
+  // 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) => {
</code_context>
<issue_to_address>
**Reruns create a second proxy pair**

When a different signer resumes or reruns deployment after the original proxies have been deployed, `getDeploymentAddress` predicts addresses from the new `root.address`, so `deployProxyIfMissing` deploys a second pair and `deployments.save` replaces the local records without disabling the original pair. Later scripts target the new pair while the original can remain authorized or wired.

Reuse existing deployment records on reruns, and do not replace them with proxies predicted from a different signer.
</issue_to_address>

### Comment 5
<location path="packages/bridge-contracts/scripts/oft/configure-oft.ts" line_range="75" />
<code_context>
+  const [signer] = await hre.ethers.getSigners();
+  const avatar = (Contracts[networkName as keyof typeof Contracts] as any)?.Avatar;
+  if (!avatar) throw new Error(`Avatar not found for ${networkName}`);
+  await verifyOftDeployment(networkName, [signer.address, avatar]);
+
   if (!flags.skipWire) wireLayerZero(networkName);
</code_context>
<issue_to_address>
**Configuration mutates before delegate check**

When the adapter is already Avatar-owned, its endpoint delegate is not the Avatar, and configuration runs with minter or limit steps enabled, `verifyOftDeployment` accepts the Avatar as owner without checking the endpoint delegate, so `configureOft` can grant the minter role and set limits before `adapter-ownership` detects the bad delegate and throws. The command fails with the bridge partly configured and the delegate mismatch unresolved.

Check the endpoint delegate during the initial preflight and abort before any configuration mutations when it is inconsistent.

Also at `packages/bridge-contracts/scripts/oft/steps/adapter-ownership.ts:26`.
</issue_to_address>

### Comment 6
<location path="packages/bridge-contracts/deploy/deployOFT.ts" line_range="142-145" />
<code_context>
+  );
+
+  const envPrefix = isDevelopment ? 'Development' : 'Production';
+  const minterBurnerProxySalt = ethers.BigNumber.from(
+    ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`${envPrefix}-GoodDollarOFTMinterBurnerV1`)),
+  );
+  const oftAdapterProxySalt = ethers.BigNumber.from(
+    ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`${envPrefix}-GoodDollarOFTAdapterV1`)),
+  );
</code_context>
<issue_to_address>
**Proxy addresses misdescribed**

When operators use the configuring guide to predict or compare proxy addresses across chains, readers of the configuring guide are told that the proxy salt gives a fixed address per environment, but these salts are passed to each chain’s ProxyFactory and the factory address affects the resulting proxy address. Operators therefore expect the Celo and XDC proxies to share an address when they do not.

Update the guide to state that proxy addresses depend on the per-chain ProxyFactory and deployer, and differ between chains.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 5 findings to address first, and this changes bridge authorization, LayerZero delegate control, proxy deployment and token-crediting behavior; an error could leave the bridge miswired, expose configuration control, or credit or strand tokens across deployments. Reverting the code would not undo deployed proxies, ownership or delegate transfers, or any cross-chain credits and access changes that already occurred.

Blocking findings: packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:20, packages/bridge-contracts/scripts/oft/steps/adapter-ownership.ts:45, packages/bridge-contracts/deploy/deployOFT.ts:183, packages/bridge-contracts/deploy/deployOFT.ts:150, packages/bridge-contracts/scripts/oft/configure-oft.ts:75


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

* @dev Inherits from OFTCoreUpgradeable (which already includes OwnableUpgradeable) and implements mint/burn logic similar to MintBurnOFTAdapter
*/
contract GoodDollarOFTAdapter is UUPSUpgradeable, OFTCoreUpgradeable {
contract GoodDollarOFTAdapter is UUPSUpgradeable, ReentrancyGuardUpgradeable, OFTCoreUpgradeable {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High · Legacy adapter storage shifts on upgrade

When a proxy deployed with the pre-PR layout is upgraded through upgradeOFT.ts, ReentrancyGuardUpgradeable adds storage before OFTCoreUpgradeable and the adapter’s fields, so upgradeOFT.ts upgrades a pre-PR proxy with a shifted layout; its token wiring, limits, and failed-request records are then read from the wrong slots, breaking the bridge and making the old state inaccessible.

Keep the old storage layout for in-place upgrades or use a fresh proxy and explicitly migrate state instead of upgrading a pre-PR proxy to this implementation.

Prompt for AI agents
In `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol` at line 20:

**Legacy adapter storage shifts on upgrade**

When a proxy deployed with the pre-PR layout is upgraded through `upgradeOFT.ts`, `ReentrancyGuardUpgradeable` adds storage before `OFTCoreUpgradeable` and the adapter’s fields, so `upgradeOFT.ts` upgrades a pre-PR proxy with a shifted layout; its token wiring, limits, and failed-request records are then read from the wrong slots, breaking the bridge and making the old state inaccessible.

Keep the old storage layout for in-place upgrades or use a fresh proxy and explicitly migrate state instead of upgrading a pre-PR proxy to this implementation.

Comment on lines +39 to 45
if (!isAvatar(currentDelegate)) {
const delegateTx = await oftAdapter.setDelegate(avatarAddress);
await delegateTx.wait();
console.log(`adapter LZ delegate: set to Avatar (${delegateTx.hash})`);
}

const tx = await oftAdapter.transferOwnership(avatarAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High · Anyone can replace the LZ delegate

When an untrusted account calls the inherited setDelegate before or after the ownership handover, the inherited setDelegate entry point is permissionless, so an attacker can set themselves as the endpoint delegate after this script sets the Avatar. They can then change the adapter’s LayerZero configuration despite the ownership handover, and can race the separate transferOwnership transaction so the script transfers ownership before detecting the wrong delegate.

Override setDelegate with an owner or governance authorization check, or otherwise prevent callers other than the intended governance from changing the endpoint delegate.

Prompt for AI agents
In `packages/bridge-contracts/scripts/oft/steps/adapter-ownership.ts` at lines 39-45:

**Anyone can replace the LZ delegate**

When an untrusted account calls the inherited `setDelegate` before or after the ownership handover, the inherited `setDelegate` entry point is permissionless, so an attacker can set themselves as the endpoint delegate after this script sets the Avatar. They can then change the adapter’s LayerZero configuration despite the ownership handover, and can race the separate `transferOwnership` transaction so the script transfers ownership before detecting the wrong delegate.

Override `setDelegate` with an owner or governance authorization check, or otherwise prevent callers other than the intended governance from changing the endpoint delegate.

} else {
console.log('GoodDollarOFTAdapter already initialized');
}
avatarAddress,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High · Fee-bearing receives revert

When the Avatar has no ERC777 recipient implementer registered and bridge fees are positive, _credit sends the fee to the Avatar through minterBurner.mint; SuperGoodDollar rejects the mint, so the entire receive reverts and incoming transfers remain undelivered.

Register the Avatar as an ERC777 recipient or route fees to a recipient that can receive ERC777 tokens.

Prompt for AI agents
In `packages/bridge-contracts/deploy/deployOFT.ts` at line 183:

**Fee-bearing receives revert**

When the Avatar has no ERC777 recipient implementer registered and bridge fees are positive, `_credit` sends the fee to the Avatar through `minterBurner.mint`; SuperGoodDollar rejects the mint, so the entire receive reverts and incoming transfers remain undelivered.

Register the Avatar as an ERC777 recipient or route fees to a recipient that can receive ERC777 tokens.

Comment on lines +149 to +150
const minterBurnerAddress: string = await proxyFactory.getDeploymentAddress(minterBurnerProxySalt, root.address);
const oftAdapterAddress: string = await proxyFactory.getDeploymentAddress(oftAdapterProxySalt, root.address);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High · Reruns create a second proxy pair

When a different signer resumes or reruns deployment after the original proxies have been deployed, getDeploymentAddress predicts addresses from the new root.address, so deployProxyIfMissing deploys a second pair and deployments.save replaces the local records without disabling the original pair. Later scripts target the new pair while the original can remain authorized or wired.

Reuse existing deployment records on reruns, and do not replace them with proxies predicted from a different signer.

Prompt for AI agents
In `packages/bridge-contracts/deploy/deployOFT.ts` at lines 149-150:

**Reruns create a second proxy pair**

When a different signer resumes or reruns deployment after the original proxies have been deployed, `getDeploymentAddress` predicts addresses from the new `root.address`, so `deployProxyIfMissing` deploys a second pair and `deployments.save` replaces the local records without disabling the original pair. Later scripts target the new pair while the original can remain authorized or wired.

Reuse existing deployment records on reruns, and do not replace them with proxies predicted from a different signer.

const [signer] = await hre.ethers.getSigners();
const avatar = (Contracts[networkName as keyof typeof Contracts] as any)?.Avatar;
if (!avatar) throw new Error(`Avatar not found for ${networkName}`);
await verifyOftDeployment(networkName, [signer.address, avatar]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High · Configuration mutates before delegate check

When the adapter is already Avatar-owned, its endpoint delegate is not the Avatar, and configuration runs with minter or limit steps enabled, verifyOftDeployment accepts the Avatar as owner without checking the endpoint delegate, so configureOft can grant the minter role and set limits before adapter-ownership detects the bad delegate and throws. The command fails with the bridge partly configured and the delegate mismatch unresolved.

Check the endpoint delegate during the initial preflight and abort before any configuration mutations when it is inconsistent.

Also at packages/bridge-contracts/scripts/oft/steps/adapter-ownership.ts:26.

Prompt for AI agents
In `packages/bridge-contracts/scripts/oft/configure-oft.ts` at line 75:

**Configuration mutates before delegate check**

When the adapter is already Avatar-owned, its endpoint delegate is not the Avatar, and configuration runs with minter or limit steps enabled, `verifyOftDeployment` accepts the Avatar as owner without checking the endpoint delegate, so `configureOft` can grant the minter role and set limits before `adapter-ownership` detects the bad delegate and throws. The command fails with the bridge partly configured and the delegate mismatch unresolved.

Check the endpoint delegate during the initial preflight and abort before any configuration mutations when it is inconsistent.

Also at `packages/bridge-contracts/scripts/oft/steps/adapter-ownership.ts:26`.

Comment on lines +142 to +145
const minterBurnerProxySalt = ethers.BigNumber.from(
ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`${envPrefix}-GoodDollarOFTMinterBurnerV1`)),
);
const oftAdapterProxySalt = ethers.BigNumber.from(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚪ Low · Proxy addresses misdescribed

When operators use the configuring guide to predict or compare proxy addresses across chains, readers of the configuring guide are told that the proxy salt gives a fixed address per environment, but these salts are passed to each chain’s ProxyFactory and the factory address affects the resulting proxy address. Operators therefore expect the Celo and XDC proxies to share an address when they do not.

Update the guide to state that proxy addresses depend on the per-chain ProxyFactory and deployer, and differ between chains.

Prompt for AI agents
In `packages/bridge-contracts/deploy/deployOFT.ts` at lines 142-145:

**Proxy addresses misdescribed**

When operators use the configuring guide to predict or compare proxy addresses across chains, readers of the configuring guide are told that the proxy salt gives a fixed address per environment, but these salts are passed to each chain’s ProxyFactory and the factory address affects the resulting proxy address. Operators therefore expect the Celo and XDC proxies to share an address when they do not.

Update the guide to state that proxy addresses depend on the per-chain ProxyFactory and deployer, and differ between chains.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant