# Audit report

> Review Submission7/ only (read Submission7/AUDIT_README.md first; Submission6 and Submission5 AUDIT_README are the reference for everything unchanged). Not deployed. It fixes Swarm audit 944905c3: the PaidAuthorization now includes the payment token (address token after recipient), so an order pays only in the token it was signed for; the signer can stop issuing orders for a token (allowedTokens); approve returns mintPaid.token; addPaymentToken requires usableAt >> 40 == 0. Diff: Submission7/build/EmberEvoGenesisCandidate.Submissio6-to-7.diff (runtime 24,427 bytes).
>
> 1. Token binding: with imdUSD v1, USDC and imdUSD v2 listed, can an order signed for one token be paid in another, at the same or a different GOLD price? Does any field of the order, the wallet's GOLD consent or the PaidAuthorization fail to bind the token?
> 2. Signer policy: with allowedTokens set, can a buyer still get or reuse an order for a token outside it (prepare, approve, a ticket for another token, a retried approve)?
> 3. GOLD book (secondary scope, offchain/gold-book, World 0727200): can a hold be made at a price no listed token charges, or for a token the registry entry does not list?
> 4. Any path where usableAt >> 40 == 0 refuses a valid add, or where the larger digest changes mintPaid's behaviour other than adding the token?
>
> 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 | `826432639d345607e8e58d73a679d7c302dcd39c` |
| Job | `e9fc751f-0479-4d33-afb2-c4b2f79f0113` |
| Judged | 2026-10-29 01:52 UTC |
| Findings | 1 low · 2 info |

Four agents audited the code as it is at `8264326`, 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: Signer token policy (allowedTokens / PAYMENT_TOKENS) is enforced in prepare only: approve still holds GOLD and signs a PaidAuthorization for a token the policy excludes (pre-policy ticket or retried a

`Submission7/offchain/signer/paid-approval.mjs:114`

```
  try{held=await this.gold.reserve({deploymentId:this.deploymentId,order,consentSignature});}
```

Submission 7 answers audit 944905c3 finding 2 (a listed token can never be disabled on chain) with an off-chain policy: PaidApprovalSigner.allowedTokens (Worker variable PAYMENT_TOKENS, worker-entry.mjs line 32) is meant to let the signer 'stop issuing orders for a token' that stays listed on chain. The only use of the policy is in prepare() (line 70: `if(this.allowedTokens&&!this.allowedTokens.includes(token))fail('PAYMENT_TOKEN_UNAVAILABLE');`). approve() (lines 103-127) verifies the HMAC ticket (line 107), the uri hash and the wallet's consent, calls gold.reserve (line 114) and signs PAID_TYPES over order.token (lines 121-122) without ever reading this.allowedTokens (the field appears only at lines 44, 45, 51, 52 and 70). The ticket is an HMAC over [deploymentId, chainId, contract, order fields] under TICKET_SECRET, which worker-entry.mjs hands to every PaidApprovalSigner instance; it carries no policy version and has no lifetime of its own. So every order prepared while the token was still allowed (or by the Worker before PAYMENT_TOKENS was set) stays approvable after the operator excludes the token, for the rest of its deadline window (PAID_TTL_SECONDS, 600 s by default, 900 s at most): GOLD is held at that token's price and a PaidAuthorization naming the excluded token is issued, which mintPaid accepts because the on-chain entry stays listed for good. A retried approve of an order the book already holds is signed again as well (the book answers 'held' from the stored order and the signature is deterministic). The policy therefore takes effect only after the longest outstanding ticket expires, not when PAYMENT_TOKENS changes, which matters exactly in the cases the policy was added for (USDC recipient blacklisted, imdUSD v1 retired at the v2 migration). The AUDIT_README (section 1 finding 2, section 2 question 2) and the signer header comment present allowedTokens as the way to stop issuing orders for a token; question 2 itself names 'a retried approve' as a case to check. The shipped test (tests/signer/paid.test.mjs lines 150-153) exercises imdOnly.prepare only; no test approves an order prepared under a wider policy. Impact is bounded: the token is still listed and usable on chain, GOLD is held at the registry's listed price and the buyer pays the full dollar amount, so no value is lost; what fails is the operator's ability to cut a token off immediately. A ticket made for another token does not bypass anything: changing order.token alone fails the HMAC (ORDER_TICKET_INVALID, verified). Fix (keeps the design): in approve(), after the ticket check and before gold.reserve, add `if(this.allowedTokens&&!this.allowedTokens.includes(order.token))fail('PAYMENT_TOKEN_UNAVAILABLE');` (order.token is the lowercase value prepare produced; the ticket guarantees it), so neither a new hold nor a signature is issued for an excluded token and an order already held is released by the book at its deadline as today. Add a test that prepares a USDC order with an unrestricted signer and asserts imdOnly.approve(...) rejects with PAYMENT_TOKEN_UNAVAILABLE and that gold.reserve was not called. Optionally fold a policy identifier (sorted allowed list) into the ticket HMAC, or document that TICKET_SECRET is rotated together with PAYMENT_TOKENS, so outstanding tickets die at once. Merged from four specialist reports (economics, flow, permissions, math) that describe the same mechanism.

**Reproduction**

Run with Node 24 and ethers 6.17 against the unmodified Submission7/offchain/signer/*.mjs (copied next to a vendor/ethers.min.js that re-exports ethers; approve reads nothing from the chain, prepare's contract reads are stubbed). State: imdUSD v1 = 0xa1..a1 (1,200 GOLD, 20e18) and USDC = 0xb2..b2 (1,600 GOLD, 20e6) listed and usable; block.timestamp 1800000000; GOLD binding stub records reserve calls and answers {state:'held'}. Two PaidApprovalSigner instances share ticketSecret 'rehearsal-ticket-secret-0123456789abcdef', signer key, chainId 11155111 and contract (the Worker before and after a PAYMENT_TOKENS change). Steps: (1) open = new PaidApprovalSigner({...common}) with no allowedTokens; p = await open.prepare({wallet:alice, look, choice:1, token:USDC}) -> p.order.token = '0xb2b2...b2b2', p.order.goldCost = '1600', deadline 1800000600. (2) imdOnly = new PaidApprovalSigner({...common, allowedTokens:[IMD]}); imdOnly.prepare({..., token:USDC}) rejects with PAYMENT_TOKEN_UNAVAILABLE (as documented). (3) consentSignature = alice.signTypedData(p.consent.domain, p.consent.types, p.consent.message); a = await imdOnly.approve({order:p.order, uri:p.uri, ticket:p.ticket, consentSignature}). Expected (AUDIT_README section 1 finding 2 / section 2 question 2): rejects with PAYMENT_TOKEN_UNAVAILABLE and gold.reserve is not called. Actual (observed output): 'imdOnly.approve(USDC order) -> SIGNED; mintPaid.token = 0xb2b2b2b2b2B2b2B2B2b2b2B2B2b2B2B2b2b2b2b2 ; signature by signer = true ; holds = [["0xb2b2...b2b2","1600"]]' (verifyTypedData over PAID_TYPES with token = USDC recovers the approval signer, which is exactly the digest mintPaid rebuilds at contract line 626, so mintPaid(..., token = USDC) succeeds on chain). (4) imdOnly.approve(same request) again: 'retried approve -> SIGNED again; same signature = true'. (5) Control: imdOnly.approve({...req, order:{...p.order, token:IMD}}) -> ORDER_TICKET_INVALID (the ticket does bind the token). Script: test/scratch/sig/repro.mjs.

### 2. Info: A malformed PAYMENT_TOKENS entry is reported to every buyer as 400 WALLET_INVALID instead of a 500 CONFIG_* error, and the paid route goes dark without a server-side log

`Submission7/offchain/signer/paid-approval.mjs:52`

```
  this.allowedTokens=allowedTokens?.map(t=>wallet(t).toLowerCase())??null;
```

The constructor normalizes each allowed token with wallet(t), the request-field validator, which throws ApprovalError('WALLET_INVALID', 400). Every other configuration check in the constructor throws a CONFIG_* code with status 500 (CONFIG_CHAIN_ID, CONFIG_METADATA_BASE, CONFIG_TTL, CONFIG_TICKET_SECRET, CONFIG_GOLD_BINDING, and CONFIG_PAYMENT_TOKENS for an empty list on line 51). worker-entry.mjs line 32 splits PAYMENT_TOKENS on commas and trims but does not validate the entries, and worker.mjs line 68 constructs the PaidApprovalSigner inside the request's try block, so the ApprovalError is caught at worker.mjs line 91 and returned to the client as HTTP 400 {ok:false,error:{code:'WALLET_INVALID'}} on every /v1/paid/prepare and /v1/approval/paid call; console.error at line 92 runs only for non-ApprovalError exceptions, so nothing is logged. Because the paid signer is constructed per request this is not a one-time startup failure but a total outage of paid minting that is misattributed to the buyer's wallet. Fix: validate allowedTokens with its own code, e.g. this.allowedTokens=allowedTokens?.map(t=>{try{const a=getAddress(t);if(a===ZeroAddress)throw 0;return a.toLowerCase();}catch{fail('CONFIG_PAYMENT_TOKENS',500);}})??null; so a misconfiguration surfaces as a 500 that monitoring catches, and document PAYMENT_TOKENS in offchain/signer/README.md.

**Reproduction**

Node 24, ethers 6.17, unmodified Submission7/offchain/signer/{worker,paid-approval}.mjs. handler = createHandler({approver, paidApprover: async()=>new PaidApprovalSigner({...valid options, allowedTokens:['0xa1..a1','0xabc']}), allowedOrigins:['https://mint.example.test']}) (what worker-entry.mjs builds from PAYMENT_TOKENS='0xa1..a1,0xabc'). Request: POST https://signer.example.test/v1/paid/prepare, Origin https://mint.example.test, Content-Type application/json, body {wallet:<valid>, look:<valid>, choice:1, token:<imdUSD>}. Expected: a 500 CONFIG_PAYMENT_TOKENS (as for every other constructor misconfiguration) or a refusal to start. Actual (observed output): 'PAYMENT_TOKENS="...,0xabc": /v1/paid/prepare -> 400 {"ok":false,"error":{"code":"WALLET_INVALID"}}'; the same for /v1/approval/paid; no console.error. Script: test/scratch/sig/repro.mjs (last step).

### 3. Info: The new signer policy variable PAYMENT_TOKENS (and PAID_TTL_SECONDS) is not documented in the signer README, which is byte-identical to Submission 6

`Submission7/offchain/signer/README.md:90`

```
要開啟付費路線，Worker 必須有 `GOLD` binding、`GOLD_DEPLOYMENT_ID` 和 `TICKET_SECRET`，缺一個就回 501。Sepolia 的設定在 `wrangler.sepolia.toml`，binding 指向 `imd-world-gold-sepolia`，**不是**正式的 `imd-world`。
```

Audit 944905c3 finding 2 is answered off chain: the operator stops issuing orders for a listed token by setting the Worker variable PAYMENT_TOKENS (worker-entry.mjs lines 31-32, PaidApprovalSigner.allowedTokens). The operator document for the Worker, offchain/signer/README.md, is unchanged from Submission 6 and its paid-route section (line 90) still lists only the GOLD binding, GOLD_DEPLOYMENT_ID and TICKET_SECRET. PAYMENT_TOKENS, its format (comma-separated addresses), that an empty value fails the Worker (CONFIG_PAYMENT_TOKENS) and that the policy is applied at prepare only (see the low finding) appear nowhere; PAID_TTL_SECONDS is also undocumented. Since an on-chain entry can never be removed, this variable is the only operational control for a token and belongs in the runbook. Fix: document PAYMENT_TOKENS (and PAID_TTL_SECONDS) next to the paid-route variables, including the behaviour of orders already prepared when the value changes and the recommendation to rotate TICKET_SECRET with it.

**Reproduction**

grep -n PAYMENT_TOKENS Submission7/offchain/signer/README.md returns nothing; grep -n PAID_TTL Submission7/offchain/signer/README.md returns nothing; diff -q Submission6/offchain/signer/README.md Submission7/offchain/signer/README.md reports no difference, while Submission7/offchain/signer/worker-entry.mjs lines 30-32 read env.PAID_TTL_SECONDS and env.PAYMENT_TOKENS. Expected: the README's Worker configuration section names PAYMENT_TOKENS and its semantics. Actual: not mentioned.

---

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