# Audit report

> Review Submission6/ only (read Submission6/AUDIT_README.md first; Submission5/AUDIT_README.md is the reference for everything unchanged). Not deployed. The change: the NFT contract takes more payment tokens at the same $20, each with its own GOLD price (imdUSD v1 at deployment and later v2: 1,200 GOLD; USDC added by the owner Safe: 1,600 GOLD). New: paymentTokens(token) packed entry, addPaymentToken(token, goldCost) (max 2, each once, 6 or 18 decimals, usable after 2 days, never removed), mintPaid(..., reveals, token). Diff: Submission6/build/EmberEvoGenesisCandidate.Submissio5-to-6.diff.
>
> 1. Can a paid mint pay one token's amount at another token's GOLD price, pay an added token before usableAt, or reuse an order or signature across tokens?
> 2. paymentTokens packing (bits 0-127 amount, 128-215 GOLD, 216-255 usableAt) and the 18 to 6 decimals conversion: any overlap, truncation or rounding?
> 3. addPaymentToken edge cases: paymentToken itself, the recipient, this contract, an EOA, decimals() reverting or huge, a repeat, a third token, an owner change in between.
> 4. A hostile added token: re-entry into mintPaid, mintFree or reveal settlement, or passing the exact-payment check without paying.
> 5. Is PaidOrderConsumed.goldCost (the paid token's GOLD price) enough for the off-chain GOLD book to settle exactly the held amount; does GenesisMinted.cost = paidCost (18 decimals) mislead when 20 USDC is paid?
>
> 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 | `8a3f641535e109964dba17bea9d5d0bdcb1bc7b9` |
| Job | `944905c3-8775-406c-80a6-ffcc873748d8` |
| Judged | 2026-10-28 23:43 UTC |
| Findings | 2 low · 3 info |

Four agents audited the code as it is at `8a3f641`, 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: PaidAuthorization binds the GOLD price, not the token: one signature pays in any listed token with the same GOLD price (imdUSD v1 and v2), so neither the signer nor the contract can refuse a listed to

`Submission6/contracts/EmberEvoGenesisCandidate.sol:620`

```
        bytes32 goldDigest = _hashTypedDataV4(keccak256(abi.encode(PAID_AUTH_TYPEHASH, orderId, msg.sender, lookKey, choice, keccak256(bytes(uri)), recipient, cost, gold, deadline, epoch)));
```

Answer to README question 1 (merged from four specialist reports). mintPaid rebuilds the signed PaidAuthorization from cost (always paidCost) and gold (the GOLD price read from paymentTokens[token]); the token address is not in the digest, the type string has no token field, and the signer's ORDER_KEYS / HMAC ticket and the GOLD book's stored order carry no token either (offchain/signer/paid-approval.mjs line 31, gold-book/server/genesis-gold.ts line 47). Two listed tokens with the same GOLD price are therefore interchangeable for one signature. The owner plan lists imdUSD v1 (deployment, 1,200) and imdUSD v2 (added, 1,200), so once v2 is added every order the signer prepares for v2 is also payable in v1 and the reverse. The README's claim that 'an order signed for anything else fails the signature check' and the separation 'an order reserved at 1,200 GOLD fails if paid in USDC, and the reverse' hold only while every listed token has a distinct GOLD price. Verified the other parts of question 1 are closed: amount and gold come from the same packed entry (no cross-price confusion), a 1,200 signature paid in USDC and a 1,600 signature paid in imdUSD both revert GOLD_RESERVATION_AUTHORITY, an added token one second before usableAt reverts PAYMENT_TOKEN_UNAVAILABLE, and a consumed orderId reverts PAID_ORDER_UNAVAILABLE in every token. Consequences: (a) the signer's prepare(token) check (usableAt, price) and its payment.token answer are advisory only; (b) entries are never removed on chain, so if imdUSD v1 must stop being accepted after v2 is listed (depeg, unbacked mint, frozen issuer: the README's own reason for the USDC fallback), the signer cannot refuse it by policy: a buyer asks for a v2 order and pays v1; (c) trust note 2 ('the GOLD book is a second gate') is price-based too: any token the owner adds at 1,200 or 1,600 is payable with held GOLD with no registry change. No funds are lost while every listed token is worth the same $20, so this is low; it rises to medium only in the scenario where a same-price token has to be refused. Fix (keeps the design): add `address token` to the PaidAuthorization type string and pass address(token) into the abi.encode at this line; add token to ORDER_KEYS/PAID_TYPES in paid-approval.mjs and to the GOLD book's ORDER_KEYS / GOLD_CONSENT_TYPES so the held order records the token; bump the EIP-712 domain version. Code size (24,353 of 24,576 bytes) must be rechecked. If the type string must stay, document that equal-priced tokens are interchangeable and give the signer no per-token refusal role.

**Reproduction**

Foundry (test/scratch/Review6.t.sol, test_q1_orderSignedFor1200_paysInV1orV2_sameSig). State: deploy with v1 (18 decimals) as paymentToken, paidCost 20e18, paidGoldCost 1200; owner addPaymentToken(v2, 1200) and addPaymentToken(usdc, 1600); warp 2 days; snapshot, reveal URIs, commitment set, mint unpaused, paid price approved; alice holds and has approved 1000 v1 and 1000 v2. Input: the signer prepared an order for token = v2 and signed PaidAuthorization(orderId keccak('o1'), alice, lookKey 0x01, choice 0, keccak('u'), recipient, 20e18, 1200, deadline now+600, epoch 1). alice calls mintPaid(0x01, 0, 'u', keccak('o1'), deadline, sig, [], IERC20(v1)). Expected per README section 1: revert GOLD_RESERVATION_AUTHORITY because the order was issued for v2. Actual: success; v1.balanceOf(recipient) == 20e18, v2 untouched, PaidOrderConsumed.goldCost == 1200. The reverse (order for v1, paid in v2 with orderId keccak('o2')) also succeeds; totalMinted == 2. Control: the same signature paid in usdc (1,600) reverts GOLD_RESERVATION_AUTHORITY (test_q1_crossPriceRefused).

### 2. Low: A listed payment token can never be disabled: the USDC fallback does not cover an imdUSD that loses value, and the only lever (setMintPaused) stops every token and the free route

`Submission6/contracts/EmberEvoGenesisCandidate.sol:412`

```
    function addPaymentToken(address token, uint256 goldCost) external onlyOwner {
```

README section 1 says USDC is added 'in case anything goes wrong with imdUSD'. paymentTokens entries are written once (constructor line 241, this function line 421) and no function clears, pauses or reprices one; mintPaid accepts any non-zero usable entry (line 607). The fallback therefore covers only a technical failure of imdUSD transfers (frozen, paused, recipient blacklisted: those mints revert and USDC mints go on). If imdUSD v1 depegs or its issuer stops honouring it, the contract keeps selling a Genesis token for 20e18 units of it plus 1,200 GOLD, and because the signature is not bound to the token (previous finding) the signer cannot refuse v1 either once v2 is listed at the same price. The only remedy is setMintPaused(true), which also stops USDC mints and mintFree. This is a design gap against the README's stated purpose, not a permission bypass: the owner is trusted and 'never removed' is deliberate (it stops the owner steering buyers to the dearer GOLD price). Decision for the owner: (a) accept and reword the README so the fallback covers only transfer failures; or (b) bind the token into PaidAuthorization (previous finding) so the signer can stop issuing orders for a token by policy with no on-chain change, which keeps 'never removed' and the discount promise intact; or (c) add an owner-only pause for added tokens only (one bit in the packed entry, re-enable subject to PAYMENT_TOKEN_DELAY), keeping the deployment token unpausable, subject to the 223 bytes of code size left.

**Reproduction**

Foundry (test/scratch/Review6.t.sol, test_pauseStopsEverything and test_q1_*). State: v1 as paymentToken (20e18, 1,200 GOLD); owner added usdc (6 decimals, 1,600) and v2 (18 decimals, 1,200); 2 days passed; sale open. Scenario: v1 trades at 0.10 USD. Input: a buyer holding 20e18 v1 (about 2 USD) presents a 1,200-GOLD PaidAuthorization (prepare with token = v1 still succeeds, or with token = v2 per the previous finding) and calls mintPaid(..., IERC20(v1)). Expected: the owner can refuse v1 while USDC and the free route stay open. Actual: the mint succeeds and recipient receives 20 v1 (test_q1_orderSignedFor1200_paysInV1orV2_sameSig shows the v1 payment path); no function writes paymentTokens[v1] after the constructor (grep: the only writes are lines 241 and 421, both guarded by paymentTokens[token] == 0 for added tokens). After setMintPaused(true), mintPaid(..., IERC20(usdc)) with a valid 1,600 signature reverts MINT_NOT_READY (test_pauseStopsEverything), and mintFree shares the same check at line 524.

### 3. Info: No contract event names the token that paid: GenesisMinted.cost is 20e18 and PaidOrderConsumed carries only the GOLD price when 20 USDC (20e6) was paid; the GOLD book settles exactly, other readers mu

`Submission6/contracts/EmberEvoGenesisCandidate.sol:636`

```
        emit PaidOrderConsumed(orderId, msg.sender, id, gold);
```

Answer to README question 5 (merged from four specialist reports). For the GOLD book the events are sufficient and exact: the signed goldCost equals the packed entry's GOLD price, which is what PaidOrderConsumed reports, and genesis-gold-reader.ts lines 134-136 require PaidOrderConsumed.goldCost == the held amount and GenesisMinted.cost == order.embeoCost (= paidCost). Both hold for every token, so settlement is exact for the held amount; changing GenesisMinted.cost to the token's units would break that reader. For any other consumer the logs are ambiguous: a 20 USDC mint and a 20 imdUSD mint emit identical GenesisMinted data (cost = 20e18), and once v2 is listed at 1,200 a v1 mint and a v2 mint emit identical PaidOrderConsumed data too. An indexer that reads cost as the amount of the token transferred records 20e18 USDC units (1e12 times too much) and one that wants per-token revenue must match the token's own Transfer log in the same receipt. Suggested minimal change if code size allows (223 bytes left): add a non-indexed `address token` (and, if possible, the unit amount) to PaidOrderConsumed and update the reader's data-length check at genesis-gold-reader.ts line 132; otherwise document that GenesisMinted.cost is the dollar price in 18 decimals and that the paying token is identified by the Transfer log in the receipt.

**Reproduction**

Foundry (test/scratch/Review6.t.sol, test_q5_eventsForUsdc, with vm.expectEmit on both events). State: usdc (6 decimals) added at 1,600 GOLD and v2 at 1,200, 2 days passed. Input: alice (1000 USDC approved) calls mintPaid(0x01, 0, 'u', keccak('o'), deadline, sig signed with goldCost 1600, [], IERC20(usdc)). Expected by a reader of the collection's own events: the token address and 20000000. Actual: GenesisMinted(alice, 1, 0x01, 0, 0, true, 20000000000000000000) and PaidOrderConsumed(keccak('o'), alice, 1, 1600); usdc.balanceOf(recipient) == 20000000; no log emitted by the collection contains the USDC address.

### 4. Info: Signer: the approve response and the HMAC ticket do not carry the payment token the order was prepared for, so the Mint page must remember it or the transaction reverts and the GOLD hold waits for the

`Submission6/offchain/signer/paid-approval.mjs:116`

```
   mintPaid:{lookKey:order.lookKey,choice:order.choice,uri,orderId:order.orderId,deadline:order.deadline,goldSignature},
```

Secondary scope. prepare requires token and returns payment {token, amount, goldCost}, but ORDER_KEYS (line 31), the ticket (line 53) and the approve response (this line) carry no token, so nothing server-side ties the signed goldCost to a token and approve returns the mintPaid arguments without the one the page must pass. A page that reloads between prepare and approve, or that defaults to paymentToken, sends a token whose GOLD price differs from the one signed; the contract rebuilds the digest with that token's price and reverts GOLD_RESERVATION_AUTHORITY before any transfer. No loss of funds: the wallet pays the gas of the failed transaction and the 1,600 GOLD stays held until the deadline (at most 900 s). Fix: include the token in the ticket input and the order (which the first finding also needs), require it in approve, and echo payment {token, amount, goldCost} next to the signature in the approve response.

**Reproduction**

Read the code: prepare (lines 59-91) returns payment.token only outside the order; ticket(order) hashes ORDER_KEYS, which has no token; approve (lines 94-118) accepts {order, uri, ticket, consentSignature} and returns mintPaid without a token. Input: prepare({wallet, look, choice: 1, token: USDC}) -> order.goldCost = '1600'; approve(...) -> mintPaid {lookKey, choice, uri, orderId, deadline, goldSignature}. The page then calls mintPaid(...mintPaid, reveals, paymentToken) (imdUSD, 1,200 GOLD). Expected: a response that tells the page which token to pass. Actual on chain (test/scratch/Review6.t.sol, test_q1_crossPriceRefused): a 1,600 signature paid in imdUSD reverts GOLD_RESERVATION_AUTHORITY.

### 5. Info: usableAt is the only packed field without a range check: block.timestamp + 2 days at or above 2^40 is truncated by the shift and the added token is usable at once (unreachable before year 36812)

`Submission6/contracts/EmberEvoGenesisCandidate.sol:421`

```
        paymentTokens[token] = usableAt << 216 | goldCost << 128 | amount;
```

Answer to README question 2. Packing review: amount (bits 0-127) is paidCost / unit <= paidCost < 2^128 (constructor check), goldCost (bits 128-215) is checked < 2^88, so the fields cannot overlap and uint128(accepted), uint88(accepted >> 128), accepted >> 216 read back exactly what was stored (verified at the extremes 2^128-1 and 2^88-1 for both the constructor and addPaymentToken; the constructor refuses 2^128 and 2^88 with PARAMETERS_REQUIRED). The 18-to-6 conversion is exact by construction: amount = paidCost / 1e12 is accepted only when amount * 1e12 == paidCost (20e18 -> 20e6; a non-divisible paidCost is refused with TOKEN_IDENTITY_REQUIRED), so no unit is lost or rounded. The one value with no check is usableAt = block.timestamp + PAYMENT_TOKEN_DELAY in the top 40 bits: the shift by 216 discards bits 40 and above, so for block.timestamp >= 2^40 - 172800 the stored usableAt wraps to a small number and the token is usable immediately instead of two days later. 2^40 seconds is the year 36812, so this is not reachable; it is recorded because the README asked whether any value can pass the checks and still overflow. Fix: add `usableAt >> 40 == 0` to the existing require, or accept and note it.

**Reproduction**

Foundry (test/scratch/Review6.t.sol, test_q2_usableAtTruncation). State: vm.warp(2^40 - 2 days + 5); owner calls addPaymentToken(usdc, 1600). Expected: paymentTokens(usdc) >> 216 == block.timestamp + 172800 == 1099511627781. Actual: 5, and a mintPaid(..., IERC20(usdc)) with a 1,600 signature in the same block succeeds (usdc.balanceOf(recipient) == 20e6) instead of reverting PAYMENT_TOKEN_UNAVAILABLE. All other question-3 edge cases are refused as documented (test_q3_edges): paymentToken itself, the recipient, this contract (decimals() missing, call reverts), an EOA, address(0), decimals() reverting, decimals() answering 2^200+6 (uint8 decoding reverts), 8 decimals, goldCost 0 or 2^88, a repeat, a third token; after an Ownable2Step transfer the old owner is refused and the new owner can add, and the limit of two is global.

---

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