Skip to content

fix(oft): audit medium/low remediations (M-01..M-05, L-01..L-09) - #15

Open
sirpy wants to merge 1 commit into
fix/oft-audit-critical-highfrom
fix/oft-audit-medium-low
Open

sirpy wants to merge 1 commit into
fix/oft-audit-critical-highfrom
fix/oft-audit-medium-low

Conversation

@sirpy

@sirpy sirpy commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #14 (base: fix/oft-audit-critical-high). Remaining medium/low findings from the OFT audit, plus scripts and tests.

Fixes

Contracts

ID Change
M-01 setBridgeFees: fee <= MAX_FEE_BPS and minFee <= maxFee (when maxFee != 0). _takeFee clamps the fee to the amount, so a minFee larger than the amount can't underflow.
M-02 _lzReceive: when limits fail on a composed message, it reverts with BRIDGE_LIMITS(reason) instead of parking it. The message stays retryable at the endpoint and the compose payload is not lost.
M-03 Separate inbound counters (bridgeReceiveDailyLimit, accountsReceiveDailyLimit), appended after nameService. Outbound counters are unchanged. _checkBridgeLimits and _resetDailyLimitsIfNeeded now take the counters as parameters.
L-01 The adapter is the spender (approvalRequired() returns true). _debit pulls with safeTransferFrom into the adapter, calls forceApprove(minterBurner), then minterBurner.burn(address(this)). MinterBurner.burn requires _from == msg.sender.
L-02 Balance-delta checks in both _debit and MinterBurner.burn (received amount mismatch).
L-03 onlyOperators checks the live dao.avatar() instead of the cached avatar.
L-04 The MinterBurner constructor calls _disableInitializers().
L-06 When onlyWhitelisted is on and the NameService or IDENTITY can't be resolved, transfers fail closed with identity not set. setNameService emits NameServiceSet.
L-07 setBridgeLimits requires minAmount <= txLimit <= dailyLimit and accountDailyLimit <= dailyLimit.
L-08 Send limits are enforced on _removeDust(amountLD).
L-09 quoteOFT override: min = minAmount; max = min(txLimit, remaining daily, remaining account daily for msg.sender), and 0 when closed.
Info Removed the dead generateGuid / _toBytes32.

Scripts / config

  • M-05: layerzero.config.ts now requires 2 DVNs: LayerZero Labs + Nethermind. LZ metadata confirms both are live (v2, not deprecated) on Celo and XDC. Other DVNs live on both chains: Horizen, Canary, Deutsche Telekom.
  • hardhat.config.ts: added eid to production-celo and production-xdc.
  • deploy/upgradeOFT.ts: upgrades of Avatar-owned proxies (adapter after handover, and the MinterBurner) go through Controller.genericCall.
    • If the deployer is not a registered scheme, the script prints the genericCall payloads to submit as a DAO proposal.
    • A deployer-owned adapter (before handover) is still upgraded directly.
  • New oft:verify task (scripts/oft/verify-invariants.ts) checks:
    • verifyOftDeployment with the owner required to be the Avatar;
    • endpoint.delegates(adapter) == Avatar;
    • the peer equals the remote adapter;
    • bridgeLimits match oft.config.json;
    • G$.isMinter(minterBurner);
    • send and receive ULN requiredDVNCount >= 2.
  • bridge-oft-token.ts: approves the adapter instead of the MinterBurner.

Tests

  • New helper test/helpers/deployMinterBurnerProxy.ts. The MinterBurner implementation can't be initialized anymore, so tests use a GoodProtocol ERC1967Proxy.
  • Regressions for M-01, M-02, M-03, L-01, L-03, L-04, L-06, L-07, L-08 and L-09, and an exposed_debit harness for the L-01 burn path.
  • Celo-fork test with real SuperGoodDollar and the real LZ endpoint:
    • a full send burns through the adapter;
    • an allowance to the MinterBurner alone is no longer enough.
  • Full suite: 114 passing, 34 pending (already pending before this PR).

⚠️ Needs reviewer decision / verification

  • MAX_FEE_BPS = 500 (5%) was picked by me. Confirm or change it.
  • Fee quoting is still open. quoteOFT returns empty oftFeeDetails (there is a TODO). The fee is taken on the destination, so amountReceivedLD in the receipt doesn't include it, and the UI/slippage handling needs a decision.
  • L-01 behaviour change for integrators: users and the UI must now approve the adapter, not the MinterBurner. Each send does two token transfers. If G$ transfer fees were ever nonzero, bridging would revert on the balance-delta check (intended).
  • M-03 storage layout: the new counters are appended. Run the OZ upgrade validation against the deployed layout before upgrading.
  • DVN choice: confirm Nethermind as the second required DVN (or use Horizen, Canary or Deutsche Telekom). It only takes effect after lz:oapp:wire.
  • oft:verify was not run against a live network: the repo has no OFT deployment artifacts yet. Run it after deploy and wiring.

Document-and-accept (no code change)

  • L-05: residual deploy-time trust, mitigated in fix(oft): audit fixes C-01, H-01, H-02, H-03, M-01a, L-05 #14 by atomic proxy init and verifyOftDeployment.
  • M-04: enable onlyWhitelisted only after Identity is confirmed on both chains. The receive side checks the recipient, so a non-whitelisted recipient's message reverts and stays retryable.
  • approveFailedRequest skips the daily counters on purpose; the owner/optimistic approval is the override.
  • Tumbling 24h window: up to 2× dailyLimit can pass around a window boundary. Set dailyLimit to half the burst you can tolerate.
  • There are no enforced options for msgType 2 (SEND_AND_CALL); composed sends must supply their own options.
  • setDelegate runs 3× during initialize (OApp, OFT and explicit). This is harmless.
  • Ownership transfer is single-step (OwnableUpgradeable). The handover script asserts the owner and delegate afterwards.
  • The constructor doesn't cross-check token against innerToken. The deploy passes the same address, and verifyOftDeployment checks token().
  • Centralization: the Avatar/owner can change fees (≤ MAX_FEE_BPS), limits, pause, peers, DVNs, operators, approve/reject parked requests, and upgrade both proxies.

Summary by Sourcery

Harden the OFT adapter and deployment workflow against remaining medium- and low-severity audit findings while adding verification tooling and regression coverage.

New Features:

  • Add post-deployment OFT invariant verification covering ownership, peers, limits, minting rights, and LayerZero DVN configuration.
  • Require dual LayerZero DVN validation and configure LayerZero production network endpoint IDs.

Bug Fixes:

  • Harden OFT fee and bridge-limit validation, including fee bounds, consistent limit relationships, dust-free enforcement, and safe handling of minimum fees.
  • Preserve retryability of composed messages that exceed receive limits instead of parking and losing their compose payload.
  • Separate inbound and outbound daily bridge counters.
  • Secure token debiting and burning by making the adapter the spender, enforcing caller ownership and balance deltas, and preventing stale-avatar authorization.
  • Fail closed when whitelist identity dependencies are unavailable and disable direct initialization of MinterBurner implementations.

Enhancements:

  • Improve OFT limit and receipt quoting for current sender capacity and bridge state.
  • Route upgrades of Avatar-owned proxies through DAO execution or emit proposal payloads when direct execution is unavailable.
  • Update bridging scripts to approve the adapter and add deployment verification tooling.

Deployment:

  • Update OFT upgrade deployment flow for DAO-controlled proxy ownership and add production endpoint identifiers.

Tests:

  • Add unit, regression, reentrancy, and Celo-fork coverage for fee validation, receive handling, counter isolation, authorization, token burning, whitelist fail-closed behavior, limit quoting, and dust handling.

Chores:

  • Remove obsolete GUID helper code and update test deployments for initializer-disabled MinterBurner implementations.

- M-01: cap fee at MAX_FEE_BPS, require minFee <= maxFee, clamp fee to amount
- M-02: revert composed messages on limit failure instead of parking
- M-03: separate inbound/outbound daily counters
- M-05: require LayerZero Labs + Nethermind DVNs; add eids to production networks
- L-01/L-02: adapter is the spender (approvalRequired true), MinterBurner burns only caller tokens with balance-delta check
- L-03: onlyOperators checks the live Controller avatar
- L-04: disable initializers on the MinterBurner implementation
- L-06: fail closed when whitelist identity is unresolvable; emit NameServiceSet
- L-07: validate setBridgeLimits
- L-08: enforce limits on the dust-free amount
- L-09: quoteOFT returns real min/max limits
- upgradeOFT routes Avatar-owned upgrades through the DAO
- add oft:verify post-deploy invariant task
- remove dead generateGuid

@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 11 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="100-103" />
<code_context>
     INameService public nameService;

+    /// @dev Inbound (receive) bridge daily limit tracking, separate so sends can't exhaust the receive budget
+    BridgeDailyLimit public bridgeReceiveDailyLimit;
+
+    /// @dev Inbound (receive) account daily limit tracking
+    mapping(address => AccountLimit) public accountsReceiveDailyLimit;
+
     /// @dev Error for bridge limits violations
</code_context>
<issue_to_address>
**Inbound limits reset on upgrade**

When the adapter is upgraded while the previous shared counters contain receive usage from the current 24-hour window, the new receive counters start at zero and do not include that usage, so inbound checks grant another daily allowance and accept receives beyond the configured cap.

Preserve the prior inbound usage when introducing the receive counters so the upgrade does not grant a fresh allowance.
</issue_to_address>

### Comment 2
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol" line_range="100-103" />
<code_context>
     INameService public nameService;

+    /// @dev Inbound (receive) bridge daily limit tracking, separate so sends can't exhaust the receive budget
+    BridgeDailyLimit public bridgeReceiveDailyLimit;
+
+    /// @dev Inbound (receive) account daily limit tracking
+    mapping(address => AccountLimit) public accountsReceiveDailyLimit;
+
     /// @dev Error for bridge limits violations
</code_context>
<issue_to_address>
**Upgrade validation rejects storage gap**

When the new implementation is checked against the deployed layout using OpenZeppelin upgrade validation, the two-slot `bridgeReceiveDailyLimit` and one-slot mapping are inserted before the unchanged 50-slot `__gap`, so OpenZeppelin’s upgrade validation sees the old gap displaced instead of reduced and rejects the implementation upgrade.

Reduce the adapter storage gap by the three slots consumed by the appended counters and validate the resulting layout before upgrading.
</issue_to_address>

### Comment 3
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTMinterBurner.sol" line_range="70" />
<code_context>
      * @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');
+        uint256 balanceBefore = token.balanceOf(address(this));
         require(token.transferFrom(_from, address(this), _amount), 'TRANSFER_FROM_FAILED');
</code_context>
<issue_to_address>
**MinterBurner upgrade breaks sends**

When the MinterBurner upgrade executes before the adapter upgrade, including when the DAO executes its proposal first, the old adapter calls `burn(user, amount)`, but the new `GoodDollarOFTMinterBurner.burn` requires `_from == msg.sender`; that call reverts and outbound sends fail until the adapter is upgraded.

Keep the new `burn` compatible with the old adapter, or ensure the two upgrades execute atomically so the old adapter cannot call the new implementation.

Also at `packages/bridge-contracts/deploy/upgradeOFT.ts:109`, `packages/bridge-contracts/deploy/upgradeOFT.ts:159`.
</issue_to_address>

### Comment 4
<location path="packages/bridge-contracts/scripts/oft/verify-invariants.ts" line_range="96-98" />
<code_context>
+    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(", ")})`
+    );
+  }
+
</code_context>
<issue_to_address>
**Untrusted DVNs pass verification**

When a send or receive ULN configuration has at least two required DVNs that are not the intended LayerZero Labs and Nethermind pair, `verifyOftInvariants` checks only `requiredDVNCount`, so configurations using unintended or untrusted providers pass and operators see verification succeed for the wrong security configuration.

Have `verifyOftInvariants` verify the required DVN identities as well as their count.
</issue_to_address>

### Comment 5
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol" line_range="411-420" />
<code_context>
+        }
+        oftLimit = OFTLimit(bridgeLimits.minAmount, maxAmount);
+
+        // TODO: destination-chain bridge fee quoting is an open decision; fees are left out of the quote for now
+        oftFeeDetails = new OFTFeeDetail[](0);
+
+        (uint256 amountSentLD, uint256 amountReceivedLD) = _debitView(
+            _sendParam.amountLD,
+            _sendParam.minAmountLD,
+            _sendParam.dstEid
+        );
+        oftReceipt = OFTReceipt(amountSentLD, amountReceivedLD);
+    }

     /**
</code_context>
<issue_to_address>
**Quotes overstate received tokens**

When a nonzero destination-chain bridge fee is configured and a caller uses `quoteOFT` to estimate destination proceeds, `quoteOFT` reports `_debitView`’s gross amount as `amountReceivedLD` and returns no fee details, while `_credit` deducts the destination fee; callers therefore display more tokens than the recipient receives and cannot derive accurate slippage.

Include the destination fee in the quote’s receipt amount and return the corresponding fee details.
</issue_to_address>

### Comment 6
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol" line_range="395" />
<code_context>
+        returns (OFTLimit memory oftLimit, OFTFeeDetail[] memory oftFeeDetails, OFTReceipt memory oftReceipt)
+    {
+        uint256 maxAmount;
+        if (!isClosed) {
+            BridgeLimits memory limits = bridgeLimits;
+            uint256 dailyUsed = bridgeDailyLimit.lastTransferReset < block.timestamp - 1 days
</code_context>
<issue_to_address>
**Ineligible senders get positive quotes**

When whitelist mode is enabled and the caller is not eligible, including when Identity cannot be resolved, `quoteOFT` calculates a positive maximum without applying the eligibility check used by `_checkBridgeClosedAndWhitelisted`; the quote shows an available amount even though the send is rejected.

Apply the same whitelist and Identity eligibility check in `quoteOFT` that the send path uses.

Also at `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:396-409`.
</issue_to_address>

### Comment 7
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol" line_range="210" />
<code_context>
     function setBridgeFees(BridgeFees memory _fees) external onlyOwner {
-        require(_fees.fee <= 10000, 'invalid fee');
+        require(_fees.fee <= MAX_FEE_BPS, 'invalid fee');
+        require(_fees.maxFee == 0 || _fees.minFee <= _fees.maxFee, 'minFee > maxFee');
         bridgeFees = _fees;
         emit BridgeFeesSet(_fees.minFee, _fees.maxFee, _fees.fee);
</code_context>
<issue_to_address>
**Stored fees exceed new cap**

When the proxy is upgraded with a previously configured `bridgeFees.fee` above 500 BPS, `setBridgeFees` caps only future updates, while `_takeFee` applies the stored value; receives therefore continue charging above the new maximum.

Ensure the fee applied by `_takeFee` cannot exceed the new cap for values already stored at upgrade.
</issue_to_address>

### Comment 8
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol" line_range="209-210" />
<code_context>
      */
     function setBridgeFees(BridgeFees memory _fees) external onlyOwner {
-        require(_fees.fee <= 10000, 'invalid fee');
+        require(_fees.fee <= MAX_FEE_BPS, 'invalid fee');
+        require(_fees.maxFee == 0 || _fees.minFee <= _fees.maxFee, 'minFee > maxFee');
         bridgeFees = _fees;
         emit BridgeFeesSet(_fees.minFee, _fees.maxFee, _fees.fee);
</code_context>
<issue_to_address>
**Minimum fee bypasses percentage cap**

When the owner configures a `minFee` at least as large as a bridged amount, or above 5% of that amount with `maxFee` set to zero, `setBridgeFees` permits the minimum fee, and `_takeFee` can clamp it only to the transfer amount; the resulting fee exceeds the 5% cap and can consume the entire transfer.

Enforce the 5% cap on the final fee charged, including the `minFee` floor.

Also at `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:238-241`.
</issue_to_address>

### Comment 9
<location path="packages/bridge-contracts/test/GoodDollarOFT.test.ts" line_range="424" />
<code_context>
+        adapter
+          .connect(user)
+          .send({ ...makeSendParam(user.address, amount), minAmountLD: 0 }, messagingFee, user.address, { value: 0 })
+      ).to.not.be.revertedWithCustomError(adapter, "BRIDGE_LIMITS");
+
       await expect(
</code_context>
<issue_to_address>
**Dust-free send test accepts failures**

When the first send reverts for any reason other than `BRIDGE_LIMITS`, including a missing adapter allowance, the assertion only excludes the `BRIDGE_LIMITS` custom error, so another revert passes and the test does not show that the dust-free amount passed the limit check.

Approve the adapter in the fixture and assert that the first send does not revert.
</issue_to_address>

### Comment 10
<location path="packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol" line_range="526" />
<code_context>
+        innerToken.safeTransferFrom(_from, address(this), amountSentLD);
+        require(innerToken.balanceOf(address(this)) - balanceBefore == amountSentLD, 'received amount mismatch');
+        innerToken.forceApprove(address(minterBurner), amountSentLD);
+        require(minterBurner.burn(address(this), amountSentLD), 'GoodDollarOFTAdapter: Burn failed');
     }

</code_context>
<issue_to_address>
**Burn events lose the token owner**

When an integrator uses `TokensBurned.from` to attribute bridge sends, the adapter calls `minterBurner.burn(address(this), amountSentLD)`, so `TokensBurned.from` records the adapter rather than the user whose tokens were pulled; event consumers no longer see the originating account.

Preserve the originating sender in a separate event field or emit an adapter event that includes the original `_from` address.
</issue_to_address>

### Comment 11
<location path="packages/bridge-contracts/test/GoodDollarOFT.e2e.test.ts" line_range="336-352" />
<code_context>
     });

     it('Should enforce maxFee when calculated fee exceeds maxFee', async function () {
-      // Set fee to 10% with maxFee of 50 tokens
+      // Set fee to 5% with maxFee of 20 tokens
       await adapter.setBridgeFees({
         minFee: ethers.utils.parseEther('0'),
-        maxFee: ethers.utils.parseEther('50'),
-        fee: 1000, // 10%
+        maxFee: ethers.utils.parseEther('20'),
+        fee: 500, // 5%
       });

-      // For 1000 tokens: 1000 * 1000 / 10000 = 100 tokens (above maxFee of 50)
</code_context>
<issue_to_address>
**Fee cap test ignores contract result**

`Should enforce maxFee when calculated fee exceeds maxFee` only computes `calculatedFee` and compares it with a local `maxFee`; it never checks the adapter’s fee. The test passes even if the adapter ignores `maxFee` and charges the full calculated fee.

Exercise the fee path and assert the fee recipient receives exactly `maxFee` and the recipient receives the net amount.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 10 findings to address first, and this changes the bridge's token-debit authorization, DAO-controlled upgrade path, whitelist behavior, DVN requirements, and inbound/outbound limit policies; a mistake could burn or release tokens, permit or block transfers, or leave production proxies running the wrong implementation. Reverting would not undo tokens already burned or messages already processed, and several changes are policy or trust-boundary decisions that take effect immediately for all users.

Blocking findings: packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:103, packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:103, packages/bridge-contracts/contracts/oft/GoodDollarOFTMinterBurner.sol:70, packages/bridge-contracts/scripts/oft/verify-invariants.ts:98, packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:420, and 5 more


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

Comment on lines +100 to +103
BridgeDailyLimit public bridgeReceiveDailyLimit;

/// @dev Inbound (receive) account daily limit tracking
mapping(address => AccountLimit) public accountsReceiveDailyLimit;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Inbound limits reset on upgrade

When the adapter is upgraded while the previous shared counters contain receive usage from the current 24-hour window, the new receive counters start at zero and do not include that usage, so inbound checks grant another daily allowance and accept receives beyond the configured cap.

Preserve the prior inbound usage when introducing the receive counters so the upgrade does not grant a fresh allowance.

Prompt for AI agents
In `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol` at lines 100-103:

**Inbound limits reset on upgrade**

When the adapter is upgraded while the previous shared counters contain receive usage from the current 24-hour window, the new receive counters start at zero and do not include that usage, so inbound checks grant another daily allowance and accept receives beyond the configured cap.

Preserve the prior inbound usage when introducing the receive counters so the upgrade does not grant a fresh allowance.

Comment on lines +100 to +103
BridgeDailyLimit public bridgeReceiveDailyLimit;

/// @dev Inbound (receive) account daily limit tracking
mapping(address => AccountLimit) public accountsReceiveDailyLimit;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Upgrade validation rejects storage gap

When the new implementation is checked against the deployed layout using OpenZeppelin upgrade validation, the two-slot bridgeReceiveDailyLimit and one-slot mapping are inserted before the unchanged 50-slot __gap, so OpenZeppelin’s upgrade validation sees the old gap displaced instead of reduced and rejects the implementation upgrade.

Reduce the adapter storage gap by the three slots consumed by the appended counters and validate the resulting layout before upgrading.

Prompt for AI agents
In `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol` at lines 100-103:

**Upgrade validation rejects storage gap**

When the new implementation is checked against the deployed layout using OpenZeppelin upgrade validation, the two-slot `bridgeReceiveDailyLimit` and one-slot mapping are inserted before the unchanged 50-slot `__gap`, so OpenZeppelin’s upgrade validation sees the old gap displaced instead of reduced and rejects the implementation upgrade.

Reduce the adapter storage gap by the three slots consumed by the appended counters and validate the resulting layout before upgrading.

* @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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · MinterBurner upgrade breaks sends

When the MinterBurner upgrade executes before the adapter upgrade, including when the DAO executes its proposal first, the old adapter calls burn(user, amount), but the new GoodDollarOFTMinterBurner.burn requires _from == msg.sender; that call reverts and outbound sends fail until the adapter is upgraded.

Keep the new burn compatible with the old adapter, or ensure the two upgrades execute atomically so the old adapter cannot call the new implementation.

Also at packages/bridge-contracts/deploy/upgradeOFT.ts:109, packages/bridge-contracts/deploy/upgradeOFT.ts:159.

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

**MinterBurner upgrade breaks sends**

When the MinterBurner upgrade executes before the adapter upgrade, including when the DAO executes its proposal first, the old adapter calls `burn(user, amount)`, but the new `GoodDollarOFTMinterBurner.burn` requires `_from == msg.sender`; that call reverts and outbound sends fail until the adapter is upgraded.

Keep the new `burn` compatible with the old adapter, or ensure the two upgrades execute atomically so the old adapter cannot call the new implementation.

Also at `packages/bridge-contracts/deploy/upgradeOFT.ts:109`, `packages/bridge-contracts/deploy/upgradeOFT.ts:159`.

Comment on lines +96 to +98
uln.requiredDVNCount >= MIN_REQUIRED_DVNS && uln.requiredDVNCount !== 255,
`${side} ULN requiredDVNCount ${uln.requiredDVNCount} >= ${MIN_REQUIRED_DVNS} (${uln.requiredDVNs.join(", ")})`
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Untrusted DVNs pass verification

When a send or receive ULN configuration has at least two required DVNs that are not the intended LayerZero Labs and Nethermind pair, verifyOftInvariants checks only requiredDVNCount, so configurations using unintended or untrusted providers pass and operators see verification succeed for the wrong security configuration.

Have verifyOftInvariants verify the required DVN identities as well as their count.

Prompt for AI agents
In `packages/bridge-contracts/scripts/oft/verify-invariants.ts` at lines 96-98:

**Untrusted DVNs pass verification**

When a send or receive ULN configuration has at least two required DVNs that are not the intended LayerZero Labs and Nethermind pair, `verifyOftInvariants` checks only `requiredDVNCount`, so configurations using unintended or untrusted providers pass and operators see verification succeed for the wrong security configuration.

Have `verifyOftInvariants` verify the required DVN identities as well as their count.

Comment on lines +411 to +420
// TODO: destination-chain bridge fee quoting is an open decision; fees are left out of the quote for now
oftFeeDetails = new OFTFeeDetail[](0);

(uint256 amountSentLD, uint256 amountReceivedLD) = _debitView(
_sendParam.amountLD,
_sendParam.minAmountLD,
_sendParam.dstEid
);
oftReceipt = OFTReceipt(amountSentLD, amountReceivedLD);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Quotes overstate received tokens

When a nonzero destination-chain bridge fee is configured and a caller uses quoteOFT to estimate destination proceeds, quoteOFT reports _debitView’s gross amount as amountReceivedLD and returns no fee details, while _credit deducts the destination fee; callers therefore display more tokens than the recipient receives and cannot derive accurate slippage.

Include the destination fee in the quote’s receipt amount and return the corresponding fee details.

Prompt for AI agents
In `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol` at lines 411-420:

**Quotes overstate received tokens**

When a nonzero destination-chain bridge fee is configured and a caller uses `quoteOFT` to estimate destination proceeds, `quoteOFT` reports `_debitView`’s gross amount as `amountReceivedLD` and returns no fee details, while `_credit` deducts the destination fee; callers therefore display more tokens than the recipient receives and cannot derive accurate slippage.

Include the destination fee in the quote’s receipt amount and return the corresponding fee details.

function setBridgeFees(BridgeFees memory _fees) external onlyOwner {
require(_fees.fee <= 10000, 'invalid fee');
require(_fees.fee <= MAX_FEE_BPS, 'invalid fee');
require(_fees.maxFee == 0 || _fees.minFee <= _fees.maxFee, 'minFee > maxFee');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Stored fees exceed new cap

When the proxy is upgraded with a previously configured bridgeFees.fee above 500 BPS, setBridgeFees caps only future updates, while _takeFee applies the stored value; receives therefore continue charging above the new maximum.

Ensure the fee applied by _takeFee cannot exceed the new cap for values already stored at upgrade.

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

**Stored fees exceed new cap**

When the proxy is upgraded with a previously configured `bridgeFees.fee` above 500 BPS, `setBridgeFees` caps only future updates, while `_takeFee` applies the stored value; receives therefore continue charging above the new maximum.

Ensure the fee applied by `_takeFee` cannot exceed the new cap for values already stored at upgrade.

Comment on lines +209 to +210
require(_fees.fee <= MAX_FEE_BPS, 'invalid fee');
require(_fees.maxFee == 0 || _fees.minFee <= _fees.maxFee, 'minFee > maxFee');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Minimum fee bypasses percentage cap

When the owner configures a minFee at least as large as a bridged amount, or above 5% of that amount with maxFee set to zero, setBridgeFees permits the minimum fee, and _takeFee can clamp it only to the transfer amount; the resulting fee exceeds the 5% cap and can consume the entire transfer.

Enforce the 5% cap on the final fee charged, including the minFee floor.

Also at packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:238-241.

Prompt for AI agents
In `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol` at lines 209-210:

**Minimum fee bypasses percentage cap**

When the owner configures a `minFee` at least as large as a bridged amount, or above 5% of that amount with `maxFee` set to zero, `setBridgeFees` permits the minimum fee, and `_takeFee` can clamp it only to the transfer amount; the resulting fee exceeds the 5% cap and can consume the entire transfer.

Enforce the 5% cap on the final fee charged, including the `minFee` floor.

Also at `packages/bridge-contracts/contracts/oft/GoodDollarOFTAdapter.sol:238-241`.

adapter
.connect(user)
.send({ ...makeSendParam(user.address, amount), minAmountLD: 0 }, messagingFee, user.address, { value: 0 })
).to.not.be.revertedWithCustomError(adapter, "BRIDGE_LIMITS");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Dust-free send test accepts failures

When the first send reverts for any reason other than BRIDGE_LIMITS, including a missing adapter allowance, the assertion only excludes the BRIDGE_LIMITS custom error, so another revert passes and the test does not show that the dust-free amount passed the limit check.

Approve the adapter in the fixture and assert that the first send does not revert.

Prompt for AI agents
In `packages/bridge-contracts/test/GoodDollarOFT.test.ts` at line 424:

**Dust-free send test accepts failures**

When the first send reverts for any reason other than `BRIDGE_LIMITS`, including a missing adapter allowance, the assertion only excludes the `BRIDGE_LIMITS` custom error, so another revert passes and the test does not show that the dust-free amount passed the limit check.

Approve the adapter in the fixture and assert that the first send does not revert.

innerToken.safeTransferFrom(_from, address(this), amountSentLD);
require(innerToken.balanceOf(address(this)) - balanceBefore == amountSentLD, 'received amount mismatch');
innerToken.forceApprove(address(minterBurner), amountSentLD);
require(minterBurner.burn(address(this), amountSentLD), 'GoodDollarOFTAdapter: Burn failed');

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 · Burn events lose the token owner

When an integrator uses TokensBurned.from to attribute bridge sends, the adapter calls minterBurner.burn(address(this), amountSentLD), so TokensBurned.from records the adapter rather than the user whose tokens were pulled; event consumers no longer see the originating account.

Preserve the originating sender in a separate event field or emit an adapter event that includes the original _from address.

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

**Burn events lose the token owner**

When an integrator uses `TokensBurned.from` to attribute bridge sends, the adapter calls `minterBurner.burn(address(this), amountSentLD)`, so `TokensBurned.from` records the adapter rather than the user whose tokens were pulled; event consumers no longer see the originating account.

Preserve the originating sender in a separate event field or emit an adapter event that includes the original `_from` address.

Comment on lines 336 to 352
it('Should enforce maxFee when calculated fee exceeds maxFee', async function () {
// Set fee to 10% with maxFee of 50 tokens
// Set fee to 5% with maxFee of 20 tokens
await adapter.setBridgeFees({
minFee: ethers.utils.parseEther('0'),
maxFee: ethers.utils.parseEther('50'),
fee: 1000, // 10%
maxFee: ethers.utils.parseEther('20'),
fee: 500, // 5%
});

// For 1000 tokens: 1000 * 1000 / 10000 = 100 tokens (above maxFee of 50)
// So fee should be 50 tokens (maxFee)
// For 1000 tokens: 1000 * 500 / 10000 = 50 tokens (above maxFee of 20)
// So fee should be 20 tokens (maxFee)
const amount = ethers.utils.parseEther('1000');
const calculatedFee = amount.mul(1000).div(10000);
const maxFee = ethers.utils.parseEther('50');
const calculatedFee = amount.mul(500).div(10000);
const maxFee = ethers.utils.parseEther('20');

// The actual fee should be maxFee (50) since calculated (100) > maxFee (50)
// The actual fee should be maxFee (20) since calculated (50) > maxFee (20)
expect(calculatedFee.gt(maxFee)).to.be.true;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Fee cap test ignores contract result

Should enforce maxFee when calculated fee exceeds maxFee only computes calculatedFee and compares it with a local maxFee; it never checks the adapter’s fee. The test passes even if the adapter ignores maxFee and charges the full calculated fee.

Exercise the fee path and assert the fee recipient receives exactly maxFee and the recipient receives the net amount.

Prompt for AI agents
In `packages/bridge-contracts/test/GoodDollarOFT.e2e.test.ts` at lines 336-352:

**Fee cap test ignores contract result**

`Should enforce maxFee when calculated fee exceeds maxFee` only computes `calculatedFee` and compares it with a local `maxFee`; it never checks the adapter’s fee. The test passes even if the adapter ignores `maxFee` and charges the full calculated fee.

Exercise the fee path and assert the fee recipient receives exactly `maxFee` and the recipient receives the net amount.

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