System Design¶
Umul is a mobile-shaped web messenger that uses a wallet address as identity. Messaging runs on the XMTP dev network; verification lookups and conversation evidence run on GIWA Sepolia.

The app encrypts and decrypts messages with a per-device XMTP installation key and a local MLS DB, while the server handles only login sessions and evidence state (Auth / Evidence). The editable source is architecture.drawio.
Components¶
| Component | Responsibility | Access to message plaintext |
|---|---|---|
frontend |
Wallet connection, XMTP E2EE messaging, Dojang lookups, evidence manifest creation and signing | Yes |
backend |
Login nonces and sessions, evidence request state and signature collection | No |
contracts |
EIP-712 multi-signature verification, Dojang re-check, evidence hash recording | No |
The frontend is the only component that touches message plaintext. The backend and the contract receive only hashes and signatures.
Layer boundaries¶
The frontend and the backend both use the same four layers.
domain: address normalization, inbox routing, evidence manifest rules, evidence state transitionsapplication: use cases and port interfacesinfrastructure: XMTP, wallet, RPC, PostgreSQL, and HTTP adapterspresentation/interfaces: screens and HTTP routes
presentation never calls an SDK directly; it uses only the ports from
application. Implementations are injected at app startup in
src/app/runtime/UmulRuntime.ts.
Runtime assembly¶
readConfig() -> createShell() --wallet sign-in--> createSession()
│ │
├─ WagmiWalletAdapter ├─ XmtpMessagingAdapter
├─ HttpSessionAdapter ├─ ViemEvidenceContractAdapter
├─ ViemDojangAdapter └─ messaging / evidence use cases
└─ HttpEvidenceAdapter
createShell holds only the dependencies that can be built before sign-in.
The XMTP client and the evidence contract adapter are built in
createSession, once the wallet address is settled.
Without VITE_EVIDENCE_CONTRACT_ADDRESS, only the evidence feature is
disabled; messaging still works.
Trust and routing¶
The result of DojangScroll.isVerified(address, attesterId) is cached per
address for one hour. An RPC error is recorded as unknown rather than
unverified, so that in an unconfirmable state messaging is still allowed and
only evidence is blocked.
Inbox routing is decided in exactly one place,
domain/inbox/classifyConversation.ts.
| Condition | Routing |
|---|---|
User marked it spam (XMTP consent denied) |
Spam inbox |
Every participant other than yourself is verified |
Main inbox |
Anything else (including unverified or unknown) |
Request inbox |
Evidence flow¶
[proposer] select messages -> build manifest -> re-query Dojang (bypass cache)
-> POST /v1/evidence-requests only if everyone is verified
-> encrypt plaintext and POST .../archive (the content key never reaches the server)
-> send XMTP control message gasok:evidence:v1:<base64url-json> (key included)
[participant] receive control message -> recompute content hash from local messages
-> sign under EIP-712 only on a match -> POST .../signatures
-> POST .../archive/keys with the restore key wrapped by a wallet-signature-derived key
[everyone signed] state READY -> recordEvidence transaction -> POST .../submission
[restore] a wallet that signed calls GET .../archive -> decrypt with a wallet signature
-> display only after re-verifying the content hash (see [Message History](message-history.md))
The contract re-queries Dojang at submission time. If verification is revoked after signing, the transaction is rejected even though the frontend's pre-check passed.