# Audit report

> Audit Ember World's EMBEO staking/payment and nontransferable GOLD at public commit 143ac7e0a709c25cf5dffd247e34cd8fa3570f0a. Full scope: pinned AUDIT_OBJECTIVE.en.txt, REQUIREMENTS.md and SCOPE.md; this compact form does not narrow it. Independent source audit only, not token launch/site building/old-job continuation. Local compilation/tests allowed; no deployment, real funds, site publication, service changes or automatic fixes.
>
> Review Solidity AND TypeScript/D1/client. Solidity-only review must explicitly mark the offchain ledger unreviewed and cannot satisfy this request. New financial source is byte-exact; existing auth/ownership/types are dependency context. Full World wiring is a zero-context patch with base/source hashes; full-World tests are a separate private gate. Do not claim complete renderer/production coverage or extend old SIWE/token audit conclusions.
>
> Rules: Ethereum mainnet EMBEO 0x2b82cdeb8477415d541799abb0cc48044c8ea5f3,18 decimals;100000 EMBEO per whole share. Each deposit creates a separate position. Each share held through UTC [00:00,next00:00) earns 1 wallet-bound GOLD only after complete finalized indexing; midday deposits start the next day. Principal is withdrawable anytime in whole shares: only withdrawn shares lose that UTC day's GOLD; surviving shares/prior GOLD remain. Later deposits cannot inherit past eligibility. GOLD is nontransferable/nontradeable; display names confer no money authority. Custody is separate from immutable payment treasury 0x1C651928150DADDDA9C2C040a9D4901d862f8eC4.
>
> Check custody/unauthorized withdrawal, exact balance deltas, token runtime/supply binding, reentrancy, approval scope, order replay, payer/amount/receiver mismatch and deployed runtime/immutables. Verify ABI/artifact hashes, dependencies/source closure and official solc 0.8.37+commit.f401782d, Cancun,optimizer200,viaIRfalse. Isolated EDR31337 tests are not mainnet/fork proof.
>
> Check finalized header continuity/parents, cursor hash/reorg halt, successful canonical receipts and exact pinned-emitter logs, allowlisted RPC, bounded staging, atomic event/cursor commits, exact bigint arithmetic, UTC/concurrent/idempotent issuance, overflow/restart safety. Forwarder/constructor/batch outer receipt from/to need not equal the event actor. Unrelated LOG0/data and large receipts are legal. Forged/removed/duplicate/stale/incomplete data must not credit funds. Durable halt must not confiscate principal. Staged rows have no economic effect before full verification/atomic fold.10000 events is an engineering cap: a larger valid block can stall indexing; it is not a transaction-gas guarantee. Contract accounts accrue GOLD but current EOA-only authority cannot spend it.
>
> Whenever an obtained numeric header reaches an announced finalized height, compare full identity, including optional witness. Below that height, RPC ancestry and log completeness remain trusted; no cryptographic finality/completeness proof is established. Optional deployment readback must compare fetched announced-finalized/numeric and cross-provider number/hash/parent/timestamp/available gas-limit identity. Assess real large-receipt, Workers50ms and D1 capacity; desktop bounds/timing do not prove production capacity.
>
> Check opaque random 256-bit order IDs, wallet/request concurrency/idempotence and immutable quote prices; IDs alone authorize no payment. Exact Paid events must fulfill valid late payments and same-slot payments whose block seconds precede quote milliseconds. Exercise actual server-to-client paid snapshot roundtrips. After quote/challenge reads, sample fresh server time and enforce final SQL session/revocation/expiry guards, including expiry/revocation during awaits.
>
> Check same-origin/session/account binding and final-write authority. GOLD requires a fresh canonical personal_sign binding origin,wallet,order,service,amount,request,nonce,expiry. Old SIWE/Permit, wrong signer/domain/order/amount or expired signatures cannot debit. Debit,journal,fulfillment/order,challenge consumption must commit atomically: no negative GOLD/double fulfillment. EMBEO entitlement needs exact server order plus finalized payment event. GOLD signing must perform no token RPC; test closed canonical-message validation with negative controls.
>
> Check every client await for account/provider/chain/session changes, stale quotes, exact allowance/reset/approval and separate final execution confirmation, cancellation/unmount, duplicate clicks and unknown sends/receipts. Persist/read back intent BEFORE wallet RPC; retain it on reopening. Late callbacks cannot overwrite newer intent; storage failure stops sends; unknown sends are never auto-retried. After pending-nonce await, recheck expiry/index halt BEFORE persistence/prompt. Recheck observed GOLD halt after final authorization await and BEFORE POST; a later halt must not erase an already-completed valid paid response. Currency/status JSON enums must be strings, never coercible arrays/objects. Malformed API types must return bounded client errors without writes.
>
> Persist pending nonce/chain and hold same-origin Web Lock through the wallet prompt. Busy locks reject without queuing; missing locks fail closed. Wrong candidate hashes cannot replace intent. Manual replacement proof binds sender/chain/nonce and canonical finalized inclusion; changed payload means replacement, not original-plan success. Adopting another tab's intent discards unrelated plans. Original wallet-returned hashes use ordinary mined confirmation, separate from finalized GOLD credit. Legacy/hashless unlock requires explicit wallet-history acknowledgement, not proof of universal non-broadcast. Locks exclude other origins/profiles/devices and independent wallet actions; actual extension nonce overrides/replacement UX remain unmeasured.
>
> Defend against hypothetical server-clock rollback across requests/workers: GOLD challenge issuance cannot precede order.createdMs; debit cannot precede quote creation or challenge.issuedMs. Reject BEFORE any write; never clamp time. This is a defensive server-clock assumption, not client control or a confirmed production incident. Preserve valid EMBEO same-slot chain/server clock skew.
>
> Report severity, paths/lines, exploit prerequisites, runnable reproductions, adversarial/regression/negative controls, failures and scope gaps. State explicitly whether BOTH Solidity and TypeScript economic boundaries were reviewed; identify material fixes/release blockers. Local tests do not establish an enabled catalogue, real wallet/mainnet deployment, production rewards or load capacity. Admission, an accepted job or zero displayed findings is not complete review: actual exact-pin judge report and findings disposition remain required.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `143ac7e0a709c25cf5dffd247e34cd8fa3570f0a` |
| Job | `592da0ae-6f65-4f4d-9636-b10cb6d9d71b` |
| Judged | 2026-10-16 14:18 UTC |
| Findings | 1 medium · 2 low · 3 info |

Four agents audited the code as it is at `143ac7e`, 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: GOLD reward journals are keyed by config id while the GOLD balance is per wallet: re-pinning deploymentBlock (new config id) re-credits every historical UTC day

`world-source/server/economy-ledger.ts:127`

```
if(reward===0n)continue;const reference=`${config.id}:${day}:${who}`;
```

settleEconomy writes one utc-reward journal per (config.id, day, wallet) and the economy_gold_apply trigger (migrations/0010_world_economy.sql:109-113) folds every journal into economy_gold_accounts, which is keyed by wallet only. economy_chain_state, economy_positions, economy_gold_days and economy_day_progress are all scoped by config.id, and config.id is the SHA-256 of [rule, chainId, token, stakingContract, paymentContract, treasury, deploymentBlock] (economy-config.ts:43). A later release that changes deploymentBlock for the same deployed contracts (for example correcting a mis-pinned start block or moving it earlier to pick up an early deposit) yields a new config id with an empty cursor; the indexer re-ingests the same finalized Staked/Withdrawn events under the new id, settlement re-walks every completed day, inserts a second reward journal per (day, wallet) and the wallet's spendable GOLD doubles. Nothing in defineEconomyConfig, settleEconomy or the schema detects that the same chain events were already credited to the wallet. This violates REQUIREMENTS.md rules 3 and 7 (one GOLD per share-day; no path may mint arbitrary GOLD) through an ordinary operational configuration change rather than an attack. Prerequisite: an operator redeploys the Worker with a different deploymentBlock (or any config-id field) while keeping the same contracts; the economy-config.ts:42 comment shows only catalogue edits were considered. The existing tests never define two configs over the same contracts. Fix (preserves design): make the reward journal reference independent of deploymentBlock (e.g. chainId:stakingContract:day:wallet) or add a UNIQUE guard per (wallet, day) for utc-reward journals and treat a conflict as already credited, and/or have defineEconomyConfig refuse a stakingContract that already has economy_chain_state rows under a different config id. Merged from audit_flow; reproduced here.

**Reproduction**

Run against tests/d1-sqlite.mjs (real migrations) with viem available: A=defineEconomyConfig({chainId:1,token:ECONOMY_TOKEN,stakingContract:S,paymentContract:P,treasury:ECONOMY_TREASURY,deploymentBlock:'100',catalogue:[]}); B=same with deploymentBlock:'99'. Ingest into A: block 100 (timestamp M = UTC midnight 2026-10-01) with one Staked(wallet W, positionId 1, shares 1, amount 1e23); block 101 at M+86400000 with no events; settleEconomy(db,A,{nowMs:M+86400001}) -> settledDays 1, economyWalletSnapshot(db,A,W).gold === '1'. Then into B: empty block 99 at M-1000, the identical block 100 event, block 101, settleEconomy(db,B,{nowMs:M+86400001}). Expected: W still holds 1 GOLD for the single complete share-day. Actual (script output on this tree): 'config A settledDays 1 GOLD now 1' then 'config B (deploymentBlock re-pinned) settledDays 1 GOLD now 2'; economy_gold_journal holds two utc-reward rows reward:<A.id>:1790812800000:W and reward:<B.id>:1790812800000:W, each delta 1.

### 2. Low: Idempotent replay of an already-completed GOLD payment throws CHALLENGE_EXPIRED once the 5-minute challenge window has passed, so a client that lost the first response is told the debited payment fail

`world-source/server/economy-ledger.ts:229`

```
if(challenge.expiresMs<=now)throw new EconomyConflict('CHALLENGE_EXPIRED');
```

payEconomyOrderGold is documented as replay-safe ('Replayed completion never adds a second journal/entitlement', line 212) and line 230 returns the paid order when existing.status==='paid' and goldRequestId matches the request. But the challenge-expiry guard on line 229 runs before that early return, so a retry of the identical (requestId, signature) after challenge.expiresMs (at most 5 minutes after issuance; the order itself stays valid for 15 minutes) throws CHALLENGE_EXPIRED although the debit, journal, fulfillment and challenge consumption already committed. handleEconomyApi maps it to HTTP 409; EconomyClient.payGold rejects, and EconomyPane.payGold (EconomyPane.tsx:52) never calls readAccount on the throw path, so the pane shows the generic 'Unable to finish' message for a payment that succeeded until the player manually refreshes the ledger. No funds are duplicated or lost (balance stays decremented exactly once). Fix: evaluate the existing.status==='paid' && goldRequestId===requestId short-circuit before the challenge expiry/consumed checks (keep signature, clock lower-bound and idempotency-conflict checks). Merged from audit_economics and audit_flow (same root cause, same line); reproduced here.

**Reproduction**

With tests/d1-sqlite.mjs and viem: config with catalogue [{id:'svc',gold:'1'}]; seed wallet W with 5 GOLD via a utc-reward journal row; createEconomyOrder(W, requestId R, 'svc', 'GOLD', nowMs t0); createEconomyGoldChallenge(orderId, requestId C, origin https://imdember.com, nowMs t0+1000); personal_sign challenge.message with W's key; payEconomyOrderGold(... requestId C, signature, nowMs t0+2000) -> status 'paid', balance 4. Replay the identical call with nowMs t0+100000 -> returns status 'paid' (idempotent). Replay the identical call with nowMs = challenge.expiresMs (t0+301000, order still valid until t0+900000). Expected: the paid order is returned exactly as the +100s replay. Actual (script output on this tree): 'replay at challenge.expiresMs -> threw CHALLENGE_EXPIRED | order status in DB: paid | balance 4'.

### 3. Low: EmbeoPayments.pay marks (payer, orderId) paid for any nonzero amount, so one wrong-amount payment forwards unrefundable EMBEO to the treasury and permanently blocks the exact payment for that order

`contracts/src/EmbeoPayments.sol:30`

```
paid[msg.sender][orderId] = true;
```

pay(orderId, amount) accepts any nonzero amount, sets paid[msg.sender][orderId] before transferring, and forwards the tokens to the immutable treasury. The contract cannot know the server quote, and the backend fulfils only a finalized Paid event whose amount equals the immutable order amount (economy-ledger.ts:62 UPDATE ... AND amount=?; economy-staging.ts:54 fold). A payment with a different amount is therefore indexed as an event that matches no order, the order stays 'pending', the tokens sit in the treasury with no refund entry point (contracts are ownerless and immutable by design), and a second call with the correct amount from the same wallet reverts OrderAlreadyPaid. Prerequisite: the payer calls pay() directly (block explorer, script, wallet that lets the user edit calldata, or a third-party/buggy client) with an amount other than order.amount; the shipped EconomyPane/EconomyWalletController always encodes the exact server amount and checks paid() before prompting (economyWallet.ts:110,140), and the mapping is per payer so no third party can block another wallet's order. It still means the rule-11 promise that 'a successful exact EMBEO payment is reconciled from its finalized receipt' becomes impossible for that order after one mistake, and nothing server-side flags the stranded tokens. Design-preserving mitigation: key the replay guard on (payer, keccak256(orderId, amount)) so the exact payment remains possible while exact replays stay blocked (server matching already requires wallet+orderId+amount and paid_event_id is UNIQUE, so no double fulfilment), and/or record indexed payment events whose (wallet, orderId) match a pending order with a different amount so operators can reconcile. Merged from audit_permissions, audit_economics and audit_flow (identical mechanism); reproduced by Foundry test in a scratch project (see reproduction).

**Reproduction**

Deploy AdversarialToken (contracts/test/AdversarialToken.sol, plain 18-decimal fixed-supply token) and EmbeoPayments(token, treasury). Server order X quotes amount 1e18 for payer W. W approves 2e18 and calls pay(X, 1): succeeds, Paid(X, W, token, treasury, 1) is emitted, treasury balance +1 wei, paid(W, X) == true. Indexer: event amount '1' != order amount '1000000000000000000', so economy_orders stays 'pending' and no economy_fulfillments row is written. W then calls pay(X, 1e18). Expected (rule 11): the exact payment can still be made and reconciled. Actual: revert OrderAlreadyPaid(); order X can never be fulfilled by W and the 1 wei is not refundable (no admin/rescue function exists). Scratch forge test run on this tree: forge test on a test that expects the second pay(X,1e18) to succeed fails with OrderAlreadyPaid().

### 4. Info: Idempotent order replay returns an expired pending quote rather than ORDER_EXPIRED

`world-source/server/economy-ledger.ts:197`

```
if(previous){if(previous.payload!==payload)throw new EconomyConflict('IDEMPOTENCY_CONFLICT');const {payload:_,...order}=previous;return order;}
```

createEconomyOrder returns the previously stored order for the same (wallet, requestId) without checking expires_ms. EconomyPane keeps orderAttempt (same requestId) for the same service/currency until the order is paid or renewOrder clears it, so a client that re-selects the same service later receives a pending order whose expiresMs is already past; the following GOLD challenge request then fails with ORDER_EXPIRED, and an EMBEO plan fails client-side with INVALID_ORDER (economyWallet.ts:101). No funds are at risk: an expired EMBEO quote is immutable and a late exact Paid event still fulfils it, and GOLD challenges cannot be issued on an expired order. The pane recovers through its expired-order notice and renewOrder. Returning ORDER_EXPIRED (or a fresh quote) on replay of an expired pending order would remove the timestamp-detection burden from clients. From audit_economics; reproduced here.

**Reproduction**

createEconomyOrder(db,config,{wallet:W,requestId:'q'.repeat(20),serviceId:'svc',currency:'EMBEO',nowMs:D0}) then the same call with nowMs:D0+86400000. Expected: ORDER_EXPIRED conflict or a fresh quote. Actual (script output on this tree): 'same id true status pending expiresMs<nowMs true', i.e. the same order id is returned with status pending and expiresMs (D0+900000) < nowMs.

### 5. Info: Trust assumption: immutable supply and code-hash binding turns any EMBEO supply/runtime change into permanent loss of withdrawals; mainnet runtime readback was not performed in this review

`contracts/src/ExactEmbeo.sol:32`

```
if (address(token).codehash != tokenCodeHash || token.totalSupply() != tokenSupply) revert TokenChanged();
```

Not a bypass. Every deposit, pay and withdraw first requires the token's current codehash and totalSupply to equal the values captured at construction. If mainnet EMBEO ever differs from the public fixture (contracts/fixtures/embeo-token-artifact.json: fixed-supply ERC-20 with no mint/burn/pause/proxy), a single supply change would make withdraw() revert forever and lock all principal, contradicting 'durable halt must not confiscate principal' at the contract layer. The binding is the requested token-identity check and should stay; the release gate must run contracts/tools/preflight.mjs against mainnet (runtime hash, decimals, totalSupply 1e27) and record the readback. This review made no mainnet RPC call, so the deployed token runtime, vault/payment immutables and deployment readback remain unverified release blockers. From audit_permissions.

**Reproduction**

State: any vault with staked principal. Input: token.totalSupply() returns a value different from the immutable tokenSupply (AdversarialToken.changeSupply() in contracts/test/AdversarialToken.sol, or a real token exposing burn()/an upgraded proxy). Call withdraw(positionId, 1). Expected per REQUIREMENTS rule 5: principal is refundable at any time. Actual: revert TokenChanged() on every call, permanently. Local control: contracts/test/contracts.test.mjs already exercises TokenChanged with the AdversarialToken; the open item is the mainnet readback, which was not executed here.

### 6. Info: Coverage statement: both Solidity and TypeScript/D1/client economic boundaries were reviewed; listed items remain unverified release blockers

`README.md:5`

Reviewed: contracts/src (ExactEmbeo, EmbeoStakingVault, EmbeoPayments) and AdversarialToken fixture; world-source/server economy-config, economy-ledger, economy-rpc, economy-staging, economy, economy-deployment; migrations 0010/0011; src/world economyClient, economyWallet, EconomyPane. Verified locally on this tree: sha256sum -c contracts/SHA256SUMS (39 OK, 0 failed); node tools/verify-source.mjs (SELECTED_SOURCE_BYTES_MATCH, 71 files, source commit adb13e9a); world-source economy tests on Node 22.23 with node:sqlite and viem 2.56.9: 156 of 162 pass, the 6 failures are environment gaps of this review setup (economy-forwarder/journey/payment-timing need contracts/test/runtime.mjs with the EDR dependency, economy-pane needs the typescript package, one ABI-selector test needs the compiled contracts interface, one Web Lock test needs navigator.locks absent on Node 22), not code defects; the pinned Node 24 runner was not available so LOCAL_TESTS.md receipts were not reproduced through the official runner; the three specialist TypeScript reproductions above; a scratch Foundry test (forge 1.8.3, solc 0.8.37, cancun, optimizer 200) for the pay() finding; additional probes confirming midday deposits start the next UTC day, a midnight deposit counts that day, withdrawal one second before midnight forfeits that day's shares, withdrawal exactly at the next midnight keeps the prior day, a closed-then-redeposited position inherits nothing, two signed challenges on one order produce exactly one debit, a wrong signer and an insufficient balance are rejected with no journal row, and a challenge clock earlier than order.createdMs is rejected before any write. Negative controls read in code: wrong signer/order/amount/origin/expired challenge rejected before any write (economy-ledger.ts:222-232); debit, journal, order, fulfilment and challenge consumption are one batch with CHECK(balance>=0); clock lower bounds reject rather than clamp (lines 182, 228); JSON enums require string type (economy.ts:66, economyClient.ts:39); client rechecks halt/expiry after the pending-nonce await and before persistence/prompt (economyWallet.ts:155) and before POST (economyClient.ts:98). Not verified and remaining release blockers: mainnet EMBEO runtime/supply readback and deployed vault/payment immutables (no network RPC performed); the excluded full-Worker test, real Workers 50 ms CPU and D1 quotas and large-receipt capacity (desktop timing does not prove production capacity); provider log completeness below the finalized head (trusted by design); real wallet-extension nonce override/replacement UX; preflight against live providers; the production catalogue (none exists in this candidate). Material fix from this review: the medium config-id reward re-credit finding should be fixed before any config re-pin is possible in production.

**Reproduction**

Not a defect. Commands run on this tree: sha256sum -c contracts/SHA256SUMS; node tools/verify-source.mjs; node --test on world-source/tests/economy-*.test.mjs (out-of-tree copy with viem 2.56.9 linked); node /tmp/revscripts/repro.mjs (outputs quoted in the findings above).

---

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