# Audit report

> Review Submission14/ only (read Submission14/AUDIT_REQUEST.md first): World source 64396ae, live as Worker 40c6477f. It answers the previous audit e4a57f91 on Submission13 (1 Medium, 6 Low, 1 Info). No Solidity changed.
>
> 1. GOLD is now credited only at finality: the fast-credit route and its minute tick were removed (worker/app.ts, server/world-gold-api.ts). Can any route, cron or admin path still credit a top-up before the finalized indexer marks the order paid, or create a world_gold_fast_credits row? Is anything left in server/world-gold-fast.ts reachable?
> 2. I-8 fix in src/world/economyWallet.ts intendedEffect: exactly one event of the intended kind for this sender from the intended contract. Any false accept or false reject for approve, pay, deposit or withdraw through an EIP-7702 / MetaMask Delegation Manager batch?
> 3. src/world/pendingGoldCredit.ts: a display-only "+N crediting" note until the ledger books the order. Can it be treated as credit, show for a payment never sent, survive a reverted or replaced payment, leak across wallets, or poll without bound?
> 4. scripts/deploy.mjs re-checks git status, HEAD and gitignored .env* after the build: any path that uploads something other than the recorded commit?
>
> Report severity, reproduction and fix. Not in scope: 3D/rendering, media, Celestao, AIMarket.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `5f0267916a166b3f375a211e49cdabff3554c858` |
| Job | `12e0525d-ca39-4d8b-adb8-ed9d6099d575` |
| Judged | 2026-10-27 04:18 UTC |
| Findings | 5 low · 1 info |

Four agents audited the code as it is at `5f02679`, 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. Low: Smart-account routed payment that reverted (status 0x0) is held as TRANSACTION_NOT_FINALIZED for the finality wait and then reported as TRANSACTION_REPLACED, never TRANSACTION_REVERTED; the "+N credit

`Submission14/source/src/world/economyWallet.ts:234`

```
  const done=same||tx.version===2&&r.status==='0x1'&&tx.hash?.toLowerCase()===candidate.toLowerCase()&&intendedEffect(tx,(receipt as {logs?:unknown}).logs,this.config);
```

finishReceipt() treats the wallet-returned hash as settled ("done") only when `same` is true (a direct EOA transaction whose mined to/input equal the request) or when the routed receipt has status 0x1 and intendedEffect() matches. A routed receipt (MetaMask Delegation Manager / EIP-7702, the only wallet configuration the owner uses for real transactions, AUDIT_REQUEST.md section 5) with status 0x0 therefore makes `done` false although it is the intent's own transaction: the hash is the one the wallet returned, and line 229 has already bound the mined record to the intent's nonce and sender, so nothing else can have run under that nonce. The `!done` branch at line 238 then demands finality: eth_getBlockByNumber('finalized') is below the receipt's block for about 13 to 15 minutes, so line 240 throws TRANSACTION_NOT_FINALIZED, fail() sets phase 'unknown', and once final line 251 maps `!done` to phase 'replaced' / TRANSACTION_REPLACED. The 'error' / TRANSACTION_REVERTED outcome at line 251 is reachable only for the direct-EOA case. Consequences: (1) the player never sees the TRANSACTION_REVERTED copy; TRANSACTION_REPLACED has no entry in goldPurchaseError, so the generic "Reference: TRANSACTION_REPLACED" line is shown for a revert; (2) pendingGoldCredit.paymentFailed() is false for phase 'unknown', so the note created at phase 'pending' (WorldGoldPanel.tsx line 81-82) stays: the top bar shows "+1,000 crediting", GoldPurchase shows "Crediting: +1,000 GOLD, expected by HH:MM" with purchaseStage 'pending', WalletBalanceHud polls /api/gold/state every 30 s, and the page says "do not pay again", for a payment that sent no EMBEO and will never be credited. The note goes only when the user presses "Check original transaction" again after finality (then as 'replaced') or after PENDING_CREDIT_MAX_MS (2 h). tests/economy-wallet-recovery.test.mjs line 84-87 encodes the wrong code ('reverted' -> TRANSACTION_REPLACED) with an already-finalized fixture, so the 'unknown' stretch is not exercised. Verdict safety is not affected (no false accept): this is a misclassified and delayed reject. Fix (minimal, keeps the finality rule for pasted and replacement hashes): in finishReceipt compute `const reverted=tx.version===2&&r.status==='0x0'&&tx.hash?.toLowerCase()===candidate.toLowerCase();` and use `const done=same||reverted||(...)`; the existing line 251 then yields phase 'error' / TRANSACTION_REVERTED at once (the nonce was consumed by this very transaction, exactly as in the direct-EOA case the code already judges without finality). Update the 'reverted' case of the recovery test to expect TRANSACTION_REVERTED with finalized=false, and give goldPurchaseError a TRANSACTION_REPLACED copy. Merged from four specialist reports (economics, permissions, math, flow) of the same mechanism.

**Reproduction**

Harness: copies of economyWallet.ts and pendingGoldCredit.ts with the fixture of tests/economy-wallet-recovery.test.mjs (Node 24.21, receiptAttempts 1, finalized block 0x1f < receipt block 0x20), plus the WorldGoldPanel effects (trackPendingCredit when paymentHash() is non-null; dropPendingCredit when payingOrder && paymentFailed()). Steps: prepare(payPlan()) for order OID gold 1000; confirm() -> wallet returns HASH, state passes {phase:'pending',step:'execute',hash:HASH} so the note is tracked; candidate = routed receipt {hash:HASH, to:DELEGATION_MANAGER, input:'0xcef6d209'+64 zeros, nonce:'0x8', status:'0x0', logs:[]}; checkReceipt(). Expected (as for the direct-EOA control): phase 'error', error 'TRANSACTION_REVERTED', paymentFailed()=true, pendingGold(A)=0n. Actual: {phase:'unknown', error:'TRANSACTION_NOT_FINALIZED', paymentFailed:false} and pendingGold(A)=1000n; a second checkReceipt() gives the same; with the finalized block advanced to 0x20 checkReceipt() gives {phase:'replaced', error:'TRANSACTION_REPLACED'} and only then pendingGold(A)=0n. Control run in the same harness, direct EOA candidate {to:PAY, input:<pay calldata>, status:'0x0'} with finalized=false: {phase:'error', error:'TRANSACTION_REVERTED', paymentFailed:true}, pendingGold 0n immediately.

### 2. Low: The "+N crediting" note is dropped only through the in-memory plan: after a reload or panel reopen (restorePending -> plan null) a reverted or replaced payment keeps its note until the 2 h window ends

`Submission14/source/src/world/WorldGoldPanel.tsx:83`

```
 useEffect(()=>{if(data&&payingOrder&&failed)dropPendingCredit(data.wallet,payingOrder);},[data,payingOrder,failed]);
```

The order whose note must be dropped is derived at line 80 from `wallet.plan`, and the drop effect at line 83 runs only when `payingOrder` is non-null. The plan exists only in the controller instance that sent the payment. WorldGoldPanel recreates the controller on every identity change and on panel reopen (line 75: new EconomyWalletController, then restorePending()), and EconomyWalletController.adoptPending() (economyWallet.ts line 91) restores the intent with `plan:null` while keeping hash and step 'execute'. A player who pays, closes the panel or reloads (the case the localStorage note was built for, line 76-77), reopens and presses "Check original transaction" on a payment that reverted or was cancelled reaches phase 'error'/TRANSACTION_REVERTED or 'replaced'/TRANSACTION_REPLACED with plan null: paymentFailed(wallet) is true but payingOrder is null, so dropPendingCredit never runs. Nothing else removes the note: settlePendingCredits (pendingGoldCredit.ts line 43) drops only notes whose order the ledger credited, and an unpaid order is never credited, so the top bar shows "+N crediting" and WalletBalanceHud polls /api/gold/state every 30 s per tab until atMs + PENDING_CREDIT_MAX_MS (2 h). Display only (purchaseStage and pendingGold grant nothing), hence Low. Fix: key the drop on the stored note's hash, which the note already carries: when paymentFailed(wallet) && wallet.hash, remove every note of data.wallet whose hash equals wallet.hash (case-insensitive), keeping the plan-based drop as well; alternatively read the order id back from the restored intent (word 0 of tx.data when the selector is d0ee4de0). Independently, let settlePendingCredits drop a note whose order is past expires_ms plus a finality margin and still 'pending'. Merged from the permissions and math specialists.

**Reproduction**

Harness over copies of economyWallet.ts and pendingGoldCredit.ts (fixture of tests/economy-wallet-recovery.test.mjs, shared store and lock between two controllers). c1.prepare(payPlan()); c1.confirm() -> HASH; the panel effect tracked the note (pendingGold(A)=1000n); c1.dispose() (panel closed / page reloaded). c2 = new controller on the same store; c2.restorePending() -> state {phase:'unknown', error:'PENDING_TRANSACTION', step:'execute', plan:null, hash:HASH}. Candidate: direct receipt for HASH {to:PAY, input:<pay calldata>, nonce:'0x8', status:'0x0'}; c2.checkReceipt() -> state {phase:'error', error:'TRANSACTION_REVERTED', plan:null}, paymentFailed(state)=true, stored intent cleared (store.size 0). The panel expression `wallet.plan?.kind==='pay'?wallet.plan.payment.orderId:null` evaluates to null, so the line-83 effect never calls dropPendingCredit. Expected: the note for OID is removed when the revert is confirmed; actual: pendingGold(A) stays 1000n. Same result for the replaced case: c2.checkReceipt(OTHER) with a finalized same-nonce record {hash:OTHER, to:A, input:'0x', nonce:'0x8'} -> {phase:'replaced', error:'TRANSACTION_REPLACED', plan:null}, payingOrder null, pendingGold(A) 1000n.

### 3. Low: A payment cancelled or replaced in the wallet (same nonce, other hash) never reaches a failed phase from the GOLD purchase page, so the "+N crediting" note and the 30 s polling last the full 2 h

`Submission14/source/src/world/pendingGoldCredit.ts:37`

```
export function paymentFailed(wallet:{phase:string;step:string|null;error:string|null}){return wallet.step==='execute'&&(wallet.phase==='replaced'||wallet.phase==='error'&&wallet.error==='TRANSACTION_REVERTED');}
```

paymentFailed() needs phase 'replaced' or 'error'+TRANSACTION_REVERTED. The controller reaches 'replaced' only when finishReceipt sees a mined receipt for some candidate hash carrying the intent's nonce. When the user cancels or speeds up the payment in MetaMask, the wallet-returned hash never mines: eth_getTransactionReceipt(HASH) stays null, waitReceipt ends after 40 x 3 s in phase 'unknown' / PENDING_TRANSACTION, and every later press of "Check original transaction" repeats that. The GOLD purchase page wires that button to controller.checkReceipt() with no hash argument (WorldGoldPanel.tsx line 105, GoldPurchase.tsx line 42) and has no field for the replacement hash (unlike the economy pane), and clearUnknownAfterWalletCheck() returns at once for a v2 intent with a hash. So the note created at broadcast (phase 'pending') is never dropped: the HUD shows "+1,000 crediting", GoldPurchase shows "Crediting: +1,000 GOLD, expected by HH:MM" (purchaseStage 'pending' for phase 'unknown') and tells the user not to pay again, and WalletBalanceHud polls /api/gold/state every 30 s per open tab (about 240 requests) until PENDING_CREDIT_MAX_MS. This is the "replaced" case AUDIT_REQUEST.md section 2 says the note is dropped on, and it is not covered by tests/pending-gold-credit.test.mjs. Display only, Low. Fix options, smallest first: (a) when the wallet sits in 'unknown'/PENDING_TRANSACTION with a hash whose receipt is null, read eth_getTransactionCount(from,'latest'); if it exceeds the stored intent nonce, the nonce was consumed by another transaction and the panel can call dropPendingCredit (and the controller can move to 'replaced' once the finalized count also exceeds it); (b) give the GOLD purchase page the replacement-hash entry the economy pane has, so checkReceipt(hash) can reach 'replaced'; (c) at minimum let settlePendingCredits drop a note whose order has passed expires_ms plus a finality margin while still 'pending'. Reported by the flow specialist; also the second input of the economics report.

**Reproduction**

Harness over copies of economyWallet.ts and pendingGoldCredit.ts with the panel effects (fixture of tests/economy-wallet-recovery.test.mjs). prepare(payPlan()); confirm() -> wallet returns HASH and the note is tracked (pendingGold(A)=1000n); eth_getTransactionReceipt(HASH) answers null on every poll (the user cancelled in MetaMask; the cancel transaction consumed nonce 0x8). Observed after confirm(): {phase:'unknown', error:'PENDING_TRANSACTION', step:'execute', paymentFailed:false}; after checkReceipt(): unchanged; after clearUnknownAfterWalletCheck(): unchanged, stored intent still present (store.size 1); pendingGold(A) = 1000n at NOW + 1 h 59 min 59.999 s and 0n only at NOW + 2 h. Expected: once the wallet hash can no longer be mined (nonce consumed by another transaction), the note is dropped and the purchase page stops saying the GOLD is being credited.

### 4. Low: deploy.mjs re-checks the tree and HEAD before wrangler bundles the Worker sources; an edit landing between the re-check and wrangler's esbuild pass ships under the recorded commit with dirty:false and

`Submission14/source/scripts/deploy.mjs:159`

```
  const w=run(['node_modules/wrangler/bin/wrangler.js','deploy',...(dryRun?['--dry-run']:[]),'--outdir',join(tmp,'worker')],
```

The post-build re-check (lines 147-150: HEAD, git status --porcelain, .env*) runs before `wrangler deploy` (line 159). wrangler does not upload a prebuilt artifact: wrangler.jsonc names `main: worker/index.ts`, and wrangler bundles it and everything it imports (worker/app.ts, server/*.ts, the shared src/world modules) with esbuild from the working tree at upload time. Nothing re-reads git status or HEAD after wrangler returns; the only post-upload verification is distAfter (line 162), which covers dist/ (static assets) and not the Worker bundle's inputs, and the manifest's `dirty` (line 167) is computed only from the two earlier readings. A tracked-file edit (editor autosave, a test watcher, a checkout or stash pop in another terminal: the same class of event as the 40e97f4 incident, now in a window of seconds rather than minutes) that lands after line 147 and before wrangler reads the tree is uploaded as the Worker, while manifest.json says commit = start HEAD, dirty:false, and the outcome is uploaded / passed / persisted with exit 0. The header comment (lines 10-11: "the record's commit is exactly what shipped") and the Submission14 evidence that Worker 40c6477f is source 64396ae rely on this guarantee, which holds only for dist/. tests/deploy.test.mjs's edited / head-moved / env-added variants all inject the change during vite build, so none covers this window. Fix (minimal, keeps the design): after `run([...wrangler deploy...])` read `git rev-parse HEAD`, `git status --porcelain --untracked-files=normal` and envFiles(ROOT) a third time; if any differs from the start, set manifest.dirty=true, record the porcelain text (e.g. tree.changedDuringUpload) and feed it into deploymentOutcome so verification is 'failed' and exitCode 1 (the upload cannot be undone, but the record must not say clean). Stronger: bundle first with `wrangler deploy --dry-run --outdir <tmp>`, re-check the tree, then upload that exact bundle with `wrangler deploy --no-bundle`, or at least compare the uploaded worker/index.js hash with a fresh dry-run bundle hash taken after the final git check (the header states index.js is byte-identical across runs of the same commit). Merged from the economics, permissions and flow specialists.

**Reproduction**

Throwaway git repo (Node 24.21, git 2.43, no network) with Submission14/source/scripts/deploy.mjs copied to scripts/, package.json {type:module, scripts.test:'node --test tests/*.test.mjs'}, a passing tests/a.test.mjs, tracked worker/app.ts = "export default {fetch(){return new Response('committed');}}", tracked src/runtime.ts = 'committed', .gitignore 'deploy-records/ node_modules/ dist/ .env*', package-lock.json '{}', wrangler.jsonc '{}', public/_headers, and the same fake node_modules/{typescript,vite,wrangler} as tests/deploy.test.mjs, except that node_modules/wrangler/bin/wrangler.js first writes worker/app.ts = "...Response('EDITED AFTER THE POST-BUILD CHECK')..." and src/runtime.ts = 'edited after the check', then writes <outdir>/index.js from the current worker/app.ts (what the real wrangler does when it bundles main from the tree) and prints 'Current Version ID: 2a5bf0b7-...'. Commit everything; run `node scripts/deploy.mjs` (real mode). Expected: refusal ("nothing was uploaded") or exit 1 with the record marked dirty, as for the 'edited' and 'head-moved' variants of tests/deploy.test.mjs. Actual (run 2026-10-27): exit code 0; outcome line {"deployment":"uploaded","verification":"passed","recordPersistence":"persisted","exitCode":0}; manifest.json has commit = start HEAD 0907577..., dirty:false, wrangler.exitCode 0, dist.unchangedDuringDeploy true, envFiles []; deploy-records/<name>/worker/index.js contains "EDITED AFTER THE POST-BUILD CHECK" while `git show HEAD:worker/app.ts` prints 'committed'; `git status --porcelain` after the run prints " M src/runtime.ts" and " M worker/app.ts".

### 5. Low: deploy.mjs never verifies the gitignored build inputs it hashes nothing for: node_modules is not checked against the recorded package-lock.json, and VITE_* variables of the deploy shell reach vite unr

`Submission14/source/scripts/deploy.mjs:67`

```
  const env={...process.env};delete env.NODE_TEST_CONTEXT;
```

The provenance record covers the git tree (start and post-build), .env* file names, and the hashes of wrangler.jsonc, package-lock.json and public/_headers; the build and bundle themselves are produced from gitignored inputs the record does not verify. (1) node_modules: tsc, vite and wrangler are run from node_modules/* (buildSteps line 62, run line 159) and bundle every dependency from it (react, three, viem helpers into dist/; the Worker's dependencies into worker/index.js). The manifest reads the three tools' version fields (pkgVersion, line 136) and hashes package-lock.json, but nothing runs `npm ci` or `npm ls` or compares node_modules/.package-lock.json with the lockfile, and `git status --porcelain --untracked-files=normal` cannot see an ignored directory. A stale tree (pull without npm ci), a package installed with --no-save, or a locally patched dependency file therefore ships under the recorded commit with dirty:false and tests 'passed', the same class of gap as the .env* re-check closed in 64396ae. (2) Environment: runStep (line 67) and run (line 132) spawn every step with `{...process.env}`; Vite's loadEnv exposes every process.env key starting with VITE_ to the client bundle as import.meta.env.VITE_* with priority over .env files, so `VITE_X=... npm run deploy` on a clean tree changes dist/ while manifest.json says dirty:false and envFiles []. The public source tree shows no import.meta.env.VITE_* read today, so (2) is a latent path that becomes live with the first such read; (1) is live now. Operator-side only (no remote attacker), hence Low. Fix: make the first real-deploy step `npm ci --ignore-scripts` (or refuse when `npm ls --package-lock-only --json` reports an invalid tree) and record its exit code in the manifest; spawn the build with an explicit minimal environment (PATH, HOME, the WRANGLER_* keys) rather than the inherited one, refusing a real deploy when any process.env key matches /^VITE_/ and listing such keys on a dry run as envFiles does. Merged from the economics, math and flow specialists.

**Reproduction**

Same throwaway repo as the wrangler-window reproduction (fake node_modules as in tests/deploy.test.mjs, tracked src/runtime.ts). (1) node_modules: edit the gitignored node_modules/vite/bin/vite.js so it writes dist/index.html 'patched dependency output' (in the real project: any file under node_modules/react/ or node_modules/three/ that vite bundles); `git status --porcelain --untracked-files=normal` is empty; `node scripts/deploy.mjs` passes both checks with exit 0, manifest commit = HEAD, dirty:false, config hashes unchanged, and dist/index.html (hashed into the record and uploaded as a static asset) contains output no file at the recorded commit produces. (2) Environment: the fake vite appends process.env.VITE_X to dist/index.html; `VITE_X=injected node scripts/deploy.mjs --dry-run --skip-tests` (run 2026-10-27) ends with {"deployment":"dry-run-passed","verification":"passed","recordPersistence":"persisted","exitCode":0}, dist/index.html = 'abcinjected', and manifest.json {dirty:false, envFiles:[]}. Expected in both: a refusal, or a record that names the input that differed from the commit.

### 6. Info: readPendingCredits keeps a note whose atMs is in the future, so after a backward clock correction (or a corrupt entry) the note and its 30 s polling outlive the 2 h display window, or never expire

`Submission14/source/src/world/pendingGoldCredit.ts:22`

```
 return stored(address).filter((p):p is PendingCredit=>{const v=p as PendingCredit;return !!v&&/^0x[0-9a-f]{64}$/.test(v.orderId)&&/^[1-9][0-9]*$/.test(v.gold)&&typeof v.hash==='string'&&Number.isSafeInteger(v.atMs)&&now-v.atMs<PENDING_CREDIT_MAX_MS;});
```

The display-window test is `now-v.atMs<PENDING_CREDIT_MAX_MS`, a signed difference with no lower bound. atMs is written from Date.now() at payment time (trackPendingCredit, line 30); Date.now() is wall-clock and not monotonic. If the device clock was ahead at payment time and is corrected afterwards (NTP sync, timezone fix, VM resume), the difference is negative and the entry is kept until the corrected clock passes atMs + 2 h, which can be arbitrarily far away; an entry with a very large atMs (corrupt or hand-edited localStorage) never expires at all. While kept, WalletBalanceHud.usePendingGold polls /api/gold/state every 30 s in every open tab (the design bounds this only by the 2 h window) and GoldPurchase shows "expected by <atMs+15 min>", a time that never arrives. The unit test "the note never outlives the display window" checks only atMs <= now. Display only and still dropped as soon as the ledger credits the order, so Info. Fix: also require `v.atMs<=now+SKEW` (e.g. 5 min) in the filter, or clamp atMs to now in trackPendingCredit and when reading, and have settlePendingCredits rewrite the list so such entries leave storage. Merged from the permissions and math specialists.

**Reproduction**

Node 24, pendingGoldCredit.ts with localStorage stubbed by a Map: const NOW=Date.UTC(2026,9,16), H=3_600_000; trackPendingCredit(W,ID,TX,'1000',NOW+3*H) (device clock 3 h ahead at payment); then, after the clock is corrected: pendingGold(W,NOW+PENDING_CREDIT_MAX_MS) -> 1000n (expected 0n: the 2 h window is over in real time); settlePendingCredits(W,[],NOW+PENDING_CREDIT_MAX_MS) -> false and readPendingCredits(W,NOW).length -> 1 (the stored entry is not removed). With the stored entry's atMs set to Number.MAX_SAFE_INTEGER: pendingGold(W,NOW+10*365*24*H) -> 1000n (never expires). Observed output: "visible at real NOW+2h: 1000n settle removes? false stored 1" and "MAX_SAFE_INTEGER atMs, 10 years later: 1000n".

---

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