# Audit report

> Security audit of Tally v0: credit lines for AI agents paying over x402, settled in USDC. CreditPool is an ERC-4626 vault that funds payments; TabManager executes agent-signed EIP-712 payment intents against owner collateral and mandates (per-payment cap, budget, expiry, Merkle seller allowlist), bills weekly statements and liquidates past-due lines; sponsored lines let one sponsor deposit back many agent lines, each with its own cap (NO_CAP, or 0 to freeze), and liquidation draws on the sponsor deposit; Escrow holds larger payments for review and dispute; ScoreRegistry stores one Merkle root of agent credit scores per daily epoch and verifies proofs on chain. Focus on: loss of lender or owner funds, accounting drift between pool.outstanding and line balances, signature replay across lines/epochs/chains, mandate budget bypass, sponsor accounting (outstanding vs. the sum of its lines, withdrawals that leave lines unbacked, cap bypass), ScoreRegistry epoch ordering, revocation and stale-record handling, escrow states that lock funds, rounding in fees and ERC-4626 share math, and reentrancy through USDC or ERC-1271 wallets. Prior reviews and fixed findings are in audits/. Report each finding with severity, location, a concrete exploit path and a fix.

| | |
|---|---|
| Repository | https://github.com/Tally-402/tally-contracts |
| Commit | `24bfa2aaab606735c557c08a00c2628d4575f2e0` |
| Job | `24f0a52b-05e5-4f64-9781-97b23c84b8e3` |
| Judged | 2026-10-24 02:05 UTC |
| Findings | 6 low · 3 info |

Four agents audited the code as it is at `24bfa2a`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: ScoreRegistry: a revoked epoch can never be republished, so revoking today's snapshot leaves no live root until the next UTC day

`src/ScoreRegistry.sol:83`

```
        if (epoch <= latestEpoch) revert EpochNotNewer(epoch, latestEpoch);
```

publish() requires epoch > latestEpoch and epoch <= block.timestamp / 1 days, and revoke() leaves latestEpoch unchanged. Once today's epoch is published and revoked (the documented recovery for a bad snapshot or one pushed by a leaked publisher key), the publisher cannot publish a corrected root for the same day (EpochNotNewer) nor tomorrow's (EpochInFuture). Until 00:00 UTC verify() is false for every leaf of that day, record() for that day is impossible, scoreOf() reports every subject whose record points at the revoked epoch as found=false, and latestEpoch points at a dead root. A holder of a leaked publisher key can repeat this at the start of every UTC day until the role is rotated, so the NatSpec claim that 'a leaked publisher key can't block future snapshots' holds only for later days. Merged from four specialists (audit_math, audit_permissions, audit_flow, audit_economics). Fix: allow publish() to overwrite an epoch whose record is revoked (accept epoch == latestEpoch when _epochs[epoch].revoked), and to keep records made under the bad root dead, add a generation counter to Epoch and Recorded (publish bumps it; record() stores it; scoreOf() and the stale-record check treat a record whose generation differs from the epoch's current generation as revoked). Alternatively have revoke() of the latest epoch roll latestEpoch back to the highest non-revoked epoch.

**Reproduction**

vm.warp(1_790_000_000) (day 20717). publisher: publish(20717, root1, now, 10, 1, uri) succeeds, latestEpoch = 20717. admin: revoke(20717). publisher: publish(20717, root2, now, 10, 1, uri2). Expected: corrected snapshot accepted. Actual: revert EpochNotNewer(20717, 20717); publish(20718, ...) reverts EpochInFuture(20718); getEpoch(20717).revoked == true and no live root exists for the day. Reproduced with the attached test, which fails on current code with EpochNotNewer(20717, 20717).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

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

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

/// Revoking the snapshot published for today leaves `latestEpoch` at today, so the publisher cannot
/// publish a corrected root for today (EpochNotNewer) and cannot publish tomorrow's (EpochInFuture).
/// Expected: a corrected snapshot can replace a revoked one for the same day.
contract ProofF1 is Test {
    ScoreRegistry reg;
    address admin = makeAddr("admin");
    address publisher = makeAddr("publisher");

    function setUp() public {
        vm.warp(1_790_000_000);
        reg = new ScoreRegistry(admin, publisher);
    }

    function test_CorrectedSnapshotCanReplaceRevokedToday() public {
        uint64 today = uint64(block.timestamp / 1 days);
        vm.prank(publisher);
        reg.publish(today, bytes32(uint256(1)), uint64(block.timestamp), 10, 1, "ipfs://bad");
        vm.prank(admin);
        reg.revoke(today);

        // Fails on current code with EpochNotNewer(today, today).
        vm.prank(publisher);
        reg.publish(today, bytes32(uint256(2)), uint64(block.timestamp), 10, 1, "ipfs://fixed");

        ScoreRegistry.Epoch memory e = reg.getEpoch(today);
        assertEq(e.root, bytes32(uint256(2)), "corrected root published");
        assertFalse(e.revoked, "replacement is live");
    }
}
```

### 2. Low: ScoreRegistry.record: once the recorded epoch is revoked, any older live epoch is accepted, so anyone can roll a subject back to an arbitrarily old favourable score

`src/ScoreRegistry.sol:120`

```
        if (epoch <= r.epoch && !_epochs[r.epoch].revoked) revert StaleRecord(epoch, r.epoch);
```

The stale-record guard is skipped entirely when the subject's currently recorded epoch is revoked. It does not require the replacement to be the newest live epoch, nor newer than the epoch the subject was recorded under before the revoked one. record() is permissionless, so the subject (or anyone benefiting from their score looking good) can pick the oldest live epoch in which the subject had a high score and record it; scoreOf() then returns found=true with that score until someone records a newer live epoch. Any on-chain consumer that reads scoreOf() without enforcing its own asOf bound acts on a score the Score engine superseded long ago. Fix: when r.epoch is revoked, still require epoch to be greater than the last non-revoked epoch recorded for the subject (keep the previous live Recorded alongside the current one and fall back to it deterministically on revocation), and document that scoreOf() consumers must bound asOf regardless.

**Reproduction**

Epochs 1, 2, 3 published on consecutive days with alice's scores 950, 400, 350 (two-leaf trees). record(2, alice) then record(3, alice): scoreOf(alice) = (true, 350, epoch 3). admin: revoke(3). Anyone: record(1, leaf(alice, 950), proof1). Expected: rejected (StaleRecord) because epoch 2 is live, newer than 1 and already recorded for alice, or scoreOf falls back to epoch 2. Actual: record(1) succeeds and scoreOf(alice) returns (true, 950, agentLikely, epoch 1, asOf of epoch 1). Reproduced with the attached test, which fails on current code with 'next call did not revert as expected'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

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

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

/// After the recorded epoch is revoked, `record` accepts ANY older live epoch, so anyone can roll a subject
/// back to an arbitrarily old, more favourable score even though a newer live epoch exists.
/// Expected: an epoch older than a newer live epoch that holds the subject is rejected (StaleRecord) or
/// the registry falls back deterministically to the previous live record.
contract ProofF2 is Test {
    ScoreRegistry reg;
    address admin = makeAddr("admin");
    address publisher = makeAddr("publisher");
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function _leaf(address who, uint16 score) internal pure returns (ScoreRegistry.ScoreLeaf memory) {
        return ScoreRegistry.ScoreLeaf(who, score, 200, 150, 100, 120, 200, 0, true, 12345, 1_780_000_000);
    }

    function _hash(uint64 epoch, ScoreRegistry.ScoreLeaf memory l) internal pure returns (bytes32) {
        return keccak256(bytes.concat(keccak256(abi.encode(epoch, l))));
    }

    function _publishTwo(uint64 epoch, ScoreRegistry.ScoreLeaf memory a, ScoreRegistry.ScoreLeaf memory b)
        internal
        returns (bytes32[] memory pa)
    {
        bytes32 ha = _hash(epoch, a);
        bytes32 hb = _hash(epoch, b);
        bytes32 root = ha < hb ? keccak256(abi.encode(ha, hb)) : keccak256(abi.encode(hb, ha));
        vm.prank(publisher);
        reg.publish(epoch, root, uint64(block.timestamp), 2, 1, "ipfs://x");
        pa = new bytes32[](1);
        pa[0] = hb;
    }

    function setUp() public {
        vm.warp(1_790_000_000);
        reg = new ScoreRegistry(admin, publisher);
    }

    function test_RevokedRecord_DoesNotAllowArbitraryOldEpoch() public {
        bytes32[] memory p1 = _publishTwo(1, _leaf(alice, 950), _leaf(bob, 300));
        vm.warp(block.timestamp + 1 days);
        bytes32[] memory p2 = _publishTwo(2, _leaf(alice, 400), _leaf(bob, 300));
        vm.warp(block.timestamp + 1 days);
        bytes32[] memory p3 = _publishTwo(3, _leaf(alice, 350), _leaf(bob, 300));
        reg.record(2, _leaf(alice, 400), p2);
        reg.record(3, _leaf(alice, 350), p3);
        vm.prank(admin);
        reg.revoke(3);

        // Epoch 2 is live, newer than epoch 1, and was recorded for alice. Recording epoch 1 must not win.
        // Fails on current code: record(1) succeeds and scoreOf reports 950 from epoch 1.
        vm.expectRevert();
        reg.record(1, _leaf(alice, 950), p1);
        (, uint16 score,, uint64 ep,) = reg.scoreOf(alice);
        assertTrue(ep != 1, "subject must not be rolled back to epoch 1");
        assertTrue(score != 950, "stale favourable score must not be readable");
    }
}
```

### 3. Low: TabManager.pay: the line epoch is a predictable counter, so intents pre-signed for a future epoch survive revokeMandate/setMandate

`src/TabManager.sol:372`

```
        if (p.epoch != l.epoch) revert StaleEpoch();
```

setMandate, revokeMandate and setAgent each do ++epoch, and their NatSpec plus the README state that this 'invalidates older signed intents'. The epoch is a sequential counter checked for equality only, so an agent (or whoever holds the agent key) can sign intents today for epoch+1, epoch+2, ... with a long deadline. Each future owner action lands on exactly one of those values and the matching pre-signed intents become payable under the new mandate. The new mandate's cap, budget, expiry and seller list still apply, so this is not a mandate bypass; the gap is that the owner's documented kill-switch for already-signed intents does not hold for setMandate/revokeMandate while the agent key is kept. An owner who pauses a line with revokeMandate and re-enables it later sees intents signed before the pause execute immediately. Fix: bind the signature to the mandate content, e.g. include mandateVersion (already tracked) or keccak256(abi.encode(maxPerPayment, budget, validUntil, sellersRoot)) in the signed struct, or derive the new epoch unpredictably (hash of lineId, old epoch and block.prevrandao). Document cancelNonce as the only reliable per-intent kill-switch while the counter design stays.

**Reproduction**

Line with epoch 1 and a live mandate (100 USDC cap, 500 USDC budget). Agent signs PaymentIntent{epoch: 3, amount: 50e6, nonce: 7, deadline: now+365d}. Owner calls revokeMandate (epoch 2) and later setMandate(...) (epoch 3). Anyone submits the old signature: pay() succeeds and outstandingOf(line) == 50e6. Expected per NatSpec of setMandate/revokeMandate: an intent signed before either change is rejected (StaleEpoch). Reproduced with the attached test, which fails on current code with 'next call did not revert as expected'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

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

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

contract USDCMock is ERC20 {
    constructor() ERC20("USD Coin", "USDC") {}
    function decimals() public pure override returns (uint8) { return 6; }
    function mint(address to, uint256 a) external { _mint(to, a); }
}

/// The line epoch is a predictable counter, so an agent can pre-sign intents for epoch+2 today.
/// `revokeMandate` then `setMandate` lands on exactly that epoch and the pre-signed intent is accepted,
/// contradicting "invalidates older signed intents" for both calls.
/// Expected: an intent signed before a mandate change is never payable after it.
contract ProofF3 is Test {
    function test_IntentSignedBeforeMandateChange_IsRejected() public {
        address admin = makeAddr("admin");
        USDCMock usdc = new USDCMock();
        CreditPool pool = new CreditPool(usdc, admin, 0);
        TabManager tabs = new TabManager(usdc, pool, admin, makeAddr("treasury"));
        bytes32 mgr = pool.MANAGER_ROLE();
        vm.prank(admin);
        pool.grantRole(mgr, address(tabs));
        address lp = makeAddr("lp");
        usdc.mint(lp, 1_000_000e6);
        vm.startPrank(lp);
        usdc.approve(address(pool), type(uint256).max);
        pool.deposit(1_000_000e6, lp);
        vm.stopPrank();

        uint256 agentPk = 0xA11CE;
        address owner = makeAddr("owner");
        usdc.mint(owner, 10_000e6);
        vm.startPrank(owner);
        usdc.approve(address(tabs), type(uint256).max);
        uint256 lineId = tabs.openLine(vm.addr(agentPk));
        tabs.depositCollateral(lineId, 1_000e6);
        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0)); // epoch 1
        vm.stopPrank();

        // Agent pre-signs for epoch 3 while the current epoch is 1.
        TabManager.PaymentIntent memory p = TabManager.PaymentIntent({
            lineId: lineId, epoch: 3, seller: makeAddr("seller"), amount: 50e6,
            resource: keccak256("r"), escrowed: false, nonce: 7, deadline: block.timestamp + 365 days
        });
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentPk, tabs.hashIntent(p));
        bytes memory sig = abi.encodePacked(r, s, v);

        vm.startPrank(owner);
        tabs.revokeMandate(lineId); // epoch 2
        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0)); // epoch 3
        vm.stopPrank();

        // Fails on current code: pay succeeds and the line owes 50 USDC from an intent signed two changes ago.
        vm.expectRevert();
        tabs.pay(p, sig, new bytes32[](0));
        assertEq(tabs.outstandingOf(lineId), 0, "pre-signed intent must not spend");
    }
}
```

### 4. Low: liquidate() and _roll() read gracePeriod/paymentWindow live, so an admin setParams call retimes statements already issued and can make lines liquidatable in the same block

`src/TabManager.sol:546`

```
        if (!(l.billed > 0 && block.timestamp > uint256(l.statementDue) + gracePeriod)) revert NotPastDue();
```

A statement stores only statementDue (fixed at roll time); the grace that follows is added from the live gracePeriod in liquidate() (line 546) and isPastDue() (line 636), and paymentWindow is read live in _roll() (line 456) when the cycle closes rather than when it opened. setParams (DEFAULT_ADMIN_ROLE) may set gracePeriod anywhere in [0, 14 days], so one admin transaction retroactively moves the deadline of every open statement: lowering it makes statements that are 1 second past due liquidatable by anyone immediately, seizing billed plus unbilled from owners and sponsors still inside the window they were told they had; raising it postpones liquidation of lines already in default. The same retroactivity class was fixed in Escrow after the 2026-10-22 review (rulingEndsAt snapshotted at flag time), so TabManager is now asymmetric with Escrow. Admin-triggered, so a trust assumption rather than an outsider exploit, but the contract does not enforce the grace promise it documents. Merged from four specialists. Fix: when the statement is issued in _roll, store the full deadline (e.g. uint64 liquidatableAt = cycleEnd + paymentWindow + gracePeriod, set only when statementDue is set) and compare block.timestamp against it in liquidate()/isPastDue(); use the live parameters only for cycles opened after the change.

**Reproduction**

Default params (billingPeriod 7d, paymentWindow 3d, gracePeriod 2d). Line pays 100 USDC direct at t0; cycleEnd = t0+7d; statementDue = t0+10d. Warp to t0+10d+1s: isPastDue(lineId) == false and liquidate(lineId) reverts NotPastDue (owner is inside the 2-day grace). Admin calls setParams(10000, 100, 7 days, 3 days, 0). Same block, anyone calls liquidate(lineId): Expected: revert NotPastDue until t0+12d. Actual: succeeds, seized == 100e6, collateral drops from 1,000 to 900 USDC. Reproduced with the attached test, which fails on current code with 'next call did not revert as expected'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

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

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

contract USDCMock is ERC20 {
    constructor() ERC20("USD Coin", "USDC") {}
    function decimals() public pure override returns (uint8) { return 6; }
    function mint(address to, uint256 a) external { _mint(to, a); }
}

/// liquidate() adds the live `gracePeriod` to a statement's stored `statementDue`, so an admin
/// setParams() call retimes every statement already issued. A statement issued under a 2-day grace
/// must keep that grace: after the admin lowers gracePeriod to 0, liquidate() must still revert
/// NotPastDue until the deadline that applied when the statement was issued.
contract ProofGrace is Test {
    function test_IssuedStatementKeepsItsGrace() public {
        vm.warp(1_800_000_000);
        address admin = makeAddr("admin");
        USDCMock usdc = new USDCMock();
        CreditPool pool = new CreditPool(usdc, admin, 0);
        TabManager tabs = new TabManager(usdc, pool, admin, makeAddr("treasury"));
        bytes32 mgr = pool.MANAGER_ROLE();
        vm.prank(admin);
        pool.grantRole(mgr, address(tabs));
        address lp = makeAddr("lp");
        usdc.mint(lp, 1_000_000e6);
        vm.startPrank(lp);
        usdc.approve(address(pool), type(uint256).max);
        pool.deposit(1_000_000e6, lp);
        vm.stopPrank();

        uint256 agentPk = 0xA11CE;
        address owner = makeAddr("owner");
        usdc.mint(owner, 10_000e6);
        vm.startPrank(owner);
        usdc.approve(address(tabs), type(uint256).max);
        uint256 lineId = tabs.openLine(vm.addr(agentPk));
        tabs.depositCollateral(lineId, 1_000e6);
        tabs.setMandate(lineId, 100e6, 500e6, uint64(block.timestamp + 30 days), bytes32(0));
        vm.stopPrank();

        uint256 t0 = block.timestamp;
        TabManager.PaymentIntent memory p = TabManager.PaymentIntent({
            lineId: lineId, epoch: tabs.getLine(lineId).epoch, seller: makeAddr("seller"), amount: 100e6,
            resource: keccak256("r"), escrowed: false, nonce: 1, deadline: block.timestamp + 1 hours
        });
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(agentPk, tabs.hashIntent(p));
        tabs.pay(p, abi.encodePacked(r, s, v), new bytes32[](0));

        // Cycle closes at t0+7d, statement due t0+10d, grace to t0+12d. We are 1 second past due.
        vm.warp(t0 + 10 days + 1);
        vm.expectRevert(TabManager.NotPastDue.selector);
        tabs.liquidate(lineId);

        // Admin lowers the grace period for future statements.
        vm.prank(admin);
        tabs.setParams(10_000, 100, 7 days, 3 days, 0);

        // Fails on current code: liquidate succeeds immediately and seizes 100 USDC of collateral.
        vm.expectRevert(TabManager.NotPastDue.selector);
        tabs.liquidate(lineId);
        assertEq(tabs.getLine(lineId).collateral, 1_000e6, "collateral seized 2 days early");
    }
}
```

### 5. Low: Pool fee share is donated to CreditPool in the same transaction, so a transient depositor can sandwich large payments and take lenders' yield with no exposure

`src/TabManager.sol:485`

```
        if (toPool > 0) usdc.safeTransfer(address(pool), toPool);
```

Lenders' only return is poolFeeShareBps of each seller fee, which _distributeFee() transfers to the pool as a plain USDC transfer inside pay() (and inside Escrow release via onRelease). CreditPool.totalAssets() = idle + outstanding, so the tpUSDC share price jumps at that instant and the gain is split pro rata among whoever holds shares in that block. ERC-4626 deposit and redeem have no cooldown, entry/exit fee or yield smoothing, and redemption is limited only by idle cash, so a searcher who sees a large direct payment (or a claim()/pass() of a large hold after reviewEndsAt) in the mempool can deposit immediately before it and redeem immediately after, capturing a share of the lender fee proportional to the capital it adds while bearing none of the lock-up or default risk. Impact per sandwich is bounded by poolFeeShareBps * sellerFeeBps of the payment (0.5% by default) times the attacker's share of the pool, so this is a yield leak, not a principal loss. Merged from audit_math and audit_economics. Fix: accrue the pool's fee share over time (e.g. linear unlocking of received fees into totalAssets with a lastFeeTime/lockedFees schedule), or impose a minimum share holding period / withdrawal cooldown.

**Reproduction**

Pool holds 1,000,000 USDC from LP. A line with 200,000 USDC collateral and a mandate allowing 100,000 USDC per payment has a signed direct intent for 100,000 USDC pending (fee 1,000 USDC at 100 bps, 500 USDC to the pool). Attacker deposits 1,000,000 USDC (now ~50% of shares), pay() executes, attacker redeems all shares in the same block. Expected: a zero-duration depositor earns nothing (gets back 1,000,000 USDC). Actual: attacker receives 1,000,249.999999 USDC (logged 1000249999999 base units), i.e. 249.999999 USDC of the 500 USDC lender fee; LP's yield for that payment is halved. Reproduced in test/scratch/Repro.t.sol::test_feeSandwich during this review.

### 6. Low: depositCollateral and depositSponsorCollateral accept deposits from anyone into any id, so a front-run openLine/openSponsor diverts a deposit sent by predicted id to an attacker who can withdraw it

`src/TabManager.sol:245`

```
        if (_lines[lineId].owner == address(0)) revert UnknownLine();
```

withdrawCollateral / withdrawSponsorCollateral are restricted to the owner, but depositCollateral (line 244) and depositSponsorCollateral (line 299) only require that the id exists. Line and sponsor ids are sequential (nextLineId++, nextSponsorId++), so an owner who opens a line in one transaction and deposits in a second transaction using the id they expect (nextLineId read off chain or from a simulation) can be front-run: an attacker calls openLine first and takes that id, the victim's openLine gets id+1, and the victim's depositCollateral(id, amount) succeeds into the attacker's line. The attacker, as that line's owner, withdraws the collateral with withdrawCollateral(id, amount, attacker) because the line has no balance. The same holds for sponsors. The contract offers no deposit-at-open path or owner check that would make the misdirected transfer fail. Fix: require msg.sender == owner in the deposit functions, or add an expectedOwner parameter that must match (revert on mismatch), or let openLine/openSponsor take an initial collateral amount so the common path is atomic.

**Reproduction**

expected = nextLineId(). Attacker: openLine(attacker) -> returns expected. Victim: openLine(victim) -> returns expected+1. Victim: approve, depositCollateral(expected, 1000e6) -> succeeds (only UnknownLine and SponsoredLine are checked). Attacker: withdrawCollateral(expected, 1000e6, attacker) -> succeeds; attacker USDC balance == 1000e6. Expected: a deposit into a line the caller does not own is rejected or cannot be misdirected by id prediction. Actual: victim loses the 1,000 USDC deposit. Reproduced in test/scratch/Repro.t.sol::test_depositFrontRun during this review.

### 7. Info: Seller fee rounds up to a whole base unit, so payments under 100 base units pay up to a 100% fee and a 1-unit payment pays the seller nothing

`src/TabManager.sol:384`

```
        uint256 fee = Math.mulDiv(p.amount, sellerFeeBps, BPS, Math.Rounding.Ceil);
```

fee = ceil(amount * sellerFeeBps / 10000). At the default 100 bps every amount in [1, 100] pays a 1-unit fee, so the effective rate is 1/amount: 100% at amount = 1, 2% at 50. At amount = 1 the seller receives amount - fee = 0 while pay() succeeds, emits Paid, consumes the nonce, charges the mandate budget and bills the owner 1 unit; the direct branch transfers 0 USDC to the seller, which USDC accepts. The 1-unit fee then splits floor(1 * 5000 / 10000) = 0 to the pool and 1 to the treasury. x402 is a micro-payment protocol, so prices of 0.0001 USDC and below are plausible for metered endpoints. The prior review flagged the opposite rounding (fee to zero); the fix moved the asymmetry onto the seller. No third-party loss, bounded to dust, so informational. Merged from three specialists. Fix: revert when amount - fee == 0 (or require amount >= BPS / sellerFeeBps), or round the fee down and accept zero-fee dust; document the minimum economic payment size.

**Reproduction**

Default params (sellerFeeBps 100, poolFeeShareBps 5000), line with a mandate allowing the amounts. pay() with p.amount = 1, escrowed = false: Expected: seller receives a positive amount or the call reverts. Actual: fee = 1, seller balance stays 0, feesAccrued == 1, outstandingOf(lineId) == 1, nonce consumed, pool receives 0. pay() with p.amount = 50: fee = 1, seller receives 49 (2% fee instead of 1%). Reproduced in test/scratch/Repro.t.sol::test_dustFee during this review.

### 8. Info: Escrow, CreditPool and TabManager constructors do not validate admin (and Escrow not usdc/manager), and setEscrow is one-shot with no wiring check, so a mis-wired deployment is dead and unrecoverable

`src/Escrow.sol:69`

```
    constructor(IERC20 usdc_, ITabManager manager_, address admin) {
```

Asymmetric initialization validation. ScoreRegistry rejects a zero admin; CreditPool (CreditPool.sol:43), TabManager (TabManager.sol:203) and Escrow (Escrow.sol:72) grant DEFAULT_ADMIN_ROLE to address(0) without reverting, leaving a deployment no one can administer (the pool can never grant MANAGER_ROLE, TabManager can never setEscrow/setParams, Escrow can never get a verifier or arbiter). Escrow also stores usdc_ and manager_ unchecked, and because Escrow.manager is immutable and TabManager.setEscrow can be called exactly once (EscrowAlreadySet) with no check that the target's manager is this contract, a TabManager pointed at an Escrow with the wrong manager has every escrowed=true intent revert OnlyManager forever and cannot be re-pointed without redeploying TabManager (abandoning all collateral accounting). Deploy.s.sol passes correct values today; this hardens the manual/alternate deployment paths the README describes. Funds are not at risk. Merged from audit_permissions and audit_flow. Fix: revert ZeroAddress() on a zero admin in all three constructors and on zero usdc_/manager_ in Escrow; in setEscrow require IEscrow(escrow_).manager() == address(this) (expose manager() on IEscrow).

**Reproduction**

new CreditPool(usdc, address(0), 0): hasRole(DEFAULT_ADMIN_ROLE, address(0)) == true and no other address holds any role. new TabManager(usdc, pool, address(0), treasury) likewise deploys. new Escrow(IERC20(address(0)), ITabManager(address(0)), address(0)) deploys with usdc() == address(0). Separately: bad = new Escrow(usdc, ITabManager(address(0)), admin); tabs.setEscrow(bad) succeeds; tabs.setEscrow(correct) then reverts EscrowAlreadySet; pay() of an intent with escrowed=true reverts OnlyManager from Escrow.open. Expected: deployment rejected at construction / wiring. Actual: deploys with an unrecoverable role set or a permanently broken escrow path. Reproduced in test/scratch/Repro.t.sol::test_zeroAdmin and ::test_escrowMiswire during this review.

### 9. Info: Trust assumptions: CreditPool DEFAULT_ADMIN can grant MANAGER_ROLE to any address and drain idle cash with fund(); TabManager admin controls treasury, fee split, LTV and grace with no timelock; Escrow

`src/CreditPool.sol:90`

```
    function fund(address to, uint256 amount) external onlyRole(MANAGER_ROLE) {
```

Documented privileged powers, not bypasses, recorded so the requester can weigh them against the README statement that depositors never lose principal in the secured design. (1) CreditPool.fund sends idle USDC to any `to` for a MANAGER_ROLE holder, and DEFAULT_ADMIN_ROLE (the deploy-time ADMIN, also the Escrow arbiter per Deploy.s.sol) can grant MANAGER_ROLE to an arbitrary EOA with no delay; that EOA can call fund(attacker, idle) up to the utilization cap (90% of totalAssets by default; the same admin can set it to 100%), and the principal never returns. Depositor principal is therefore exactly as safe as the admin key. (2) TabManager admin may setTreasury to any non-zero address, setPoolFeeShare in 0..100% (affecting in-flight escrow holds whose split is decided at release), setParams to retime open statements (separate finding), and setEscrow once with no validation. (3) Escrow ARBITER_ROLE may resolve() a Held hold at any time, refunding a delivered job or releasing an undelivered one before the review window ends, without a flag. None of these need a code change if the roles are held by the intended Safe; a timelock on CreditPool's DEFAULT_ADMIN_ROLE, or renouncing the role admin of MANAGER_ROLE after wiring, would make the 'depositors never lose principal' property hold independently of the admin key.

**Reproduction**

Pool holds 1,000,000 USDC idle, outstanding 0. admin: pool.grantRole(MANAGER_ROLE, attackerEOA). attackerEOA: pool.fund(attackerEOA, 900_000e6) succeeds (idle check passes, 900k <= 90% of 1,000k); attacker balance == 900_000e6 and pool.outstanding == 900_000e6 with no line backing it. Depositors can withdraw only the remaining 100k idle. Expected per README: depositors never lose principal. Actual: one privileged key moves 90% of deposits in one block with no delay. Reproduced in test/scratch/Repro.t.sol::test_adminDrain during this review.

---

Judge's submission `8e0c8e4dbae52f810b6e1869a5b49431dac89c6d10b595e3691da59d883396d6`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
