# Audit report

> Review Submission8/ only (read Submission8/AUDIT_README.md first; Submission7, 6 and 5 AUDIT_README are the reference for everything unchanged). Not deployed. The contract is unchanged from Submission7 (same runtime, 24,427 bytes). It fixes Swarm audit e9fc751f in the approval signer: allowedTokens (PAYMENT_TOKENS) is now checked in approve as well as prepare; a malformed PAYMENT_TOKENS entry is a 500 CONFIG_PAYMENT_TOKENS; PAYMENT_TOKENS and PAID_TTL_SECONDS are documented. Every change: Submission8/build/offchain.Submission7-to-8.diff.
>
> 1. Signer policy: with allowedTokens set, can any request still get a GOLD hold or a PaidAuthorization for a token outside it (a ticket from before the change, a retried approve, letter case of order.token, a ticket made for another token)?
> 2. Configuration: can any PAYMENT_TOKENS value (empty entries, spaces, zero address, duplicates) still be reported as the buyer's error, or silently allow every token?
> 3. GOLD book (secondary scope, offchain/gold-book, World 6be9ef3): settlement now requires a Transfer emitted by order.token from the order's wallet to the order's recipient in the same receipt. Is that exactly the transfer mintPaid makes (safeTransferFrom(msg.sender, paymentRecipient, amount)) for every kind of wallet (EOA, Safe, ERC-4337, EIP-7702)?
>
> Report severity, reproduction and fix. Not in scope: artwork, the Mint web page, World 3D.

| | |
|---|---|
| Repository | https://github.com/tungweb3/ember-evo-genesis-review.git |
| Commit | `096eb7bac089356a3417353f651580cd2474e338` |
| Job | `d1c83d3d-03e9-485b-8968-641b2ea0afc3` |
| Judged | 2026-10-29 05:31 UTC |
| Findings | 1 low · 4 info |

Four agents audited the code as it is at `096eb7b`, 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: PAYMENT_TOKENS set to the empty string (or 0) is treated as unset and silently allows every on-chain token, while the signer README promises 500 CONFIG_PAYMENT_TOKENS

`Submission8/offchain/signer/worker-entry.mjs:32`

```
  ...env.PAYMENT_TOKENS?{allowedTokens:String(env.PAYMENT_TOKENS).split(',').map(s=>s.trim()).filter(Boolean)}:{}});
```

The Worker entry passes allowedTokens to PaidApprovalSigner only when env.PAYMENT_TOKENS is truthy. A variable that is set but empty (PAYMENT_TOKENS = "" in wrangler.toml [vars], an empty dashboard value, or a TOML integer 0) is falsy, so no allowedTokens key is passed, the constructor's `allowedTokens!==undefined&&!allowedTokens.length` check at paid-approval.mjs:53 never runs, this.allowedTokens is null, and both policy checks (prepare at paid-approval.mjs:72, approve at paid-approval.mjs:113) are skipped: orders, GOLD holds and PaidAuthorizations are issued for every token the contract lists. Submission8/offchain/signer/README.md:96 documents the opposite for exactly this case (設了但是空的 … 付費路線每次都回 500 CONFIG_PAYMENT_TOKENS: set but empty, every paid request answers 500 CONFIG_PAYMENT_TOKENS), and presents PAYMENT_TOKENS as the only switch for stopping a token because on-chain tokens can never be removed. Only whitespace or comma-only values (" ", ",", " , ") reach the documented 500, because they survive the truthiness test and are filtered to []. Nothing is logged. tests/signer/paid.test.mjs constructs PaidApprovalSigner directly, so this parsing line is not exercised. This is the question-2 answer: one PAYMENT_TOKENS value silently allows every token. Duplicates and surrounding spaces are accepted harmlessly; a malformed entry, the zero address, empty entries between commas, and the whitespace-only forms all behave as documented. Merged from four specialists (audit_math, audit_permissions, audit_flow, audit_economics), who reported the same line and mechanism.

**Reproduction**

Ran the Submission8 signer (copied with ethers v6 installed outside the repo) with the worker-entry.mjs line-32 expression verbatim: test/scratch/repro-signer.mjs. Results: PAYMENT_TOKENS="" -> parsed {} -> constructed, allowedTokens = null; PAYMENT_TOKENS=0 -> same; PAYMENT_TOKENS=" " / "," / " , " -> {allowedTokens:[]} -> throws CONFIG_PAYMENT_TOKENS 500; "0xabc" and a list containing the zero address -> CONFIG_PAYMENT_TOKENS 500. Then, with the signer built from PAYMENT_TOKENS="" and a stubbed contract listing imdUSD and USDC: approve of a USDC order prepared by an unrestricted signer -> 200 with a PaidAuthorization for USDC and the GOLD stub records a hold (`e. PAYMENT_TOKENS="" signer approves USDC ticket -> OK token=0xbBbB… held=2`); prepare for USDC -> OK. Expected per README line 96: 500 CONFIG_PAYMENT_TOKENS on every paid request. Fix: test presence, not truthiness: `...(env.PAYMENT_TOKENS!==undefined&&env.PAYMENT_TOKENS!==null?{allowedTokens:String(env.PAYMENT_TOKENS).split(',').map(s=>s.trim()).filter(Boolean)}:{})`, so "" becomes [] and the existing constructor check returns CONFIG_PAYMENT_TOKENS; add a worker-entry test for "", " " and ",".

### 2. Info: A malformed, zero or untrimmed CONTRACT_ADDRESS is still reported to every buyer as 400 WALLET_INVALID (the misattribution audit e9fc751f finding 2 fixed for PAYMENT_TOKENS only), in both signers

`Submission8/offchain/signer/paid-approval.mjs:55`

```
  this.contractAddress=wallet(contractAddress);this.chainId=chainId;this.signer=signer;this.metadataBase=metadataBase;this.ttl=ttlSeconds;
```

The Submission8 change wraps only the allowedTokens entries so a bad one is CONFIG_PAYMENT_TOKENS 500. The next statement validates the operator's CONTRACT_ADDRESS with the same buyer-input helper wallet(), which throws ApprovalError('WALLET_INVALID',400). worker-entry.mjs:29 passes env.CONTRACT_ADDRESS untrimmed (only secrets go through secret()), the Worker builds the signer per request inside the handler's try block (worker.mjs:68) and returns e.status/e.code to the client (worker.mjs:91) without logging (console.error runs only for non-ApprovalError exceptions). So a truncated paste, a checksum-broken mixed-case address, a trailing newline or the zero address makes every /v1/paid/prepare and /v1/approval/paid answer 400 {code:'WALLET_INVALID'}, which the Mint page shows as the buyer's own wallet being invalid, with nothing in the Worker log. FreeApprovalSigner has the same line (free-approval.mjs:55), so /v1/approval/free and /v1/eligibility report it the same way. No permission is bypassed and no wrong token is accepted; this is the diagnostic inconsistency the previous audit flagged, left on a second variable. Related: worker.mjs:91 returns every ApprovalError, including the 500 configuration codes (CONFIG_PAYMENT_TOKENS, CONFIG_TTL, …), without console.error, so a bad deploy is only learned from buyers. Merged from audit_math, audit_permissions, audit_flow and audit_economics (same line and mechanism), with the logging note from audit_economics.

**Reproduction**

test/scratch/repro-signer.mjs, section 'CONTRACT_ADDRESS misconfiguration', with otherwise valid constructor arguments: '0xabc' -> paid: throws WALLET_INVALID 400, free: throws WALLET_INVALID 400; '0x'+'0'*40 -> WALLET_INVALID 400 (both); the right address with a trailing '\n' -> WALLET_INVALID 400 (both); the right address with one checksum letter lowercased -> WALLET_INVALID 400 (both). Compare the same run: PAYMENT_TOKENS='0xabc' -> CONFIG_PAYMENT_TOKENS 500. Expected: a 500 configuration code (CONFIG_CONTRACT_ADDRESS), like CONFIG_CHAIN_ID, CONFIG_TTL and CONFIG_PAYMENT_TOKENS. Fix: in both constructors `try{this.contractAddress=wallet(contractAddress);}catch{fail('CONFIG_CONTRACT_ADDRESS',500);}`, trim CONTRACT_ADDRESS in worker-entry.mjs like the secrets, and in worker.mjs:91 log ApprovalErrors with status>=500 (`if(e.status>=500)console.error('approval config',e.code);`).

### 3. Info: GOLD consent is ECDSA-only in the signer and the book: a Safe or ERC-4337 account can never be order.wallet, so the paid route is EOA and EIP-7702 only (question 3: the Transfer check matches mintPaid

`Submission8/offchain/signer/paid-approval.mjs:117`

```
  if(consenter.toLowerCase()!==order.wallet)fail('INVALID_GOLD_CONSENT',400);
```

Answer to question 3. On chain, mintPaid binds the PaidAuthorization to wallet = msg.sender (contract line 626) and pays with token.safeTransferFrom(msg.sender, paymentRecipient, amount) (line 639), and paymentRecipient equals order.recipient (the ticket binds it; it never changes). So the receipt of any wallet that can mint holds one Transfer emitted by order.token with topics (order.wallet, order.recipient), whatever outer transaction wraps the call (a Safe execTransaction, EntryPoint handleOps, an EIP-7702 batch), and the reader's filter at genesis-gold-reader.ts:150-153 (emitter = order.token, 3 topics, from = m.wallet, to = order.recipient, receipt.to ignored) selects exactly that log. Verified with the reader copy and a receipt shaped like mintPaid's (test/scratch/repro-reader.mjs): full-amount Transfer(wallet -> recipient) by order.token settles; a Transfer by another listed token of the same GOLD price, no Transfer, a 4-topic Transfer, and a Transfer from a relayer or paymaster address all leave the order held (MINT_PROOF_CONFLICT). Off chain, however, the consent step accepts only an ECDSA signature that recovers to order.wallet: ethers verifyTypedData here (line 116-117) and viem recoverTypedDataAddress in the book (genesis-gold.ts:133-135). Neither tries ERC-1271 isValidSignature. A Safe or a 4337 account has no key for its own address, and its owner's signature recovers to the owner, so approve answers INVALID_GOLD_CONSENT before any hold; naming the owner EOA as order.wallet instead fails on chain with GOLD_RESERVATION_AUTHORITY because msg.sender would be the account. The paid route is therefore EOA (and EIP-7702 delegated EOA) only, end to end, and the reader's Safe/4337 branch (comment at genesis-gold-reader.ts:127-128) and Submission5/AUDIT_README.md:112 ('smart-wallet and bundled mints settle') describe a path no hold can reach. Nothing is lost and no permission is bypassed: the refusal comes before GOLD is held or a token is paid. The gap is an undocumented limitation. The 4-topic case is worth one sentence in the runbook: a token whose Transfer indexes value (or emits none) would leave every consumed order held forever; the listed tokens (OpenZeppelin ERC20 imdUSD, FiatToken USDC) emit the standard 3-topic event. Merged from audit_permissions and audit_economics (same line, same mechanism) and the question-3 part of audit_math.

**Reproduction**

test/scratch/repro-signer.mjs, case f: unrestricted signer, prepare({wallet: 0x5a…5a (a contract address), look, choice:1, token: imdUSD}) succeeds; approve with the exact consent typed data signed by the Safe owner key 0x22…22 -> ApprovalError INVALID_GOLD_CONSENT 400, gold.reserve not called (held list unchanged). The book's verifyConsent (genesis-gold.ts:131-136) does the same comparison with recoverTypedDataAddress. Expected if contract wallets were supported, as the reader comment and Submission5 README line 112 assume: a hold, a PaidAuthorization and settlement by the Transfer check. Fix (design decision for the World team): either document in the signer README, the audit README and the Mint page that paid orders need an EOA or EIP-7702 wallet and return a specific code (e.g. CONTRACT_WALLET_UNSUPPORTED) when eth_getCode(order.wallet) is non-empty and not a 7702 delegation, or verify the consent with ERC-1271 (eth_call isValidSignature at a finalized block) in both the signer and the book, documenting that an isValidSignature answer can change.

### 4. Info: GOLD-book settlement matches the payment Transfer by emitter, from and to only; the transferred amount is never compared with the token's price

`Submission8/offchain/gold-book/server/genesis-gold-reader.ts:152`

```
    const t=topics(l);return t.length===3&&t[0]===R5_TOPICS.transfer&&addrTopic(t[1])===m.wallet&&addrTopic(t[2])===recipient;});
```

The new check (World audit e3e9b712 finding 3) proves that order.token paid the recipient from the wallet in the receipt, but it never reads the log's data word, so any Transfer of that token from the wallet to the recipient satisfies it, including a 1-wei transfer the wallet batches alongside the mint from an EIP-7702 or 4337 account. Today this is not exploitable: mintPaid's exact-payment check (contract line 640) forces the full amount in the token named by the PaidAuthorization, the signer builds that signature from the same order the book held (the token is in the ticket, the consent and the signature), so an extra Transfer can only accompany a mint that already paid correctly in the same token. The amount is the remaining distinguishing evidence only if the signer ever signs a token different from the one the book held, which is the scenario the check was added to guard; it is a hardening note, not a loss. Separate mechanism from the ECDSA finding (different file and effect), so kept apart. Merged from audit_math's question-3 finding.

**Reproduction**

test/scratch/repro-reader.mjs case 6: the Submission8 reader copy, a held imdUSD order, and a receipt whose only Transfer is emitted by imdUSD from the wallet to the recipient with data = 1 wei (word(1n)) next to the correct PaidOrderConsumed and GenesisMinted logs. Actual: read() returns a consumed proof (tokenId 7) and the book would settle the hold. Expected if the check meant to prove the order's own payment: MINT_PROOF_CONFLICT. The same run shows the check does refuse a Transfer by another token, by no token, with 4 topics, or from another address. Fix (optional): record each registry token's unit amount (paymentToken/addedTokens, e.g. amount '20000000000000000000' or '20000000') and require wordAt(String(l.data),0)===BigInt(entry.amount) for the matching log; and state in the runbook that a token must emit the standard 3-topic Transfer before it is added on chain.

### 5. Info: PAYMENT_TOKENS cannot recall a PaidAuthorization approve already returned: an excluded token stays mintable until order.deadline and the book settles it as consumed, while the audit README says such o

`Submission8/AUDIT_README.md:18`

```
An order the book already holds is released at its deadline as before.
```

The approve check at paid-approval.mjs:113 stops new holds and signatures for an excluded token. It cannot touch a buyer whose approve succeeded before PAYMENT_TOKENS changed: that buyer already holds the deterministic PaidAuthorization and the mintPaid arguments. The contract reads only paymentTokens[token] (never removed on chain), appearanceSigner, signerEpoch and the deadline (contract lines 612-627), none of which the signer policy changes, so the mint goes through for up to PAID_TTL_SECONDS (at most 900 s) after the change, and the book then settles the hold as consumed because the order's own token paid. The README's 'An order the book already holds is released at its deadline as before' (and signer README line 99: GOLD already held is returned after the deadline) is true only of a held order whose approve response was lost. The only recall is on chain: owner revokePaidOrder(orderId) (contract line 647) or setMintPaused. This is a documentation and operations gap, not a bypass: the signer policy is a stop on new issuance, as Submission7 describes. Merged from audit_permissions.

**Reproduction**

test/scratch/repro-signer.mjs: an unrestricted signer prepares and approves a USDC order (case e shows an approve returning mintPaid arguments with token=USDC and a goldSignature, the GOLD stub recording the hold). Switching to a signer with allowedTokens=[imdUSD] afterwards refuses a retried approve (case a: PAYMENT_TOKEN_UNAVAILABLE) but changes nothing in the signature already returned: it is signTypedData over the same PaidAuthorization (paid-approval.mjs:126-128), verifies against signer.address, and mintPaid(..., orderId, deadline, goldSignature, reveals, USDC) sent before order.deadline passes every require at contract lines 612-627 exactly as before the policy change; the reader then finds PaidOrderConsumed, GenesisMinted and the USDC Transfer(wallet -> recipient) and settles the hold as consumed. Expected per the README: the held order is released at its deadline. Fix: state in both READMEs that PAYMENT_TOKENS stops issuance only, that outstanding signed orders remain mintable for up to PAID_TTL_SECONDS, and that revokePaidOrder (or pausing) is the recall; optionally have the operator runbook list open held orders of the excluded token (genesisGoldOrder) for revocation.

---

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