# Audit report

> Review Submission26/ only (read Submission26/AUDIT_REQUEST.md first). World source 24a3efa, NOT deployed; no real Celestao payment and no real grant has ever been made. Job 6fc92ac1 on Submission25 gave no final report and nobody reviewed the math area, so the scope is everything since fd46d69. The wallet-lock test fails on purpose until this audit is handled.
>
> 1. Math, not reviewed in this range: grant limits and rolling windows, grantLeft and grantsLeft, the 3-attempt and 1-hour counts, the 24-hour and 15-minute clocks, the 5-minute lapse, the 30-day clock, refund amounts against what was paid.
> 2. Signature: forge, reuse or stretch one across sites, recipients, amounts or request ids. Message ambiguity, reason encoding, the origin taken from the request, expiry, the replay path before the check, and whether the test seam reaches production. Is personal_sign without chainId acceptable here?
> 3. One reading, one order: any interleaving of reload, repeat, lapse, retire, payment and delivery that charges twice or loses a paid order. Can a wallet reach another wallet's run?
> 4. Waiting and stuck orders: can note_wait end a run with a saved or delivered report? Races between note_wait, strike, abandon, the lease and a late complete. A status in the wrong group. Can a healthy order be refunded by the clock that never resets, more easily than section 5 says?
> 5. Retention: can prune_failed remove a run that can still be delivered or has a report? A late delivery or re-purchase after clearing. Can the cleaner stop quietly?
> 6. Polar: any input that passes validate and still fails compute because of location or timezone, so a buyer pays and is refunded.
> 7. Birth-time form: any way the two lists reach payment with a time the buyer did not choose (half-filled pick, language switch, unknown time switched on and off, going back).
> 8. Privacy text against code: any sentence in world-wallet.js the code does not make true.
> 9. Refund truth: anything that makes World refund a delivered order, or keep the money for one not delivered.
>
> Known items are in AUDIT_REQUEST.md sections 1 to 9. Report severity, reproduction and fix. Not in scope: chart engines, report wording, 3D/rendering, media, AIMarket, Genesis.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `9dc6831483fa3eacd50cd272ffba09ca95e62276` |
| Job | `447bb70d-fbd9-4c65-8179-27d5bb44cffa` |
| Judged | 2026-11-06 11:13 UTC |
| Findings | 3 low · 1 info |

Four agents audited the code as it is at `9dc6831`, 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: Refunded readings cannot be repurchased with the page’s existing request id

`Submission26/source/integrations/celestao/celestao_world/vault.py:108`

```
            old=self.db.execute('SELECT * FROM runs WHERE owner=? AND request_id=?',(owner,request_id)).fetchone()
            if old:
                if old['system']!=system or old['input_hash']!=digest: raise VaultError('IDEMPOTENCY_CONFLICT')
```

The request-id lookup returns failed runs, whereas the input-digest lookup excludes them. The browser retains one request id for the same system and input for the lifetime of the page, including a restored page. After a terminal failure it therefore reopens the failed run and its refunded World order instead of allowing the repurchase promised in AUDIT_REQUEST section 4. No second charge occurs. Fix by rotating the client request id for a terminal run, or atomically renaming the failed row’s request_id before creating a replacement, while preserving the old run/order for late delivery and refunds. Merely setting old=None is insufficient because UNIQUE(owner,request_id) would still reject the replacement.

**Reproduction**

Executed the actual Vault against an in-memory SQLite database: at T=1780000000000 prepare wallet 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa with requestId fixture_paid_request_001, system bazi and input {"name":"Synthetic","date":"1990-01-02","time":"03:04","consent":true}; admit, bind order 0x1111111111111111111111111111111111111111111111111111111111111111, claim at T+1 and fail that attempt with CHART_NOT_GENERATED (the provider path that triggers World’s refund). At T+1000000 call prepare with the identical request id/system/input. Expected: a purchasable replacement, or a terminal response that causes the page to rotate its key. Actual: the same run id is returned with state=failed and expires_ms already past. Calling prepare with requestId fixture_paid_reloaded_02 instead returns a different prepared run, confirming the key-specific blockage. The page never removes this key from attempts, and the prepare route returns the old run’s checkout URL.

### 2. Low: Permanent prepare conflicts are misreported as temporary storage outages

`Submission26/source/integrations/celestao/celestao_world/paid.py:39`

```
        run=vault.prepare(ident.wallet,value.requestId,value.system,payload,True,clock())
```

prepare does not translate VaultError(IDEMPOTENCY_CONFLICT). The application’s general VaultError handler maps it to HTTP 503 STORAGE_UNAVAILABLE, and the page advises retrying. This request-id conflict is permanent and does not indicate a database outage. Fix by catching this code around both vault.prepare calls and returning 409 IDEMPOTENCY_CONFLICT, with client guidance to start a new request. Preserve 503 for genuine storage errors.

**Reproduction**

Executed the supplied prepare route function and storage_error handler extracted from their ASTs, with the real in-memory Vault, a pass-through processor.validate and a successful World prepare stub. For signed-in wallet 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, first prepare {requestId:fixture_paid_request_001,system:bazi,input:{name:Synthetic,date:1990-01-02,time:03:04,consent:true},saveConsent:true} succeeds. Repeat with the same requestId/input but system=ziwei. Expected: 409 IDEMPOTENCY_CONFLICT. Actual: the route propagates that VaultError and the actual handler returns 503 {"error":"STORAGE_UNAVAILABLE"}. A second identical retry produces the same exception and 503. This reproduction isolates the actual route and handler; the full FastAPI stack was not available in this package.

### 3. Low: Grant form requests a signature for amounts above the remaining daily allowance

`Submission26/source/src/world/WorldGoldPanel.tsx:64`

```
 <button disabled={!grantFormReady({to:grantTo,gold:grantGold,checked:grantChecked})||data.grantLeft==='0'||!signGrant} onClick={()=>void sendGrant()}>{zh?'確認發放':'Send GOLD'}</button>
```

The button checks only whether grantLeft equals zero; grantFormReady checks the per-grant maximum of 5,000, not the remaining rolling allowance. An amount above a positive grantLeft is therefore offered for signing even though the server must refuse it. GRANT_DAILY_LIMIT then incorrectly says the limit is reached although some allowance remains. This is a client validation and wording defect, not an issuance bypass. Fix by comparing the amount against grantLeft, checking grantsLeft, and distinguishing an excessive requested amount from exhaustion of the rolling amount/count limit.

**Reproduction**

Executed the supplied grantFormReady and grantError functions after Node’s built-in TypeScript stripping. With recipient 0xcccccccccccccccccccccccccccccccccccccccc, gold="2", checked=true, grantLeft="1", grantsLeft="96", a valid reason and signGrant present, grantFormReady returns true and the exact button disabled expression returns false. Separately applied the supplied 0024 migration to in-memory SQLite and inserted grants of 5000,5000,5000,4999 at T=1780000000000 (using the package’s masked allowed actor literal). The rolling total/count were (19999,4); inserting 2 more failed with GRANT_DAILY_LIMIT. The actual English error mapper returned "The 24-hour grant limit is reached (20,000 GOLD or 100 grants)." Expected: disable this amount or explain that only 1 GOLD remains. Full panel rendering and a real wallet signature were not used.

### 4. Info: The privacy page’s 30-day backup limit depends on unenforced manual deletion

`Submission26/source/integrations/celestao/web/world-wallet.js:93`

```
  [say('資料保存多久','How long data is kept'),say('未完成付款的出生資料：如果 World 沒有接受這筆結帳，會在結帳期限（15 分鐘）過後的下一次清理時刪除；如果 World 已接受但你沒有付款，會在期限過後保留至少 30 天，之後由系統清理。已付款的報告和出生資料會加密保存在玄曜伺服器上的錢包收藏；移到垃圾桶可以還原，目前沒有永久刪除的功能。如果付款後報告做不出來而退款，這筆訂單的出生資料會在判定失敗後保留至少 30 天，以便處理問題，之後由系統清理。這種情況和上面「World 已接受但你沒有付款」的情況，清理後，玄曜伺服器上只留下不含出生資料的結帳代碼和錢包雜湊值，用來辨認之後收到的付款通知，這兩項會一直保留。World 的訂單和退款紀錄另外保留，不會跟著清理；其中有一個由出生資料和玄曜伺服器的密鑰算出的雜湊值。只看這個值無法還原出生資料，但同時拿到這把密鑰和 World 紀錄的人，可以拿猜測的出生資料來比對。已清理的資料，備份檔裡最多再留 30 天。','Birth details from an unfinished payment: if World does not accept the checkout, they are deleted at the next cleanup after the checkout window (15 minutes) ends; if World accepted it but you did not pay, they are kept for at least 30 days after the window ends and then cleaned up by the system. Paid reports and birth details stay encrypted in your wallet collection on the Celestial Atlas server; records moved to trash can be restored, and permanent deletion is not available yet. If a report cannot be made after payment and the order is
```

The privacy text gives cleared data a maximum of 30 additional days in backups. Vault.backup creates a complete SQLite snapshot, but no supplied cleanup path expires, rotates or deletes those snapshots. Main-database pruning cannot remove encrypted birth details from an earlier backup. AUDIT_REQUEST sections 1 and 7 already acknowledge manual operator deletion; this is an informational, disclosed operational dependency rather than a newly discovered payment vulnerability. Fix by enforcing backup expiration operationally and checking it before release, or making the operator-deletion dependency explicit in the privacy text.

**Reproduction**

Executed actual Vault.backup with its destination filesystem calls redirected to an in-memory SQLite destination, so no repository or backup files were created. At T=1780000000000, prepare/admit a synthetic bazi run, bind a paid order, claim, fail with CHART_NOT_GENERATED, and call prune_failed(T) to start retention. Take the backup. prune_failed(T+30*86400000) removes the live row. After prune_failed(T+61*86400000), the live runs count is 0 but the backup runs count remains 1, and the backup input_blob still decrypts to the original input using the vault key. Expected from the stated upper bound: the snapshot containing cleared birth details is expired within 30 days of live deletion. Actual: the supplied code never expires it; operator deletion is required. Backup production procedures were not supplied or exercised.

---

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