# Audit report

> Review Submission24/ only (read Submission24/AUDIT_REQUEST.md first). World source fd46d69, NOT deployed; no real Celestao payment and no real grant has ever been made. It fixes audit 521bc017 and adds an administrator GOLD grant, which is new authority: the first GOLD credit with no paid order or failed service behind it. The wallet-lock test fails on purpose until this audit is handled.
>
> 1. Grant limits and authority: any path past 5,000 per grant, 20,000 or 100 grants per 24 hours, or for another wallet. Include D1 batch behaviour, races, clocks, replays, session end and cross-site requests.
> 2. Ledger consistency: anything that assumes every credit has an order, or must count world_gold_grants. Is a 7-day session enough for this authority, or should each grant need a wallet signature? One grant wallet is the treasury.
> 3. Pay and receive after the rework: interleavings of prepare, admit, refusal, expiry, repeat, delivery, restart and the 15-minute limit that lose a paid order or charge twice.
> 4. Retention: can unadmitted be wrong so that it deletes a run a buyer holds, or keeps data no buyer holds?
> 5. Budgets: can one buyer still close checkout for others, or can the new keys or the limit table's new behaviour bypass a limit?
> 6. Migrations 0023 and 0024: partial application, the trigger in both orders, and the view rebuild on D1.
>
> Known items are in AUDIT_REQUEST.md section 4. Report severity, reproduction and fix. Not in scope: chart engines, report wording, 3D/rendering, media, AIMarket, Genesis.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `4343691c54e25a72d547ca00ee991b628123e393` |
| Job | `37a926e3-85be-40b6-a656-1fa263e9b485` |
| Judged | 2026-11-04 15:36 UTC |
| Findings | 1 medium · 6 low · 2 info |

Four agents audited the code as it is at `4343691`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: Administrator GOLD grant is authorised by the 7-day session cookie alone: no per-grant wallet signature, unlike every GOLD spend, and one grant wallet is the treasury

`Submission24/source/server/world-gold.ts:22`

```
const authCheck=(db:D1Database,wallet:string,now:number,auth?:{tokenHash:string})=>check(db,auth?`EXISTS(SELECT 1 FROM sessions WHERE token_hash=? AND address=? AND revoked_at IS NULL AND expires_at>? AND wallet_type='EOA' AND verification_method='ECDSA')`:'1',...auth?[auth.tokenHash,wallet,now]:[]);
```

Answer to AUDIT_REQUEST section 5 item 2 (merged from four specialist reports). The grant branch of goldAdminCommand (world-gold.ts:256-266) and the HTTP handler (world-gold-api.ts:57, 71, 79) prove only possession of the World session cookie: authCheck at line 22 requires a sessions row with the cookie's token_hash, the actor address, wallet_type='EOA', verification_method='ECDSA', unrevoked and unexpired. Nothing in the request is signed by the granting wallet's key. Every other GOLD-moving action a user takes (service payment, mint) needs an explicit wallet signature over the amount (world-gold-api.ts:29-31 and WorldGoldPanel.tsx payGold); the one action that creates GOLD with no order behind it does not. Sessions live 7 days with no step-up. A copied cookie (device or browser-profile compromise, a same-origin XSS on imdember.com, a leaked HAR) therefore issues grants to any address including the holder's own: 4 x 5,000 per rolling 24 h, about 140,000 GOLD over the cookie's life, each row immutable with no correcting entry (section 4), spendable on services and mint. GRANT-WALLET-2 is ECONOMY_TREASURY (tests/world-gold-grants.test.mjs:38), so the payment-receiving key is used interactively in a browser for this. The limits, triggers, origin/Sec-Fetch-Site checks, replay and race handling were checked and hold; this finding is about the credential behind the authority, which section 1 acknowledges and section 5 asks to be judged. It is a trust-assumption gap, not a permission bypass: an attacker still needs the cookie. Fix that keeps the owner's design (same two wallets, same limits, same switch): require in the grant command an EIP-712 signature by the session wallet over {recipient (lowercase), gold, requestId, reasonHash, chainId, domain imdember.com, deadline <= 10 min}; verify it with viem verifyTypedData/recoverTypedDataAddress against session.address before building the batch, store the signature in the world_gold_admin_actions payload (so a replay still compares equal) and refuse an expired deadline. Optionally bind a per-session total in the application guard (sum per sessions.token_hash <= 20,000) so one cookie cannot reach the weekly figure, and sign in with a dedicated grant EOA rather than the treasury address.

**Reproduction**

State: WORLD_GOLD_GRANTS='enabled', migrations through 0024 applied, economy configured and not halted, a live EOA/ECDSA sessions row for a grant wallet G whose cookie value an attacker has copied (no key, no wallet extension). Input: POST https://imdember.com/api/gold/admin, headers Origin: https://imdember.com, Content-Type: application/json, Cookie: <SESSION_COOKIE>=<copied token>, body {"expectedAddress":G,"requestId":"<fresh 16-80 char id>","reason":"ops","command":{"action":"grant","wallet":"0x<attacker address>","gold":"5000"}}. Expected for issuance authority of this size: refusal unless the grant wallet's key signed (recipient, amount, request id, chain, expiry). Actual: 200 {ok:true,result:{id,...}}, a world_gold_grants row and economy_gold_accounts[attacker] += 5000 by the 0024 apply trigger. Repeating with new request ids and amounts 5000,5000,5000,4999 reaches 20,000 within the day, and again each day until the session expires. The shipped fixture tests/world-gold-grants.test.mjs fixture() lines 22-27 exercises exactly this cookie-only path (one INSERT INTO sessions, then the POST with the cookie) and every grant test passes on it with no signature anywhere in the request.

### 2. Low: grantLeft reports only the 20,000 GOLD budget and ignores the 100-grant count limit, so the panel offers GOLD that every grant request will refuse

`Submission24/source/server/world-gold-api.ts:68`

```
   const [granted,recentGrants]=canGrant?await Promise.all([deps.db.prepare('SELECT COALESCE(SUM(delta),0) n FROM world_gold_grants WHERE created_ms>?').bind(now-GOLD_GRANT_WINDOW_MS).first<{n:number}>(),deps.db.prepare('SELECT id,wallet,CAST(delta AS TEXT) gold,actor,created_ms FROM world_gold_grants ORDER BY created_ms DESC,id DESC LIMIT 10').all()]):[null,null];
```

Merged from three specialist reports. The state route computes grantLeft from the 24-hour SUM(delta) only (world-gold-api.ts:68-69). The count limit GOLD_GRANT_DAILY_COUNT=100 is enforced by the application guard (world-gold.ts:266) and by the 0024 trigger (line 22) but is never read into the state, and WorldGoldPanel.tsx:58 renders grantLeft as the remaining allowance and keeps the form open. After 100 small grants an operator sees a large positive number, sends a grant, and gets GRANT_DAILY_LIMIT with no way to tell from the panel which limit was hit or when it reopens; the 10-minute GRANT_DUPLICATE_RECENT rule then also blocks the natural retry. No limit is bypassed. Fix: in the same canGrant branch also select COUNT(*) FROM world_gold_grants WHERE created_ms>? over the same window, return grantLeft=String(count>=GOLD_GRANT_DAILY_COUNT?0:Math.max(0,GOLD_GRANT_DAILY-sum)) plus grantsLeft=String(Math.max(0,GOLD_GRANT_DAILY_COUNT-count)), and let grantFormReady disable the button when either is zero.

**Reproduction**

Fixture from tests/world-gold-grants.test.mjs: insert 100 rows of delta 1 with for(let i=0;i<100;i++)direct(f.db,{id:'n'+i}) (as the test 'no more than a hundred grants in 24 hours' does at line 233). GET /api/gold/state?expected_address=<grant wallet> with the grant cookie. Expected: grantLeft '0' (or grantsLeft '0') because no grant can be made for 24 hours. Actual: canGrant true, grantLeft '19900'. Then POST /api/gold/admin {action:'grant',wallet:RECIPIENT,gold:'1'}: 409 GRANT_DAILY_LIMIT. The existing test asserts the refusal but never reads grantLeft.

### 3. Low: Checksum guard on the grant recipient is skipped for an all-uppercase address, so a mistyped uppercase paste is paid rather than refused

`Submission24/source/server/world-gold.ts:262`

```
  if(/[a-f]/.test(typed)&&/[A-F]/.test(typed)&&!isAddress(cmd.wallet as string,{strict:true}))fail('INVALID_ADDRESS_CHECKSUM');
```

The guard runs viem isAddress(...,{strict:true}) only when the typed address contains both a lowercase and an uppercase hex letter. An address typed entirely in uppercase hex has no [a-f] match, so the strict check is skipped, economyAddress() lowercases it and the grant is written. viem 2.56.9 (the project's pinned version) strict isAddress accepts only the all-lowercase or the exact EIP-55 spelling: for the uppercase spelling of an address whose checksum form is mixed case it returns false, so the code deliberately skips a check the library would fail. The comment on this line says the purpose is that 'a mistyped character is refused rather than paid to nobody', and AUDIT_REQUEST section 1 says 'a mixed-case address must pass its checksum'. Hex letters are only ever uppercased by EIP-55, so an all-uppercase string is almost always a checksummed address that an explorer, spreadsheet, OCR or chat client uppercased; a one-character typo in such a paste is granted, irreversibly, to an address nobody holds. The client rule grantFormReady (src/world/goldGrantRules.ts:5) has the same gap. Fix: run the strict check whenever the typed address is not all-lowercase, i.e. if(typed!==typed.toLowerCase()&&!isAddress(cmd.wallet as string,{strict:true}))fail('INVALID_ADDRESS_CHECKSUM'); mirror it in grantFormReady, and say in the panel that lowercase is accepted unchecked.

**Reproduction**

Verified with viem 2.56.9 outside the repository: getAddress('0x'+'ab12'.repeat(10)) = 0xAb12AB12Ab12AB12ab12Ab12ab12aB12AB12aB12; isAddress('0x'+'AB12'.repeat(10),{strict:true}) returns false; /[a-f]/.test('AB12'.repeat(10)) returns false so the line-262 predicate is false and the check is skipped. Input, with the fixture of tests/world-gold-grants.test.mjs: f.grant('1','0x'+'AB12'.repeat(10)). Expected: 409 INVALID_ADDRESS_CHECKSUM and no row, the answer the existing test 'a mixed-case address must pass its checksum' gets for a single flipped character. Actual: 200 ok and a world_gold_grants row for 0xab12ab12ab12ab12ab12ab12ab12ab12ab12ab12. Control: f.grant('1','0xAB12AB12Ab12AB12ab12Ab12ab12aB12AB12aB12') (checksum form with its first b uppercased) is refused with INVALID_ADDRESS_CHECKSUM, because it contains both cases.

### 4. Low: Vault claim lease (120 s) is never renewed while a computation runs; only the per-process active map stops a second claim, so a second Celestao process re-computes a paid order and discards the first

`Submission24/source/integrations/celestao/celestao_world/vault.py:131`

```
            self.db.execute("UPDATE runs SET state='working',attempt=?,lease_ms=? WHERE id=?",(attempt,now+120000,rid))
```

vault.claim (vault.py:129-131) hands out a new attempt whenever state='working' and lease_ms<=now, with lease_ms=now+120000, and nothing renews it while processor.compute runs. The design in AUDIT_REQUEST section 3 ('A computation keeps its attempt while it runs. After 15 minutes it is cancelled') is implemented only through the in-memory dict `active` in paid.py:90-96. So the vault protects a running computation for 120 s; after that the protection exists only inside the one process that started it. Any deliver call that reaches a process whose `active` lacks the run (a second uvicorn worker or replica behind the proxy, or a restarted process while the old one still finishes its computation during the 60 s shutdown wait) re-claims the run, starts a second computation of the same paid order, and the first computation's vault.complete fails with LEASE_LOST so its result is thrown away. World retries delivery every minute, so with N processes up to N simultaneous annual or profile computations of one order can occupy the two shared engine slots, which section 3 says closes the engine to public requests. No GOLD is lost: the order stays paid and is delivered by whichever attempt finishes last. The package does not include serve_production.py, so the single-process assumption could not be confirmed. Fix: make the vault the authority. Either set lease_ms to now+COMPUTE_LIMIT_MS in vault.claim so the lease covers the whole allowed computation and the 15-minute replacement happens at lease expiry, or have calculate() renew the lease (UPDATE runs SET lease_ms=? WHERE id=? AND attempt=?) every 60 s while it runs; keep the `active` map for the in-process cancel.

**Reproduction**

Reproduced against the submission's vault module with two Vault instances on one database file (standing in for two processes), run from outside the repository: t=0 P1.claim -> attempt a1 (lease t+120 s), P1 computation starts (5 minutes). t=60 s P2.claim -> None (lease held). t=130 s P2.claim -> a new attempt a2 (P2 has no `active` entry; paid.py line 96 calls vault.claim whenever `running` is falsy), a second computation starts. t=300 s P1.complete(a1) -> VaultError LEASE_LOST, the first result is discarded (paid.py:70 logs delivery_pending). t=430 s P2.complete(a2) -> kept. Expected: one computation per run until it ends or the 15-minute limit replaces it. The shipped test test_a_computation_past_its_lease_is_not_restarted_and_its_result_is_kept (test_paid.py:79) passes only because it runs one process.

### 5. Low: Delivery wait catches the builtin TimeoutError only; on CPython 3.10 and older asyncio.wait_for raises asyncio.TimeoutError, so every report longer than 8 s answers 500 instead of pending

`Submission24/source/integrations/celestao/celestao_world/paid.py:104`

```
            except TimeoutError:pass
```

paid.py:103-104 waits DELIVERY_WAIT_SECONDS on the shielded task and swallows the timeout so the route answers kind='pending' inside World's 10 s client timeout (fix for 521bc017 finding 7). asyncio.wait_for raises asyncio.exceptions.TimeoutError, which became an alias of the builtin TimeoutError only in Python 3.11; on 3.8 to 3.10 it is a distinct Exception subclass and `except TimeoutError` does not catch it. The exception then escapes the route, FastAPI answers 500 with no signed body, World's createCelestaoDelivery throws DELIVERY_UNAVAILABLE (celestao-delivery.ts:21) and the job is retried after its 60 s lease. The computation keeps running under the shield and is delivered on a later call, so no order is lost, but the intended pending answer never happens on those interpreters and every slow report produces a 500 and an unexplained retry. The package pins no interpreter version (no deployment/ or release image, no requires-python anywhere in the tree), and evidence/celestao_side_summary.txt does not state the version the suite ran on; on this machine (3.12) asyncio.TimeoutError is TimeoutError. The code is only correct on 3.11+. Fix: `except (TimeoutError,asyncio.TimeoutError):pass`, and pin or assert sys.version_info>=(3,11) at startup.

**Reproduction**

State: Celestao service on CPython 3.10 or older, a paid run claimed, processor.compute longer than 8 s (any annual or profile report). Input: World's signed POST /api/internal/world-gold/deliver for that run. Expected: 200 with a signed envelope kind='pending'. Actual: asyncio.exceptions.TimeoutError propagates past `except TimeoutError`, HTTP 500, no signature; World logs nothing (status is not 409) and retries after the lease. Verification on this machine: python3 -c 'import asyncio;print(asyncio.TimeoutError is TimeoutError)' prints True on 3.12 and False on 3.10 (documented in the 3.11 changelog: 'asyncio.TimeoutError is now an alias of TimeoutError'). The shipped test test_a_slow_report_answers_pending_inside_worlds_timeout_and_is_delivered_later passes only on 3.11+.

### 6. Low: A signed prepare whose bridge or sign-in session lapses between the grant read and the insert answers 409 IDEMPOTENCY_CONFLICT instead of 401, so Celestao deletes the run and shows an outage rather th

`Submission24/source/server/celestao-checkout.ts:44`

```
   if(!row||row.wallet!==identity.address||row.product!==product.product||row.input_hash!==b.inputHash)return bad(409,'IDEMPOTENCY_CONFLICT');
```

Line 36 validates the bridge session with readCelestaoGrant at clock()=t0. Line 37 takes a fresh now=t1 and the INSERT ... SELECT at lines 38-42 inserts only when b.expires_at>t1 AND s.expires_at>t1 AND s.revoked_at IS NULL. If the bridge session (MIN(session expiry, created+1h), celestao-auth.ts:72) or the sign-in session expires in the gap t0<expiry<=t1, or is revoked by a concurrent logout, the statement inserts nothing without error. Line 43 then finds no row and line 44 answers 409 IDEMPOTENCY_CONFLICT, a code that means 'this run id belongs to another wallet, product or hash'. The second readCelestaoGrant at line 45, which would answer 401, is never reached. On the Celestao side bridge.py:65-66 maps only 401/403 AUTH_REQUIRED to AuthDenied; a 409 becomes BridgeUnavailable, so paid.py:41-43 deletes the fresh run and answers 503 CHECKOUT_UNAVAILABLE, which the overlay presents as a temporary outage instead of a sign-in prompt. The code assumes 'no row after the upsert' can only mean a conflicting row. Narrow window, no money moves. Fix: when row is null, re-run readCelestaoGrant first and answer 401 AUTH_REQUIRED if it is gone (or check meta.changes===0 together with no row); keep 409 for a row that exists and differs.

**Reproduction**

Fixture from tests/celestao-checkout.test.mjs setup(t): bridge session row with expires_at = NOW+1. Call handleCelestaoCheckout with a correctly signed prepare body and deps.now = a clock returning NOW for every call up to and including the readCelestaoGrant at line 36 (passes: NOW+1>NOW) and NOW+1 from the line-37 call onward (the INSERT's b.expires_at>NOW+1 is false, nothing inserted; the signature timestamp stays within its 30 s window). Equivalent live state: the user logs out in another tab between the two reads. Expected: 401 AUTH_REQUIRED. Actual: 409 IDEMPOTENCY_CONFLICT with no checkout row; Celestao's prepare_checkout raises BridgeUnavailable, the run is deleted by forget_unpaid, and the buyer gets HTTP 503 CHECKOUT_UNAVAILABLE.

### 7. Low: A grant to a contract-wallet address is accepted and irreversible, but the recipient can never spend it because every GOLD write requires an EOA/ECDSA session

`Submission24/source/server/world-gold-api.ts:57`

```
  if(write&&(session.walletType!=='EOA'||session.verificationMethod!=='ECDSA'))return await authReply({error:'EOA_REQUIRED'},403);
```

The recipient check (world-gold.ts:259-262 and 0024 trigger line 18) accepts any well-formed non-zero address, as AUDIT_REQUEST section 1 states. A smart-contract wallet (Safe, ERC-4337 account) can sign in to World with ERC-1271 and sees the granted balance in /api/me/gold, but every write on /api/gold/* is refused with EOA_REQUIRED at this line and the write batch's authCheck (world-gold.ts:22) requires wallet_type='EOA' AND verification_method='ECDSA', so the holder can never quote a service or mint. With the grant immutable and no correcting entry (section 4), such a grant permanently inflates economy_gold_accounts and the recovery totals (economy-recovery.ts:237) with GOLD nobody can use, and the daily budget it consumed is gone. The checksum guard protects against a mistyped character but not against this class of wrong recipient. Operator error, bounded by 5,000 per grant, no attacker gain. Fix: in the grant branch refuse a recipient that has an existing sessions row with wallet_type<>'EOA' (no RPC needed), or read eth_getCode through the Worker's existing chain access as sign-in already does, and fail('RECIPIENT_NOT_EOA'); otherwise say in the panel that the recipient must be an EOA.

**Reproduction**

State: grants enabled, grant wallet signed in, recipient C is a Safe whose owner has signed in to World before (sessions row wallet_type='SCA'/'CONTRACT', verification_method='ERC1271', as the test 'a wallet other than an EOA session cannot grant' sets up). Input: POST /api/gold/admin with command {action:'grant',wallet:C,gold:'5000'}. Actual: 200, world_gold_grants row, balance 5000 for C. Then C's owner signs in via ERC-1271 and POSTs /api/gold/quote: 403 EOA_REQUIRED (line 57); the 5,000 GOLD is unspendable and cannot be clawed back. Expected: the grant is refused or needs an explicit override when the recipient is known not to be an EOA.

### 8. Info: The grant form is offered when no economy config is loaded, although every grant is then refused with GOLD_NOT_CONFIGURED

`Submission24/source/server/world-gold-api.ts:67`

```
   const canGrant=isAdmin&&deps.grantsEnabled===true&&session.walletType==='EOA'&&session.verificationMethod==='ECDSA'&&GOLD_GRANT_WALLETS.includes(wallet)&&await goldGrantsReady(deps.db);
```

canGrant at line 67 does not check deps.config, but the POST path requires it: line 78 answers 503 GOLD_NOT_CONFIGURED for every admin action other than repair/refund, and world-gold.ts:263 fails the grant branch. When the release gate fails (worker/app.ts:214 swallows the error and leaves config null, which line 216 passes as deps.config) the panel shows the grant fieldset and a positive grantLeft while each attempt answers 503. No GOLD moves and nothing is bypassed; the state and the write disagree. Fix: add &&!!deps.config to the canGrant expression.

**Reproduction**

State: WORLD_GOLD_GRANTS='enabled', 0024 applied, grant wallet signed in, deps.config null (fixture: fixture(t,GRANT_A,{config:null})). Input: GET /api/gold/state?expected_address=<grant wallet>. Actual: canGrant true, grantLeft '20000', paymentsReady false. POST a grant: 503 GOLD_NOT_CONFIGURED. Expected: canGrant omitted, as it is when the switch is off or the migration is partial.

### 9. Info: Replay detection compares the raw JSON serialisation of [command,reason], so the same grant re-sent with another key order or address casing is refused as IDEMPOTENCY_CONFLICT rather than replayed

`Submission24/source/server/world-gold.ts:231`

```
 const id=await economyDigest(wallet+':'+rid),payload=JSON.stringify([cmd,args.reason]);
```

payload is JSON.stringify([cmd,args.reason]) where cmd is the parsed body object in the caller's key order (command() at world-gold-api.ts:23-27 only checks the key set). AUDIT_REQUEST section 1 says a request id 'replays any number of times', but the comparison at line 232 is byte-wise on the serialisation, not on the normalised grant (recipient lowercased, amount, reason). The shipped panel always serialises {action,wallet,gold} in the same order and sends the address as typed, so this is not reachable from WorldGoldPanel.tsx; any other client (a script re-sending a lost answer, a future panel that lowercases the address) gets 409 for a grant that was already paid, and the operator may then send it again under a new id, where only the 10-minute GRANT_DUPLICATE_RECENT rule stands between them and a second payment. Fix: compare a canonical form (sorted keys, economyAddress(cmd.wallet), String(Number(cmd.gold)), reason as stored) or store and compare a digest of it.

**Reproduction**

Fixture from tests/world-gold-grants.test.mjs with request id R: POST body {expectedAddress,requestId:R,reason:'Fixture tester grant',command:{action:'grant',wallet:RECIPIENT,gold:'1000'}} -> 200. Re-send with command:{wallet:RECIPIENT,gold:'1000',action:'grant'} (same fields, different key order) or with wallet 0xCCC...C (uppercase spelling of the same recipient): expected {id,replayed:true}; actual 409 IDEMPOTENCY_CONFLICT. world_gold_grants still has one row, so no double payment on this path.

---

Judge's submission `4739150cb5b2661875e59c99bbd59c9796aa92a156d4f0c6518e326ed3dbbefb`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
