{"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":"79f6731a-835e-4df4-90bf-d07c0ba201c5","kind":"audit","nodes":[{"acceptedSubmissionHash":"37335120ce5ebfed6fb2ef518983cd1a60226aea53eff0ae19c47bb870bfd7f1","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":"e07acac68133bdcf49be00b20f7f64e08e7432c05d9a1e60cda32b4ea1060a1a","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":"6ee0066c4832f4e5b1e245e273bf04d1464eeb8e3d482dd5a2b3274617432927","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":"e8b928979b6755dfb9b3f0f5d25ef67f645f0600f0c874c714d9cce349c5ee72","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":"09cf2bbf167939a8a35277b632bef18b10b0faa28f6fa5da71d14b89707d9219","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":"Audit the contracts in kadiri-seer/quietpay as they are.","parentJobId":null,"planHash":"47ff2c342151e2a463037c99dc481feaa2b13dcb696fd2cbc9f5aa2e3f5b4fd9","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"79f6731a-835e-4df4-90bf-d07c0ba201c5","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":"51876","feedbackHash":"86c89c437d9157dddaf93a398257f8062e0dee6b0ad3bfb5d0b342d993342d6f","nodeKey":"audit_economics","submissionHash":"37335120ce5ebfed6fb2ef518983cd1a60226aea53eff0ae19c47bb870bfd7f1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51558","feedbackHash":"b888a9bed30afed9e5f5c6337b35d1c2d28957e571d46351af3f3ce70d575d52","nodeKey":"audit_flow","submissionHash":"e07acac68133bdcf49be00b20f7f64e08e7432c05d9a1e60cda32b4ea1060a1a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51487","feedbackHash":"5a1c37c288ab7dbdcc2d26524cec483c59d4e098c0c520618d34fb391f514a3c","nodeKey":"audit_judge","submissionHash":"6ee0066c4832f4e5b1e245e273bf04d1464eeb8e3d482dd5a2b3274617432927","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51883","feedbackHash":"25ff11fc10109e02e57e694b4fa245df71ca791a65c278461d4fc940a5122df9","nodeKey":"audit_math","submissionHash":"e8b928979b6755dfb9b3f0f5d25ef67f645f0600f0c874c714d9cce349c5ee72","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52686","feedbackHash":"2ffc29ebe0fd16986cd7ec595c53e9286202f26953d583b011b346d9fee08240","nodeKey":"audit_permissions","submissionHash":"09cf2bbf167939a8a35277b632bef18b10b0faa28f6fa5da71d14b89707d9219","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"9996a61dd3b96f9010b896a788287430bcbaeae6df4e579151c5b5c79ef02cd0","state":"completed","submissions":[{"artifacts":[],"attempt":2,"bundleHash":null,"device":"8a05ec1ea94411ea","findings":[{"citation":"resolved","description":"Access / asymmetry gap. `payOnce` (lines 86-100) is callable by anyone, and the only thing it records for one-time enforcement is `paidOnce[requestId] = true`. The guard on line 94 then refuses every later `payOnce` for that ID, whoever calls and whatever they pay. Nothing in the call ties `requestId` to the recipient (the stealth address is caller-chosen and cannot be checked onchain) or to the link's amount (`amount` only has to be non-zero and equal the amount byte-encoded in the caller's own metadata). So the first caller who knows a request ID decides the link's fate, and the cheapest way to be first is to pay 1 base unit to an address the caller controls.\n\nWho can trigger it: anyone who sees the link (the 16-byte request ID is in the link itself, src/lib/link.ts:11), or anyone who sees the honest payer's pending `payOnce` transaction, since the ID is a plain calldata argument. Cost: gas plus 1 base unit (0.000001 USDG), which the griefer sends to themselves.\n\nWhy it matters here: the app treats the flag as authoritative. src/lib/payer.ts:288-298 reads `paidOnce(requestId)` and, when true, fails preflight with \"This link was already paid\" without checking what was paid or to whom; the recipient's scan (src/lib/receive.ts:30-38) ignores the griefer's announcement because its stealth address does not derive from their keys, so the recipient sees nothing. The honest payer is told the invoice is paid, the recipient has received nothing, and the link can never be paid through the router again. Before v2 the app explicitly refused to count an announcement unless the one-time address had received at least the link's amount (SECURITY.md lines 158-162, payer.ts:69-73) precisely because \"anyone holding a link can post an announcement with its ID\"; the v2 contract reintroduces the weaker rule onchain and the app now defers to it. Asymmetry: `pay` (line 78) writes nothing, so a non-app payer can also pay a fixed link without ever consuming its ID, meaning the onchain record is both over-inclusive (burnable by a stranger) and under-inclusive (skipped by `pay`).\n\nNo funds are lost; the impact is a cheap, targeted denial of service on individual payment links and a dispute between payer and recipient. Fix options, each a design decision: (a) stop treating `paidOnce` as authoritative in preflight and return to counting a request ID as paid only when the announced stealth address received at least the link's amount (keeping the onchain flag as a race guard between honest payers only); (b) record `paidOnce[requestId] = amount` (or amount and stealth address) instead of a bool so the client can tell a 1-unit burn from a real payment, and let the honest payer proceed when the recorded amount is below the link's amount; (c) if a strict onchain one-time rule is wanted, the ID must be bound to something only the recipient can produce, which the current link format does not carry.","line":98,"path":"contracts/src/QuietPayRouterV2.sol","reproduction":"Setup (anvil or testnet): deploy ERC5564Announcer A, QuietPayTestUSDG T, QuietPayRouterV2 R(T, A). Recipient creates a fixed link with requestId = 0x11111111111111111111111111111111 for 7_000_000 (7 tUSDG). Attacker (any account that has seen the link): T.faucet(1); T.approve(R, 1); R.payOnce(0x1111...1111, attackerOwnedAddress, 1, 0x02<32 bytes>, metadata) where metadata = 1 byte view tag + 0xa9059cbb + 20 bytes of T + uint256(1) + 0x515001 + 0x1111...1111 (76 bytes). Actual: succeeds, R.paidOnce(0x1111...1111) == true, Announcement emitted for attackerOwnedAddress; cost 1 base unit (returned to the attacker's own address) plus gas. Honest payer then: T.approve(R, 7_000_000); R.payOnce(0x1111...1111, realStealthAddress, 7_000_000, ephemeralPubKey, correct 76-byte metadata). Actual: reverts AlreadyPaid(0x1111...1111); the app's preflight (payer.ts:290-298) reports \"This link was already paid\" and refuses to send. Expected: a payment that did not deliver the link's amount to the recipient's one-time address should not block the legitimate payment, which is what the app enforced before v2 (payer.ts:69-73). The recipient's scan shows no payment for the link because the griefer's stealth address does not match their keys.","severity":"medium","snippet":"    paidOnce[requestId] = true;","title":"payOnce lets anyone burn a one-time link for ~1 base unit: paidOnce is keyed on an unauthenticated request ID with no binding to recipient or amount"},{"citation":"resolved","description":"In the v1 router the caller supplies `token` and `announcer` (line 37-44) and the only checks are non-zero addresses (line 48). The low-level `token.call(...)` on line 53-54 returns `ok == true` with empty return data when `token` has no code, so line 55 does not revert, and the function goes on to forward the ETH stipend and call `announcer.announce(...)` (line 62) with caller-chosen metadata. The result is an Announcement whose indexed `caller` is the QuietPay router but behind which no token transfer happened. v2 closes this (constructor `NotAContract` check, line 64, and the `balanceOf` delta check, line 121), but v1's code hash is still accepted by the app (src/config/contracts.ts:18-20, chain.ts:143), so a network configured with a v1 router remains exposed. Impact is limited to announcement pollution that looks router-originated (the app verifies Transfer events before treating a payment as real, and the payer only harms themselves), hence low. Fix: for any network still running v1, redeploy v2 and drop the v1 hash from KNOWN_ROUTER_CODE_HASHES; or, if v1 must stay, require `token.code.length != 0` and check the recipient's balance delta as v2 does.","line":55,"path":"contracts/src/QuietPayRouter.sol","reproduction":"Deploy QuietPayRouter (v1) R1 and ERC5564Announcer A. Pick any address with no code, e.g. 0x000000000000000000000000000000000000dEaD. Call R1.pay(0x...dEaD, A, 0x1234567890123456789012345678901234567890, 1_000_000, 0x02 followed by 32 bytes, 57 bytes of metadata) with msg.value 0. Actual: the transaction succeeds and A emits Announcement(1, 0x1234..., R1, ...). No ERC-20 Transfer occurred anywhere. Expected: revert with TokenTransferFailed, as QuietPayRouterV2 does (it refuses a codeless token at construction and reverts AmountMismatch when the recipient's balance does not grow by `amount`).","severity":"low","snippet":"    if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TokenTransferFailed();","title":"Router v1 pay() treats a codeless token address as a successful transfer and emits an announcement without moving tokens"},{"citation":"resolved","description":"Documented privileged power, not a permission bypass. `updatePoolConfiguration` (line 239-253, onlyRole(_OWNER_ROLE)) accepts any `_vettingFeeBPS` below 10_000; `_handleDeposit` (line 332) reads the current fee at execution time and `deposit` has no maximum-fee or minimum-received argument. A deposit submitted while the fee is 0 bps (the testnet value, scripts/deploy-testnet.ts:142) can therefore be charged whatever fee is in force when it is mined, and the retained amount is sweepable through `withdrawFees` (line 264-272). This is upstream 0xbow code, unchanged, and intentional, but the actor matters: scripts/deploy-testnet.ts:139 makes the owner the GitHub Actions DEPLOYER_PRIVATE_KEY account (described as a throwaway key), and docs/shielded-pool.md:165-169 says only that the owner can 'change fees within limits'. Record it as a trust assumption: the owner key can take up to 99.99% of any deposit that lands after a fee change, with no delay. Mitigation within the agreed design: move the owner to a multisig behind a timelock before any mainnet pool (already listed in docs/shielded-pool.md:202), and have the client pass the expected fee or read it in the same block and refuse to deposit when it changed.","line":396,"path":"contracts/vendor/privacy-pools/src/contracts/Entrypoint.sol","reproduction":"State: pool registered with vettingFeeBPS = 0. User U submits deposit(tUSDG, 100_000_000, precommitment) expecting 100 tUSDG credited. Owner O submits updatePoolConfiguration(tUSDG, 1_000_000, 9_999, 100) and it is mined first. U's deposit executes with _config.vettingFeeBPS = 9_999: _amountAfterFees = 100_000_000 - (100_000_000 * 9_999 / 10_000) = 10_000 base units (0.01 tUSDG) go into the pool commitment, 99_990_000 stay in the Entrypoint. O then calls withdrawFees(tUSDG, O) and receives 99.99 tUSDG. Expected by the user: a 0% vetting fee as displayed when the deposit was built. Actual: 99.99% taken, with no onchain parameter letting the user bound the fee.","severity":"info","snippet":"    if (_vettingFeeBPS >= 10_000 || _maxRelayFeeBPS >= 10_000) revert InvalidFeeBPS();","title":"Trust assumption: pool owner can set a vetting fee up to 99.99% and apply it to deposits already in flight; the Entrypoint owner is the CI deployer key"},{"citation":"resolved","description":"Documented privileged power, not a permission bypass. `_authorizeUpgrade` lets `_OWNER_ROLE` replace the Entrypoint implementation at any time with no timelock. In the relay path, PrivacyPool.withdraw (PrivacyPool.sol:110-129) pushes the withdrawn tokens to `_withdrawal.processooor`, which for every relayed withdrawal is the Entrypoint (Entrypoint.sol:141), and only then does Entrypoint.relay forward them to `_data.recipient` (line 167-169). The proof binds the recipient inside `_withdrawal.data`, but the pool does not pay the recipient; it pays the Entrypoint and trusts the Entrypoint's code to forward. A replacement implementation whose `relay` simply does not forward keeps every relayed withdrawal that lands after the upgrade. Withdrawals made directly against the pool (processooor = the user's own address) and `ragequit` are unaffected, so pool principal at rest is safe, which is what SECURITY.md:209-210 and docs/shielded-pool.md:166-168 describe (\"The owner cannot take pool funds\"). The gap is funds in transit through the Entrypoint, i.e. every withdrawal made with the QuietPay relayer, and the owner is currently the CI deployer key (scripts/deploy-testnet.ts:139). This is upstream design; record the power precisely in SECURITY.md and docs/shielded-pool.md, and before mainnet put the owner behind a multisig and timelock (docs/shielded-pool.md:202) so users can exit or withdraw directly before an upgrade takes effect.","line":308,"path":"contracts/vendor/privacy-pools/src/contracts/Entrypoint.sol","reproduction":"State: Entrypoint proxy E owned by O, pool P registered for tUSDG, user U has an approved commitment of 50 tUSDG. O deploys an implementation whose relay(...) calls _pool.withdraw(_withdrawal, _proof) and returns without the two _transfer calls, then calls E.upgradeToAndCall(newImpl, \"\"). U (or the relayer on U's behalf) submits relay(withdrawal{processooor: E, data: {recipient: U2, feeRecipient: relayer, relayFeeBPS: 50}}, proof, scope). Actual: P pushes 50 tUSDG to E (PrivacyPool.sol:124), the new relay keeps them, E.withdrawFees(tUSDG, O) sweeps 50 tUSDG to O. Expected per SECURITY.md:209 (\"The owner cannot take pool funds\"): the owner should not be able to obtain user withdrawals. Precondition: owner key acts maliciously or is compromised; no unprivileged actor can trigger this.","severity":"info","snippet":"  function _authorizeUpgrade(address) internal override onlyRole(_OWNER_ROLE) {}","title":"Trust assumption: the UUPS owner can upgrade the Entrypoint and keep relayed withdrawals in flight, which the security notes say the owner cannot do"}],"hash":"09cf2bbf167939a8a35277b632bef18b10b0faa28f6fa5da71d14b89707d9219","nodeId":"7acbf05d-7cf6-4621-b0ee-248005687ad5","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds four findings.\n\n**Scope and limits.** The permission passes (Access Control, Trust Gap, Asymmetry) were run over the four QuietPay contracts in `contracts/src`, the vendored Privacy Pools core (Entrypoint, PrivacyPool, State, both implementations), the deploy scripts that hand out roles, and the app code that consumes the on-chain state. The shell sandbox failed to initialise on every attempt, so Foundry could not run. No test was executed and no proof is attached; every finding is substantiated by a traced reproduction against the source instead. The repository also has no Foundry configuration of its own, so a verifier running `forge` will need to supply one.\n\n**Findings written:**\n\n- **Medium, QuietPayRouterV2 `payOnce`.** The one-time flag is keyed only on the 16-byte request ID with no binding to recipient or amount. Anyone who has seen the link, or the honest payer's pending transaction, can call `payOnce` with 1 base unit to an address they control. The honest payment then reverts with `AlreadyPaid`, and the app's preflight treats the flag as authoritative and reports the link as paid while the recipient has received nothing. The pre-v2 app deliberately refused to count an announcement unless the link's amount had actually arrived, and v2 reintroduced the weaker rule on-chain.\n- **Low, QuietPayRouter v1 `pay`.** A codeless `token` address makes the low-level call succeed with empty return data, so the router emits a router-originated announcement without any transfer. v2 fixed this, but the v1 code hash is still accepted by the app.\n- **Info, trust assumption on the pool owner (fees).** The owner can set a vetting fee up to 99.99% with no delay and it applies to deposits already in flight. The owner is the CI deployer key.\n- **Info, trust assumption on the pool owner (upgrade).** The UUPS owner can replace the Entrypoint and keep every relayed withdrawal in transit, because the pool pays the Entrypoint rather than the recipient. SECURITY.md currently states the owner cannot take pool funds.\n\n**Not reported after tracing:** `receive` and `fallback` surfaces, proxy initialisation (atomic in the proxy constructor, implementation disabled), reentrancy across `pay`/`payOnce`, precommitment front-running on deposits (self-harm only), announcer spam (documented, no false matches), and the testnet faucet (intentional mock).","treeHash":null,"usage":{"cachedInputTokens":1200726,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":27708,"runtime":"claude","turns":48,"wallClockMs":443245}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d03fc84904b54d9e","findings":[{"citation":"resolved","description":"payOnce consumes a request ID on the first successful call, whatever the amount and whatever the destination. The contract cannot tell whether `stealthAddress` belongs to the link's recipient (the stealth meta-address is only in the link, never onchain) and it does not know the link's amount, so the only binding is `requestId`. Anyone who has seen the link (a group chat, a forwarded invoice, a QR on a counter, or on a chain with a public mempool anyone who sees the genuine payer's pending payOnce calldata) can call payOnce with the link's request ID, an address they control as the one-time address, and amount 1 (0.000001 tUSDG). The announcement metadata is self-consistent (amount 1, magic, request ID) so every check passes, `paidOnce[requestId]` becomes true, and the genuine payment later reverts with AlreadyPaid. The app then treats the onchain record as authoritative: src/lib/payer.ts lines 290 to 298 report 'This link was already paid' from `paidOnce` alone, without the 'received at least the link amount' check the announcement scan applies. The recipient's scanner never matches the attacker's announcement (wrong ephemeral key / address), so the recipient sees nothing and is never paid; the payer is told the invoice is settled. Economics: the attacker's cost is gas plus one base unit that lands in their own second address (net zero); the victim is the recipient, who loses the invoice until they notice and issue a new link, and the payer, who may believe they or someone else paid. SECURITY.md's reasoning that faking a payment 'means paying in full' no longer holds for v2 (one unit suffices) and never held against self-payment. Minimal fix that preserves the one-time design: record the paid amount instead of a boolean (for example `paidAmount[requestId]`) and reject a payOnce only when an earlier payment of at least this call's `amount` was recorded, so a dust payment cannot consume an invoice; alternatively key the record on (requestId, amount). Either way burning then costs the full invoice amount (still self-paid), which is the inherent floor without recipient involvement; document that residual and have the app show 'someone paid this ID' as a warning with the recorded amount rather than a hard block. The proof test passes under either fix.","line":98,"path":"contracts/src/QuietPayRouterV2.sol","proof":"// SPDX-License-Identifier: AGPL-3.0-or-later\npragma solidity 0.8.23;\n\nimport \"forge-std/Test.sol\";\nimport {QuietPayRouterV2} from \"contracts/src/QuietPayRouterV2.sol\";\nimport {QuietPayTestUSDG} from \"contracts/src/QuietPayTestUSDG.sol\";\nimport {ERC5564Announcer} from \"contracts/src/ERC5564Announcer.sol\";\n\n/// A one-time link (fixed amount 7 tUSDG) is burned by a third party who holds the link:\n/// they call payOnce with the link's request ID, their own address as the \"one-time address\"\n/// and amount 1 (one base unit, 0.000001 tUSDG). `paidOnce[requestId]` is set, so the real\n/// payer's payOnce for the full 7 tUSDG reverts with AlreadyPaid and the recipient never gets\n/// paid. Fails on the current code; passes once a payment of less than the link's amount to a\n/// self-chosen address can no longer consume the request ID (or payOnce otherwise lets the\n/// genuine payment through).\ncontract RouterV2OneTimeLinkBurnTest is Test {\n  QuietPayRouterV2 router;\n  QuietPayTestUSDG token;\n  ERC5564Announcer announcer;\n\n  address payer = address(0xA11CE);\n  address attacker = address(0xBAD);\n  /// A second address the attacker controls (a self-transfer would give a zero balance delta).\n  address attackerSink = address(0xBAD2);\n  address recipientStealth = address(0x5EA1);\n  bytes16 requestId = 0x0102030405060708090a0b0c0d0e0f10;\n  uint256 linkAmount = 7_000_000; // 7 tUSDG\n\n  function setUp() public {\n    token = new QuietPayTestUSDG();\n    announcer = new ERC5564Announcer();\n    router = new QuietPayRouterV2(address(token), address(announcer));\n    vm.prank(payer);\n    token.faucet(linkAmount);\n    vm.prank(attacker);\n    token.faucet(1);\n  }\n\n  function metadata(uint256 amount) internal view returns (bytes memory) {\n    // view tag (1) | transfer selector (4) | token (20) | amount (32) | \"QP\" magic+version (3) | request ID (16)\n    return abi.encodePacked(bytes1(0x42), bytes4(0xa9059cbb), address(token), amount, bytes3(0x515001), requestId);\n  }\n\n  function ephemeralKey() internal pure returns (bytes memory) {\n    return abi.encodePacked(bytes1(0x02), bytes32(uint256(1)));\n  }\n\n  function test_linkHolderBurnsOneTimeLinkForOneBaseUnit() public {\n    // Attacker \"pays\" the link's request ID to themself with one base unit.\n    vm.startPrank(attacker);\n    token.approve(address(router), 1);\n    router.payOnce(requestId, attackerSink, 1, ephemeralKey(), metadata(1));\n    vm.stopPrank();\n    assertEq(token.balanceOf(attackerSink), 1, \"attacker kept their unit\");\n\n    // The genuine payer now pays the full invoice to the recipient's one-time address.\n    vm.startPrank(payer);\n    token.approve(address(router), linkAmount);\n    router.payOnce(requestId, recipientStealth, linkAmount, ephemeralKey(), metadata(linkAmount));\n    vm.stopPrank();\n\n    assertEq(token.balanceOf(recipientStealth), linkAmount, \"recipient must receive the invoice amount\");\n  }\n}","reproduction":"Setup: router v2 bound to tUSDG and the announcer; recipient creates a fixed 7 tUSDG link with requestId R. Attacker (any link holder): faucet(1); approve(router, 1); payOnce(R, attackerSink, 1, 0x02||32 bytes, metadata = viewTag(1)||0xa9059cbb||token||uint256(1)||0x515001||R). Result: success, paidOnce(R) == true, attackerSink balance 1. Genuine payer: approve(router, 7_000_000); payOnce(R, recipientStealth, 7_000_000, key, metadata(amount 7_000_000, R)). Expected: 7 tUSDG reaches recipientStealth (the recipient's invoice is payable; a dust self-payment should not settle it). Actual: revert AlreadyPaid(R); recipientStealth balance stays 0; the Pay page's preflight shows 'This link was already paid'. See test/scratch/RouterV2OneTimeLinkBurn.t.sol. Note: the shell sandbox on this machine failed to initialize, so forge was not run here; the test is written to fail on the current code at the second payOnce (revert) and to pass once a sub-amount self-payment no longer consumes the ID.","severity":"medium","snippet":"    paidOnce[requestId] = true;","title":"QuietPayRouterV2.payOnce lets any link holder burn a one-time link for one base unit paid to themselves"},{"citation":"resolved","description":"PrivacyPool.validWithdrawal (contracts/vendor/privacy-pools/src/contracts/PrivacyPool.sol line 59) accepts a withdrawal only if `_proof.ASPRoot() == ENTRYPOINT.latestRoot()`; there is no root history for the approval set. QuietPay's approver publishes a new root as soon as any deposit older than POOL_APPROVAL_DELAY_SECONDS (0 on testnet) is unapproved, throttled only to one updateRoot per 10 seconds, and the endpoint is public (30 calls per minute per client) and is also polled by every browser with a pending deposit. A Groth16 withdrawal proof takes 5 to 10 seconds to make in the browser, after which the relayer simulates and sends. So a single actor who deposits the 1 tUSDG minimum roughly every 10 seconds and calls POST /api/pool/approvals keeps latestRoot changing at the throttle rate; every honest proof is built against a root that is replaced before submission and the Entrypoint.relay simulation reverts with IncorrectASPRoot. The relayer maps that to 'The approval list changed', the Pool page (src/app/pool/PoolFlow.tsx lines 315 to 348) re-proves exactly once and then surfaces the error; scheduled deliveries (src/lib/pool/deliver.ts) retry later and hit the same wall. This is a seam between the contract's latest-root rule, the approver policy and the product promise that approved notes can be withdrawn privately: each piece is correct alone. Cost to the attacker: gas for one deposit (about 350k gas, about 0.00004 ETH at 0.1 gwei) per 10 seconds; tUSDG is free from the faucet and on any network the deposits are recoverable by ragequit. Side cost to the operator: the approver signs each updateRoot with the relayer key, so the attacker also burns the relayer's ETH at one root push per 10 seconds. A related, pricier variant uses the state root history of 64 (State.sol ROOT_HISTORY_SIZE): 64 deposits inside a proof window also invalidate it. Victims: every pool user; funds are not lost (ragequit still works) but the private exit the pool exists for is unavailable while the attack runs. Fix in the approver, not the audited contracts: batch root updates on a fixed cadence that comfortably exceeds proving plus relay time (for example at most one root per 2 to 5 minutes, or only when the pending set has been stable for N seconds), publish the next-root schedule in GET /api/relay so the browser can start proving right after a root lands, and have the relayer wait for the next root window rather than reject when a root changes mid-flight.","line":48,"path":"src/app/api/pool/approvals/route.ts","reproduction":"State: pool registered (minimumDeposit 1_000_000, delay 0), approver running, victim holds an approved note. Attacker loop every 10 s: faucet(1_000_000) (or reuse balance); approve(entrypoint, 1_000_000); Entrypoint.deposit(tUSDG, 1_000_000, freshPrecommitment); then POST /api/pool/approvals. Observed: each loop iteration passes the 10-second guard at approvals/route.ts:48 and refreshApprovals pushes updateRoot (one new label, new root). Victim: prepareWithdrawal builds the proof with aspRoot = R_k (5 to 10 s), POSTs to /api/relay. Expected: relay succeeds (note is approved and its proof is valid for the approval set it was built against). Actual: Entrypoint.relay simulation reverts IncorrectASPRoot because latestRoot is now R_(k+1); relayer answers 422 'The approval list changed. Generate the proof again.'; the one automatic retry fails the same way; the withdrawal never lands while the loop runs. Measurable in tests/e2e-pool.test.ts style setup by inserting one deposit plus refreshApprovals between prepareWithdrawal and relayWithdrawal.","severity":"medium","snippet":"    if (last && now - Number(last.timestamp) < 10) {","title":"Immediate per-deposit approval roots plus the pool's latest-root-only rule let one depositor keep every private withdrawal proof stale"},{"citation":"resolved","description":"The v1 router is still compiled into contracts/artifacts, still accepted by verifyRouter (src/lib/chain.ts line 143 returns version 1 for its code hash) and still used by submitRoutedPayment when a v1 address is configured. Unlike v2 it takes the token and announcer from the caller, never compares the metadata's token and amount with the transfer, never checks the one-time address's balance delta, and accepts any ephemeralPubKey. A payer can therefore post an announcement whose `caller` is the trusted router address while moving a different amount, a different token, or (with a fee-on-transfer or non-crediting token at the caller-chosen address) nothing at all. The recipient UI only shows a payment as claimable after it finds real Transfer logs, which bounds the harm to confusion and extra scanning, but the announcement's provenance (router as caller) carries no guarantee in v1. Fix: drop the v1 hash from KNOWN_ROUTER_CODE_HASHES so the app never routes through v1 (the testnet deployment already uses v2), or backport the v2 metadata and balance-delta checks.","line":62,"path":"contracts/src/QuietPayRouter.sol","reproduction":"Deploy QuietPayRouter (v1), tUSDG and the announcer. Payer: faucet(7_000_000); approve(router, 1); pay(tUSDG, announcer, stealth, 1, 0x1234 (2-byte 'key'), metadata = viewTag||0xa9059cbb||tUSDG||uint256(7_000_000)). Expected (by the v2 rule the product documents): revert BadMetadata / BadEphemeralKey because the announcement does not describe the transfer. Actual: success; Transfer of 1 base unit to stealth, Announcement(schemeId 1, stealth, caller = router, ephemeralPubKey 0x1234, metadata amount 7_000_000).","severity":"low","snippet":"    IERC5564AnnouncerLike(announcer).announce(SCHEME_ID, stealthAddress, ephemeralPubKey, metadata);","title":"QuietPayRouter v1 announces metadata that need not describe the transfer and does not verify the delivered amount"},{"citation":"resolved","description":"relayEconomics values the token fee at the configured RELAYER_ETH_PRICE (3000 token units per ETH by default) and only then allows the relay and the gas drop. The pool is deployed against QuietPayTestUSDG, which anyone can mint at 10,000 tUSDG per faucet call, so the 'fee' that is meant to pay for the relayer's gas and the top-up is free to produce. Each accepted withdrawal costs the relayer the relay gas (about 0.000075 ETH at 0.1 gwei with the 750k quote) plus up to RELAYER_GAS_DROP_ETH (0.0002 ETH) plus a 21k transfer, about 0.00028 ETH, in exchange for tokens with no value, and withdrawing to a fresh address each time makes the drop fire every time. Rate limits (30 relays per 10 minutes per client id, which is a salted hash of the forwarded IP) bound this at roughly 0.0084 ETH per 10 minutes per IP, so a handful of IPs empties a modestly funded hot wallet in hours, after which GET /api/relay reports available:false and every user's private withdrawals stop. The docs name a live price feed and a balance monitor as mainnet blockers; the testnet-specific gap is that the economic guard is a no-op whenever the pool token is mintable for free. Fix: on the mock-token network, require the withdrawal's deposit age or a per-recipient drop cooldown, cap the gas drop per recipient and per day, or disable the drop and keep a balance alarm.","line":62,"path":"src/lib/pool/relayer.ts","reproduction":"Attacker with testnet ETH for gas: faucet(10_000_000_000) repeatedly; deposit 100 tUSDG notes; for each note run prepareWithdrawal(amount 100 tUSDG, recipient = fresh address, feeBPS 50) and POST /api/relay. Expected (per the comment on RelayerPolicy.tokenUnitsPerEth): the fee pays for the relay, so the relayer's ETH balance does not fall in net terms. Actual: per relay the relayer spends about 0.000075 ETH gas plus 0.0002 ETH drop plus 21k gas, receives 0.5 tUSDG (free to mint); after 30 relays per 10-minute window per client id the balance falls by about 0.0084 ETH; with the drop on, `available` becomes false once the balance is below 0.0004 ETH.","severity":"low","snippet":"  const feeWei = (((p.amount * p.feeBPS) / 10_000n) * WEI) / p.tokenUnitsPerEth;","title":"Relayer's fee-covers-gas rule is defeated on testnet by the free faucet token, so its ETH can be drained in bounded amounts"}],"hash":"37335120ce5ebfed6fb2ef518983cd1a60226aea53eff0ae19c47bb870bfd7f1","nodeId":"5c01edfb-596c-4eb9-afbc-cbd886d06219","outcome":"completed","summary":"The review is complete and the findings file is written. Here is the recap.\n\n**Outcome.** The economics audit (Economic Security, Invariant, Flow Gap passes) of the QuietPay contracts found no fund-theft path. It found two medium and two low defects, all recorded in `.imd-findings.json` with concrete inputs. One caveat up front: the Bash sandbox on this machine refused to initialize on every attempt (\"Failed to create bridge sockets\"), so I could not run forge. The proof test is written and reasoned through line by line, but it was not executed here.\n\n**Findings**\n\n- **Medium, router v2 one-time links can be burned for one base unit.** Anyone holding a link can call `payOnce` with the link's request ID, an address they control, and amount 1. The ID is consumed, the genuine payment reverts with AlreadyPaid, and the Pay page reports the link as paid from the onchain flag alone. Net cost to the attacker is gas. Proof test at `test/scratch/RouterV2OneTimeLinkBurn.t.sol`, written to fail at the second `payOnce` on current code and pass once a sub-amount self-payment no longer consumes the ID.\n- **Medium, private withdrawals can be kept perpetually stale.** The pool accepts only the latest approval root, and the approver publishes a new root for every deposit at a 10-second cadence. One depositor adding a 1 tUSDG deposit every 10 seconds makes every browser-generated proof outdated before the relayer submits it. Funds are not lost, but the pool's private exit is unavailable while it runs. The fix belongs in the approver's cadence, not the audited contracts.\n- **Low, router v1** still verifies and routes in the app but does not bind metadata to the transfer or check the delivered amount.\n- **Low, relayer ETH drain on testnet.** The fee-covers-gas rule values a token anyone can mint for free, so repeated withdrawals to fresh addresses spend the relayer's ETH for nothing, bounded by the per-IP rate limit.\n\n**Coverage and trust assumptions.** Router v1 and v2, the announcer, the mock token, and the vendored Privacy Pools contracts (unchanged upstream, audited) were read in full, along with the deploy scripts, relayer, approver, and the docs. Entrypoint owner and upgrade powers, the relayer hot key, and the approver's root authority are intentional roles and are documented as trust assumptions rather than reported as findings. The vitest suite exercises the router well but has no test for a sub-amount `payOnce` by a third party or for a root change between proof and relay.\n\n**What's next.** Run the proof test with forge on a machine where the shell works, and confirm the JSON file parses. I could not do either here.","treeHash":null,"usage":{"cachedInputTokens":2182558,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":40111,"runtime":"claude","turns":63,"wallClockMs":616989}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"911c99a95f538613","findings":[],"hash":"5d847c36afd002b85a24efddae2b7a07b475b23be6ca0fc84332677ce0bd30a0","nodeId":"7acbf05d-7cf6-4621-b0ee-248005687ad5","outcome":"failed","summary":"API Error: Your account can't receive replies from Claude right now. You can find the next step in the Anthropic Verification Portal: https://portal.anthropic.com/…","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":2232}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9e51ef2afd7c2af8","findings":[{"citation":"resolved","description":"Merged from four specialist reports (economics, math, permissions, flow) that describe the same mechanism. payOnce (lines 86-100) is callable by anyone and the only state it records is paidOnce[requestId] = true (line 98); line 94 then refuses every later payOnce for that ID. Nothing binds the request ID to the recipient (the stealth address and ephemeral key are caller-chosen and cannot be checked onchain, since the stealth meta-address exists only inside the link) or to the invoice amount (`amount` only has to be non-zero and equal the amount field in the caller's own metadata, line 105). The 16-byte request ID is bytes 47..62 of every payment link (src/lib/link.ts:11), so everyone the link was shown to knows it. Such a party calls payOnce with the ID, a second address they control as the stealth address, amount 1, any 33-byte key starting 0x02/0x03 and self-consistent 76-byte metadata. All checks pass, the unit lands in their own address, and the ID is consumed. The honest payer's payOnce then reverts AlreadyPaid. The app makes this authoritative: src/lib/payer.ts:290-298 reads paidOnce and fails preflight with 'This link was already paid' in every mode, and submitRoutedPayment (payer.ts:560-564) has no other path for fixed links. The recipient's scanner never matches the griefer's announcement (src/lib/receive.ts:36 requires the ephemeral key to derive to their keys), so the recipient sees nothing and is never paid. Before v2 the app refused to count an announcement unless the one-time address had received at least the link's amount (SECURITY.md:158-162, payer.ts:69-73, 'Faking that means paying in full'); the onchain flag reintroduces the weaker rule at a cost of one base unit plus gas. No funds are lost; the impact is a cheap targeted denial of service on any one-time invoice by anyone holding the link, leaving the payer with a dead-end error and the recipient unpaid until they notice and issue a new link. Fix that preserves the one-time design: record what was paid instead of a bool (for example mapping(bytes16 => uint256) paidAmount, rejecting a payOnce only when an earlier payment of at least this call's amount was recorded, or keying the record on keccak256(requestId, amount)); then a sub-amount self-payment cannot consume the ID and burning costs the full invoice amount, which is the floor without recipient involvement. In the app, treat the onchain record as 'paid' only when the recorded amount is at least the link amount, and warn rather than hard-fail otherwise. Reproduced by reading the code; the attached proof test was reviewed line by line but could not be executed because this session's shell sandbox failed to initialize, so forge did not run here.","line":98,"path":"contracts/src/QuietPayRouterV2.sol","proof":"// SPDX-License-Identifier: AGPL-3.0-or-later\npragma solidity 0.8.23;\n\nimport \"forge-std/Test.sol\";\nimport {QuietPayRouterV2} from \"contracts/src/QuietPayRouterV2.sol\";\nimport {QuietPayTestUSDG} from \"contracts/src/QuietPayTestUSDG.sol\";\nimport {ERC5564Announcer} from \"contracts/src/ERC5564Announcer.sol\";\n\n/// A one-time link (fixed amount 7 tUSDG) is burned by a third party who holds the link:\n/// they call payOnce with the link's request ID, their own address as the \"one-time address\"\n/// and amount 1 (one base unit, 0.000001 tUSDG). `paidOnce[requestId]` is set, so the real\n/// payer's payOnce for the full 7 tUSDG reverts with AlreadyPaid and the recipient never gets\n/// paid. Fails on the current code; passes once a payment of less than the link's amount to a\n/// self-chosen address can no longer consume the request ID (or payOnce otherwise lets the\n/// genuine payment through).\ncontract RouterV2OneTimeLinkBurnTest is Test {\n  QuietPayRouterV2 router;\n  QuietPayTestUSDG token;\n  ERC5564Announcer announcer;\n\n  address payer = address(0xA11CE);\n  address attacker = address(0xBAD);\n  /// A second address the attacker controls (a self-transfer would give a zero balance delta).\n  address attackerSink = address(0xBAD2);\n  address recipientStealth = address(0x5EA1);\n  bytes16 requestId = 0x0102030405060708090a0b0c0d0e0f10;\n  uint256 linkAmount = 7_000_000; // 7 tUSDG\n\n  function setUp() public {\n    token = new QuietPayTestUSDG();\n    announcer = new ERC5564Announcer();\n    router = new QuietPayRouterV2(address(token), address(announcer));\n    vm.prank(payer);\n    token.faucet(linkAmount);\n    vm.prank(attacker);\n    token.faucet(1);\n  }\n\n  function metadata(uint256 amount) internal view returns (bytes memory) {\n    // view tag (1) | transfer selector (4) | token (20) | amount (32) | \"QP\" magic+version (3) | request ID (16)\n    return abi.encodePacked(bytes1(0x42), bytes4(0xa9059cbb), address(token), amount, bytes3(0x515001), requestId);\n  }\n\n  function ephemeralKey() internal pure returns (bytes memory) {\n    return abi.encodePacked(bytes1(0x02), bytes32(uint256(1)));\n  }\n\n  function test_linkHolderBurnsOneTimeLinkForOneBaseUnit() public {\n    // Attacker \"pays\" the link's request ID to themself with one base unit.\n    vm.startPrank(attacker);\n    token.approve(address(router), 1);\n    router.payOnce(requestId, attackerSink, 1, ephemeralKey(), metadata(1));\n    vm.stopPrank();\n    assertEq(token.balanceOf(attackerSink), 1, \"attacker kept their unit\");\n\n    // The genuine payer now pays the full invoice to the recipient's one-time address.\n    vm.startPrank(payer);\n    token.approve(address(router), linkAmount);\n    router.payOnce(requestId, recipientStealth, linkAmount, ephemeralKey(), metadata(linkAmount));\n    vm.stopPrank();\n\n    assertEq(token.balanceOf(recipientStealth), linkAmount, \"recipient must receive the invoice amount\");\n  }\n}","reproduction":"Deploy QuietPayTestUSDG T, ERC5564Announcer A, QuietPayRouterV2 R = new QuietPayRouterV2(T, A). Recipient issues a fixed 7 tUSDG link with requestId R1 = 0x0102030405060708090a0b0c0d0e0f10. Attacker (any address that has seen the link, with a second address SINK): T.faucet(1); T.approve(R, 1); R.payOnce(R1, SINK, 1, 0x02 || 32 bytes, meta1) where meta1 = 0x42 || 0xa9059cbb || bytes20(T) || uint256(1) || 0x515001 || R1 (76 bytes). Actual: succeeds, R.paidOnce(R1) == true, T.balanceOf(SINK) == 1, Announcement emitted for SINK. Honest payer: T.faucet(7_000_000); T.approve(R, 7_000_000); R.payOnce(R1, recipientStealth, 7_000_000, key, meta7) with uint256(7_000_000) in bytes 25..56. Expected (contract header lines 22-23 and the pre-v2 rule in SECURITY.md:158-162): the invoice is still payable because no payment of the link's amount reached the recipient. Actual: revert AlreadyPaid(R1); T.balanceOf(recipientStealth) stays 0; the Pay page preflight (src/lib/payer.ts:296-298) reports 'This link was already paid' and refuses to send. Note: a self-transfer (stealthAddress == attacker) fails the balance-delta check at line 121, which is why a second attacker-controlled address is used.","severity":"medium","snippet":"    paidOnce[requestId] = true;","title":"QuietPayRouterV2.payOnce lets any link holder burn a one-time link for one base unit paid to themselves"},{"citation":"resolved","description":"Reported by the economics specialist; reproduced by tracing the three pieces. (1) PrivacyPool.validWithdrawal (contracts/vendor/privacy-pools/src/contracts/PrivacyPool.sol:59) accepts a withdrawal only when _proof.ASPRoot() == ENTRYPOINT.latestRoot(); the Entrypoint keeps no root history for the approval set (Entrypoint.sol:279-282 returns only the last element). (2) The approver endpoint is public, rate-limited to 30 calls per minute per client, and its only pacing is the 10-second gap at route.ts:48; refreshApprovals (src/lib/pool/relayer.ts:220-275) sends updateRoot whenever the approved label set has grown, and with POOL_APPROVAL_DELAY_SECONDS at its default 0 (src/lib/pool/server.ts:79) every new deposit grows it at once. (3) The browser builds the withdrawal proof against info.aspRoot read at prepare time (src/lib/pool/actions.ts:104-110, 193), then POSTs to the relayer, which simulates Entrypoint.relay and maps IncorrectASPRoot to 'The approval list changed' (relayer.ts:158-161); PoolFlow.tsx:315-348 re-proves exactly once and scheduled deliveries (src/lib/pool/deliver.ts:79) retry once. So one actor who makes a minimum deposit (1 tUSDG, free from the faucet) and calls POST /api/pool/approvals every 10 seconds forces a new latestRoot at the throttle rate. Any honest withdrawal whose fetch-events, prove (Groth16 in the browser, several seconds) and relay cycle does not fit inside a single 10-second root window is rejected, and the one automatic retry hits the next root. Funds are not lost (ragequit does not depend on the ASP root), but the private exit the pool exists for is unavailable for everyone while the loop runs. Attacker cost per 10 seconds: one deposit's gas; tUSDG is free and the deposits are recoverable by ragequit. Side effect: the approver signs each updateRoot with the relayer key (route.ts:61-67), so the attacker also spends the relayer's ETH at one transaction per 10 seconds. Each piece is correct on its own; the defect is the seam between the contract's latest-root rule and the approver's immediate-publish policy. Fix in the approver, not the vendored contracts: publish roots on a fixed cadence that comfortably exceeds proving plus relay time (for example at most one root per few minutes, or only when the pending set has been stable for N seconds), expose the next-root time in GET /api/relay so clients can start proving right after a root lands, and have the relayer hold a proof until the next window rather than reject it.","line":48,"path":"src/app/api/pool/approvals/route.ts","reproduction":"State: pool registered (minimumDeposit 1_000_000, vettingFeeBPS 0, approval delay 0), approver running with the relayer key, victim V holds an approved note. Deterministic variant in the tests/e2e-pool.test.ts setup: V calls prepareWithdrawal (proof carries aspRoot = R_k, the current latestRoot); before relayWithdrawal runs, attacker A does T.faucet(1_000_000); T.approve(entrypoint, 1_000_000); Entrypoint.deposit(T, 1_000_000, freshPrecommitment); then refreshApprovals(...) is invoked (the POST /api/pool/approvals path once 10 s have passed since the last RootUpdated). Observed by code: refreshApprovals computes a root over one more label, which differs from info.aspRoot (relayer.ts:258-259), and sends updateRoot, so latestRoot becomes R_(k+1). V's relayWithdrawal: the simulateContract at relayer.ts:158 reverts IncorrectASPRoot (PrivacyPool.sol:59) and the relayer answers 422 'The approval list changed. Generate the proof again.' Expected: a proof built against the approval set that contains V's deposit is relayed. Actual: rejected; repeating A's step every 10 s makes the Pool page's single retry (PoolFlow.tsx:335-346) and deliver.ts's single retry fail the same way, and no private withdrawal lands while the loop runs.","severity":"medium","snippet":"    if (last && now - Number(last.timestamp) < 10) {","title":"Immediate per-deposit approval roots plus the pool's latest-root-only rule let one depositor keep every private withdrawal proof stale"},{"citation":"resolved","description":"Merged from three specialist reports (economics: metadata not verified; permissions and flow: codeless token) that share one root cause: the v1 router takes token and announcer from the caller (lines 37-44), checks only that they are non-zero (line 48), and never compares the announcement with the transfer. Two consequences. (a) A low-level call to an address with no code returns ok == true with empty return data, so line 55 does not revert; execution forwards the stipend and calls announcer.announce (line 62), producing an Announcement whose indexed caller is the router with no token transfer behind it, contradicting the NatSpec promise at lines 14-20 that the announcement and the payment are atomic. (b) Even with the real token, the metadata's token and amount fields and the ephemeral key are not checked, and the recipient's balance delta is not verified, so an announcement can describe a different amount or token than was moved. v2 fixes both (constructor NotAContract check at QuietPayRouterV2.sol:64, _checkTransferMetadata at lines 102-106, balance delta at line 121). v1 is still accepted by the app: its code hash is in KNOWN_ROUTER_CODE_HASHES (src/config/contracts.ts:18-20) and verifyRouter returns version 1 for it (src/lib/chain.ts:143), so any network configured with a v1 router is exposed. The testnet (.env.production) uses the v2 deployment. Impact is limited to announcement pollution that looks router-originated: the app only shows a payment as claimable after it finds real Transfer logs, and a payer who sends less than announced only misleads themselves. Fix: drop the v1 hash from KNOWN_ROUTER_CODE_HASHES so the app never routes through v1, or, if v1 must stay deployable, add `if (token.code.length == 0) revert TokenTransferFailed();` and the v2 metadata and balance-delta checks.","line":55,"path":"contracts/src/QuietPayRouter.sol","reproduction":"Deploy QuietPayRouter (v1) R1 and ERC5564Announcer A. (a) Call R1.pay{value: 0}(0x000000000000000000000000000000000000dEaD, A, S, 5_000_000, 0x02 || 32 bytes, 57 bytes of metadata) from any account with no token balance or approval. Expected: revert TokenTransferFailed. Actual: `token.call` to the codeless address returns (true, empty), line 55 passes, and A emits Announcement(1, S, R1, key, metadata) with no Transfer event anywhere. (b) With tUSDG T: payer T.faucet(7_000_000); T.approve(R1, 1); R1.pay(T, A, S, 1, 0x1234, metadata with uint256(7_000_000) in bytes 25..56). Expected by the v2 rule: revert BadMetadata / BadEphemeralKey. Actual: success; Transfer of 1 base unit to S and an Announcement whose metadata says 7_000_000 and whose ephemeral key is 2 bytes. Deploying QuietPayRouterV2 with the codeless address reverts NotAContract and the mismatched metadata reverts BadMetadata, confirming the gap is v1-only.","severity":"low","snippet":"    if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TokenTransferFailed();","title":"QuietPayRouter v1 announces caller-chosen metadata and treats a codeless token address as a successful transfer"},{"citation":"resolved","description":"Reported by the economics specialist; reproduced by code trace. relayEconomics values the token fee at the configured RELAYER_ETH_PRICE (default 3000 tokens per ETH, src/lib/pool/server.ts:51-58, 80) and allows the relay plus a gas drop of up to RELAYER_GAS_DROP_ETH (default 0.0002 ETH, server.ts:71) when the fee at that price covers them. The pool's token is QuietPayTestUSDG, which anyone mints at up to 10,000 tUSDG per faucet call (contracts/src/QuietPayTestUSDG.sol:29-34), so the 'fee' that is meant to pay the relayer is free to produce and the comment on RelayerPolicy.tokenUnitsPerEth ('someone could make many tiny withdrawals to addresses they control and collect the relayer's ETH') describes exactly what remains possible. Per accepted withdrawal the relayer spends the relay gas (RELAY_GAS_QUOTE 750k with 20% headroom, relayer.ts:164-182) plus the gas drop to any recipient whose balance is below the drop (relayer.ts:168-176, 190-193), in exchange for worthless tokens; withdrawing to a fresh address each time fires the drop every time. The only bound is the rate limit of 30 relays per 10 minutes per client id, which is a salted hash of the forwarded IP (src/app/api/relay/route.ts:15, 64; src/lib/server/guard.ts:132-145), so a few IPs empty a modestly funded hot wallet, after which GET /api/relay reports available:false (route.ts:48) and every user's private withdrawals stop. The docs already list a live price and a balance monitor as mainnet work (docs/shielded-pool.md:203-205); the testnet-specific gap is that the economic guard cannot work while the pool token is mintable for free. Fix: on the mock-token network require a minimum deposit age or a per-recipient drop cooldown, cap the gas drop per recipient and per day, or disable the drop and keep a balance alarm.","line":62,"path":"src/lib/pool/relayer.ts","reproduction":"Attacker with a little testnet ETH: T.faucet(10_000_000_000); deposit 100 tUSDG notes through Entrypoint.deposit; after approval, for each note run prepareWithdrawal(amount 100_000_000, recipient = fresh empty address, feeBPS 50) and POST the result to /api/relay. Expected per the tokenUnitsPerEth comment: the fee pays for the relay and the top-up, so the relayer's ETH does not fall in net terms. Actual per relay: relayEconomics sees feeWei = (100_000_000 * 50 / 10_000) * 1e18 / 3000e6 = 1.67e14 wei (0.000167 ETH); at a 0.1 gwei gas price relayCost = 900_000 * 1e8 = 9e13 wei, so surplus = 7.7e13 wei, ok == true and drop = min(surplus - 21_000 * 1e8, 0.0002 ETH) = about 0.000075 ETH; the relayer pays the relay gas and sends the drop to the fresh address, receiving 0.5 tUSDG that cost nothing to mint. Thirty such relays per 10-minute window per client id, repeated from several IPs, drive the relayer balance below gasDrop * 2, at which point /api/relay returns available:false.","severity":"low","snippet":"  const feeWei = (((p.amount * p.feeBPS) / 10_000n) * WEI) / p.tokenUnitsPerEth;","title":"Relayer's fee-covers-gas rule is a no-op against the free faucet token, so its ETH can be drained in bounded amounts"},{"citation":"resolved","description":"Reported by the math specialist; reproduced by code trace. _pay rejects only address(0) as the recipient. With stealthAddress == address(this), transferFrom(msg.sender, router, amount) succeeds for any standard token, the balance-delta check at line 121 passes because the router's own balance rises by exactly amount, the stipend branch is skipped when msg.value == 0, and the announcement is emitted. The contract has no function that moves tokens out (only pay and payOnce exist, and tests/e2e-router-v2.test.ts:296-300 asserts that), so the tokens are frozen forever, contradicting the header's 'it never holds funds between calls' (line 25). The same holds for stealthAddress == token. The app always derives a fresh stealth address, so only a caller who crafts the call directly loses funds, which keeps this low. Fix: `if (stealthAddress == address(0) || stealthAddress == address(this) || stealthAddress == token) revert ZeroAddress();` (or a dedicated BadRecipient error).","line":109,"path":"contracts/src/QuietPayRouterV2.sol","reproduction":"Deploy QuietPayTestUSDG T, ERC5564Announcer A, QuietPayRouterV2 R(T, A). Payer: T.faucet(1_000_000); T.approve(R, 1_000_000); R.pay{value: 0}(address(R), 1_000_000, 0x02 || 32 bytes, meta57) with meta57 = 0x42 || 0xa9059cbb || bytes20(T) || uint256(1_000_000). Expected: revert (the router must never hold funds). Actual: success, Announcement emitted with stealthAddress == R, T.balanceOf(R) == 1_000_000, and no code path can ever reduce it. With msg.value > 0 the same call reverts StipendTransferFailed because the router has no receive function, which shows the self-address case was only ever blocked by accident on the ETH leg.","severity":"low","snippet":"    if (stealthAddress == address(0)) revert ZeroAddress();","title":"QuietPayRouterV2 accepts its own address as the stealth address and permanently locks the tokens it receives"},{"citation":"resolved","description":"Reported by the permissions specialist; confirmed as a documented privileged power, not a bypass. updatePoolConfiguration (lines 239-253, onlyRole(_OWNER_ROLE)) accepts any vetting fee below 10_000 bps with no delay; _handleDeposit (line 332) reads the fee in force when the deposit executes, and neither deposit overload (lines 111-126) takes a maximum-fee or minimum-received argument. A deposit sent while the fee is 0 bps (the testnet value, scripts/deploy-testnet.ts:142) is charged whatever fee is in force when it is mined, and the retained amount is sweepable through withdrawFees (lines 264-272). This is unchanged upstream 0xbow code and intentional. The actor matters: scripts/deploy-testnet.ts:139 makes the owner the GitHub Actions deployer account, and docs/shielded-pool.md:165-169 says only that the owner can 'change fees within limits'. Record precisely that the owner key can take up to 99.99% of any deposit that lands after a fee change. Mitigation within the agreed design: owner behind a multisig and timelock before any mainnet pool (already listed at docs/shielded-pool.md:202), and have the client read the fee in the same block as the deposit and refuse when it changed, or pass an expected fee if the Entrypoint is ever extended.","line":396,"path":"contracts/vendor/privacy-pools/src/contracts/Entrypoint.sol","reproduction":"State: pool registered with vettingFeeBPS = 0. User U submits deposit(tUSDG, 100_000_000, precommitment) expecting 100 tUSDG credited. Owner O submits updatePoolConfiguration(tUSDG, 1_000_000, 9_999, 100) and it is mined first. U's deposit executes with _config.vettingFeeBPS = 9_999: _deductFee gives 100_000_000 - (100_000_000 * 9_999 / 10_000) = 10_000 base units (0.01 tUSDG) into the pool commitment, 99_990_000 stay in the Entrypoint. O calls withdrawFees(tUSDG, O) and receives 99.99 tUSDG. Expected by the user: the 0% fee shown when the deposit was built. Actual: 99.99% taken, with no onchain parameter letting the user bound the fee. Precondition: owner key acts maliciously or is compromised.","severity":"info","snippet":"    if (_vettingFeeBPS >= 10_000 || _maxRelayFeeBPS >= 10_000) revert InvalidFeeBPS();","title":"Trust assumption: the Entrypoint owner can set a vetting fee up to 99.99% that applies to deposits already in flight; the owner is the CI deployer key"},{"citation":"resolved","description":"Reported by the permissions specialist; confirmed as a documented privileged power, not a bypass. _authorizeUpgrade lets _OWNER_ROLE replace the implementation at any time with no timelock. In the relay path PrivacyPool.withdraw pushes the withdrawn tokens to _withdrawal.processooor (PrivacyPool.sol:124), which for every relayed withdrawal must be the Entrypoint (Entrypoint.sol:141), and only then does Entrypoint.relay forward them to _data.recipient and the fee recipient (lines 167-169). The proof binds the recipient inside _withdrawal.data, but the pool pays the Entrypoint and trusts its code to forward. A replacement implementation whose relay omits the two _transfer calls keeps every relayed withdrawal that lands after the upgrade, and withdrawFees (line 264) then sweeps it. Direct withdrawals (processooor = the user) and ragequit do not pass through the Entrypoint, so pool principal at rest is safe, which is what SECURITY.md:209-210 and docs/shielded-pool.md:166-168 describe ('The owner cannot take pool funds'). The gap is funds in transit through the Entrypoint, that is every withdrawal made with the QuietPay relayer, and the owner is currently the CI deployer key (scripts/deploy-testnet.ts:139). Upstream design; record the power precisely in SECURITY.md and docs/shielded-pool.md, and before mainnet put the owner behind a multisig and timelock so users can exit or withdraw directly before an upgrade takes effect.","line":308,"path":"contracts/vendor/privacy-pools/src/contracts/Entrypoint.sol","reproduction":"State: Entrypoint proxy E owned by O, pool P registered for tUSDG, user U has an approved commitment of 50 tUSDG. O deploys an implementation whose relay(...) calls _pool.withdraw(_withdrawal, _proof) and returns without the two _transfer calls, then calls E.upgradeToAndCall(newImpl, \"\"). U (or the relayer for U) submits relay(withdrawal{processooor: E, data: {recipient: U2, feeRecipient: relayer, relayFeeBPS: 50}}, proof, scope). Actual: P pushes 50 tUSDG to E (PrivacyPool.sol:124), the new relay keeps them, E.withdrawFees(tUSDG, O) sweeps 50 tUSDG to O. Expected per SECURITY.md:209: the owner cannot obtain user withdrawals. Precondition: owner key acts maliciously or is compromised; no unprivileged actor can trigger this.","severity":"info","snippet":"  function _authorizeUpgrade(address) internal override onlyRole(_OWNER_ROLE) {}","title":"Trust assumption: the UUPS owner can upgrade the Entrypoint and keep relayed withdrawals in flight, which the security notes say the owner cannot do"}],"hash":"6ee0066c4832f4e5b1e245e273bf04d1464eeb8e3d482dd5a2b3274617432927","nodeId":"3beb84fb-1142-45be-8212-12a4b8cfa810","outcome":"completed","summary":"The shell sandbox stayed unavailable for the whole session, so every reproduction was done by reading the code rather than executing it. The findings file is written and complete.\n\n**Outcome:** 12 specialist findings reduced to 7 in `.imd-findings.json`, all reproduced against the tree by code trace. Nothing was dropped as non-reproducing.\n\n**Merges**\n- The four payOnce reports (economics, math, permissions, flow) describe one mechanism and are now one medium finding on `contracts/src/QuietPayRouterV2.sol` line 98. The math specialist had rated it low; medium stands because anyone holding a link can kill the invoice for one base unit plus gas, and the app has no fallback path.\n- The three v1 router reports (codeless token accepted twice, metadata unverified once) share one root cause and are one low finding on `contracts/src/QuietPayRouter.sol` line 55.\n\n**Kept as reported**\n- Medium: approval root churn. The pool accepts only the latest ASP root, the approver publishes a new root per deposit with a 10-second floor, and the client retries once. The reproduction is deterministic in the e2e-pool setup by inserting a deposit and root refresh between proving and relaying.\n- Low: relayer ETH drain through the free faucet token, and router v2 locking tokens sent to its own address.\n- Info: the two owner trust assumptions on the vendored Entrypoint, the in-flight vetting fee and the upgrade path that holds relayed withdrawals in transit.\n\n**Proof status.** The economics specialist's Foundry test is attached to the payOnce finding. I checked it line by line: the 76-byte metadata layout, the sink address needed to pass the balance-delta check, and the AlreadyPaid revert on the honest payer's call all match the contract. It was not executed. The shell sandbox failed to initialize on every attempt, so neither forge nor a JSON validator ran here. The repository also has no foundry.toml; the proof imports contracts by their repository-root path, which is the convention the task asked for.\n\n**Not verified.** The 5 to 10 second browser proving time cited in the root-churn finding is the specialist's measurement, not mine. The finding's deterministic variant does not depend on it.","treeHash":null,"usage":{"cachedInputTokens":1044089,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":26380,"runtime":"claude","turns":43,"wallClockMs":376615}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"323aec17bfc8d652","findings":[{"citation":"resolved","description":"QuietPayRouterV2.payOnce marks `paidOnce[requestId] = true` (line 98) and refuses every later call for the same ID (line 94). The record is bound to nothing but the 16-byte request ID: not to the recipient (the stealth address and ephemeral key are caller-chosen and cannot be checked onchain), not to the invoice amount (`amount` only has to equal the amount byte-field in the caller's own metadata, line 105), and not to the payer. Every holder of the payment link knows the request ID (README 'Payment link format', bytes 47..62), and so does anyone who sees a payOnce transaction before it is sequenced. Such a party can call payOnce with the victim's request ID, `amount = 1` base unit, any 33-byte key starting 0x02/0x03, and a stealth address they control. The transfer goes to themselves, so the net cost is gas plus one micro-token. From then on the honest payer's payOnce reverts with AlreadyPaid, and the app's preflight (src/lib/payer.ts:290-298) reads `paidOnce` and reports 'This link was already paid' without offering any other route (submitRoutedPayment always uses payOnce for fixed links). The recipient never sees the griefer's announcement because its ephemeral key does not match their viewing key (src/lib/receive.ts:36), so from both sides the invoice silently dies. The previous off-chain check deliberately did not trust an announcement alone and required that the announced address 'received at least the link's amount' (SECURITY.md 'Partial transaction failures', src/lib/payer.ts:99); the onchain gate is weaker than that and, unlike a scan result, cannot be overridden. Impact: denial of service of any one-time invoice by anyone who has seen the link (a QR shown at a counter, a link in a group chat, a bystander at the sequencer feed), forcing the recipient to issue a new link and leaving the payer with a dead-end error. No funds are stolen. Fix options, each a design decision: (a) keep the hard lock but record what was paid, e.g. `mapping(bytes16 => bytes32) paidRecord` = keccak256(stealthAddress, ephemeralPubKey, amount), and let clients verify the recorded stealth address derives from the link's meta-address and that the amount is at least the invoice; on a mismatch the payer client treats the record as spam and pays through a path that still carries the request ID in metadata (today `pay` rejects 76-byte metadata, line 79, so that path would have to accept it); or (b) drop the onchain lock and keep the off-chain check with the amount bound. Either way the lock should not be the sole authority for 'already paid'.","line":94,"path":"contracts/src/QuietPayRouterV2.sol","reproduction":"Deploy QuietPayTestUSDG T, ERC5564Announcer A, QuietPayRouterV2 R = new QuietPayRouterV2(T, A). Let RID = 0x11223344556677889900aabbccddeeff be the request ID of a 100 tUSDG invoice whose recipient one-time address is S. Griefer G (any address that has the link): T.faucet(1); T.approve(R, 1); R.payOnce(RID, G2, 1, hex'02' ++ 32 zero bytes, meta) where G2 != G is another address G controls and meta = 0x01 ++ a9059cbb ++ bytes20(T) ++ bytes32(1) ++ 515001 ++ RID (76 bytes). Succeeds: R.paidOnce(RID) == true, T.balanceOf(G2) == 1. Honest payer P: T.faucet(100_000_000); T.approve(R, 100_000_000); R.payOnce(RID, S, 100_000_000, key, meta100) where meta100 has bytes32(100_000_000) in bytes 25..56. Expected (per README 'one-time payment links': the invoice can be paid once, to its recipient): the call succeeds and T.balanceOf(S) == 100_000_000. Actual: revert AlreadyPaid(RID); T.balanceOf(S) == 0, and the QuietPay payer UI shows 'This link was already paid' for every payer thereafter. Cost to G: gas plus 1 base unit that G keeps.","severity":"medium","snippet":"    if (paidOnce[requestId]) revert AlreadyPaid(requestId);","title":"payOnce one-time lock is keyed only on the request ID, so any link holder can permanently block an invoice with a 1-unit payment to themselves"},{"citation":"resolved","description":"In v1 the `token` is a caller parameter (line 38) and is only checked against address(0) (line 48). A low-level `token.call(...)` to an address with no code returns `ok = true` with empty return data, and the empty-return tolerance on line 55 then treats that as a successful transferFrom. Execution continues to forward the ETH stipend and emit the ERC-5564 Announcement through the caller-chosen announcer (line 62), so the router (whose address appears as `caller` in the event) vouches for a payment that never happened. The NatSpec on lines 14-20 promises 'if any step fails, the whole payment reverts, so a payment can never land without its announcement', and the inverse is implied: no announcement without a payment. The app mitigates this off-chain (it passes only the allowlisted token and confirms Transfer logs before showing a payment as claimable), and v2 fixes it by binding the token at deployment and checking `code.length` (QuietPayRouterV2.sol:64) plus a balance delta. v1 remains recognised by the app (KNOWN_ROUTER_CODE_HASHES, version 1 in src/lib/chain.ts:143) and is still deployable from this tree, so the defect is live wherever v1 is configured. Fix: `if (token.code.length == 0) revert TokenTransferFailed();` before the call, or verify the recipient balance delta as v2 does.","line":55,"path":"contracts/src/QuietPayRouter.sol","reproduction":"Deploy ERC5564Announcer A and QuietPayRouter R. Pick EOA = 0x000000000000000000000000000000000000dEaD (no code), stealth S = any EOA, key = 33 bytes. Call R.pay{value: 0}(EOA, A, S, 5_000_000, key, metadata) from any account with no token balance or approval anywhere. Expected: revert TokenTransferFailed (nothing was transferred). Actual: the call succeeds and A emits Announcement(1, S, R, key, metadata); no Transfer event exists and S received nothing. With the same call to v2 (QuietPayRouterV2) deployment itself reverts NotAContract for a code-less token, confirming the v1-only gap.","severity":"low","snippet":"    if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TokenTransferFailed();","title":"QuietPayRouter (v1) treats a token address with no code as a successful transfer and emits the announcement without moving funds"}],"hash":"e07acac68133bdcf49be00b20f7f64e08e7432c05d9a1e60cda32b4ea1060a1a","nodeId":"6003b68f-d7e4-4915-bd21-39770daa1e80","outcome":"completed","summary":"The review is complete and the findings are written to `.imd-findings.json`.\n\n**Scope covered.** I traced every entry point of the four QuietPay contracts: `pay` and `payOnce` in router v2, `pay` in router v1, `announce` in the announcer, and all five state-changing functions of the test token. For each I walked the execution path through the metadata checks, the low-level token call, the balance delta, the ETH stipend callback and the announcement, and tested reentrancy, return-value handling, no-code targets, sentinel inputs and parameter divergence. I also read the vendored Privacy Pools contracts and compared the Entrypoint and PrivacyPool sources against the pinned upstream commit. They match, so the \"unchanged\" claim holds for the files I checked.\n\n**Findings written** (2):\n\n- **Medium, router v2 `payOnce`.** The one-time lock is keyed only on the 16-byte request ID, which every link holder knows. Anyone with the link can call `payOnce` with the victim's ID, a 1-unit amount and an address they control. The real payer's `payOnce` then reverts with `AlreadyPaid`, the app's preflight reports the link as paid, and the recipient never sees the griefer's announcement because the ephemeral key does not match. Net attacker cost is gas. The earlier off-chain check at least required the announced address to have received the invoice amount. I proposed recording what was paid so clients can verify it, or keeping the lock advisory, and flagged both as design decisions.\n- **Low, router v1 `pay`.** A caller-supplied token address with no code makes the low-level call succeed with empty return data, which the code treats as a successful transfer. The announcement is emitted without any funds moving. Router v2 fixes this by binding the token at deployment and checking the balance delta. The app still recognises v1 by code hash.\n\n**What was clean.** Router v2's checks-effects ordering, reentrancy guard, exact-amount delivery, fee-on-transfer rejection, false-returning and void-returning token handling, stipend cap, compressed-key check and atomic revert all held under the adversarial inputs I traced. Approvals cannot be spent by anyone but the approving payer. The test token and announcer behave as documented.\n\n**Limitation.** The Bash sandbox failed to initialise on every attempt in this session, so I could not run `forge` or the project's Vitest suite, and I wrote no Foundry proof files. Neither finding is critical or high, so no proof was required. Both reproductions are written as exact call sequences with expected and actual results so the author and verifier can run them directly.","treeHash":null,"usage":{"cachedInputTokens":1446569,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":29500,"runtime":"claude","turns":48,"wallClockMs":479936}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"707624586655cf74","findings":[{"citation":"resolved","description":"Boundary (sentinel / self-address input). _pay only rejects address(0) as the recipient. If the caller passes the router's own address, transferFrom(msg.sender, address(this), amount) succeeds, the balance-delta check at line 121 passes (the router's balance rises by exactly `amount`), and the announcement is emitted. The contract has no function that can move tokens out (no owner, no sweep, no transfer path), so the tokens are frozen forever. This contradicts the documented invariant in the contract header (line 25: 'it never holds funds between calls') and the test 'never holds funds and has no owner functions' in tests/e2e-router-v2.test.ts, which only checks the balance after well-formed payments. Same outcome for stealthAddress == token (tokens sent to the token contract), which the router could also reject cheaply. Impact is limited to the caller's own funds (the app always derives a fresh stealth address), so severity is low. Fix: in _pay, `if (stealthAddress == address(0) || stealthAddress == address(this) || stealthAddress == token) revert ZeroAddress();` (or a dedicated BadRecipient error).","line":109,"path":"contracts/src/QuietPayRouterV2.sol","reproduction":"Deploy QuietPayTestUSDG (tUSDG), ERC5564Announcer, then QuietPayRouterV2(tUSDG, announcer). Payer: tUSDG.faucet(1_000_000); tUSDG.approve(router, 1_000_000). Build 57-byte metadata: [0]=any view tag, [1..4]=0xa9059cbb, [5..24]=tUSDG address, [25..56]=uint256(1_000_000). Call router.pay(address(router), 1_000_000, hex\"02\" ++ 32 arbitrary bytes, metadata) with msg.value = 0. Expected: revert (router must never hold funds). Actual: call succeeds, Announcement emitted with stealthAddress == router, tUSDG.balanceOf(router) == 1_000_000 and no code path can ever reduce it. With msg.value > 0 the same call reverts with StipendTransferFailed (router has no receive), which shows the self-address case was not considered for the token leg.","severity":"low","snippet":"    if (stealthAddress == address(0)) revert ZeroAddress();","title":"QuietPayRouterV2 accepts its own address as the stealth address and permanently locks the tokens it receives"},{"citation":"resolved","description":"payOnce binds the request ID only to the metadata it carries; it does not (and cannot) bind it to the recipient's stealth meta-address or to the link's amount. So anyone who knows a fixed link's 16-byte request ID (it is bytes 47..62 of the link, visible to everyone the link was shared with) can call payOnce with a stealth address they control and amount = 1 base unit, and the router records paidOnce[requestId] = true. The real payer's preflight (src/lib/payer.ts lines 290-298) reads paidOnce and reports 'This link was already paid' with status fail, so the app refuses to pay; the recipient's scanner never matches the bogus announcement (wrong keys) and sees nothing. The link is burned at a cost of gas plus 1 unit of tUSDG/USDG sent to the attacker's own address. The pre-v2 design (findExistingPayments, comment at src/lib/payer.ts line 72: 'Faking that means paying in full') required at least the link's full amount as float; v2 lowers that to 1 base unit and makes the flag authoritative. No funds are lost and the recipient can issue a new link, so severity is low; it is reported because it is a concrete, cheap denial of a documented guarantee ('a request ID can be paid through payOnce only once, whoever pays', line 22-23). Mitigations that preserve the design: in the app, treat paidOnce as 'paid' only when the matching announcement's transfer is >= the link amount (reuse findExistingPayments logic) and otherwise warn instead of hard-failing; or derive the onchain key from keccak(requestId, amount) so a 1-unit self-payment cannot set the flag for the real amount (the attacker then needs the full amount as float, as in v1).","line":98,"path":"contracts/src/QuietPayRouterV2.sol","reproduction":"Setup as in the e2e test: tUSDG, announcer, RouterV2(tUSDG, announcer). Recipient creates a fixed link for 7_000_000 (7 tUSDG); its requestId R is in the link. Attacker (any address that has seen the link): tUSDG.faucet(1); tUSDG.approve(router, 1); metadata76 = [viewTag][a9059cbb][tUSDG][uint256(1)][0x515001][R]; router.payOnce(R, attackerEOA2, 1, hex\"02\"++32 bytes, metadata76). Actual: succeeds, paidOnce(R) == true, attackerEOA2 received 1 unit. Then the legitimate payer runs preflight for the link: readContract paidOnce(R) returns true, check id 'unpaid' is status 'fail', PreflightResult.ok == false; a direct router.payOnce(R, realStealth, 7_000_000, ...) reverts with AlreadyPaid(R). Expected (per the header comment and the v1 threat model): marking a link paid should cost the attacker at least the link's amount, and the real payer should not be blocked by a 1-unit payment to a third party.","severity":"low","snippet":"    paidOnce[requestId] = true;","title":"Anyone holding a one-time link can mark it paid onchain for one base unit, blocking the real payer (outside the assigned math area)"}],"hash":"e8b928979b6755dfb9b3f0f5d25ef67f645f0600f0c874c714d9cce349c5ee72","nodeId":"cf27479d-277d-4839-961f-689e2cf929ff","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds two substantiated low-severity findings. No critical, high or medium defects were found in the math area.\n\n**What was reviewed.** All four custom contracts (QuietPayRouterV2, QuietPayRouter v1, QuietPayTestUSDG, ERC5564Announcer), the adversarial test mocks, and the vendored Privacy Pools contracts (Entrypoint, PrivacyPool, State, the Complex and Simple implementations, ProofLib, the LeanIMT library). I applied the Math Precision, Boundary and Numerical Gap passes to each, then followed the inputs the contracts receive into the off-chain math that produces them: amount parsing, link encoding, metadata layout, stipend estimation, relayer fee economics, and the stealth-key and pool-key scalar derivations.\n\n**Math result.** The custom contracts contain no division and only two subtractions, both checked. Metadata slice offsets match the off-chain encoder byte for byte. The stipend cap, the uint128 link amount, the pool's uint128 deposit bound and the 32-depth tree limit are consistent with each other. Vendored fee math rounds fees down, which is user-favorable and bounded to one base unit per operation; with the deployed configuration the vetting fee is zero and the relayer refuses amounts whose fee cannot cover gas. Scalar derivations reduce 384-bit or 512-bit HKDF output modulo the group or field order and add one to avoid zero. I found no reachable overflow, truncation, scale mismatch or boundary that moves value incorrectly.\n\n**Findings recorded:**\n- **Router locks tokens sent to itself (low).** Passing the router's own address as the stealth address passes every check and leaves the tokens frozen with no recovery path, contradicting the contract's \"never holds funds\" invariant. Reproduction and a one-line fix are in the file.\n- **One-time link can be burned for one base unit (low, outside the math area).** Anyone who has seen a fixed link can call `payOnce` with a 1-unit self-payment, setting the paid flag so the real payer's app refuses to pay. The earlier scan-based design required the full amount as float. No funds are lost.\n\n**Limitations.** The Bash sandbox failed to initialize on every attempt, so Foundry never ran. Neither finding is high enough to require a proof test, so nothing was lost there, but the suite in `tests/` was not executed either. The repository has no Foundry layout, so a proof for any future high finding would need a scratch project with forge-std added.","treeHash":null,"usage":{"cachedInputTokens":1484749,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":34646,"runtime":"claude","turns":63,"wallClockMs":593527}}],"verification":[]}