{"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":"c90eb7ff-7de6-4934-be82-7e11791b1769","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"45f433c2790bc6114815e2fe66b43de8f7973787c0a8665d7f841cb5db8f5a23","dependsOn":["scaffold_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"6b037a7b6601e883cf8a906c1520c0624817d42d8310b65c2f43679204608af3","skillId":"adversarial-review","tools":[]},"key":"adversarial_review","kind":"code","role":"review","skillHash":"6b037a7b6601e883cf8a906c1520c0624817d42d8310b65c2f43679204608af3","skillId":"adversarial-review","state":"accepted"},{"acceptedSubmissionHash":"b3ad6b8f17f6e2fc1ae342540f4e9fd17fdca3fc582c64a0c57174cc19a94dfb","dependsOn":[],"execution":{"network":true,"profile":"none","requires":["network"],"skillHash":"7ae2f33d07dd65f04780071437d0c74200f8f58a323bc9324fa8524f587719c6","skillId":"scaffold-project","tools":[]},"key":"scaffold_project","kind":"code","role":"implement","skillHash":"7ae2f33d07dd65f04780071437d0c74200f8f58a323bc9324fa8524f587719c6","skillId":"scaffold-project","state":"accepted"}],"objective":"Build imd-sdk: a typed TypeScript client and `imd` CLI for the IMD swarm's paid requests. Library: capabilities(), check(action, input), importRepo(url, kind), quote(action, input), pay(order, signer), status(order), waitFor(order), job(id), jobReport(id), schedules(owner), plus typed input interfaces for job.open, job.continue, launch.open, workflow.open, oracle.request, schedule.create and schedule.topup taken from https://imd.fun/docs. CLI: imd capabilities | check <file> | import <url> | quote <file> | pay <order> [--execute] | status <order> | job <id> | schedules <owner>, with --json output. The signer is pluggable (a viem account by default). Installable straight from GitHub (`npm i github:<owner>/<repo>`), so commit a prepare/build script. Paid-request flow on https://api.imd.fun (server-side only; browser origins get 403). 1) Make a bearer token: 32 random bytes as hex, header Authorization: Bearer <token>. 2) POST /requests/quote {requestKey: new UUID, action, input} returns {order:{id}} (422 invalid_input lists problems). 3) POST /requests/{id}/submit with no body returns 402 with a challenge: accepts[], quote{id, quoteHash, action, payment{asset, amount, payTo}, expiresAt}, resource, resourceUrl, requesterScopeHash. 4) Check accepts[0] against capabilities and the quote. 5) Sign EIP-712 Permit2 PermitWitnessTransferFrom: domain {name \"Permit2\", chainId 1, verifyingContract 0x000000000022D473030F116dDEE9F6B43aC78BA3}; types PermitWitnessTransferFrom(TokenPermissions permitted, address spender, uint256 nonce, uint256 deadline, Witness witness), TokenPermissions(address token, uint256 amount), Witness(address to, uint256 validAfter); spender = x402 exact Permit2 proxy 0x402085c248EeA27D92E8b30b2C58ed07f9E20001; random 256-bit nonce; deadline at most quote.expiresAt minus 5 s; witness {to: payTo, validAfter: 0}. The payment object is {x402Version: 2, resource, accepted: accepts[0], payload: {signature, permit2Authorization: {from, permitted{token, amount}, spender, nonce, deadline, witness{to, validAfter}}}} with numbers as decimal strings and no extra fields (extra fields fail as invalid_payment_shape). 6) Sign EIP-712 QuoteApproval: domain {name \"IdentityMD Paid Action\", version \"1\", chainId 1}; fields resource string (= resourceUrl), requesterScopeHash bytes32 (0x + value), quoteId string, quoteHash bytes32 (0x + value), paymentHash bytes32 (sha256 of the payment object serialised as key-sorted JSON), action string, asset address, amount uint256, payTo address, expiresAt uint256. 7) POST /requests/{id}/submit again with header PAYMENT-SIGNATURE: base64(JSON payment) and body {quoteSignature}: 202 pending or 200 outcome. 8) Poll GET /requests/{id} with the same bearer until the status leaves quoted, payment_pending and admission_pending. Payment is IMD 0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7 on Ethereum mainnet, 0.5 IMD per action (per run for schedules); the wallet needs a one-time IMD approve to Permit2; the server pays gas. Free helpers: POST /requests/check {action, input} (the evaluator's verdict, no payment; it is noisy, so retry up to 3 times), POST /requests/import {url, kind} (public GitHub repo to repoUrl + baseCommit), GET /openapi.json (actions and limits under x-imd-actions), GET /requests/capabilities (price, asset, payTo, quote lifetime, launch chains). Full reference: https://imd.fun/docs#paid Key safety is a hard requirement. The private key is read only from an environment variable, never logged, printed, written to disk or sent anywhere except as signatures. Dry run (stop before signing) is the default and real payment needs an explicit flag. A per-request and a per-day IMD spending cap are enforced before any signature. Refuse to pay when the challenge's asset, payTo or amount differ from GET /requests/capabilities or from the quote (this also blocks look-alike address poisoning). Never pay more than the quoted amount. Tests must never spend real IMD or touch mainnet: run them against a local mock server with throwaway test keys.  Label it everywhere it is presented (README top, CLI --help, site banner) as experimental: \"Experimental, commissioned as a test of the IMD swarm. It may not work as described. Read the code, start with small amounts, no warranty.\" Add one line at the end of the README: \"Commissioned through paid IMD swarm requests.\"","parentJobId":null,"planHash":"8377b077aca9d6e8421db4992471aacbd84bfe2e4efc214ed9771903d6365933","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"c90eb7ff-7de6-4934-be82-7e11791b1769","publication":{"commit":null,"deliveredAt":null,"repoUrl":"https://github.com/identity-md-launches/launch-601-build-imd-sdk-typed-typescript"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"50955","feedbackHash":"7c0e4bdf05b8fc67fd9388c1dd78b99659eeab6f755043f5dad2447183f13d0c","nodeKey":"adversarial_review","submissionHash":"d58e3753808de54b0f3f30237f661cf887f74aff5054f802554d1e48d0b27518","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50974","feedbackHash":"7a5c6c0e8d2969b303c7292c4ba54bcb38ef4c4f2888256a79f4197afb36499a","nodeKey":"adversarial_review","submissionHash":"45f433c2790bc6114815e2fe66b43de8f7973787c0a8665d7f841cb5db8f5a23","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51548","feedbackHash":"ef49a139325eb8860d872d023990a996db150ebb4aae39831565a24ba7d8d5db","nodeKey":"scaffold_project","submissionHash":"9db2c8574b09019c0a884720e63c82693918240fad53a349e4fd96119b9d8be6","tag1":"verification:structural","tag2":"acceptance-v2","value":1},{"agentId":"51236","feedbackHash":"59da9b920ab3dfec1b2964c189a7bee2d70ba183dda8925ed42989949a2fa4b6","nodeKey":"scaffold_project","submissionHash":"b3ad6b8f17f6e2fc1ae342540f4e9fd17fdca3fc582c64a0c57174cc19a94dfb","tag1":"verification:structural","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f0514094d31c957560a5b6b4591dfa72a8b4abf44439666ebe731cd3e29ad96b","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"98b4506bef931d13","findings":[{"citation":"resolved","description":"The fix for the per-day cap persists the day's spend in daily-spend.json and serialises concurrent pay() calls with an in-process promise queue. The CLI, however, runs every command in a fresh process, and the ledger is updated with an unlocked read-then-write (readFile, compare, writeFile). Two processes that reach line 61 at about the same time both read the same prior balance, both pass the cap check, both sign and submit a Permit2 permit, and the second write overwrites the first, so the ledger records only one payment. The per-day cap, which the task requires to be enforced before any signature, can therefore be exceeded by any automation that pays for several orders in parallel, and the ledger then under-reports the spend for the rest of the day. Sequential invocations are correctly refused (verified). Fix: take an exclusive lock around read-check-write (e.g. an O_EXCL lock file with a short timeout, or append-only entries per payment that are summed), or write the reservation with wx/rename semantics and re-read after writing.","line":61,"path":"src/index.js","reproduction":"Local mock whose GET /requests/capabilities answers after ~600 ms (to align the two processes; real network latency does the same). Fresh XDG_STATE_HOME, IMD_API=<mock>, IMD_PRIVATE_KEY=<throwaway test key>, default caps (0.5 IMD per day). Run `imd quote req.json` twice (order-1, order-2), then start `imd pay order-1 --execute --json & imd pay order-2 --execute --json & wait`. In 5 of 10 iterations both processes print {\"status\":\"payment_pending\"}, the mock receives two signed submits of 500000000000000000 each (1.0 IMD against a 0.5 IMD cap), and daily-spend.json afterwards contains {\"<today>\":\"500000000000000000\"}. Expected: exactly one process pays and the other prints \"daily IMD spending cap exceeded\", and the ledger always equals the number of signatures issued times the amount.","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 cap ledger has no cross-process lock: two concurrent `imd pay --execute` processes both sign and the second overwrites the first reservation"},{"citation":"resolved","description":"The mock-server test creates ImdClient with no cap or ledger override, and the constructor (src/index.js:28) always resolves the ledger to $XDG_STATE_HOME/imd-sdk/daily-spend.json or ~/.local/state/imd-sdk/daily-spend.json. The test's successful mock payment of 500000000000000000 is written to that real file. Consequences: (1) a second `npm test` or `node --test` run on the same UTC day fails with \"daily IMD spending cap exceeded\", so the committed suite is not repeatable on the author's or a reviewer's machine; (2) after running the tests, a real `imd pay --execute` with the default 0.5 IMD daily cap is refused for the rest of the day even though no real IMD was spent; (3) test state leaks into the user's home directory. The task requires tests to run against a local mock with throwaway keys; they should not touch user state. Fix: let ImdClientOptions accept a spendFile (or ledger store) and point the tests at a temp directory, or set XDG_STATE_HOME to a fresh tmp dir at the top of the test file; additionally consider isolating per-baseUrl.","line":53,"path":"test/paid-flow.test.mjs","reproduction":"HOME=/tmp/fakehome (unset XDG_STATE_HOME). Run `node --test test/*.test.mjs` twice in the same UTC day. First run: pass 2, fail 0, and /tmp/fakehome/.local/state/imd-sdk/daily-spend.json now contains {\"<today>\":\"500000000000000000\"}. Second run: the test \"quote, 402, both signatures, submit and polling run against a local mock only\" fails with Error: daily IMD spending cap exceeded. Same result with XDG_STATE_HOME set to any directory reused across two runs. Expected: both runs pass and no file is written outside a test-owned temporary directory.","severity":"medium","snippet":"    const client=new ImdClient({baseUrl:m.url,signer});","title":"Test suite debits the real persistent daily ledger: `npm test` passes once per UTC day per machine and then fails, and blocks real `imd pay --execute` for the rest of the day"},{"citation":"resolved","description":"The daily amount is reserved at line 61 and never released on any later error. Retaining it after a failed submit is correct, because signatures have already left the process. But the deadline check at line 62 and the signer call at line 66 run after the reservation and before any signature is produced; when either throws, nothing has been signed yet, the server holds nothing, and still the day's cap is debited by the full amount. With the default maxPerDay of 0.5 IMD, a single quote that was challenged within 5 s of its expiry, or a single rejected hardware-wallet prompt, locks the user out of all payments for the rest of the UTC day. Fix: release (or never commit) the reservation when the abort happens before signer.signTypedData has been called, i.e. move the deadline check before the reservation and roll back in the catch at line 66 only if signTypedData threw before returning.","line":62,"path":"src/index.js","reproduction":"Fresh XDG_STATE_HOME, default caps. (a) Mock whose quote and challenge carry expiresAt = now + 4 s; client.pay(order, signer, {execute:true}) throws \"quote expires too soon to sign safely\" (correct) but daily-spend.json now holds {\"<today>\":\"500000000000000000\"}; a following pay() on a valid quote from a second mock throws \"daily IMD spending cap exceeded\" although zero signatures were produced. (b) Signer {address, signTypedData: async()=>{throw new Error('user rejected')}}: first pay() throws \"user rejected\", ledger shows 500000000000000000, the next pay() with a working signer throws \"daily IMD spending cap exceeded\"; the mock received 0 signed submits in both cases. Expected: the ledger stays at 0 when no signature was created, and the second payment succeeds.","severity":"low","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":"Reservation is kept when pay() aborts before any signature exists (quote expiring, signer rejecting), so one refused attempt consumes the whole default daily cap with nothing signed"},{"citation":"resolved","description":"The fix added IMD_MAX_PER_REQUEST and IMD_MAX_PER_DAY, but the --help text lists only IMD_API, IMD_REQUEST_TOKEN and IMD_PRIVATE_KEY, and the README mentions neither variable nor that maxPerRequest/maxPerDay are integer token units of 10^-18 IMD. A user who sets IMD_MAX_PER_DAY=1 intending 1 IMD gets a 1-wei cap and every payment refused with \"per-request IMD spending cap exceeded\"; the failure direction is safe, which is why this is informational. An unparsable value (e.g. IMD_MAX_PER_DAY=0.5) surfaces as a raw BigInt SyntaxError. Fix: document both variables and the unit, and validate them with a fixed message.","line":6,"path":"src/cli.js","reproduction":"`node dist/cli.js --help | grep -c MAX` prints 0; `grep -c IMD_MAX README.md` prints 0. `IMD_MAX_PER_DAY=0.5 IMD_API=<mock> IMD_PRIVATE_KEY=<test key> imd pay order-1 --execute` prints \"Cannot convert 0.5 to a BigInt\". Expected: documented variables with units and a fixed validation message.","severity":"info","snippet":"const help = `${warning}\\n\\nUsage: imd <command> [arguments] [--json]\\n\\nCommands:\\n  capabilities\\n  check <file>                 JSON: { action, input }\\n  import <url>\\n  quote <file>                 JSON: { action, input }\\n  pay <order> [--execute]      dry run by default; --execute signs\\n  status <order>\\n  job <id>\\n  schedules <owner>\\n\\nEnvironment: IMD_API, IMD_REQUEST_TOKEN, IMD_PRIVATE_KEY (only read with pay --execute).`;","title":"CLI cap environment variables are undocumented and cap units (wei, 18 decimals) are unstated in README and --help"}],"hash":"45f433c2790bc6114815e2fe66b43de8f7973787c0a8665d7f841cb5db8f5a23","nodeId":"6321539e-5a4f-4980-b904-cad13aa875f8","outcome":"completed","summary":"All seven earlier findings are fixed and I reproduced each fix. Four new, smaller defects are in `.imd-findings.json`. No repository file was changed; the findings file is git-excluded and is the only addition.\n\n**Earlier findings, settled as fixed**\n\n- **PERMIT2 constant (critical).** Now 40 hex chars. A real `pay({execute:true})` against the mock produced a Permit2 signature that viem recovers to the signer's address under the canonical Permit2 domain, and the QuoteApproval signature recovers the same way. A viem account as signer also pays successfully.\n- **Negative amount (high).** Amounts `-5`, `+5`, `0x10`, `5.0`, `1e18`, `0` and 2^256 are all refused with 'invalid payment amount' before any signature.\n- **Key leaked via BigInt error (high).** A mistyped key makes the CLI print only 'invalid private key'.\n- **Process-local daily cap (medium).** Sequential CLI pays are refused after the first on the same day. Env caps exist.\n- **Reserve-after-sign (medium).** A failed submit keeps the reservation and a retry is refused with one nonce issued. Concurrent in-process pays yield one payment and one refusal.\n- **Original-quote cross-check (low).** The documented quote shape with differing amount or payee is now refused.\n- **Key in enumerable field (low).** JSON.stringify and util.inspect show only the address.\n\n**New findings, ranked**\n\n1. **Medium.** The persisted ledger has no cross-process lock. Two simultaneous `imd pay --execute` processes both signed in 5 of 10 runs and the ledger then recorded only one payment. Anchored at src/index.js:61.\n2. **Medium.** The tests debit the real daily ledger under the user's home. A second test run on the same UTC day fails with 'daily IMD spending cap exceeded', and real payments are blocked for the rest of that day. Anchored at test/paid-flow.test.mjs:53.\n3. **Low.** Aborts before any signature exists, such as a quote expiring within 5 s or the signer rejecting, still consume the full daily cap.\n4. **Info.** The new cap env vars and their wei units appear in neither `--help` nor the README, and a non-integer value surfaces as a raw BigInt error.\n\nEach finding carries exact inputs, observed output and expected behaviour, and all snippets were verified to match the tree line for line.","treeHash":null,"usage":{"cachedInputTokens":1036171,"inputTokens":452,"model":"claude-fable-5-1","outputTokens":29073,"runtime":"claude","turns":32,"wallClockMs":772511}},{"artifacts":[],"attempt":1,"bundleHash":"ce9a1b39813c73036605b629b09c6c4622a32721d205b9fe61e992256749a4ef","device":"0116c8455d2c62d4","findings":[],"hash":"9db2c8574b09019c0a884720e63c82693918240fad53a349e4fd96119b9d8be6","nodeId":"6ec41e79-0e2a-4648-bb72-9226815287e9","outcome":"completed","summary":"Implemented `imd-sdk` with dependency-free Node 20 runtime, typed declarations, CLI, safety checks, static site warning banner, and GitHub-install `prepare` build.\n\nKey files: [README.md](/home/imd-worker/.identitymd/work/c90eb7ff-7de6-4934-be82-7e11791b1769/6ec41e79-0e2a-4648-bb72-9226815287e9/README.md), [client](/home/imd-worker/.identitymd/work/c90eb7ff-7de6-4934-be82-7e11791b1769/6ec41e79-0e2a-4648-bb72-9226815287e9/src/index.js), [typed API](/home/imd-worker/.identitymd/work/c90eb7ff-7de6-4934-be82-7e11791b1769/6ec41e79-0e2a-4648-bb72-9226815287e9/src/index.d.ts), [CLI](/home/imd-worker/.identitymd/work/c90eb7ff-7de6-4934-be82-7e11791b1769/6ec41e79-0e2a-4648-bb72-9226815287e9/src/cli.js), and [tests](/home/imd-worker/.identitymd/work/c90eb7ff-7de6-4934-be82-7e11791b1769/6ec41e79-0e2a-4648-bb72-9226815287e9/test/paid-flow.test.mjs).\n\n`npm test` passes: fixed independent viem signature vectors plus the full local mock quote → 402 → two signatures → submit → polling flow.","treeHash":"f577a1a75fc6979a1a763d716090efeecf9cfb11","usage":{"cachedInputTokens":2701312,"inputTokens":119978,"model":null,"outputTokens":34189,"runtime":"codex","turns":4,"wallClockMs":741510}},{"artifacts":[],"attempt":1,"bundleHash":"3209a5fc57004d3012834aeefb5d1ab9923433f16c4d1ccc3b6f9159afde3a93","device":"08261d0cc6850daf","findings":[],"hash":"b3ad6b8f17f6e2fc1ae342540f4e9fd17fdca3fc582c64a0c57174cc19a94dfb","nodeId":"6ec41e79-0e2a-4648-bb72-9226815287e9","outcome":"completed","summary":"Fixed all seven findings and recorded the responses in [.imd-responses.json](/home/imd-worker/.identitymd/work/c90eb7ff-7de6-4934-be82-7e11791b1769/6ec41e79-0e2a-4648-bb72-9226815287e9/.imd-responses.json). The daily spend ledger persists across CLI runs; reservations are serialized within a process.\n\n`npm run build` passed. `npm test` passed the fixed signature vectors, but the local mock test failed because this environment could not connect to its loopback server. I also verified the Permit2 address is 40 hex characters and that invalid keys and signer inspection do not expose key material.","treeHash":"2ecc1ec2243cd7d4fbdcae527309e71ed7bc883a","usage":{"cachedInputTokens":328704,"inputTokens":43835,"model":"gpt-6-luna","outputTokens":4593,"runtime":"codex","turns":3,"wallClockMs":143344}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3c7630b22a73c1fb","findings":[{"citation":"resolved","description":"The canonical Permit2 address is 0x000000000022D473030F116dDEE9F6B43aC78BA3 (40 hex chars). The SDK constant drops the 'D' after '116d', leaving 39 chars. crypto.js unhex() silently truncates odd-length hex to 19 bytes and field('address') pads it to 31 bytes, so typedDataDigest hashes a misaligned EIP712Domain struct. With LocalPrivateKeySigner, pay({execute:true}) therefore produces a Permit2 signature that recovers to an unrelated address under the real Permit2 domain; the QuoteApproval (which is correct) and the bogus permit are still submitted to the server, which cannot settle them (payment_failed). With a viem account as signer, viem rejects the domain outright (InvalidAddressError: Address \"0x000000000022d473030f116dee9f6b43ac78ba3\" is invalid), so pay() throws after the challenge. Either way no real payment can ever succeed. The tests hide this: test/paid-flow.test.mjs:10 hard-codes the correct address literal instead of importing PERMIT2 from the SDK, and the mock-server test only regex-checks the signature length and never recovers the signer.","line":8,"path":"src/index.js","reproduction":"node -e \"import('./src/index.js').then(m=>console.log(m.PERMIT2.length-2))\" prints 39 (expected 40). With viem: sign {domain:{name:'Permit2',chainId:1,verifyingContract:PERMIT2}, PermitWitnessTransferFrom message {permitted:{token:IMD,amount:5e17n},spender:X402_PERMIT2_PROXY,nonce:42n,deadline:1800000000n,witness:{to:payTo,validAfter:0n}}} using LocalPrivateKeySigner(0x59c6995e998f97a5a0044976f0945389dc9e86dae88c7a8412c8b4f11f99f37b); recoverTypedDataAddress with the real verifyingContract 0x000000000022D473030F116dDEE9F6B43aC78BA3 returns 0xe6cA6dBb6603eFe0DB778E1A8474e365a01e2956, expected 0x0F740EEC79B13A840AC194A801aa5D55741f873b. The same message signed with the correct constant matches viem byte-for-byte, so only the constant is wrong. privateKeyToAccount(KEY).signTypedData with the SDK's constant throws 'Address \"0x000000000022d473030f116dee9f6b43ac78ba3\" is invalid'.","severity":"critical","snippet":"export const PERMIT2 = '0x000000000022d473030f116dee9f6b43ac78ba3';","title":"PERMIT2 constant is 39 hex chars (missing a 'D'); every real Permit2 signature is over a malformed domain and never verifies"},{"citation":"resolved","description":"verifyTerms only checks amount > maxPerRequest and spent+amount > maxPerDay. BigInt('-5') is -5n, which is below any cap, so the checks pass. The Permit2 message then carries amount:-5n and crypto.js b32() encodes a negative bigint in two's complement, so the signed TokenPermissions.amount is 2^256-5 (verified: the SDK's signature for -5n is byte-identical to viem's signature for (1n<<256n)-5n). Permit2.permitWitnessTransferFrom only requires requestedAmount <= permitted.amount, so whoever holds that signature (the counterparty that produced the challenge) can pull the wallet's entire IMD allowance to Permit2, not 0.5 IMD. This is exactly the server-controlled-terms scenario the caps and 'never pay more than the quoted amount' requirement exist for; the capabilities/quote/challenge equality checks do not help because all three come from the same origin. Fix: require amount to match /^[0-9]+$/, be > 0 and < 2^256 before BigInt().","line":47,"path":"src/index.js","reproduction":"Local mock whose /requests/capabilities, challenge.quote.payment and accepts[0] all carry amount:'-5' (asset IMD, payTo matching). new ImdClient({baseUrl:mock,signer,maxPerRequest:'1000',maxPerDay:'1000'}); await client.quote('job.open',{objective:'a'}); await client.pay(order,signer,{execute:true}) returns {status:'payment_pending'} (expected: throw before signing). The submitted payload has permit2Authorization.permitted.amount '-5' and payload.signature equals the viem signature of the same permit with amount 115792089237316195423570985008687907853269984665640564039457584007913129639931.","severity":"high","snippet":"    const amount=BigInt(accepted.amount);if(amount>this.maxPerRequest)throw new Error('per-request IMD spending cap exceeded');","title":"Negative amount strings pass both spending caps and sign a Permit2 for 2^256-5 tokens"},{"citation":"resolved","description":"LocalPrivateKeySigner's constructor calls addressFromPrivateKey -> num() -> BigInt(privateKey). When the key contains any non-hex character, V8 throws SyntaxError whose message embeds the whole input string. The CLI (src/cli.js:24) prints error.message to stderr for every error, so a key with a single typo (63 of 64 nibbles correct, 1024 candidates to brute-force) is written to the terminal, CI logs or whatever captures stderr. Requirement: the key is never logged or printed. Library callers that log error messages hit the same path. Fix: validate the key with /^(0x)?[0-9a-fA-F]{64}$/ and throw a fixed message before BigInt().","line":67,"path":"src/crypto.js","reproduction":"IMD_PRIVATE_KEY=59c6995e998f97a5a0044976f0945389dc9e86dae88c7a8412c8b4f11f99f37g node dist/cli.js pay order-1 --execute  (no server needed; the signer is built before any request). stderr: 'Cannot convert 0x59c6995e998f97a5a0044976f0945389dc9e86dae88c7a8412c8b4f11f99f37g to a BigInt'. Expected: a fixed message such as 'invalid private key' with no key material.","severity":"high","snippet":"export function addressFromPrivateKey(privateKey) { const Q=mul(num(privateKey)); return hex(keccak256(concat(b32(Q[0]),b32(Q[1]))).slice(12)).toLowerCase(); }","title":"A mistyped IMD_PRIVATE_KEY is printed to stderr by the CLI via the BigInt SyntaxError message"},{"citation":"resolved","description":"The daily counter is an in-memory module-level Map. Every `imd pay --execute` invocation is a fresh process starting from 0 spent, so the per-day cap collapses to the per-request cap; the CLI also exposes no option or environment variable to set maxPerRequest/maxPerDay, so it always runs at the 0.5 IMD defaults. The README claims per-request and per-day caps of 0.5 IMD are enforced. Fix: persist the day's spend (e.g. a file under the user's config dir) and add CLI flags/env for both caps.","line":11,"path":"src/index.js","reproduction":"Start the local mock; IMD_API=<mock> IMD_PRIVATE_KEY=<test key>; run `imd quote req.json` three times (order-1..3) then `imd pay order-1 --execute`, `imd pay order-2 --execute`, `imd pay order-3 --execute` in the same UTC day. All three return payment_pending and the mock receives 3 signed permits of 500000000000000000 each (1.5 IMD) against a 0.5 IMD per-day cap. Expected: the second invocation throws 'daily IMD spending cap exceeded'.","severity":"medium","snippet":"const daily = new Map();","title":"Per-day cap lives in a process-local Map, so the CLI (and any restarted process) enforces no daily cap at all"},{"citation":"resolved","description":"verifyTerms reads daily.get(key) at line 48 but the spend is only recorded with daily.set at the end of pay(), after both signatures have been created and sent. Two consequences: (1) TOCTOU: concurrent pay() calls in one process each see spent=0 and both sign. (2) If the signed submit fails (network error, 5xx, 4xx such as payment_rejected), the signatures have already been handed to the server but nothing is debited, so a retry signs a second permit with a fresh random nonce; the counterparty now holds two independently valid Permit2 authorisations, each for the full amount, and the cap no longer bounds exposure. Fix: reserve the amount atomically before signing and release it only on a definitive failure.","line":63,"path":"src/index.js","reproduction":"(1) client=new ImdClient({baseUrl:mock,signer,maxPerDay:'500000000000000000'}); o1,o2 = two quotes; await Promise.all([client.pay(o1.order,signer,{execute:true}),client.pay(o2.order,signer,{execute:true})]) -> both fulfilled, mock receives 2 signed submits totalling 1.0 IMD. (2) mock answers the first signed submit with 500: first pay() throws ImdError 'internal'; a second pay() on the same order returns payment_pending; mock has received 2 signed permits with 2 distinct nonces, each for 500000000000000000. Expected in both: only one signature set exists for 0.5 IMD.","severity":"medium","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]);daily.set(terms.key,(daily.get(terms.key)||0n)+terms.amount);return response.body;","title":"Daily cap is checked before signing but only debited after a successful submit: concurrent pay() calls and failed submits bypass it"},{"citation":"resolved","description":"The documented /requests/quote response (imd.fun/docs) is {created, order:{id,status,quote:{id,action,amount,payTo,expiresAt}}}: the nested quote has no `payment` key. The guard `originallyQuoted?.payment&&...` is therefore always false, so a 402 challenge whose amount, payTo or expiresAt differ from what quote() returned is accepted silently, and the 'challenge quote differs from the original quote' error is unreachable. The remaining defences (accepts[0] vs challenge.quote vs capabilities, IMD asset constant, caps) still apply, so impact is limited to losing one layer of the required 'differs from the quote' refusal.","line":45,"path":"src/index.js","reproduction":"Mock /requests/quote returns {created:true,order:{id:'order-1',status:'quoted',quote:{id:'order-1',action:'job.open',amount:'1',payTo:'0x1111111111111111111111111111111111111111',expiresAt:E}}}; the 402 challenge and capabilities say amount 500000000000000000, payTo 0x4e0fa57bde726079356537e2f34d671e9f41adbc. client.quote(...) then client.pay(order,signer,{execute:true}) returns payment_pending. Expected: 'challenge quote differs from the original quote'.","severity":"low","snippet":"    if(originallyQuoted?.payment&&(!same(qp,paymentOf(originallyQuoted))||originallyQuoted.action!==q.action||String(originallyQuoted.expiresAt)!==String(q.expiresAt)))throw new Error('challenge quote differs from the original quote');","title":"Cross-check against the originally quoted terms never runs with the documented quote response shape"},{"citation":"resolved","description":"this.privateKey is a plain enumerable field and ImdClient stores the signer on this.signer. JSON.stringify(signer), console.log(client) and util.inspect(client) all emit the full key, which conflicts with the 'never logged, printed' requirement for routine debugging of the client object. Keep the key in a closure or a private #field and define a custom inspect that omits it.","line":76,"path":"src/crypto.js","reproduction":"const c=new ImdClient({signer:new LocalPrivateKeySigner('0x59c6...f37b')}); JSON.stringify(c.signer) -> {\"privateKey\":\"0x59c6995e998f97a5a0044976f0945389dc9e86dae88c7a8412c8b4f11f99f37b\",\"address\":...}; util.inspect(c).includes('59c6995e') -> true. Expected: no key material in either.","severity":"low","snippet":"  /** @param {string} privateKey */ constructor(privateKey) { this.privateKey=privateKey.startsWith('0x')?privateKey:`0x${privateKey}`;this.address=addressFromPrivateKey(this.privateKey); }","title":"LocalPrivateKeySigner keeps the key as an enumerable public property, so inspecting the signer or client prints it"}],"hash":"d58e3753808de54b0f3f30237f661cf887f74aff5054f802554d1e48d0b27518","nodeId":"6321539e-5a4f-4980-b904-cad13aa875f8","outcome":"completed","summary":"The audit is complete. Findings are in `.imd-findings.json`, no repository files were changed, and all scratch work lived in `/tmp`.\n\n**What I checked.** I verified the SDK's keccak, secp256k1/RFC6979 signing and EIP-712 encoding against viem with the test vectors plus 20 random Permit2 messages. They match byte for byte. The canonical JSON function matches the one published in the IMD docs, the QuoteApproval types and field sources match the docs, the Permit2 types and nonce generation are correct, and the deadline is clamped to expiresAt minus 5 seconds. The live capabilities endpoint has the shape the term check expects. The defects are in constants, input validation and cap bookkeeping, not in the cryptography.\n\n**Findings, ranked:**\n\n1. **Critical: wrong Permit2 address.** The constant on `src/index.js:8` has 39 hex characters (a dropped `D`). The in-house signer silently pads the truncated bytes and signs a malformed domain. Under the real Permit2 domain the signature recovers to a random address. A viem signer throws an invalid-address error instead. No real payment can ever succeed. The tests miss it because the vector test hard-codes the correct literal rather than importing the SDK constant, and the mock test never recovers the signature.\n2. **High: negative amounts bypass both caps.** An amount string of `-5` in capabilities, quote and challenge passes the comparisons and both cap checks, then the encoder two's-complements it. The resulting permit authorises 2^256-5 tokens, verified identical to viem's signature for that value. Any party holding the challenge can drain the full Permit2 allowance.\n3. **High: key leak on typo.** A private key with one non-hex character makes BigInt throw a message containing the whole string, and the CLI prints that message to stderr. 63 of 64 nibbles leak.\n4. **Medium: per-day cap is process-local.** Each CLI invocation starts at zero. Three consecutive `imd pay --execute` runs submitted 1.5 IMD against a 0.5 IMD daily cap. The CLI also has no way to set either cap.\n5. **Medium: cap is debited only after a successful submit.** Two concurrent calls both pass the check and both sign. A 500 on the signed submit leaves the counter untouched, so the retry hands the server a second valid permit with a fresh nonce.\n6. **Low: original-quote cross-check is dead code.** The documented quote response nests amount and payTo without a `payment` key, so the \"differs from the original quote\" branch never executes.\n7. **Low: the private key is an enumerable property.** Logging or JSON-stringifying the client or signer prints it.\n\n**Next for the author:** fix the constant and add a test that imports it and recovers the signature, validate amounts as positive canonical decimals before BigInt, validate key format before parsing, and reserve daily spend before signing with persistence for the CLI.","treeHash":null,"usage":{"cachedInputTokens":746926,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":32192,"runtime":"claude","turns":26,"wallClockMs":407209}}],"verification":[{"checks":[],"detail":"paths and tree verified; no suite was run for this kind of work","evaluation":"structural","profile":"none","status":"accepted","submissionHash":"9db2c8574b09019c0a884720e63c82693918240fad53a349e4fd96119b9d8be6","verifiedTreeHash":"f577a1a75fc6979a1a763d716090efeecf9cfb11","verifierVersion":"0.1.0+68ddf5e4"},{"checks":[],"detail":"paths and tree verified; no suite was run for this kind of work","evaluation":"structural","profile":"none","status":"accepted","submissionHash":"b3ad6b8f17f6e2fc1ae342540f4e9fd17fdca3fc582c64a0c57174cc19a94dfb","verifiedTreeHash":"2ecc1ec2243cd7d4fbdcae527309e71ed7bc883a","verifierVersion":"0.1.0+68ddf5e4"}]}