Deuteronomy 8:18

Security

Authorization (IDOR) audit

2 July 2026 · scope: IDOR / authorization across DepositRouter, UnwindRouter, RateLens, FeeManager · verdict: all safe

This audit targeted a single question: can a caller act on another user's position, or redirect another user's funds, via an address / receiver / recipient / index parameter (an IDOR-class bug)? The answer is no. Every fund-moving external function sources value from msg.sender (a balance the caller owns and has approved), and any receiver parameter (to / recipient) only redirects the caller's own funds. G1 is non-custodial and holds no positions, so there is no position registry to reference — IDOR is structurally impossible on the exit/claim side. Six adversarial Foundry tests attempt the exploits and prove they fail.

Findings

FunctionParamsAuthorizationVerdict
UnwindRouter.unwind
UnwindRouter.sol:75
shares, toshares pulled via safeTransferFrom(msg.sender, …) (:105); to redirects only the caller's own proceeds (:106); nonReentrantSafe
DepositRouter.depositGrow / depositFixed
:547,558,569,579
asset, amountfunder = recipient = msg.sender (:553/564/575/585)Safe
DepositRouter.depositGrowFor / depositFixedFor
:603,616,632,645
recipientrequires bridgeLanesEnabled (:609) AND msg.sender == bridgeExecutor (:610); funder = msg.sender (:612)Safe
DepositRouter route / guardian / bridge admin
:219–534
asset, vault, guardian, executoronlyOwner; pause/unpause onlyGuardian (:308,313)Owner-gated
FeeManager fee / destination timelock
FeeManager.sol:77–132
newBps, newDestinationonlyOwner + propose→wait FEE_TIMELOCK→commitOwner-gated
RateLens price / fixedQuote / growVaultState / ftsoV2
RateLens.sol:41–98
vault, pt, poolall view — no state, no fundsNo surface

Adversarial tests (all passing)

6 passed / 0 failed. Source: test/IdorAuthorization.t.sol (embedded verbatim below).

Informational notes (not vulnerabilities)

Scope & caveats

This audit covers authorization / IDOR only. It does not re-cover the economic and slippage guarantees (min-out, the FTSO fair-price floor, fee conservation, atomicity — those live in the Foundry fuzz / mutation / fork suites), nor the trust assumptions of the external protocols G1 routes into (ICHI, Spectra, SparkDEX). Those are separate threat classes with their own tests. As stated on the main security page, an independent human audit has not yet taken place; a formal third-party contest audit is planned, and we will not claim an audit that has not happened.

Evidence — full test source

Embedded verbatim from packages/contracts/test/IdorAuthorization.t.sol. Run it with forge test --match-path test/IdorAuthorization.t.sol.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {DepositRouter} from "../src/DepositRouter.sol";
import {UnwindRouter} from "../src/UnwindRouter.sol";
import {MockERC20} from "./mocks/MockERC20.sol";
import {MockICHIVault, MockUniPool} from "./mocks/MockICHIVault.sol";
import {MockUnwindVault} from "./mocks/MockUnwindVault.sol";

/// @title  IDOR / authorization audit — proofs
/// @notice Adversarial tests for the audit's central question: can a caller act on
///         ANOTHER user's position, or redirect ANOTHER user's funds, via an
///         address / receiver / recipient parameter?
///
///         The invariant these tests prove: every fund-moving external function
///         sources value from `msg.sender` (an approved balance the caller owns),
///         and a receiver parameter (`to` / `recipient`) only ever redirects the
///         CALLER'S OWN funds. No position-ID / index / receiver arg lets an
///         attacker touch a victim's position. G1 is non-custodial and holds no
///         positions, so there is no position registry to IDOR against.
///
///         Non-fork unit tests (mock venues) so they run in the default suite.
contract IdorAuthorizationTest is Test {
    // config mirrors ForkBase / MoneyPathFuzz
    uint16 constant FEE_BPS = 30;
    uint16 constant MAX_FEE_BPS = 100;
    uint256 constant TIMELOCK = 2 days;
    uint16 constant SLIP_BPS = 300;
    uint16 constant SLIP_CAP = 1000;

    address feeDest = makeAddr("feeDest");
    address guardian = makeAddr("guardian");
    address victim = makeAddr("victim");
    address attacker = makeAddr("attacker");
    address executor = makeAddr("executor");

    /* ------------------------------------------------------------------ */
    /*  UnwindRouter.unwind(vault, shares, min0, min1, to)  [src line 75]  */
    /* ------------------------------------------------------------------ */

    function _unwindEnv() internal returns (UnwindRouter u, MockUnwindVault vault, MockERC20 t0, MockERC20 t1) {
        u = new UnwindRouter(SLIP_BPS, SLIP_CAP);
        t0 = new MockERC20("T0", "T0", 18);
        t1 = new MockERC20("T1", "T1", 18);
        vault = new MockUnwindVault(address(t0), address(t1), 1_000e18, 1_000e18);
        // fund the vault so its withdraw() can actually pay out the underlying
        t0.mint(address(vault), 1_000e18);
        t1.mint(address(vault), 1_000e18);
    }

    /// EXPLOIT ATTEMPT — attacker tries to unwind the victim's position (even with
    /// the victim having approved the router) and redirect the proceeds to himself.
    /// unwind() pulls shares via safeTransferFrom(msg.sender,...) [src line 105], so
    /// the attacker can only ever spend HIS OWN shares — of which he has none.
    function test_unwind_attacker_cannot_take_victim_position() public {
        (UnwindRouter u, MockUnwindVault vault,,) = _unwindEnv();
        vault.mint(victim, 100e18);
        // worst case: victim has left a max approval to the UnwindRouter
        vm.prank(victim);
        IERC20(address(vault)).approve(address(u), type(uint256).max);

        vm.prank(attacker);
        vm.expectRevert(); // ERC20 insufficient balance: shares are pulled from the attacker
        u.unwind(address(vault), 100e18, 0, 0, attacker);

        // victim's position is fully intact; nothing moved
        assertEq(vault.balanceOf(victim), 100e18, "victim shares were moved");
        assertEq(vault.balanceOf(attacker), 0, "attacker gained shares");
    }

    /// POSITIVE CONTROL — the owner of the shares CAN redeem them, and may direct
    /// the proceeds to any address (their choice). The `to` param redirects only
    /// the caller's OWN redeemed funds; it is not an authorization hole.
    function test_unwind_owner_redeems_and_may_choose_recipient() public {
        (UnwindRouter u, MockUnwindVault vault, MockERC20 t0, MockERC20 t1) = _unwindEnv();
        vault.mint(victim, 100e18);
        address chosen = makeAddr("victimChosenDest");

        vm.startPrank(victim);
        IERC20(address(vault)).approve(address(u), 100e18);
        u.unwind(address(vault), 100e18, 0, 0, chosen);
        vm.stopPrank();

        assertEq(vault.balanceOf(victim), 0, "victim shares not burned");
        assertGt(t0.balanceOf(chosen), 0, "proceeds not delivered to chosen dest");
        assertGt(t1.balanceOf(chosen), 0, "proceeds not delivered to chosen dest");
    }

    /* ------------------------------------------------------------------ */
    /*  DepositRouter — deposit + depositXFor(recipient,...)              */
    /* ------------------------------------------------------------------ */

    function _routerWithGrow() internal returns (DepositRouter r, MockERC20 asset, MockICHIVault vault) {
        r = new DepositRouter(
            FEE_BPS, feeDest, MAX_FEE_BPS, TIMELOCK, address(1), address(2), address(3), guardian, SLIP_BPS, SLIP_CAP
        );
        asset = new MockERC20("A", "A", 18);
        MockERC20 assetB = new MockERC20("B", "B", 18);
        MockUniPool vpool = new MockUniPool(address(asset));
        vault = new MockICHIVault(address(asset), address(assetB), true, false, address(vpool), 2_000e18, 1_000e18, 1_000e18);
        r.setGrowRoute(address(asset), address(vault), true); // owner == this
    }

    /// EXPLOIT ATTEMPT — an attacker tries to spend a victim's approved balance by
    /// calling the plain deposit entry. _depositGrow pulls from `funder`, which every
    /// external entry hardcodes to msg.sender [src lines 553/564/612/...], so the
    /// pull comes from the attacker (who has nothing), never the victim.
    function test_deposit_cannot_pull_a_victims_approved_funds() public {
        (DepositRouter r, MockERC20 asset,) = _routerWithGrow();
        asset.mint(victim, 1_000e18);
        vm.prank(victim);
        asset.approve(address(r), type(uint256).max);

        vm.prank(attacker);
        vm.expectRevert(); // pulls from attacker (msg.sender), who has no balance/allowance
        r.depositGrow(address(asset), 100e18, 0);

        assertEq(asset.balanceOf(victim), 1_000e18, "victim funds were pulled by attacker's call");
    }

    /// depositGrowFor is DARK by default — the on-behalf path reverts for everyone
    /// until the owner flips the lane on. [src line 609]
    function test_depositGrowFor_disabled_by_default() public {
        (DepositRouter r, MockERC20 asset,) = _routerWithGrow();
        vm.prank(attacker);
        vm.expectRevert(DepositRouter.LanesDisabled.selector);
        r.depositGrowFor(victim, address(asset), 100e18, 0);
    }

    /// Even with the lane live, ONLY the owner-registered bridge executor may drive
    /// depositGrowFor — an arbitrary caller is rejected. [src line 610]
    function test_depositGrowFor_only_registered_executor() public {
        (DepositRouter r, MockERC20 asset,) = _routerWithGrow();
        r.setBridgeLanesEnabled(true);
        r.setBridgeExecutor(executor);

        vm.prank(attacker);
        vm.expectRevert(DepositRouter.NotBridgeExecutor.selector);
        r.depositGrowFor(victim, address(asset), 100e18, 0);
    }

    /// The KEY on-behalf proof: even the authorized executor spends its OWN funds.
    /// The `recipient` param sets where the position is minted — it CANNOT make the
    /// router pull the recipient's (victim's) balance. _depositGrow pulls from
    /// funder == msg.sender == executor [src line 679].
    function test_depositGrowFor_executor_spends_own_not_recipients_balance() public {
        (DepositRouter r, MockERC20 asset, MockICHIVault vault) = _routerWithGrow();
        r.setBridgeLanesEnabled(true);
        r.setBridgeExecutor(executor);

        // recipient (victim) is funded AND has approved the router; executor has nothing
        asset.mint(victim, 1_000e18);
        vm.prank(victim);
        asset.approve(address(r), type(uint256).max);

        vm.prank(executor);
        vm.expectRevert(); // pull is from the executor (msg.sender), who has no funds
        r.depositGrowFor(victim, address(asset), 100e18, 0);
        assertEq(asset.balanceOf(victim), 1_000e18, "recipient's balance was pulled via the recipient param");

        // POSITIVE CONTROL — executor funds ITSELF, deposits on behalf: funds leave
        // the executor, the position is minted to the recipient, victim's own balance
        // is untouched. This is the intended non-custodial "on behalf" behaviour.
        asset.mint(executor, 100e18);
        vm.startPrank(executor);
        asset.approve(address(r), 100e18);
        r.depositGrowFor(victim, address(asset), 100e18, 0);
        vm.stopPrank();

        assertEq(asset.balanceOf(executor), 0, "executor did not fund its own on-behalf deposit");
        assertGt(vault.balanceOf(victim), 0, "position was not minted to the recipient");
        assertEq(asset.balanceOf(victim), 1_000e18, "recipient's own balance was touched");
    }
}