# Audit report

> Review Submission13/ only (read Submission13/AUDIT_REQUEST.md first): World source ae1f640, live as Worker a718a392. No Solidity changed; the mainnet contracts are unchanged.
>
> 1. GOLD fast credit (new money path): server/world-gold-fast.ts, migrations/0021_world_gold_fast_credit.sql, server/world-gold-api.ts, server/economy-rpc.ts (economyReceiptPayment). Can GOLD be credited without a real Paid event for that exact order (foreign receipt, another order's log in the same tx, wrong contract, non-canonical block, reorg race)? Can one payment be credited twice (fast + finalized settle, repair-topup, concurrent requests)? Are revert and debt correct, and can the debt block on GOLD spending be bypassed?
> 2. Wallet receipt acceptance through the MetaMask Delegation Manager (src/world/economyWallet.ts): is "receipt carries exactly the intended effect" enough when to/input differ?
> 3. Session compared by value (address + expiresAt) instead of object identity (economyWallet.ts guard, sameSession): can two different sessions collide?
> 4. Picking the same wallet keeps the session (src/world/auth.ts, eth_accounts probe): can a provider that only claims the address gain authority?
>
> Report severity, reproduction and fix. Not in scope: 3D/rendering, media, Celestao, AIMarket.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `80271665bb1e32fbb617a7f5378e9c80a1f68cc8` |
| Job | `e4a57f91-a2a6-4b10-b9bd-fe573bafb7a8` |
| Judged | 2026-10-27 02:02 UTC |
| Findings | 1 medium · 6 low · 1 info |

Four agents audited the code as it is at `8027166`, 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: maintainFastGold examines only the 16 lowest-block credits and never ages out rows it cannot act on, so 16 'unpaid at finality' (or persistently non-definite) credits stop every later revert forever

`Submission13/source/server/world-gold-fast.ts:101`

```
  WHERE f.state='credited' AND o.status='pending' ORDER BY f.block_number,f.order_id LIMIT ?`,FAST_GOLD_BATCH).all<CreditRow>()).results;
```

The minute tick selects at most FAST_GOLD_BATCH=16 rows with state='credited' and order status='pending', ordered by block_number ascending, with no offset, cursor or exclusion. Two outcomes leave a row unchanged yet still selectable every tick: (a) the designed 'canonical yet unpaid at finality' branch (line 121: FAST_CREDIT_UNPAID_AT_FINALITY, `continue`, no state change, no admin action exists to drain it), and (b) any verification error that is neither in DEFINITE_REFUSALS nor transient and recurs for that hash (caught at line 124, retried indefinitely). Such rows have the lowest block numbers, so once 16 exist they fill the whole window on every tick. A younger credit whose transaction was reorged out and never re-included is then never examined: it is never reverted, no debt is recorded, and its GOLD stays spendable, which defeats the only compensating control the fast path has. Each stuck row also spends three RPC reads of the 'economy:fast' budget per minute (verifiedPayment at line 108 runs before the `passed` check at line 117). Precondition: 16 credits in a non-terminal state. Branch (a) needs the finalized indexer to pass a block holding a canonical, exact-match Paid without marking the order paid; the code treats that disagreement as expected enough to give it its own outcome, and the fast matcher already differs from the quote path in at least one input (it ignores expires_ms, see the separate finding) and accepts ancestor-config orders (orderRow lineage join, line 39), while economy-ledger.ts is not in the package, so this review could not confirm the two matchers agree. Merged from four specialist reports (audit_math, audit_economics, audit_permissions, audit_flow) of the same mechanism. Fix: give reported rows a terminal or flagged state (e.g. a `reported_ms`/`attention` column or a 'disputed' state) and exclude them from the window; rotate retried rows by updated_ms (bump it on each examination) instead of block_number so every credited row is eventually examined; check `passed`/`block_number>cursor` before spending RPC reads; add a reason-logged admin action to acknowledge or force-revert an UNPAID_AT_FINALITY credit.

**Reproduction**

Reproduced with node:sqlite running the real migration 0021 over minimal stubs of the base tables and the exact SELECT from lines 99-101. State: 16 rows in world_gold_fast_credits, state='credited', block_number 150..165, orders status='pending' (the receipts stay canonical, so each tick reports FAST_CREDIT_UNPAID_AT_FINALITY and leaves them unchanged); a 17th credit for order O17 at block 170 whose transaction was reorged away (eth_getTransactionReceipt now null); economy_chain_state.last_block=200, not halted. Expected: a tick reverts O17 (state 'reverted', fast-revert -10 or a debt). Actual: on ticks 1, 2 and 3 the SELECT ... ORDER BY f.block_number,f.order_id LIMIT 16 returns the same 16 rows (blocks 150..165) and never O17; O17 stays 'credited' and its wallet's book keeps the 10 GOLD. With the package's own fixture (tests/world-gold-fast.test.mjs) the same state is: 16 x [f.c.pay(tx_i,150+i,{orderId:id_i,amount}); await f.fast(id_i,tx_i)], then f.c.pay(tx17,170,...); await f.fast(id17,tx17); f.c.receipts.delete(tx17); await f.finalize(200) with no payments; every (await f.maintain()) returns checked:16, reverted:0, failures = 16 x FAST_CREDIT_UNPAID_AT_FINALITY.

### 2. Low: Debt collection scans only the 16 oldest debts and skips uncollectable ones without rotating, so 16 abandoned debts permanently block GOLD spending for every later debtor who can repay

`Submission13/source/server/world-gold-fast.ts:130`

```
   const take=BigInt(view.available)<BigInt(d.amount)?BigInt(view.available):BigInt(d.amount);if(take<=0n)continue;
```

maintainFastGold selects debts with `amount>0 ORDER BY created_ms,order_id LIMIT 16` (line 126) and, for a wallet with 0 available GOLD, just `continue`s without recording progress. A debt is by construction left on a wallet whose GOLD was already spent, and an abandoned wallet never receives a later credit, so such rows never leave the head of the ordering. Once 16 exist, the same 16 come back every minute and no newer debt is examined. A later debtor whose wallet has since been credited enough to repay stays blocked by world_gold_fast_debt_blocks_payment and world_gold_fast_debt_blocks_hold (GOLD_DEBT_OUTSTANDING) indefinitely, although the migration's own comment promises that later credits repay the debt. Same root cause as the credit-window finding (bounded window, no rotation) but a different loop, impact and fix. Fix: select only collectable debts by joining the wallet's available balance in SQL (e.g. `... WHERE d.amount>0 AND COALESCE((SELECT balance FROM economy_gold_accounts WHERE wallet=d.wallet),0)-COALESCE((SELECT SUM(amount) FROM world_gold_holds WHERE wallet=d.wallet AND state='held'),0)>0`), or page with a rotating cursor so uncollectable rows leave the window.

**Reproduction**

Reproduced with node:sqlite on the real migration 0021 and the exact SELECT of line 126. State: 17 wallets each fast-credited 10, spent 10 (service-payment), then reverted with the cursor past their block, so world_gold_fast_debts holds 17 rows of amount 10 with available 0 (D1..D16 sort before D17 by created_ms,order_id). Wallet 17 then receives an ordinary credit of 100 (available 100). Expected: a tick collects D17 (fast-debt-collect -10) and wallet 17 can pay for a service. Actual: on every tick the window is the same 16 rows, D17 is absent, collected=0; D17 stays at amount 10 with available 100, and INSERT INTO economy_gold_journal(... kind='service-payment') for wallet 17 aborts with GOLD_DEBT_OUTSTANDING.

### 3. Low: revert() and debt collection compute the amount to take from a balance read outside the write batch; a hold or service payment landing in between leaves reserved > book (LEDGER_INTEGRITY) or an overdr

`Submission13/source/server/world-gold-fast.ts:142`

```
 const gold=BigInt(c.gold),take=BigInt(view.available)<gold?BigInt(view.available):gold,owed=gold-take;
```

revert() reads book/reserved with a separate statement (line 141), computes take and owed in JavaScript, then writes them in db.batch (lines 143-150). The batch's write-check re-verifies the order, the credit state and the cursor but not that `take` is still available, and no debt row exists until the batch commits, so neither debt trigger protects the window. The minute tick runs in the same isolate as fetch handlers (worker/app.ts:259 waitUntil), so a mint hold (reserveMintGold) or a GOLD service payment of the same wallet can commit between the read and the batch. Hold case: the fast-revert subtracts the full credit while the hold still reserves it: book 0, reserved 10, available -10, owed=0 so no debt row; goldBalanceView then throws LEDGER_INTEGRITY for that wallet on every snapshot (/api/me/gold, /api/gold/state) and the hold can still be consumed by settleMintGold. Payment case: the outcome depends on whether economy_gold_accounts carries a non-negative CHECK (its migration, 0013-0020, is not in the package): without one the balance goes negative with no debt; with one the batch fails and is retried next tick (benign). The debt-collect loop (lines 129-133) has the same read-then-write shape with no availability predicate at all. One D1 round trip wide, so a narrow race, not a reliable exploit; the same pattern is guarded inside the batch everywhere else in world-gold.ts. Merged from audit_math, audit_economics and audit_flow. Fix: put the availability into the batch, either as a write-check predicate (`CAST(book AS INTEGER)-reserved >= ?` with the computed take, so the batch aborts and the next tick recomputes) or by computing the delta in SQL (INSERT ... SELECT -MIN(?, book-held)) and deriving the debt from it.

**Reproduction**

Reproduced with node:sqlite on migration 0021 with the exact revert statements of lines 143-150. State: wallet W fast-credited 10 (book 10, reserved 0), the credit's block 350 <= cursor 400, order pending, receipt now absent. Step 1: revert() reads available=10 -> take=10, owed=0. Step 2 (before the batch): INSERT INTO world_gold_holds VALUES('mint1',W,10,'held') is accepted, because no debt row exists yet. Step 3: the revert batch runs: the write-check passes (order pending, credit credited, cursor >= 350), fast-revert -10 is inserted. Expected: the hold refused, or take bounded to 0 and a 10-GOLD debt recorded. Actual: book=0, reserved=10, 0 rows in world_gold_fast_debts, goldBalanceView throws LEDGER_INTEGRITY. Variant with a 3-GOLD service payment in step 2 on a stub account table without a CHECK: book=-3, 0 debt rows.

### 4. Low: Fast-credit polling and maintainFastGold spend the same ECONOMY_CHAIN_LIMITER key 'economy:fast'; one signed-in client from about 15 addresses can exhaust it with unmined hashes, turning fast credit o

`Submission13/source/worker/app.ts:186`

```
      const fast=await maintainFastGold(env.DB,config,economyRpc(env,'economy:fast'),at);
```

The player route POST /api/gold/orders/{id}/fast-credit is budgeted per client IP only (ECONOMY_LIMITER 20/min, key 'world-gold-fast:'+ip, world-gold-api.ts:48), while every accepted request spends the global per-location key 'economy:fast' of ECONOMY_CHAIN_LIMITER (600/min, wrangler.jsonc:62): economyReceiptPayment issues eth_chainId and eth_getTransactionReceipt on every call (economy-rpc.ts:83-84), and an unknown hash is never refused (null receipt -> 200 'waiting', so the probe is free to repeat). The minute tick's maintainFastGold uses the same key (this line), so player traffic competes with the safety net. One EOA session cookie can be replayed from any number of addresses and a pending top-up order never stops being a valid target (status is checked, expires_ms is not). 15 IPs x 20 requests x 2 reads = 600 reads/min. While the key is exhausted: every other player's fast-credit POST gets 503 CHAIN_UNAVAILABLE (world-gold-fast.ts:59) and falls back to finality; maintainFastGold's verifiedPayment throws ECONOMY_RPC_RATE_LIMITED for each credit (caught at world-gold-fast.ts:124, recorded as a failure), so no credit is moved, reverted or turned into a debt for the duration, and a reorged-away credit's GOLD stays spendable longer than designed. Caveat: Cloudflare rate-limit bindings count per location, so the attacker denies the location their requests land in, and reaches the tick only when it runs there. Availability only; no wrong credit. The finalized indexer's key 'economy:rpc' is unaffected. Merged from audit_economics, audit_permissions and audit_flow. Fix: give the tick its own key (e.g. 'economy:fast-maintain'); bound fast-credit RPC spend per order (e.g. at most one receipt lookup per order per 10 s, matching FAST_CREDIT_SCHEDULE_MS, or a cap on 'waiting' answers per order); drop the per-request eth_chainId (fixed to chain 1 and the block is verified against its header anyway); refuse fast credit for an order past expires_ms.

**Reproduction**

State: GOLD_FAST_CREDIT='1'; one wallet W signed in once (one cookie); one pending EMBEO top-up order O from POST /api/gold/quote. Input: from 15 distinct IPv4 addresses, 20 requests/min each of POST /api/gold/orders/O/fast-credit with body {expectedAddress:W, transactionHash:'0x'+'11'.repeat(32)} (unmined). Each passes allow('world-gold-fast:'+ip), the session check and orderRow, then economyReceiptPayment spends two 'economy:fast' limiter hits (eth_chainId, eth_getTransactionReceipt -> null -> {status:'waiting'}). 15 x 20 x 2 = 600 hits per minute. Expected: other players' fast credits and the tick's maintenance keep their reads. Actual: ECONOMY_CHAIN_LIMITER.limit({key:'economy:fast'}) returns success=false for the rest of the window; app.ts:168 throws ECONOMY_RPC_RATE_LIMITED; every other wallet's fast-credit POST answers 503 CHAIN_UNAVAILABLE; maintainFastGold returns failures [{orderId, code:'ECONOMY_RPC_RATE_LIMITED'}] for each examined credit with moved:0, reverted:0. In the package's fixture the same is visible by passing an rpc that throws Error('ECONOMY_RPC_RATE_LIMITED'): fastCreditTopup rejects CHAIN_UNAVAILABLE and maintainFastGold reverts nothing (tests/world-gold-fast.test.mjs line 158 shows the equivalent for ECONOMY_RPC_UNAVAILABLE).

### 5. Low: economyReceiptPayment runs the strict log parser over every receipt log before filtering by emitter, so one anonymous (LOG0) log from another contract in the payment transaction makes the fast path re

`Submission13/source/server/economy-rpc.ts:94`

```
    const c=object(candidate),l=log({...c,removed:c.removed??false});   // receipts may omit `removed`; a removed log is still refused
```

The loop at lines 93-98 calls log() on every entry of receipt.logs and only afterwards (line 95) drops entries whose address or topics[0] are not the payment contract's Paid. log() (line 55) throws MALFORMED_RPC_LOG for topics.length<1 (an anonymous event), topics.length>4 or non-hex data. An anonymous event is a legal log any other contract in the same transaction may emit; the finalized indexer explicitly tolerates it ('Unrelated receipt logs can be LOG0', line 241) by parsing only the matched log index. MALFORMED_RPC_LOG is neither in DEFINITE_REFUSALS nor transient(), so fastCreditTopup rethrows it, handleWorldGoldApi answers 503 MALFORMED_RPC_LOG (not in FAST_REFUSALS), and the client follower (fastGoldCredit.ts RETRY set) treats it as non-transient and ends in 'fallback': the payer is credited only at finality although the payment is canonical and exact. With smart-account routing (the case this submission was built for) the Paid log already shares the receipt with Delegation Manager logs, so additional logs are the normal case. Fail-closed, no money at risk. Fix: filter on the raw candidate's address and topics[0] first and call log()/decode() only on matching candidates (as the finalized path does), or catch MALFORMED_RPC_LOG per candidate and skip entries not from the payment contract.

**Reproduction**

Using the package's fixture (tests/world-gold-fast.test.mjs, pay() accepts extraLogs): const id=await f.quote(),tx=hash(0xc0); f.c.pay(tx,150,{orderId:id,amount:f.order(id).amount,extraLogs:[{address:'0x'+'7'.repeat(40),topics:[],data:'0x',transactionHash:tx,logIndex:'0x1',blockNumber:hex(150),blockHash:f.c.blockHash(150),removed:false}]}); await f.fast(id,tx). Expected: {status:'credited',gold:'10'} (exactly one canonical Paid for this order from the payment contract, payer and amount match). Actual, by tracing the code: line 94 calls log() on the anonymous entry, line 55 fails `l.topics.length<1` and throws EconomyRpcError('MALFORMED_RPC_LOG') before line 95 could skip it; fastCreditTopup line 59 rethrows (not transient); the route answers 503 {error:'MALFORMED_RPC_LOG'}; the browser follower ends in 'fallback'. The finalized indexer accepts the same receipt (line 244-245 inspects only the matched index).

### 6. Low: fastCreditTopup accepts a pending order whose expires_ms has passed, unlike goldQuote (ORDER_EXPIRED): a never-expiring polling target and a divergence from the quote's validity window

`Submission13/source/server/world-gold-fast.ts:58`

```
 if(row!.status!=='pending')fail('ORDER_NOT_PENDING');
```

economy_orders rows carry expires_ms = created_ms + 900000 (world-gold.ts:79) and goldQuote refuses to hand back a pending order past it (retryId -> ORDER_EXPIRED, world-gold.ts:61). fastCreditTopup checks only status='pending' here, the batch write-check (line 62) checks only status, and the SQL trigger world_gold_fast_credit_insert (migration 0021 line 39) likewise. Consequences: (1) a quote from hours or days ago remains a free, permanent target for the RPC-budget probing in the separate finding, with no need to re-quote; (2) a late on-chain payment of a stale quote (the contract knows no expiry) is fast-credited at the quoted GOLD even if the product was since repriced or disabled; (3) the fast matcher and the finalized matcher now differ on at least this input, which is exactly the class of divergence that produces a permanently 'unpaid at finality' credit if the finalized matcher honours expiry (economy-ledger.ts is not in the package, so this could not be confirmed either way). Fix: select expires_ms in orderRow and fail('ORDER_EXPIRED') when row.expires_ms <= nowMs; add `AND expires_ms>?` to the batch write-check (and optionally to the insert trigger) so the refusal is atomic; ORDER_EXPIRED is already mapped to 409 in world-gold-api.ts.

**Reproduction**

Reproduced the SQL side with node:sqlite on migration 0021: an economy_orders row with status='pending' and expires_ms = now - 86400000 (one day past) accepts INSERT INTO world_gold_fast_credits(... state='credited') and the fast-topup entry; the trigger raises nothing. Code side: orderRow (line 37) does not select expires_ms and this line checks status only, so for POST /api/gold/orders/O/fast-credit {expectedAddress:W, transactionHash:H} one day after quoting O: expected 409 ORDER_EXPIRED (what POST /api/gold/quote answers for the same O); actual: with H unmined, 200 {status:'waiting'} indefinitely, spending RPC budget per call; with H a real Paid of O, 200 {status:'credited',gold:'10'} and a credit row for an order a day past its validity.

### 7. Low: The fast-credit debt binds only the debtor wallet: GOLD spent before finality on a payment that is later reorged out is a delivered loss, recoverable only if that wallet is ever used again

`Submission13/source/migrations/0021_world_gold_fast_credit.sql:95`

```
WHEN NEW.kind='service-payment' AND EXISTS(SELECT 1 FROM world_gold_fast_debts WHERE wallet=NEW.wallet AND amount>0)
```

The compensating control for crediting at inclusion is revert() plus the debt: it takes back what is still available and records the remainder in world_gold_fast_debts, and the two 0021 triggers block service payments and mint holds of that wallet while amount>0. The two triggers do cover every GOLD spend path found in the package (economy_gold_journal 'service-payment' for research/AIMarket/Celestao orders, world_gold_holds for mints), so there is no missing spend kind. The gap is that the debt row is keyed by wallet and nothing else ties the debtor to anything durable (no deposit, no EOA history, no seat); a wallet that spent its fast GOLD on a service before the finalized cursor passed the block is simply abandoned, and the delivered service is the loss. An attacker cannot force a reorg, but can raise the odds at near-zero cost by broadcasting pay() and a conflicting same-nonce transaction to different builders repeatedly; a failed attempt only means the player paid for GOLD normally, a successful one yields one quote of GOLD in services per fresh wallet. This is a consequence of the owner's accepted design ('GOLD already spent becomes a debt') and is reported as a trust/design risk, not a permission bypass; the fix is a scope decision. Options that preserve the design: bound the fast-credited GOLD a wallet may spend before finality (e.g. a per-wallet unsettled-credit spend cap, or hold delivery of services paid with still-unsettled fast GOLD until the credit settles), or require some durable standing (age, prior settled top-up) before fast credit is granted.

**Reproduction**

State: fresh wallet W, no GOLD. 1) POST /api/gold/quote (gold-pack, 10 GOLD) -> order X. 2) W sends pay(X, amount) and a same-nonce conflicting transaction to different builders; pay lands in block N. 3) POST /api/gold/orders/X/fast-credit -> {status:'credited',gold:'10'}. 4) Within ~15 min W buys a 10-GOLD service (payEconomyOrderGold writes service-payment -10; the job is queued and delivered). 5) Block N is reorged out and the conflicting transaction becomes canonical; after the cursor passes N the tick runs revert(): available 0, take 0, world_gold_fast_debts(X, W, 10). Expected: the operator recovers the 10 GOLD of service value or the delivery waits for settlement. Actual: the debt only blocks W's future service payments and holds (migration lines 94-99); W is never used again; the next attempt uses a new EOA. The package's test (tests/world-gold-fast.test.mjs line 124) exercises only the same wallet repaying.

### 8. Info: intendedEffect accepts a smart-account receipt when any one log matches; the documented 'exactly the intended effect' is not what is enforced

`Submission13/source/src/world/economyWallet.ts:57`

```
 return logs.some(l=>{const log=l as {address?:unknown;topics?:unknown;data?:unknown};const topics=Array.isArray(log.topics)?log.topics.map(lower):[];
```

The commit and the docstring (lines 41-44) state that a wallet-returned hash whose to/input differ counts as done only when the receipt carries exactly the intended effect. The implementation returns logs.some(...): one matching Approval/Paid/Staked/Withdrawn log from the intended contract for this sender with this call's arguments is enough, whatever else the delegated batch did (another Approval to a different spender, an Approval back to 0, a transfer). What the check does prove is sound: the emitting contracts bind payer/player to msg.sender, and under EIP-7702 the smart account executes at the EOA's own address, so a matching log shows this sender's call with these arguments ran in that transaction, and it applies only to the hash the wallet itself returned (version 2, tx.hash===candidate); a pasted hash still needs finality and to/input equality. Money impact is nil: the server re-verifies the receipt for fast credit and the finalized indexer for everything else; the approve step re-runs inspect() and reads the real allowance (line 248); purchaseStage shows 'credited' only from the server book. The remaining gap is UI truthfulness and the overstated guarantee. Merged from audit_math and audit_flow. Fix: either require that no other effect-relevant log of the intended contract for this sender appears in the receipt (one matching log and no other Approval/Paid/Staked/Withdrawn from the token, payment or staking contract with topics[1]==from), or keep the shortcut and state the weaker guarantee in the docstring.

**Reproduction**

Plan step approve(staking, 1000) from W; the wallet returns hash H whose receipt has status 0x1, to=W (delegation), input != tx.data, and logs [Approval(W, staking, 1000), Approval(W, staking, 0)] (or an extra Approval(W, attacker, max)). Expected under the stated rule: not 'exactly' the intended effect, so the hash is treated as a replacement and needs finality (lines 233-237). Actual: intendedEffect returns true on the first log, done=true, phase 'confirmed' for the approval step; inspect() then reads allowance 0 and returns to 'approve-required', so the UI first reports success for an approval whose final allowance is 0. Not a money defect.

---

Judge's submission `8f9cf41fb2c104d9d4f29174fd751eaac54ddc2464bab6dd43e5d0d558c1557c`, 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.
