{"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":"ae3c9745-7363-4bd2-bfaf-dc8944649cd8","kind":"audit","nodes":[{"acceptedSubmissionHash":"7532d7bc5cc1393fd17ae96c2bd4e1a35dd7b01a7d149dc086178a052c83e5e8","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0b46bf7c37d30d9e0e1dd080295ce1a5da996823aabce13cde0f03743777b61f","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"6b68bfdb7192dd540e4e3de42b9a3066e3108a376ce05349382673132196335e","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"7f935a189537f257c5e7002d17f3c388b8274d20ffd65b49663e845e1ee56a37","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0f062f7ce1b364fbcd6cc5cbf2a0dc3b684ffb5412637f87018e9f973d252892","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit this TypeScript client for the IMD swarm's paid requests before anyone funds it. Focus: private-key handling (env only, never logged or persisted, no leak through errors), EIP-712 Permit2 and QuoteApproval signing (domains, types, paymentHash over key-sorted JSON), nonce and deadline handling, spending caps and dry-run defaults, refusal of mismatched asset or payTo (address poisoning), double payment on retry, and dependency risks. Each finding with location, impact and the exact call sequence that triggers it.","parentJobId":null,"planHash":"b0e55de62374d10313eeadba3eb5bbf21ab593630d5c44188df7e7df73844500","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"ae3c9745-7363-4bd2-bfaf-dc8944649cd8","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":"51226","feedbackHash":"377ba9750d574ec429f8bcd51bfb7177040e12232d5f344faefebf97040c1cc7","nodeKey":"audit_economics","submissionHash":"7532d7bc5cc1393fd17ae96c2bd4e1a35dd7b01a7d149dc086178a052c83e5e8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50956","feedbackHash":"fd5ab458bfe1fb81e67a2aaafafb4514c7f075c9282e3bc874711e5116e818f0","nodeKey":"audit_flow","submissionHash":"0b46bf7c37d30d9e0e1dd080295ce1a5da996823aabce13cde0f03743777b61f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50971","feedbackHash":"e790ed79f7153b718c0bcfc41d72a43a76cb2daade21ac3fa7ea14fd5b012248","nodeKey":"audit_judge","submissionHash":"6b68bfdb7192dd540e4e3de42b9a3066e3108a376ce05349382673132196335e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50974","feedbackHash":"03e3dcdd4c3cc3bc67a99e8d0531d659b6d131ab423e50a7b3a95a1d8009fa4b","nodeKey":"audit_math","submissionHash":"7f935a189537f257c5e7002d17f3c388b8274d20ffd65b49663e845e1ee56a37","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51046","feedbackHash":"4d8f4f8e4d613be7c5a7d9ed0092a651eab6df784492f9c5209ad9406ea75d0e","nodeKey":"audit_permissions","submissionHash":"0f062f7ce1b364fbcd6cc5cbf2a0dc3b684ffb5412637f87018e9f973d252892","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"26bc3a0e4dd2d35c71f2cdca6ac4fddd2b25ffabd11fb0a0b5a59fb0932f30dd","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bb0a3bf63233e5e5","findings":[{"citation":"resolved","description":"reservationQueue serializes reservations only inside one loaded JavaScript module. Separate CLI invocations have independent queues and daily maps, but update the same daily-spend.json with an unlocked read/modify/write. Two processes can both read zero, each authorize the entire daily budget, and overwrite the ledger with the same single-payment total. This violates the spending limit even with honest responses and different orders. N concurrent processes can authorize N times the configured cap, subject to wallet balance and Permit2 allowance. Use a process-safe lock or transactional store around the whole reservation and atomic, durable ledger writes; a JavaScript Promise alone cannot protect the CLI. The shipped dist/index.js has the same issue.","line":61,"path":"src/index.js","reproduction":"Start two Node/CLI processes under the same OS user, with the same funded signer, XDG_STATE_HOME, an empty daily-spend.json, and default maxPerRequest=maxPerDay=500000000000000000. In each call pay on a different order with execute:true. Each unsigned submit returns 402 for 500000000000000000 IMD base units; quote.payment, accepts[0], and capabilities agree on asset 0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7 and payTo 0x4e0fa57bde726079356537e2f34d671e9f41adbc, expiresAt=now+600; status returns {status:\"quoted\"}. Interleave: process A reads ledger {}, process B reads ledger {}, A writes the current UTC date -> \"500000000000000000\", B writes the identical value, then both sign and submit. Expected: at most one submission and the other fails its daily cap. Actual: two independently signed 0.5 IMD payments (1 IMD total), final ledger only 0.5 IMD. Reproduced using two actual child processes and LocalPrivateKeySigner with public test key 1; an IPC-backed in-memory filesystem held both reads until both processes had their zero snapshot. No real payment or disk mutation was performed.","severity":"high","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Concurrent CLI processes bypass the persistent daily spending cap"},{"citation":"resolved","description":"Every execute:true pay call generates a new random Permit2 nonce, and the client retains neither the signed payment nor an in-flight/completed marker for the order. If a paid POST reaches the server but its response is lost and the next challenge still returns 402, the retry produces another valid permit and QuoteApproval for the same quote. The two nonces defeat transaction-level replay protection: each authorization can be settled once, charging twice for one order. There is no automatic paid retry loop; this is triggered by retrying pay after its error, including a new CLI invocation. Persist and reuse the exact signed authorization for an unresolved order, serialize calls per order, and reconcile ambiguous results before issuing any replacement. The quote approval does not protect the token transfer itself: the [x402 exact proxy](https://github.com/coinbase/x402/blob/main/contracts/evm/src/x402ExactPermit2Proxy.sol) accepts permit/owner/witness/signature without an order ID, and [Permit2](https://github.com/Uniswap/permit2/blob/main/src/SignatureTransfer.sol) rejects only a reused nonce. The shipped dist/index.js has the same issue.","line":63,"path":"src/index.js","reproduction":"Use the same signer, an empty ledger, default caps of 0.5 IMD, and one order-1 priced at 250000000000000000 base units. Return a valid 402 challenge with quote id quote-1, a fixed 32-byte quoteHash, matching IMD asset and payTo across accepts/quote.payment/capabilities, and expiresAt=now+600; GET status returns {status:\"quoted\"}. First call client.pay(\"order-1\",undefined,{execute:true}): record the signed POST and then reject fetch with TypeError(\"fetch failed after upload\"), modeling a delivered request with a lost response before status has been updated. Call pay again on order-1 with execute:true while the unsigned submit still returns the same 402. Actual, reproduced with LocalPrivateKeySigner and public test key 1: four signatures, two signed POSTs, same quote ID, different Permit2 nonces and signatures, and 500000000000000000 total authorized for the single 250000000000000000 order. With at least 0.5 IMD balance and allowance, settling the first permit and then the second before deadline uses distinct nonce bitmap bits and transfers 0.25 IMD each. Expected: retry reuses the first authorization or refuses/reconciles the unresolved payment. Local reproduction verified the emitted signatures and payment hashes; no live onchain transfer was sent.","severity":"high","snippet":"    const nonce=BigInt(`0x${randomBytes(32).toString('hex')}`);","title":"Retrying an ambiguous submission signs a second independently spendable payment"},{"citation":"resolved","description":"verifyTerms reads the challenge payment through paymentOf(q), but compares the saved quote using originallyQuoted.payTo and originallyQuoted.amount directly. When quote() or status() returns a quote with the same {payment:{asset,amount,payTo}} structure that the payment flow itself requires at line 67, the original payTo and amount appear undefined and an identical challenge is rejected. The documented quote -> pay flow cannot execute for this response shape. Normalize the original quote payment with paymentOf(originallyQuoted) before comparing it, and retain identity/expiry checks. The same implementation is shipped in dist/index.js.","line":49,"path":"src/index.js","reproduction":"Return POST /requests/quote = {order:{id:\"order-1\"},quote:q}, where q={id:\"quote-1\",quoteHash:\"22\" repeated 32 times,action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:\"500000000000000000\",payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:floor(Date.now()/1000)+600}. Call client.quote(\"job.open\",{}) then client.pay(\"order-1\",signer,{execute:true}); have the unsigned POST /requests/order-1/submit return 402 with quote:q and accepts:[q.payment], and GET /requests/capabilities return {actions:[{action:\"job.open\",payment:q.payment}]}. Expected: unchanged terms pass and signing begins. Actual, reproduced with the real ImdClient and an injected fetch: it throws \"challenge quote differs from the original quote\" with zero signer calls. Returning {quote:q} from status() triggers the same failure without the quote cache.","severity":"medium","snippet":"    if(originallyQuoted&&(!eqAddress(originallyQuoted.payTo,qp.payTo)||String(originallyQuoted.amount)!==String(qp.amount)||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"Unchanged quotes with nested payment terms are rejected before payment"},{"citation":"resolved","description":"pay permanently increments both the on-disk ledger and the in-memory daily total before checking quote expiry or invoking either signer. Failures before a payable request exists never release this reservation. An ordinary expiry race or rejected signer request therefore blocks further payments for the UTC day even though nothing was paid. With the defaults a single failed 0.5 IMD attempt exhausts the whole allowance. Validate the complete challenge and deadline before reserving, and release reservations when signing fails before submission; retain and reconcile reservations for ambiguous network outcomes rather than blindly refunding them. The same implementation is shipped in dist/index.js.","line":61,"path":"src/index.js","reproduction":"Start with an empty daily ledger and default maxPerRequest=maxPerDay=500000000000000000. Call pay(\"order-expired\",signer,{execute:true}). Return status={status:\"quoted\"}, matching IMD asset/amount/payTo in the 402 challenge quote.payment, accepts[0], and capabilities, but set quote.expiresAt=floor(Date.now()/1000)+4. Include the normal quote id/hash, resource and requesterScopeHash. Actual: line 61 records 500000000000000000; line 62 throws \"quote expires too soon to sign safely\"; zero signer calls and zero paid submissions occur. Immediately call pay(\"order-fresh\",signer,{execute:true}) with the same price and expiresAt=now+600. Expected: the fresh order can spend the untouched daily budget. Actual: \"daily IMD spending cap exceeded\". Reproduced with real ImdClient, injected HTTP responses, and filesystem calls redirected to an in-memory ledger.","severity":"medium","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}\n    const expiry=BigInt(terms.q.expiresAt), now=BigInt(Math.floor(Date.now()/1000)), deadline=expiry-5n;if(deadline<=now)throw new Error('quote expires too soon to sign safely');","title":"An expired quote consumes the entire daily cap without signing or paying"},{"citation":"resolved","description":"verifyTerms checks only asset/payTo/amount and never verifies accepted.network, scheme, or extra.assetTransferMethod. pay then unconditionally signs chainId 1 and the exact Permit2 proxy while copying the contradictory acceptance fields into the transmitted payment. For a Sepolia challenge this discloses an actual mainnet transfer authorization, rather than refusing an unsupported network; a conforming Sepolia facilitator cannot use it, while anyone possessing it can submit it to the mainnet exact proxy if the wallet has balance and allowance. Similarly, an upto or eip3009 challenge receives an incompatible exact Permit2 signature. Enforce network=eip155:1, scheme=exact, and assetTransferMethod=permit2 before reserving or invoking a signer; reject contradictory metadata. This is also present in dist/index.js.","line":50,"path":"src/index.js","reproduction":"Start with a clean ledger and a LocalPrivateKeySigner (public test key 1 suffices). Call pay(\"order-1\",undefined,{execute:true}). Return GET status={status:\"quoted\"}; supply a 402 challenge whose accepts[0]={scheme:\"exact\",network:\"eip155:11155111\",asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:\"100000000000000000\",payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\",maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}, with matching quote.payment/capabilities, expiresAt=now+600 and well-formed quote hashes/resource/scope. Expected: reject the unsupported Sepolia network with no signature. Actual: both signer calls receive domain.chainId=1, the first authorizes the mainnet Permit2/exact-proxy transfer, and the signed POST still declares accepted.network=eip155:11155111. Reproduced locally; the signatures matched independent Foundry cast wallet sign --data results. Repeating with network=eip155:1 and scheme=upto, or extra.assetTransferMethod=eip3009, also signs and submits without rejecting the mismatch.","severity":"medium","snippet":"    if(!same(accepted,qp)||!same(accepted,cp)||!eqAddress(accepted.asset,IMD_TOKEN))throw new Error('challenge payment terms differ from quote or capabilities');","title":"A non-mainnet challenge still produces spendable Ethereum-mainnet signatures"},{"citation":"resolved","description":"The original-quote comparison covers only payTo, amount, action, and expiresAt; it omits quote ID, quoteHash and asset. Therefore a stored flat quote (the shape this guard expects) does not bind the later QuoteApproval to the quoted request. A stale or substituted challenge with the same price, payee, action and expiry passes, and line 67 signs the replacement quote ID/hash. This can authorize a different job or input at the same price; signing quoteHash is ineffective for consent if that hash is not checked against the original. Normalize original/challenge schemas and compare the immutable identity and all signed economic fields before signing. Also check that the approved resource and requester scope belong to the selected order/session. The same issue exists in dist/index.js.","line":49,"path":"src/index.js","reproduction":"POST /requests/quote returns {order:{id:\"order-1\"},quote:{id:\"quote-1\",quoteHash:\"22\" repeated 32 times,action:\"job.open\",asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:\"500000000000000000\",payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\",expiresAt:T}}, with T=now+600. Call quote(\"job.open\",{objective:\"review A\"}) then pay(\"order-1\",signer,{execute:true}). Return 402 with quote={id:\"quote-ATTACK\",quoteHash:\"aa\" repeated 32 times,action:\"job.open\",payment:{asset,amount,payTo},expiresAt:T}; accepts[0] and capabilities contain the same payment, and resource/scope are well formed. Expected: refusal because quote ID/hash differ from those cached for order-1. Actual, reproduced with real ImdClient and injected fetch/signer: verification passes, both signatures are requested, and QuoteApproval.message.quoteId=\"quote-ATTACK\", quoteHash=0x followed by 64 a characters; the client submits it. This reproduction deliberately supplies the flat original shape used by the guard, so the separate nested-payment rejection does not interrupt this path.","severity":"medium","snippet":"    if(originallyQuoted&&(!eqAddress(originallyQuoted.payTo,qp.payTo)||String(originallyQuoted.amount)!==String(qp.amount)||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"Quote identity can change while the original-quote safety check passes"},{"citation":"resolved","description":"The Permit2 deadline is always quote.expiresAt-5, with no bound from accepted.maxTimeoutSeconds or a local authorization lifetime. When quote validity exceeds the payment window, the client issues a token-transfer authorization usable well after payment should time out. The [x402 schema](https://github.com/coinbase/x402/blob/main/specs/x402-specification-v2.md) defines maxTimeoutSeconds as the maximum payment-completion time, and the [reference Permit2 client](https://github.com/coinbase/x402/blob/main/typescript/packages/mechanisms/evm/src/shared/permit2.ts) bounds deadline by now+maxTimeoutSeconds. A returned timeout/error does not revoke the already sent permit: the exact proxy does not inspect accepted metadata or the offchain QuoteApproval. Validate a positive finite timeout and use min(quote expiry minus safety margin, now plus the allowed timeout), with a final freshness check before submission. dist/index.js is identical.","line":62,"path":"src/index.js","reproduction":"At t=floor(Date.now()/1000), execute pay(\"order-1\",signer,{execute:true}) with matching valid IMD payment terms, accepted.maxTimeoutSeconds=60, and quote.expiresAt=t+600. Use status={status:\"quoted\"}, well-formed resource/scope/hash, and matching capabilities. Capture the signed PermitWitnessTransferFrom and decoded PAYMENT-SIGNATURE. Expected: authorization expires no later than t+60. Actual, reproduced locally with a real LocalPrivateKeySigner: message.deadline and permit2Authorization.deadline equal t+595. Thus at t+120 (after the stated 60-second timeout) this unused authorization is still within its onchain deadline, allowing a late debit if balance and allowance remain. No live settlement was attempted.","severity":"medium","snippet":"    const expiry=BigInt(terms.q.expiresAt), now=BigInt(Math.floor(Date.now()/1000)), deadline=expiry-5n;if(deadline<=now)throw new Error('quote expires too soon to sign safely');","title":"Permit2 authorizations outlive the advertised payment timeout"}],"hash":"0b46bf7c37d30d9e0e1dd080295ce1a5da996823aabce13cde0f03743777b61f","nodeId":"087c5f15-f986-4f9d-a422-1fca9d10a73c","outcome":"completed","summary":"Wrote 7 substantiated findings—2 high, 5 medium—to [.imd-findings.json](./.imd-findings.json).\n\nHighest risks: concurrent processes bypass the daily cap, and retries create independently spendable duplicate payments.\n\nValidated with local reproductions, the existing tests, and independent Foundry signature checks. No source files changed or live payments sent.","treeHash":null,"usage":{"cachedInputTokens":1712640,"inputTokens":139325,"model":"gpt-6-astra","outputTokens":19101,"runtime":"codex","turns":5,"wallClockMs":845434}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"02ae6543274731ab","findings":[{"citation":"resolved","description":"Every executing pay() call creates a fresh Permit2 nonce, and neither the signed payload nor an in-flight/submitted marker is saved per order. After a submission has reached the server but the response is lost, another pay() produces a second authorization for the same purchase. Even status() returning payment_pending is ignored except for its optional quote fields at line 60. The Permit2 witness binds only the payee and validAfter, not the order or QuoteApproval: the two different nonces allow two independent token transfers. Default caps still allow this for requests below half the daily cap. Keep a durable order-scoped pending authorization and reuse its exact payload/nonce on retry; serialize payments per order and reconcile pending/settled status before creating another authorization. Do not treat a transport failure as proof that the first authorization was unused. The [x402 proxy reference implementation](https://raw.githubusercontent.com/x402-foundation/x402/main/specs/schemes/exact/scheme_exact_evm.md#reference-implementation-x402exactpermit2proxy) exposes public settle(permit, owner, witness, signature) without an order identifier or QuoteApproval check.","line":63,"path":"src/index.js","reproduction":"Start with no daily spend, a wallet with at least 0.2 IMD and Permit2 allowance of at least 0.2 IMD. A mock /requests/o1/submit returns a valid 402 challenge for q1/job.open, IMD_TOKEN, amount=\"100000000000000000\", payTo=0x1111111111111111111111111111111111111111, expiresAt=now+3600, network=eip155:1, scheme=exact, extra.assetTransferMethod=permit2, valid resource and 32-byte scope/quote hashes. /capabilities returns the same payment and GET /requests/o1 returns {order:{id:\"o1\"},status:\"quoted\"}. Call pay(\"o1\",signer,{execute:true}); capture its PAYMENT-SIGNATURE server-side, then make fetch reject with TypeError(\"fetch failed\") instead of delivering the response. Set GET status to payment_pending while the unsigned submit still returns 402 (e.g. a stale challenge response), and call pay(\"o1\",signer,{execute:true}) again. Expected: reconcile or retry the original signed payload; no second transfer authority. Actual, reproduced with LocalPrivateKeySigner and in-memory filesystem/fetch doubles: four signing calls and two submissions with distinct nonces, each authorizing 0.1 IMD to the same payee; the client reads payment_pending and still signs. Both payloads independently authorize payment, totalling 0.2 IMD for one 0.1 IMD order. No live settlement was performed.","severity":"high","snippet":"    const nonce=BigInt(`0x${randomBytes(32).toString('hex')}`);","title":"Retrying one order signs a second independently spendable payment"},{"citation":"resolved","description":"reservationQueue and daily are module-local, but all CLI processes share daily-spend.json. The file read/check/write is not protected by a cross-process lock or an atomic transaction. Two pay processes can both read the same old balance, both pass the cap, and overwrite one another's reservation. Thus the guard does not bound authorized daily spending in ordinary concurrent CLI/worker use. writeFile also truncates the ledger in place and all read/JSON errors are treated as an empty ledger, so crashes or overlapping reads can erase the effective balance. Use an inter-process transactional reservation (including atomic durable persistence); fail closed on corrupt state rather than treating it as zero.","line":61,"path":"src/index.js","reproduction":"On the same UTC day, launch two separate Node/CLI processes under the same OS user/XDG_STATE_HOME, each with default maxPerRequest=maxPerDay=500000000000000000, the same funded signer, and distinct valid orders priced at 300000000000000000 IMD base units. Their challenge, quote.payment and capabilities agree on IMD_TOKEN, payTo=0x1111111111111111111111111111111111111111 and the amount, with a future expiry. Start with ledger {}. Schedule both readFile operations before either writeFile; both observe spent=0, check 0+0.3<=0.5, and write {\"<UTC day>\":\"300000000000000000\"}. Both then sign and submit, authorizing 0.6 IMD although the daily limit is 0.5; the file records only 0.3. Expected: exactly one proceeds. Reproduced with two actual Node child processes using the unmodified client and LocalPrivateKeySigner; IPC-backed filesystem doubles enforced this read/read/write/write interleaving without writing external state. Each process signed twice and submitted once, and the final shared ledger was 0.3 IMD. The same race works with two different order IDs because the ledger has no order dimension.","severity":"high","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Concurrent CLI processes bypass the persisted daily spending cap"},{"citation":"resolved","description":"verifyTerms reads the challenge through paymentOf(q), but reads originallyQuoted.payTo and originallyQuoted.amount directly. The quote shape consumed by pay at line 67 and used in test/paid-flow.test.mjs:24 stores these fields under quote.payment. Consequently a complete, unchanged original quote fails the original-quote guard, while a response omitting the original quote bypasses it. This prevents legitimate library and CLI payments whenever quote() or status() includes the complete nested quote. Normalize both sides through paymentOf before comparing, and preserve the original quote identity and hash for validation.","line":49,"path":"src/index.js","reproduction":"Use payment={asset:IMD_TOKEN,payTo:\"0x1111111111111111111111111111111111111111\",amount:\"100000000000000000\"} and q={id:\"q1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment,expiresAt:1800000100}. Have POST /requests/quote return {order:{id:\"o1\"},quote:q}, POST /requests/o1/submit return HTTP 402 {accepts:[payment],quote:q}, and GET /requests/capabilities return {actions:[{action:\"job.open\",payment}]}. Call client.quote(\"job.open\",{}), then client.pay(\"o1\",signer,{execute:true}). Expected: identical terms pass and signing begins. Actual: throws \"challenge quote differs from the original quote\" before signing because originallyQuoted.payTo/amount are undefined. {order:{id:\"o1\",quote:q}} fails identically. Reproduced by invoking verifyTerms with both shapes; the same challenge succeeds when the original response contains only {order:{id:\"o1\"}}.","severity":"medium","snippet":"    if(originallyQuoted&&(!eqAddress(originallyQuoted.payTo,qp.payTo)||String(originallyQuoted.amount)!==String(qp.amount)||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"Unchanged quotes with nested payment terms are rejected before signing"},{"citation":"resolved","description":"The persistent ledger is incremented before expiry validation and before either signer call, and no failure path releases a reservation known to have produced no payment. An expired/near-expiry challenge at the default request amount exhausts the full daily budget even though zero signatures and zero paid submissions exist. A user cannot pay a later valid quote that day; the false spend also survives a process restart. Validate complete signing inputs and deadlines before reserving, and release reservations for failures conclusively occurring before any authorization is exposed. Preserve reservations for ambiguous submission failures.","line":61,"path":"src/index.js","reproduction":"With an empty ledger and default maxPerRequest=maxPerDay=\"500000000000000000\", serve a consistent challenge/capabilities payment of 0.5 IMD to 0x1111111111111111111111111111111111111111, q.action=\"job.open\", q.payment matching accepted terms, q.expiresAt=Math.floor(Date.now()/1000), and status={order:{id:\"o1\"},status:\"quoted\"} with no quote field. Call pay(\"o1\",signer,{execute:true}). Expected: reject expiry without consuming spend allowance. Actual: writes {\"<UTC day>\":\"500000000000000000\"} then throws \"quote expires too soon to sign safely\", with zero signer calls and zero paid submissions. Serve an otherwise identical valid challenge with expiresAt=now+3600 and call pay again: it now throws \"daily IMD spending cap exceeded\". Reproduced end-to-end with in-memory filesystem/fetch doubles.","severity":"medium","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Expired quotes consume the daily budget without producing any signature"},{"citation":"resolved","description":"verifyTerms validates only asset/payTo/amount. It does not require accepted.network to be eip155:1, even though both EIP-712 domains are unconditionally chainId:1 at lines 65 and 67. pay() then echoes the unsupported network in accepted while emitting a real mainnet Permit2 authorization. A misrouted or poisoned non-mainnet challenge therefore consumes the budget and receives a signature authorizing mainnet funds; that signature cannot validate against the advertised network. Require the configured/supported network, exact scheme and Permit2 transfer method before reserving or signing, and derive the domains and payload from the same validated configuration.","line":50,"path":"src/index.js","reproduction":"With an empty ledger, serve HTTP 402 with accepts[0]={scheme:\"exact\",network:\"eip155:8453\",asset:IMD_TOKEN,amount:\"100000000000000000\",payTo:\"0x1111111111111111111111111111111111111111\",maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}. quote={id:\"q1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:IMD_TOKEN,amount:\"100000000000000000\",payTo:the same address},expiresAt:now+3600}; include valid resource/resourceUrl/requesterScopeHash. Let capabilities agree on the payment and status return {order:{id:\"o1\"}}. Call pay(\"o1\",LocalPrivateKeySigner,{execute:true}). Expected: reject unsupported Base network without signing. Actual: two signatures are generated, both with domain.chainId=1, and the posted payment still says accepted.network=\"eip155:8453\". Reproduced with a known test signer and in-memory fetch/filesystem doubles; captured the signing arguments and submitted payload. No onchain transaction was sent.","severity":"medium","snippet":"    if(!same(accepted,qp)||!same(accepted,cp)||!eqAddress(accepted.asset,IMD_TOKEN))throw new Error('challenge payment terms differ from quote or capabilities');","title":"Challenges for other networks still receive mainnet spending signatures"},{"citation":"resolved","description":"The Permit2 deadline is derived solely from quote.expiresAt minus five seconds; accepted.maxTimeoutSeconds is never applied. A quote lifetime longer than the payment timeout produces a transfer authorization redeemable well after the advertised payment window. With witness.validAfter=0, nothing else in the permit restricts this window. Delayed or retained authorizations remain usable until the quote expiry even if the caller treated the payment as timed out. Bound the deadline by both quote expiry and a validated local/advertised maximum authorization duration. The [official x402 client implementation](https://raw.githubusercontent.com/coinbase/x402/main/typescript/packages/mechanisms/evm/src/shared/permit2.ts) derives the Permit2 deadline from now + maxTimeoutSeconds.","line":62,"path":"src/index.js","reproduction":"At time T with no daily spend, use otherwise consistent mainnet Permit2 terms for 100000000000000000 IMD base units, payTo=0x1111111111111111111111111111111111111111, accepts[0].maxTimeoutSeconds=60, and quote.expiresAt=T+3600. Supply valid resource and hashes, matching capabilities, and status={order:{id:\"o1\"}}; call pay(\"o1\",signer,{execute:true}). Expected: permit deadline no later than T+60 (and before quote expiry). Actual, reproduced using LocalPrivateKeySigner and inspecting PAYMENT-SIGNATURE: permit2Authorization.deadline=T+3595 and witness.validAfter=\"0\". At T+120 the advertised 60-second window has ended but the signed permit still authorizes the transfer for another 3475 seconds, assuming its nonce is unused and balance/allowance remain sufficient. No live transfer was attempted.","severity":"medium","snippet":"    const expiry=BigInt(terms.q.expiresAt), now=BigInt(Math.floor(Date.now()/1000)), deadline=expiry-5n;if(deadline<=now)throw new Error('quote expires too soon to sign safely');","title":"Permit validity ignores the advertised maximum payment timeout"}],"hash":"0f062f7ce1b364fbcd6cc5cbf2a0dc3b684ffb5412637f87018e9f973d252892","nodeId":"625e3b61-9b90-41ab-928f-c7c136a2fd52","outcome":"completed","summary":"Saved six substantiated findings—2 high, 4 medium—to [.imd-findings.json](/home/imd-worker/.identitymd/work/ae3c9745-7363-4bd2-bfaf-dc8944649cd8/625e3b61-9b90-41ab-928f-c7c136a2fd52/.imd-findings.json).\n\nThe high findings cover duplicate payment authorizations on retry and concurrent-process daily-cap bypass.\n\nBoth existing tests passed with filesystem mocks. Findings include concrete reproductions and verified source locations. Client files remain unchanged; no live transactions were sent.","treeHash":null,"usage":{"cachedInputTokens":1538304,"inputTokens":125097,"model":"gpt-6-astra","outputTokens":14785,"runtime":"codex","turns":6,"wallClockMs":700069}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"35c52a5b502e847c","findings":[{"citation":"resolved","description":"Each executing pay() call generates a fresh random Permit2 nonce without saving an order-scoped authorization or reconciling pending status. If a paid POST reaches the service but its reply is lost, a retry receiving another 402 signs a second independent transfer for the same quote. The reference [Permit2 implementation](https://github.com/Uniswap/permit2/blob/main/src/SignatureTransfer.sol) consumes nonces individually; the [exact proxy](https://github.com/coinbase/x402/blob/main/contracts/evm/src/x402ExactPermit2Proxy.sol) does not bind settlement to an IMD order or QuoteApproval. Thus both can debit a funded, approved wallet before their deadlines if both are settled. This reproduction proves duplicate valid client authorizations, not that the absent production backend settles both. Persist and reuse the exact pending payload across retries/restarts, serialize per order, and reconcile status before creating replacements. dist/index.js is identical.","line":63,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Set A=\"250000000000000000\". First call pay(\"order-1\",undefined,{execute:true}); capture its paid POST, then throw TypeError(\"fetch failed after upload\") to model delivery with a lost reply. Leave the unsigned submit returning the same 402, change GET status to {status:\"payment_pending\"}, and call pay again. Expected: reuse/reconcile the first authorization or refuse a second. Actual: four signer calls, two signed POSTs for quote-1 with distinct nonce/signature/paymentHash values, and ledger total \"500000000000000000\". Independently ran cast wallet sign --data on all four captured typed-data objects with public test key 1: signatures matched byte-for-byte. SHA-256 of each independently key-sorted payment JSON matched its QuoteApproval paymentHash. With at least 0.5 IMD balance and allowance, the distinct unused nonces represent two 0.25 IMD authorizations for one order; no live transfer was attempted.","severity":"high","snippet":"    const nonce=BigInt(`0x${randomBytes(32).toString('hex')}`);","title":"Retrying an unresolved order creates a second valid spending authorization"},{"citation":"resolved","description":"reservationQueue and daily protect only one module instance. Independent CLI processes perform unlocked read/check/write operations on the shared daily-spend.json, so each can reserve against the same prior balance and the last write loses the other reservation. The default 0.5 IMD limit can authorize 0.6 IMD from two 0.3 IMD calls, or N times the cap with N full-cap concurrent calls. In-place writeFile and catch-all read/JSON error handling also allow truncated state to reset the cap on restart. Use a process-safe transactional reservation, atomic durable replacement, and fail closed on unexpected ledger corruption. dist/index.js is identical.","line":61,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Run two actual Node child processes with the same signer, UTC day and spendFile, each calling pay on a different order at A=\"300000000000000000\" with default 0.5 IMD request/day caps. Redirect only ledger I/O through parent IPC; hold each read response until both have taken the initial \"{}\" snapshot, then release both. Observed ordering is read A, read B, write A/B, write B/A. Expected: one process authorizes 0.3 IMD and the other fails its daily cap. Actual: both complete two signatures and one paid POST, authorizing 600000000000000000 total; the final shared ledger records only 300000000000000000. All four signatures independently matched cast wallet sign --data. Separately, after a successful 0.3 IMD call, replaced the virtual ledger contents with the empty string (the state possible after truncation/crash), loaded a fresh module and retried another 0.3 IMD payment: it also succeeded and recorded only 0.3 IMD. No real filesystem ledger or chain was used.","severity":"high","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Concurrent CLI processes bypass the persistent daily spending cap"},{"citation":"resolved","description":"verifyTerms unwraps challenge.quote.payment but reads payTo and amount directly from the saved quote. A complete quote response or status response containing quote.payment is therefore rejected even when unchanged, preventing the documented quote-to-pay flow. The existing paid-flow test bypasses this comparison by returning only the order ID. Normalize both payment objects before comparing their asset, payTo and amount, and retain metadata/identity checks. dist/index.js contains the identical defect.","line":49,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Set A=\"500000000000000000\". Make POST /requests/quote return {order:{id:\"order-1\"},quote:q}, where q is exactly the challenge quote. Call client.quote(\"job.open\",{}), then client.pay(\"order-1\",undefined,{execute:true}). Expected: unchanged terms pass and two signatures are requested. Actual: \"challenge quote differs from the original quote\", zero signatures, zero signed POSTs, and unchanged ledger. Also reproduced with {order:{id:\"order-1\",quote:q}} returned by quote(), and with a fresh client loading that same shape from status().","severity":"medium","snippet":"    if(originallyQuoted&&(!eqAddress(originallyQuoted.payTo,qp.payTo)||String(originallyQuoted.amount)!==String(qp.amount)||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"Unchanged nested quotes fail the original-quote comparison"},{"citation":"resolved","description":"pay persists its reservation before validating expiry or obtaining either signature. It never releases it on definite pre-submission failure. One expired 0.5 IMD quote or rejected signer request exhausts the default daily limit even though no payment authorization reached the service. Validate the complete signing inputs first; track reservation states and release on failures known to occur before exposing an authorization. Keep ambiguous submitted attempts reserved until reconciled. Refunding every HTTP error or debiting only after success would introduce overspending and retry races. dist/index.js is identical.","line":61,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Set A=\"500000000000000000\" and expiresAt=T+4. Call pay(\"order-1\",undefined,{execute:true}): actual error is \"quote expires too soon to sign safely\", zero signing calls and zero paid POSTs, but the ledger contains {[current UTC day]:\"500000000000000000\"}. Change only expiresAt to T+600 and call pay again: actual error is \"daily IMD spending cap exceeded\". Expected: both expiry refusal and a signer throwing before any signature leave the budget available. A separate clean run with a signer that throws Error(\"user rejected on device\") also retained the full debit and blocked the next call.","severity":"medium","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Failures before authorization permanently consume the daily spending budget"},{"citation":"resolved","description":"verifyTerms checks only asset, payTo and amount, leaving network, scheme and transfer method unchecked. pay always signs chainId 1 for the mainnet Permit2/exact proxy and echoes contradictory accepted fields in the header. An unsupported-chain challenge therefore obtains an unintended mainnet authorization, while a verifier following the advertised chain cannot validate it. Requiring a mainnet wallet is not a reason to sign contradictory remote terms silently. Require the configured eip155:1 network, exact scheme and permit2 transfer method consistently across the challenge, quote and capabilities before reserving/signing. dist/index.js is identical.","line":50,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Set A=\"100000000000000000\"; change accepts[0].network and quote.payment.network to \"eip155:11155111\". Call pay(\"order-1\",undefined,{execute:true}). Expected: reject unsupported Sepolia terms before any signing. Actual: two real signatures with domain.chainId=1 and a signed POST whose accepted.network is still eip155:11155111. Both signatures match independent cast wallet sign --data outputs. Separately repeated on fresh clients with mainnet network but scheme=\"upto\", and with extra.assetTransferMethod=\"eip3009\": both cases also signed and submitted exact Permit2 authorizations. Mainnet loss requires the wallet to have mainnet IMD balance/allowance and a recipient of the permit to settle it; no settlement was sent.","severity":"medium","snippet":"    if(!same(accepted,qp)||!same(accepted,cp)||!eqAddress(accepted.asset,IMD_TOKEN))throw new Error('challenge payment terms differ from quote or capabilities');","title":"Unsupported networks and payment schemes still receive mainnet Permit2 signatures"},{"citation":"resolved","description":"pay derives the deadline solely from quote.expiresAt minus five seconds, ignoring accepted.maxTimeoutSeconds. Quote validity and transfer-authorization lifetime are different bounds. With a 600-second quote and a 60-second payment window, the transmitted permit remains usable for 595 seconds. The [reference Permit2 client](https://github.com/coinbase/x402/blob/main/typescript/packages/mechanisms/evm/src/shared/permit2.ts) bounds deadline by now + maxTimeoutSeconds. An offchain timeout does not revoke the signature. Validate a positive bounded timeout and use min(quote expiry minus margin, now plus allowed timeout); recheck freshness before exposing payment. dist/index.js is identical.","line":62,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Set A=\"100000000000000000\". At T=1800000000 call pay(\"order-1\",undefined,{execute:true}) with maxTimeoutSeconds=60 and expiresAt=T+600. Expected: PermitWitnessTransferFrom.message.deadline and transmitted permit2Authorization.deadline <=1800000060. Actual: both equal 1800000595; witness.validAfter is zero. Independently verified both signatures with cast and checked the payload hash. At T+120 the advertised payment window has ended but the permit is still within its signed deadline for 475 more seconds; redemption additionally requires an unused nonce, balance and allowance. No live settlement was attempted.","severity":"medium","snippet":"    const expiry=BigInt(terms.q.expiresAt), now=BigInt(Math.floor(Date.now()/1000)), deadline=expiry-5n;if(deadline<=now)throw new Error('quote expires too soon to sign safely');","title":"Permit2 deadlines exceed the advertised maximum payment timeout"},{"citation":"resolved","description":"The original-quote guard compares only payTo, amount, action and expiresAt. Even when that guard is reached and passes, it does not bind QuoteApproval to the cached quote ID/hash (or original asset). A substituted challenge with the same visible terms can therefore authorize different work at the same price. This is separate from the nested-schema rejection: the failing case uses the flat saved shape that this guard currently accepts. Compare immutable quote identity/hash and all signed terms with a normalized saved quote, and bind resource/scope to the selected order/session; fail closed if the required original quote is absent. dist/index.js is identical.","line":49,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Set A=\"100000000000000000\". POST /requests/quote returns {order:{id:\"order-1\"},quote:{id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",asset:IMD_TOKEN,amount:A,payTo:PAY_TO,expiresAt:T+600}} with flat payment fields. Call quote(\"job.open\",{objective:\"review A\"}). For pay(\"order-1\",undefined,{execute:true}), return the common nested challenge with only quote.id changed to \"quote-ATTACK\" and quote.quoteHash changed to \"aa\".repeat(32). Expected: reject identity/hash mismatch before signing. Actual: two independently cast-verified signatures and a signed POST; QuoteApproval.message.quoteId=\"quote-ATTACK\" and quoteHash=\"0x\"+\"aa\".repeat(32). The saved flat shape is essential to this reproduction; an unchanged full nested saved quote is rejected by the separate schema defect.","severity":"medium","snippet":"    if(originallyQuoted&&(!eqAddress(originallyQuoted.payTo,qp.payTo)||String(originallyQuoted.amount)!==String(qp.amount)||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"Original-quote validation permits a different quote identity and hash"},{"citation":"resolved","description":"The amount equality against capabilities treats every policy price as the total. schedule.create and schedule.topup are priced per run, so a valid two-run quote cannot pass even with sufficiently high user caps. The [capabilities response](https://api.imd.fun/requests/capabilities) identifies both actions in its pricedPer map; the [Quote schema](https://api.imd.fun/openapi.json) supplies runs and unitAmount. Validate the quoted run count against the original request and calculate the expected total with integer arithmetic, while still enforcing caps on the full total. dist/index.js is identical.","line":50,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Use a fresh client, no original quote in status, and maxPerRequest=maxPerDay=\"2000000000000000000\". Change quote.action to \"schedule.create\", quote.runs=2, quote.unitAmount=\"500000000000000000\", and both accepted.amount and quote.payment.amount to \"1000000000000000000\". Capabilities is {actions:[{action:\"schedule.create\",payment:{asset:IMD_TOKEN,payTo:PAY_TO,amount:\"500000000000000000\"},quoteTtlSeconds:600}],pricedPer:{\"schedule.create\":\"run\"}}. Call pay(\"order-1\",undefined,{execute:true}). Expected: validate and authorize 1 IMD for two runs. Actual: \"challenge payment terms differ from quote or capabilities\" with zero signatures. Repeated with schedule.topup with the same result. Using status without a saved quote isolates this defect from the independently reproduced nested-quote rejection.","severity":"medium","snippet":"    if(!same(accepted,qp)||!same(accepted,cp)||!eqAddress(accepted.asset,IMD_TOKEN))throw new Error('challenge payment terms differ from quote or capabilities');","title":"Multi-run schedule payments are compared against a single-run capabilities price"},{"citation":"resolved","description":"canon emits the literal undefined for an undefined object value. pay embeds challenge.resource without validating its presence, hashes the malformed string, signs QuoteApproval, and submits an unparseable PAYMENT-SIGNATURE. The service cannot accept the request, while the daily reservation remains consumed and a usable Permit2 signature has already been exposed. Validate the entire challenge before reserving/signing and ensure canonicalization either emits valid JSON or rejects unsupported values; never emit an undefined token. dist/index.js is identical.","line":16,"path":"src/index.js","reproduction":"Offline reproduction against the unmodified src/index.js, using injected HTTP Response objects, in-memory node:fs/promises ledger bindings and LocalPrivateKeySigner with public test scalar 1 (0x followed by 63 zeroes and 1); no network or live settlement. Fix Date.now() at T=1800000000; use empty daily state and default caps unless specified. Common challenge: quote={id:\"quote-1\",quoteHash:\"22\".repeat(32),action:\"job.open\",payment:{asset:\"0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7\",amount:A,payTo:\"0x4e0fa57bde726079356537e2f34d671e9f41adbc\"},expiresAt:T+600}; accepts[0]={scheme:\"exact\",network:\"eip155:1\",...quote.payment,maxTimeoutSeconds:60,extra:{assetTransferMethod:\"permit2\"}}; resource={url:\"https://api.example/requests/order-1\",description:\"job\",mimeType:\"application/json\"}, resourceUrl=resource.url, requesterScopeHash=\"11\".repeat(32). GET capabilities returns {actions:[{action:\"job.open\",payment:quote.payment}]}; GET status returns {status:\"quoted\"} without a quote unless specified. The unsigned POST /requests/order-1/submit returns this 402 challenge; the signed POST returns 202 {status:\"payment_pending\"} unless specified. Set A=\"100000000000000000\" and omit only challenge.resource, retaining resourceUrl and the scope/hash fields. Call pay(\"order-1\",undefined,{execute:true}). Expected: reject malformed challenge before signing. Actual: two real signing calls, one paid POST and a 0.1 IMD ledger entry. Decode PAYMENT-SIGNATURE with Buffer.from(header,\"base64\").toString(): it contains the literal sequence \"resource\":undefined; JSON.parse on that exact transmitted text throws SyntaxError. The mock returns 202 solely to capture output; no claim is made that a real server accepts malformed JSON.","severity":"low","snippet":"const canon = (v) => v===null?'null':Array.isArray(v)?`[${v.map(canon).join(',')}]`:typeof v==='object'?`{${Object.keys(v).sort().map(k=>`${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`:JSON.stringify(v);","title":"Missing resource produces an invalid payment JSON header after signing"},{"citation":"resolved","description":"Exported signDigest() and addressFromPrivateKey() pass the private-key text directly to BigInt without the LocalPrivateKeySigner validation/normalization. A 64-hex key missing 0x makes BigInt include the full key in its SyntaxError, exposing it to callers that log errors. dist/crypto.js ships with the package and package.json has no exports restriction. This was reproduced through the distributed helper imports; the documented LocalPrivateKeySigner/CLI path normalizes this input and does not have this leak. Validate and normalize at both helper boundaries and throw static key errors; optionally restrict the package export surface.","line":61,"path":"src/crypto.js","reproduction":"Run Node in the repository: const {signDigest,addressFromPrivateKey,LocalPrivateKeySigner}=await import(\"./dist/crypto.js\"); const k=\"ab\".repeat(32); invoke signDigest(k,new Uint8Array(32)) and addressFromPrivateKey(k), catching each error. Expected: accept the normalized valid scalar or return a static error that contains no key. Actual: both throw SyntaxError with message \"Cannot convert \"+k+\" to a BigInt\", including every one of the 64 key characters. A control new LocalPrivateKeySigner(k) constructs successfully. Only this public synthetic key was used.","severity":"low","snippet":"  const d=num(privateKey); if(d<=0n||d>=N)throw new Error('invalid private key'); const z=BigInt(hex(digest));","title":"Deep-importable crypto helpers disclose malformed private keys in errors"},{"citation":"resolved","description":"The constructor checks only the key string shape, then derives an address without checking 0 < d < secp256k1 order N. For N+5 it publishes the address of scalar 5 even though signDigest later rejects the stored key. An operator can be given a wallet address to fund by a signer that can never authorize its funds with the configured key. Validate the scalar range at construction, before exposing address, and use a static validation error. This does not prove funds are irrecoverable: an informed operator can derive the reduced scalar. dist/crypto.js is identical.","line":77,"path":"src/crypto.js","reproduction":"In Node, import LocalPrivateKeySigner and addressFromPrivateKey from src/crypto.js. Let N=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n and k=\"0x\"+(N+5n).toString(16). Expected: new LocalPrivateKeySigner(k) throws invalid private key. Actual: it constructs with address 0xe1ab8145f7e55dc933d51a18c793f901a3a0b276, equal to addressFromPrivateKey(\"0x5\"). Call signer.signTypedData({domain:{name:\"test\",chainId:1},primaryType:\"T\",types:{T:[{name:\"x\",type:\"uint256\"}]},message:{x:1n}}): it rejects with \"invalid private key\". No funded key was used.","severity":"low","snippet":"  /** @param {string} privateKey */ constructor(privateKey) { if(typeof privateKey!=='string'||! /^(0x)?[0-9a-fA-F]{64}$/.test(privateKey))throw new Error('invalid private key');this.#privateKey=privateKey.startsWith('0x')?privateKey:`0x${privateKey}`;this.address=addressFromPrivateKey(this.#privateKey); }","title":"LocalPrivateKeySigner accepts out-of-range scalars that can never sign"}],"hash":"6b68bfdb7192dd540e4e3de42b9a3066e3108a376ce05349382673132196335e","nodeId":"a7aa301a-aafe-4c7b-9ca5-0e7bec7ef29d","outcome":"completed","summary":"Saved 11 reproduced, deduplicated findings to [.imd-findings.json](/home/identitymd5/.identitymd/work/ae3c9745-7363-4bd2-bfaf-dc8944649cd8/a7aa301a-aafe-4c7b-9ca5-0e7bec7ef29d/.imd-findings.json): 2 high, 6 medium, 3 low.\n\nBoth existing tests passed; captured signatures matched Foundry `cast`. Application files are unchanged. Reproductions were local; no live payments occurred.","treeHash":null,"usage":{"cachedInputTokens":1649152,"inputTokens":243734,"model":"gpt-6-astra","outputTokens":16334,"runtime":"codex","turns":6,"wallClockMs":737536}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"720122d0ca9f60ca","findings":[{"citation":"resolved","description":"verifyTerms() compares originallyQuoted.payTo and originallyQuoted.amount with qp, although the payment fields in the supported challenge/quote shape live under quote.payment. A quote response containing the full quote, or a status response containing order.quote, therefore fails even when all terms are identical. The documented quote -> pay workflow cannot execute, including the CLI path that reloads a full quote from status. The existing successful mock avoids this branch by returning only an order id. Normalize the original quote with paymentOf(originallyQuoted), validate its asset as well as amount/payTo, and compare metadata separately. The same code is shipped in dist/index.js.","line":49,"path":"src/index.js","reproduction":"Use q={id:'quote-1',quoteHash:'22'.repeat(32),action:'job.open',payment:{asset:'0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7',amount:'500000000000000000',payTo:'0x4e0fa57bde726079356537e2f34d671e9f41adbc'},expiresAt:Math.floor(Date.now()/1000)+600}. Have POST /requests/quote return {order:{id:'order-1'},quote:q}; POST /requests/order-1/submit without payment return 402 with quote:q and accepts:[{scheme:'exact',network:'eip155:1',...q.payment}]; GET /requests/capabilities returns {actions:[{action:'job.open',payment:q.payment}]}. Call client.quote('job.open',{objective:'test'}), then client.pay(result.order,signer,{execute:true}). Expected: unchanged terms pass. Actual: throws 'challenge quote differs from the original quote' before either signature. Reproduced with an injected fetch and counting signer; zero signatures. Returning {order:{quote:q}} as the original also fails.","severity":"medium","snippet":"    if(originallyQuoted&&(!eqAddress(originallyQuoted.payTo,qp.payTo)||String(originallyQuoted.amount)!==String(qp.amount)||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"Normal nested quotes cannot pass the original-payment comparison"},{"citation":"resolved","description":"pay() writes the full amount to the persistent daily ledger before checking quote expiry or obtaining either signature, and never releases the reservation on a definite pre-submission failure. A stale quote or a signer rejecting the request consumes the same budget as a submitted payment. At the default limits a single 0.5 IMD expired quote blocks every subsequent payment for the UTC day, even across process restarts, although nothing was signed or paid. Validate deadlines and the complete payload before reserving, and use explicit reservation states that release on failures known to occur before submission. Preserve reservations for ambiguous network/settlement outcomes. The same code is shipped in dist/index.js.","line":61,"path":"src/index.js","reproduction":"Start with an empty daily-spend.json and default maxPerRequest=maxPerDay='500000000000000000'. Have status return {id:'order-1',status:'quoted'} (no original quote), capabilities return job.open payment {asset:'0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7',payTo:'0x4e0fa57bde726079356537e2f34d671e9f41adbc',amount:'500000000000000000'}, and the 402 challenge contain these matching terms with quote.expiresAt=Math.floor(Date.now()/1000)+4. Call pay('order-1',countingSigner,{execute:true}). It throws 'quote expires too soon to sign safely' with zero signer calls, but the ledger now records 500000000000000000 for today. Change only expiresAt to now+600 and call pay again: it throws 'daily IMD spending cap exceeded' without signing. Expected: a refusal before any authorization leaves today's budget available. Reproduced offline with the built-in fs bindings replaced by an in-memory ledger.","severity":"medium","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Rejected pre-signing attempts permanently consume the daily spending budget"},{"citation":"resolved","description":"reservationQueue only serializes calls within one module instance. Independent CLI/Node processes read, check and overwrite the same daily-spend.json without any inter-process lock or atomic reservation. Both can authorize payments against the same old balance, and the last write erases the other reservation. Two 0.3 IMD requests authorize 0.6 IMD under the default 0.5 IMD daily cap while recording only 0.3 IMD; more concurrent processes increase the discrepancy. A crash during the non-atomic write can also leave invalid JSON that the empty catch treats as no historical spending in a fresh process. Protect read/check/update with an inter-process lock or transactional store, replace the file atomically, and fail closed on unexpected read/parse errors. The same code is shipped in dist/index.js.","line":61,"path":"src/index.js","reproduction":"Use two independent Node/CLI processes with the same XDG_STATE_HOME, signer and UTC day, an initially empty ledger, and default maxPerRequest=maxPerDay=500000000000000000. Each calls pay on its own order with execute:true. Each order's 402 challenge and capabilities agree on IMD, payTo=0x4e0fa57bde726079356537e2f34d671e9f41adbc, amount='300000000000000000', expiresAt=now+600; status contains no original quote, as allowed by the client. Interleave A.readFile -> B.readFile (both see {}) -> A.writeFile(day=300000000000000000) -> B.writeFile(day=300000000000000000) -> both sign and POST payment. Expected: at most one request signs, since 0.3+0.3>0.5. Actual: both submit distinct signed Permit2 nonces and the final ledger is only 0.3 IMD. Reproduced with two real child processes and LocalPrivateKeySigner using public test scalar 1; an IPC-backed in-memory filesystem barrier forced the specified read interleaving without changing repository files or contacting a chain.","severity":"medium","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Concurrent CLI processes bypass the persistent daily spending cap"},{"citation":"resolved","description":"Every pay() call that receives 402 generates a new Permit2 nonce and signs a new payment, even after a previous signed POST for the same order failed with an ambiguous transport error. There is no stored per-order payment, in-flight lock, or unresolved-attempt state. The two authorizations have different nonces and payment hashes, so Permit2 replay protection does not make them the same spend. If the first request is still being settled while the service returns a stale 402, a normal retry authorizes another charge for the same work. This is distinct from the daily-cap race: it occurs sequentially in one client and within the cap. Persist the exact signed attempt before submission, coalesce concurrent calls, and retry the same payload or reconcile its outcome before authorizing a new nonce. The backend/proxy is absent from this tree, so this reproduction proves duplicate valid client authorizations, not that the production service actually settles both.","line":63,"path":"src/index.js","reproduction":"With an empty ledger and default 0.5 IMD caps, use a single client and signer, and order-1 priced at '200000000000000000'. Return matching IMD/payTo/amount from the 402 challenge and capabilities, quote expiry now+600, and status {id:'order-1',status:'quoted'} without a nested original quote. First call pay('order-1',signer,{execute:true}); the fetch mock captures its PAYMENT-SIGNATURE and then throws TypeError('connection lost after request was sent'), modeling receipt by the server before loss of its reply. Keep the initial submit endpoint returning 402 while that attempt is unresolved. Call pay('order-1',signer,{execute:true}) again and capture its signed POST. Expected: reuse/reconcile the first authorization, or refuse another until its outcome is known. Actual: two signed 0.2 IMD Permit2 payloads for the same order, distinct nonces, and 0.4 IMD reserved. Reproduced with LocalPrivateKeySigner using public test scalar 1 and in-memory I/O. If both captured attempts are processed, they are independent authorizations rather than a replay of one consumed nonce.","severity":"medium","snippet":"    const nonce=BigInt(`0x${randomBytes(32).toString('hex')}`);","title":"Retrying an unresolved payment creates a second independent spend authorization"},{"citation":"resolved","description":"verifyTerms() only compares asset, payTo and amount. It never validates accepted.network against the hardcoded chainId:1 in both signing domains. A challenge advertising another chain is accepted and sent back with mainnet Permit2 and QuoteApproval signatures. A verifier following the advertised chain cannot validate these signatures; the client has also reserved spending and emitted an unintended mainnet authorization. Because this client deliberately supports Ethereum mainnet, reject any network other than eip155:1 (and unsupported payment scheme/transfer method) before reserving or signing, rather than deriving an unrestricted domain from untrusted input. The same code is shipped in dist/index.js.","line":50,"path":"src/index.js","reproduction":"Use a fresh ledger, matching quote/capabilities payment {asset:'0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7',amount:'500000000000000000',payTo:'0x4e0fa57bde726079356537e2f34d671e9f41adbc'}, expiry now+600, and a status response with no original quote. In the 402 challenge set accepts[0]={scheme:'exact',network:'eip155:8453',...payment,extra:{assetTransferMethod:'permit2'}}. Call pay('order-1',recordingSigner,{execute:true}). Expected: reject this unsupported chain with zero signer calls. Actual: both signer calls use domain.chainId=1, while the posted PAYMENT-SIGNATURE JSON still contains accepted.network='eip155:8453'. Reproduced offline by capturing both typed-data objects and the actual POST header.","severity":"low","snippet":"    if(!same(accepted,qp)||!same(accepted,cp)||!eqAddress(accepted.asset,IMD_TOKEN))throw new Error('challenge payment terms differ from quote or capabilities');","title":"Non-mainnet payment challenges still produce mainnet signatures"}],"hash":"7532d7bc5cc1393fd17ae96c2bd4e1a35dd7b01a7d149dc086178a052c83e5e8","nodeId":"92855d59-ce6f-4e0e-9c5c-b3de6e161089","outcome":"completed","summary":"Recorded 5 findings—4 medium, 1 low—in [.imd-findings.json](/Users/hhyy/.lobster_wallets/identitymd/work/ae3c9745-7363-4bd2-bfaf-dc8944649cd8/92855d59-ce6f-4e0e-9c5c-b3de6e161089/.imd-findings.json).\n\nThey cover quote rejection, budget exhaustion, concurrent cap bypass, duplicate retry authorizations, and signing-domain mismatch.\n\nExisting tests passed with in-memory spending storage. Targeted reproductions confirmed the client defects; live settlement was not tested. Source files remain unchanged.","treeHash":null,"usage":{"cachedInputTokens":1222400,"inputTokens":89197,"model":"gpt-6-astra","outputTokens":14041,"runtime":"codex","turns":6,"wallClockMs":667179}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"98b4506bef931d13","findings":[{"citation":"resolved","description":"verifyTerms() compares the challenge quote against the quote saved by quote() (this.quotes) or returned by status(). For the challenge side it unwraps the payment object with paymentOf(q) (qp = q.payment), but for the saved side it reads originallyQuoted.payTo and originallyQuoted.amount directly. The IMD API's published schema (https://api.imd.fun/openapi.json, components Order.quote and Challenge.quote) places both fields under quote.payment, so originallyQuoted.payTo and originallyQuoted.amount are always undefined; eqAddress(undefined, '0x4e0f...') is false and the function throws 'challenge quote differs from the original quote' for every order. Both lookups fail the same way: quote() stores {created, order:{quote:{payment:{...}}}} and status() returns {status, order:{quote:{payment:{...}}}}; quote?.quote is undefined and quote?.order?.quote is the nested Quote. Impact: with execute:true the SDK can never sign or submit a payment against the live service, so the deliverable is non-functional; and whenever a server (or a test double, including the repository's own mock) returns an order without a quote object the whole cross-check is silently skipped, so the README's claim that the payee is verified against the original quote does not hold in either case. Expected: identical terms pass and a different payTo fails. Fix: unwrap both sides with paymentOf() (const op = paymentOf(originallyQuoted)) and compare op.payTo/op.amount/op.asset; treat a missing original quote as a hard error instead of skipping the check.","line":49,"path":"src/index.js","reproduction":"Mock the documented shapes: POST /requests/quote -> 201 {created:true, order:{id, status:'quoted', quote:{id, action:'job.open', expiresAt:now+600, payment:{network:'eip155:1', asset:'0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7', amount:'500000000000000000', payTo:'0x4e0fa57bde726079356537e2f34d671e9f41adbc', decimals:18, scheme:'exact'}, quoteHash:'22'.repeat(32)}}}; empty POST /requests/{id}/submit -> 402 challenge whose accepts[0] and quote carry exactly the same asset/amount/payTo; GET /requests/capabilities -> the live document. Sequence: const q = await client.quote('job.open', {objective:'x'}); await client.pay(q.order, signer, {execute:true}). Expected: two signatures and a submit. Actual: throws Error('challenge quote differs from the original quote') before any signature; the same happens with a fresh client (CLI path) that falls back to status(). Counter-case: set client.quotes to a response with no quote object (as the repository's own test mock returns) and make challenge.accepts[0].payTo and capabilities payTo both 0x4e0fa57bde726079356537e2f34d671e9f41ad00 while the saved quote said ...adbc: pay() signs and submits a Permit2 witness.to of ...ad00 without error.","severity":"high","snippet":"    if(originallyQuoted&&(!eqAddress(originallyQuoted.payTo,qp.payTo)||String(originallyQuoted.amount)!==String(qp.amount)||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"pay() always rejects a genuine challenge: quote cross-check reads payTo/amount from the wrong level of the saved quote"},{"citation":"resolved","description":"The Permit2 nonce is 32 random bytes generated on every pay() call, and the client keeps no record (in this.quotes or the spend ledger) that an order has already been signed. Permit2 unordered nonces make every such signature independently redeemable: two signatures with different nonces for the same amount, payee and token are two separate pull authorizations. The natural retry after a lost response (fetch throws or the server answers 5xx after it has already stored the first PAYMENT-SIGNATURE) therefore hands the service a second live authorization, and the local daily ledger is debited twice for one order. The only thing preventing a double settlement is the server's own 409 handling, which the client neither relies on explicitly nor can verify; the API documentation says 'Retry the same order' without promising it will discard the earlier permit. Expected: a retry for the same order either resubmits the identical payload or refuses to sign again. Fix: derive the nonce deterministically from the order and quote (e.g. BigInt(toHex(sha256('imd:' + terms.q.id + ':' + terms.q.quoteHash)))) so Permit2 itself rejects any second use on chain, and cache {payload, quoteSignature} per order id so a retry resubmits the same bytes; also move the ledger debit after a successful submit (see the ledger finding).","line":63,"path":"src/index.js","reproduction":"Mock: challenge as documented; first submit with PAYMENT-SIGNATURE answers 500 {error:'internal'}, second answers 202 {status:'payment_pending'}. Sequence with maxPerDay 10 IMD: pay(order, signer, {execute:true}) -> throws ImdError 'internal'; pay(order, signer, {execute:true}) again -> 'payment_pending'. Actual: the server received 2 PAYMENT-SIGNATURE payloads with permit2Authorization.nonce values that differ and signatures that differ, both for witness.to 0x4e0fa57bde726079356537e2f34d671e9f41adbc and amount 500000000000000000; daily-spend.json holds '1000000000000000000' for one 0.5 IMD order. Expected: one authorization per order.","severity":"medium","snippet":"    const nonce=BigInt(`0x${randomBytes(32).toString('hex')}`);","title":"Retrying pay() signs and transmits a second, independently valid Permit2 authorization for the same order"},{"citation":"resolved","description":"The reservation block reads the ledger, adds terms.amount, and writes daily-spend.json before signer.signTypedData() and before the submit request. Nothing in the two later failure paths (signer throws, server rejects, network error, or the TypeError at line 67 when the quote lacks a payment object) restores the previous value. With the shipped defaults maxPerRequest == maxPerDay == 0.5 IMD, a single failed attempt consumes the entire daily budget although zero tokens were authorized, and the next attempt that day is refused with 'daily IMD spending cap exceeded'. Combined with the retry finding, a successful retry after a lost response debits twice. Expected: the ledger reflects authorizations that were actually signed and accepted (or at least signed). Fix: reserve, then on any exception before the submit response is accepted write back the previous amount (try/catch around signing and submit), or debit only after a 200/202 response.","line":61,"path":"src/index.js","reproduction":"Mock: documented challenge; a signer whose signTypedData throws Error('user rejected on device'); default caps. Sequence: pay(order, refusingSigner, {execute:true}) -> throws 'user rejected on device', zero submits sent; then with a working LocalPrivateKeySigner the same day: pay(order, signer, {execute:true}). Expected: proceeds (nothing has been spent). Actual: throws 'daily IMD spending cap exceeded'; daily-spend.json contains {\"<today>\":\"500000000000000000\"}. The same lockout follows when the paid submit answers 503.","severity":"medium","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Daily spend ledger is debited before signing and submission and never rolled back, so one failure locks all payments for the rest of the UTC day"},{"citation":"resolved","description":"same(accepted, cp) requires String(accepted.amount) === String(cp.amount), where cp is the capabilities Policy entry for the action. The live capabilities document marks schedule.create and schedule.topup as pricedPer:'run' with the note 'payment.amount is the price of one; a quote charges it once per unit bought', and the Quote schema carries unitAmount and runs for that purpose. For runs = 2 the challenge's accepted.amount is 1000000000000000000 while capabilities payment.amount is 500000000000000000, so the equality fails and the SDK throws 'challenge payment terms differ from quote or capabilities'. The amount cap and the ledger math otherwise use atomic units consistently; it is the invariant 'accepted.amount == per-unit price' that is wrong for priced-per-run actions. Expected: compare accepted.amount against cp.amount * runs (with runs taken from the saved quote, defaulting to 1 when pricedPer is absent) and still enforce maxPerRequest on the total. Fail-closed, but it makes two of the seven typed actions unusable through this client.","line":50,"path":"src/index.js","reproduction":"Mock: capabilities actions [{action:'schedule.create', payment:{amount:'500000000000000000', asset, payTo, network:'eip155:1', decimals:18}, pricedPer:'run', quoteTtlSeconds:600}]; quote {action:'schedule.create', unitAmount:'500000000000000000', runs:2, payment:{amount:'1000000000000000000', ...same asset/payTo}}; challenge accepts[0].amount '1000000000000000000'. Client with maxPerRequest and maxPerDay 2e18. Sequence: quote('schedule.create', {runs:2}); pay(order, signer, {execute:true}). Expected: signs 1.0 IMD. Actual: Error('challenge payment terms differ from quote or capabilities').","severity":"medium","snippet":"    if(!same(accepted,qp)||!same(accepted,cp)||!eqAddress(accepted.asset,IMD_TOKEN))throw new Error('challenge payment terms differ from quote or capabilities');","title":"verifyTerms compares the quoted total against the capabilities per-unit price, so schedule.create / schedule.topup with runs > 1 can never be paid"},{"citation":"resolved","description":"reservationQueue serialises reservations only inside one Node process. The CLI runs one process per command, so two concurrent 'imd pay --execute' invocations both read daily-spend.json before either writes it, both see spent = 0, both pass the cap and both sign. There is no O_EXCL lock file, rename-based atomic write, or re-read after write. The in-memory daily Map does not help across processes. Expected: at most maxPerDay authorized per UTC day regardless of concurrency. Fix: take an exclusive lock (e.g. open a lock file with the 'wx' flag and retry, or use proper-lockfile style mkdir locking) around read, check and write; write via temp file + rename.","line":61,"path":"src/index.js","reproduction":"Two child processes each run new ImdClient({baseUrl, signer}) with default caps (0.5/0.5) and pay(orderId, undefined, {execute:true}) for an order quoted at 0.5 IMD; a proxy holds both /requests/capabilities responses until both have asked, so the ledger read happens simultaneously. Expected: one 'PAID', one 'daily IMD spending cap exceeded'. Actual: both print PAID payment_pending; the mock received 2 signed payloads, 1.0 IMD authorized against a 0.5 IMD daily cap.","severity":"low","snippet":"    const day=terms.key;let release;const previous=reservationQueue;reservationQueue=new Promise(resolve=>{release=resolve;});await previous;try{let ledger={};try{ledger=JSON.parse(await readFile(this.spendFile,'utf8'));}catch{}const spent=BigInt(ledger[day]||daily.get(day)||0);if(spent+terms.amount>this.maxPerDay)throw new Error('daily IMD spending cap exceeded');ledger[day]=(spent+terms.amount).toString();await mkdir(dirname(this.spendFile),{recursive:true});await writeFile(this.spendFile,JSON.stringify(ledger),{mode:0o600});daily.set(day,spent+terms.amount);}finally{release();}","title":"Daily cap can be exceeded by concurrent CLI processes: ledger read-modify-write has no cross-process lock"},{"citation":"resolved","description":"The Permit2 domain and the QuoteApproval domain both use chainId 1, and verifyTerms never inspects accepted.network, accepted.scheme, accepted.extra.assetTransferMethod or q.payment.network. The API's quote-approval section states the QuoteApproval chainId is 'the number in quote.payment.network (eip155:<chainId>)'. If the service ever offers a non-mainnet network (or a mismatched challenge is injected), the SDK silently signs a mainnet-domain Permit2 and QuoteApproval for terms that name another chain. Permit2's domain separator includes block.chainid so that permit is not redeemable on the other chain (fail closed), but the client has signed and transmitted an authorization it never validated, and the QuoteApproval is simply wrong. Expected: refuse unless accepted.network === q.payment.network === 'eip155:1' (and scheme 'exact', assetTransferMethod 'permit2'), or derive chainId from the network string and pin the Permit2/proxy addresses per chain.","line":65,"path":"src/index.js","reproduction":"Mock challenge with accepts[0].network 'eip155:8453' and quote.payment.network 'eip155:8453', everything else unchanged. pay(order, signer, {execute:true}). Expected: throw before signing. Actual: signTypedData is called twice with domains {name:'Permit2',chainId:1,...} and {name:'IdentityMD Paid Action',version:'1',chainId:1}; the submit goes out and returns payment_pending.","severity":"low","snippet":"    const permit={domain:{name:'Permit2',chainId:1,verifyingContract:PERMIT2},primaryType:'PermitWitnessTransferFrom',types:{PermitWitnessTransferFrom:[{name:'permitted',type:'TokenPermissions'},{name:'spender',type:'address'},{name:'nonce',type:'uint256'},{name:'deadline',type:'uint256'},{name:'witness',type:'Witness'}],TokenPermissions:[{name:'token',type:'address'},{name:'amount',type:'uint256'}],Witness:[{name:'to',type:'address'},{name:'validAfter',type:'uint256'}]},message:{permitted:{token:terms.accepted.asset,amount:terms.amount},spender:X402_PERMIT2_PROXY,nonce,deadline,witness:{to:terms.accepted.payTo,validAfter:0n}}};","title":"Chain id is hardcoded to 1 in both EIP-712 domains while the challenge network and quote.payment.network are never checked"},{"citation":"resolved","description":"The CLI constructs ImdClient for every command with maxPerRequest/maxPerDay taken from IMD_MAX_PER_REQUEST / IMD_MAX_PER_DAY. These variables appear nowhere in the README or in the help text, and the README talks about caps in IMD ('0.5 IMD'). BigInt() parsing means: '0.1' or '1e18' throws SyntaxError 'Cannot convert 0.1 to a BigInt' even for 'imd capabilities'; '1' yields a 1 wei cap so every payment is refused with 'per-request IMD spending cap exceeded'; an empty string is not nullish, bypasses the ?? default and yields 0n. Expected: documented variables, a decimal-IMD parser (or an explicit 'wei' suffix) and a clear error naming the variable. All outcomes are fail-closed; the risk is usability and a user raising the value by mistake because the unit is unclear.","line":13,"path":"src/cli.js","reproduction":"IMD_MAX_PER_REQUEST=0.1 node src/cli.js capabilities -> prints 'Cannot convert 0.1 to a BigInt', exit 1, no request made. new ImdClient({maxPerRequest:''}).maxPerRequest === 0n. new ImdClient({maxPerRequest:'1'}).maxPerRequest === 1n (one wei).","severity":"low","snippet":"  const client=new ImdClient({baseUrl:process.env.IMD_API,token:process.env.IMD_REQUEST_TOKEN,maxPerRequest:process.env.IMD_MAX_PER_REQUEST,maxPerDay:process.env.IMD_MAX_PER_DAY});let out;","title":"CLI spending-cap environment variables are undocumented, parsed as raw wei, and a decimal value aborts every command"},{"citation":"resolved","description":"The API documents requestKey as an idempotency key: 'Reuse the requestKey when retrying the same input' and returns 200 for a retry versus 201 for a new order, with 409 on conflict. quote() always sends randomUUID() and offers no way to pass one in, so a retry after a timeout or a 503 ('saved state is retained') creates a second priced order. Orders are free until paid, but the caller then holds two orders for one intent and pay() can be run on both; with the retry finding this doubles exposure. Fix: accept an optional requestKey in quote(action, input, {requestKey}) and surface it in the CLI, defaulting to a UUID derived from sha256(canon({action,input})) or a caller-supplied value.","line":36,"path":"src/index.js","reproduction":"Call client.quote('job.open', {objective:'x'}) twice against a mock that records the requestKey of each POST /requests/quote. Expected (per API guidance): identical requestKey on the retry. Actual: two different UUIDs, two orders created (mock log quotes === 2), no option to reuse the first.","severity":"low","snippet":"  async quote(action,input) { const out=(await this.request('/requests/quote',{method:'POST',headers:this.headers(true),body:JSON.stringify({requestKey:randomUUID(),action,input})})).body;const id=out?.order?.id;if(id)this.quotes.set(id,out);return out; }","title":"quote() generates a fresh requestKey on every call, so a retried quote creates a duplicate order instead of reusing the saved one"},{"citation":"resolved","description":"pay() returns only response.body. The random nonce, the deadline and both signatures exist only inside the call. If the order never settles (service outage, order stuck in payment_pending, or the retry case) the payer has a live Permit2 authorization for 0.5 IMD until quote expiresAt and no way to call Permit2.invalidateUnorderedNonces(wordPos, mask) because the nonce is unknown; the CLI prints nothing about it either. Expected: return (or log with --json) {nonce, deadline, permitSignature, quoteSignature, paymentHash} alongside the server response, and document how to invalidate. This is a usability/safety gap rather than a loss path, since the amount and payee are bounded by the signed witness.","line":68,"path":"src/index.js","reproduction":"Run pay(order, signer, {execute:true}) against the mock; inspect the resolved value: it is exactly the server body {status:'payment_pending'} and contains no nonce, deadline or signature; the SDK keeps no record either (client.quotes.get(id) is the original quote response).","severity":"low","snippet":"    const quoteSignature=await signer.signTypedData(approval);const response=await this.request(`/requests/${encodeURIComponent(id)}/submit`,{method:'POST',headers:{...this.headers(true),'PAYMENT-SIGNATURE':Buffer.from(canon(payment)).toString('base64')},body:JSON.stringify({quoteSignature})},[200,202]);return response.body;","title":"The signed Permit2 nonce and signature are discarded, so a pending authorization cannot be invalidated by the payer"},{"citation":"resolved","description":"signDigest() and addressFromPrivateKey() call num(privateKey) = BigInt(privateKey) without the LocalPrivateKeySigner regex guard. For a 64-hex key passed without the 0x prefix (a common mistake) BigInt throws SyntaxError whose message contains the full key text, e.g. 'Cannot convert 59c6995e...f37b to a BigInt'; the CLI and most loggers print error.message. package.json declares no \"exports\" map, so dist/crypto.js is importable from the installed package (import {signDigest} from 'imd-sdk/dist/crypto.js'), which the README's 'the key is never logged' claim does not cover. Expected: validate the key with the same regex (or catch and rethrow a static 'invalid private key') in signDigest/addressFromPrivateKey, and add an \"exports\" field exposing only index.js.","line":61,"path":"src/crypto.js","reproduction":"import {signDigest} from 'imd-sdk/dist/crypto.js'; signDigest('59c6995e998f97a5a0044976f0945389dc9e86dae88c7a8412c8b4f11f99f37b', new Uint8Array(32)). Expected: Error('invalid private key'). Actual: SyntaxError: Cannot convert 59c6995e998f97a5a0044976f0945389dc9e86dae88c7a8412c8b4f11f99f37b to a BigInt (full key in the message). Same for addressFromPrivateKey with the same input.","severity":"low","snippet":"  const d=num(privateKey); if(d<=0n||d>=N)throw new Error('invalid private key'); const z=BigInt(hex(digest));","title":"Deep-importable crypto helpers echo the private key text inside BigInt conversion errors"},{"citation":"resolved","description":"The constructor only checks the 64-hex shape. For a value d >= N, addressFromPrivateKey computes mul(d) = (d mod N)*G and reports that address as signer.address, while signDigest later throws 'invalid private key'. The result is a client that advertises a wallet address it cannot sign for; pay() reaches the ledger reservation (debiting the day's budget, see the ledger finding) before the signer throws. Expected: reject d == 0 or d >= N in the constructor with the same static error.","line":77,"path":"src/crypto.js","reproduction":"new LocalPrivateKeySigner('0x' + (N + 5n).toString(16)) where N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141. Expected: throws 'invalid private key'. Actual: constructs; .address === addressFromPrivateKey('0x5') === 0xe1ab8145f7e55dc933d51a18c793f901a3a0b276; signTypedData(...) then rejects with 'invalid private key'.","severity":"low","snippet":"  /** @param {string} privateKey */ constructor(privateKey) { if(typeof privateKey!=='string'||! /^(0x)?[0-9a-fA-F]{64}$/.test(privateKey))throw new Error('invalid private key');this.#privateKey=privateKey.startsWith('0x')?privateKey:`0x${privateKey}`;this.address=addressFromPrivateKey(this.#privateKey); }","title":"LocalPrivateKeySigner accepts a scalar >= secp256k1 n and reports the address of (key mod n), then fails at signing time"},{"citation":"resolved","description":"canon() falls through to JSON.stringify for scalars, which returns the value undefined (not a string) for undefined; template interpolation then writes the bare token undefined into the output. payment.resource is taken from the challenge without validation, so a challenge lacking resource (or an accepts[0] containing an undefined-valued key when built programmatically) yields a header like {\"accepted\":...,\"resource\":undefined,...}. The sequence signs the Permit2, computes paymentHash over the malformed text, signs the QuoteApproval, debits the ledger and only then is rejected by the server (invalid_payment_shape). Expected: canon() throws on undefined/bigint/function values, and pay() validates the challenge fields it embeds (resource object, resourceUrl string, requesterScopeHash 64-hex) before signing.","line":16,"path":"src/index.js","reproduction":"Mock challenge identical to the documented one but without the resource property. pay(order, signer, {execute:true}): Expected: refuse before signing. Actual: signer called twice, ledger debited, submit sent with PAYMENT-SIGNATURE decoding to text containing '\"resource\":undefined', which JSON.parse rejects (SyntaxError: Unexpected token 'u').","severity":"low","snippet":"const canon = (v) => v===null?'null':Array.isArray(v)?`[${v.map(canon).join(',')}]`:typeof v==='object'?`{${Object.keys(v).sort().map(k=>`${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`:JSON.stringify(v);","title":"canon() emits invalid JSON for undefined values, producing an unparseable PAYMENT-SIGNATURE after both signatures were already made"},{"citation":"resolved","description":"The default (non-execute) path returns {dryRun:true} right after the 402, before verifyTerms, the caps, the deadline check and the ledger. A user who follows the README ('imd pay ORDER_ID' then '--execute') gets no preview of the terms, the amount against the caps, or the cross-check failures above; everything surfaces only once a key is in the environment. Expected: run verifyTerms and the cap/deadline checks in dry-run mode and return the validated terms (amount, payTo, asset, expiresAt, deadline) so the user can inspect them before supplying IMD_PRIVATE_KEY.","line":58,"path":"src/index.js","reproduction":"Mock challenge with accepts[0].payTo 0x4e0fa57bde726079356537e2f34d671e9f41ad00 while capabilities says ...adbc. pay(order) without execute: returns {dryRun:true, order:{id}, message:'Payment not signed...'} with no error; pay(order, signer, {execute:true}) throws 'challenge payment terms differ from quote or capabilities'.","severity":"info","snippet":"    if(!options.execute)return {dryRun:true,order:{id},message:'Payment not signed. Pass execute:true to sign and submit.'};","title":"Dry-run pay() fetches the challenge but skips verifyTerms, so a dry run never exercises the checks that execute:true will apply"},{"citation":"resolved","description":"mul() is a plain double-and-add loop that performs an extra point addition for every set bit of the scalar, and inv() is a variable-time extended Euclid; the RFC 6979 nonce k and the private key d both flow through them. For a CLI running locally this is only exploitable by a co-resident attacker measuring the process, so it is informational, but it should be stated in the README's threat model and users with real funds steered to the viem/hardware signer path, or the library should switch to a constant-time ladder.","line":54,"path":"src/crypto.js","reproduction":"Time signDigest(KEY, digest) for two keys of Hamming weight 1 and 200: the number of add() calls equals the popcount of the nonce, so the runtime differs measurably (observable with performance.now() over repeated calls).","severity":"info","snippet":"function mul(k,A=G) { let out=null, p=A;while(k){if(k&1n)out=add(out,p);p=dbl(p);k>>=1n;}return out; }","title":"Scalar multiplication and modular inverse are variable-time; local signing leaks timing of the private key"}],"hash":"7f935a189537f257c5e7002d17f3c388b8274d20ffd65b49663e845e1ee56a37","nodeId":"57194655-8968-441d-ad52-a48deb227bce","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 14 substantiated findings, each reproduced against a local mock that uses the shapes published in the live API's OpenAPI document. No repository file was modified. There is no Solidity in scope, so no Foundry proofs apply.\n\n**Headline result.** The paid flow cannot work against the live service as written. `verifyTerms` reads `payTo` and `amount` from the top level of the saved quote, but the API nests them under `quote.payment`, so every `pay(execute:true)` throws \"challenge quote differs from the original quote\" before signing. When a server omits the quote object, the same check is silently skipped, so the README's payee cross-check holds in neither case.\n\n**Findings by severity**\n\n- **High.** Quote cross-check reads the wrong level of the saved quote (always throws on real data, silently skipped otherwise), `src/index.js:49`.\n- **Medium.** A retried `pay()` signs a second Permit2 authorization with a fresh random nonce for the same order and debits the ledger twice. Only the server's 409 handling stands between that and a double pull.\n- **Medium.** The daily ledger is debited before signing and submission and never rolled back. With the default 0.5/0.5 caps, one rejected signature or one 503 locks payments for the rest of the UTC day.\n- **Medium.** Amount is compared against the capabilities per-unit price, so `schedule.create` and `schedule.topup` with more than one run can never be paid.\n- **Low.** Cross-process ledger race lets two concurrent CLI runs exceed the daily cap. Chain id hardcoded to 1 with the challenge network never checked. CLI cap variables undocumented, parsed as wei, and a decimal value aborts every command. Fresh `requestKey` per quote defeats the API's idempotent retry. Signed nonce discarded so a pending permit cannot be invalidated. Deep-import of `dist/crypto.js` echoes a 0x-less key in a BigInt error. Constructor accepts a scalar at or above the curve order. `canon()` emits `undefined` as bare text, so a malformed challenge is signed before the server rejects it.\n- **Info.** Dry run skips all term checks. Scalar multiplication is variable-time.\n\n**What checked out.** The Permit2 types match the witness string embedded in the x402 proxy bytecode on mainnet, the token at the hardcoded address is IMD with 18 decimals, the QuoteApproval domain, types and message mapping match the API's published definition, `paymentHash` is SHA-256 over key-sorted compact JSON as specified, the key is held in a private field that neither `util.inspect` nor JSON serialization exposes, `dist/` is byte-identical to `src/`, and the package has zero dependencies.","treeHash":null,"usage":{"cachedInputTokens":1134915,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":51576,"runtime":"claude","turns":31,"wallClockMs":661138}}],"verification":[]}