User Flows and Call Details¶
What gets called behind the scenes when you press something on screen, and what the returned values mean. Each diagram's numbers map 1:1 to the table below it.
Features at a glance¶
| Feature | Screen | Key calls | Flow |
|---|---|---|---|
| Wallet sign-in | S1 | personal_sign → POST /v1/auth/verify |
Flow 1 |
| Three-way inbox routing | S2 | DojangScroll.isVerified + XMTP consent |
Flow 2 |
| 1:1 / group conversations | S3 S4 | createDmWithIdentifier / createGroupWithIdentifiers |
Flow 3 |
| On-chain evidence | S3 S5 S6 | POST /v1/evidence-requests → recordEvidence |
Flow 4 |
| Marking spam | S3 | updateConsentState(Denied) |
Flow 5 |
Cast¶
| Notation | What it is | Where it runs |
|---|---|---|
App |
React screens and the useUmulApp state machine |
Browser |
Wallet |
EIP-1193 wallet (GIWA Wallet / MetaMask / WalletConnect) | Browser extension or mobile |
XMTP |
XMTP Browser SDK + dev network | Browser WASM + remote |
API |
Fastify backend | localhost:3000 |
PG |
PostgreSQL 17 | Behind the backend |
Dojang |
DojangScroll predeploy |
GIWA Sepolia |
Registry |
ConversationEvidenceRegistry |
GIWA Sepolia |
One principle covers the rest. Message plaintext never leaves the browser.
What API and Registry see is only hashes and signatures.
Flow 1 — Wallet sign-in (S1 → S2)¶
sequenceDiagram
participant U as User
participant App
participant Wallet
participant API
participant PG
participant XMTP
participant Dojang
U->>App: 1. tap "Connect with GIWA Wallet"
App->>Wallet: 2. eth_requestAccounts
Wallet-->>App: 3. [address]
App->>Wallet: 4. eth_chainId
Wallet-->>App: 5. 0x164ce (91342)
App->>Wallet: 6. wallet_switchEthereumChain (if needed)
App->>API: 7. POST /v1/auth/nonce {address}
API->>PG: 8. INSERT wallet_login_nonces (nonce_hash)
API-->>App: 9. {message, expiresAt}
App->>Wallet: 10. personal_sign(message)
Wallet-->>App: 11. signature
App->>API: 12. POST /v1/auth/verify {message, signature}
API->>PG: 13. consume nonce + create session (atomic)
API-->>App: 14. 200 + Set-Cookie umul_session
App->>XMTP: 15. Client.create(signer)
XMTP-->>App: 16. inboxId, installationId
App->>Dojang: 17. isVerified(myAddress, TESTNET_FAUCET)
Dojang-->>App: 18. bool
App->>API: 19. GET /v1/evidence-requests
API-->>App: 20. {items: [...]}
App->>XMTP: 21. conversations.syncAll()
XMTP-->>App: 22. Conversation[]
App->>Dojang: 23. isVerified(peer) × N
Dojang-->>App: 24. bool × N
App-->>U: 25. conversation list (S2)
| # | Call | What comes back and why it matters |
|---|---|---|
| 1 | ConnectWallet.execute("giwa") |
Use case entry. The only argument is the wallet kind |
| 2-3 | eth_requestAccounts |
The account address the user approved. This address is the identity. No email or phone number is used |
| 4-5 | eth_chainId |
The current chain. 0x164ce = 91342 = GIWA Sepolia |
| 6 | wallet_switchEthereumChain |
Requests a switch if on another chain. A rejection aborts with chain_switch_rejected |
| 7 | POST /v1/auth/nonce |
Requests the login challenge |
| 8 | INSERT wallet_login_nonces |
Stores the SHA-256 hash, not the nonce itself. A breached DB leaks no signable challenge |
| 9 | {message, expiresAt} |
An EIP-4361 (SIWE) string containing domain, URI, chainId 91342, nonce, issuedAt, expirationTime. Expires in 5 minutes |
| 10-11 | personal_sign |
The wallet signs the message. The private key never enters the app |
| 12 | POST /v1/auth/verify |
Requests signature verification |
| 13 | Consume nonce + create session | Atomic in a single CTE. Concurrent requests with the same nonce succeed exactly once |
| 14 | Set-Cookie umul_session |
HttpOnly; SameSite=Lax. The token itself is not in the DB, only its SHA-256 hash. Valid for 7 days |
| 15-16 | Client.create(signer) |
Creates or recovers the XMTP identity. The 10-installation-per-inbox limit means repeated sign-ins from cleared storage exhaust the slots |
| 17-18 | DojangScroll.isVerified |
Whether my wallet is Dojang-verified. true shows a green "Dojang verified" pill in settings |
| 19-20 | GET /v1/evidence-requests |
Evidence requests where I am a participant. Reading this is how the app learns about proposals a peer created. A failure does not block sign-in |
| 21-22 | conversations.syncAll() |
Pulls network conversations into the local DB. Without it, a conversation the peer opened first never appears in the list |
| 23-24 | isVerified(peer) |
Verification status per peer. Results are cached per address for 1 hour. An RPC error is recorded as unknown, not unverified |
| 25 | — | Inbox routing complete. See Flow 2 below |
The three per-address states
| Value | Meaning | Inbox | Evidence |
|---|---|---|---|
verified |
Dojang attestation present | Main | Allowed |
unverified |
Lookup succeeded, no attestation | Request | Blocked |
unknown |
Could not confirm due to RPC failure | Request | Blocked |
unknown is never grouped with verified. Showing an unconfirmed peer as
though they were verified is by far the more dangerous error.
Flow 2 — Inbox routing (S2)¶
sequenceDiagram
participant XMTP
participant App
participant Dojang
participant U as User
XMTP-->>App: 1. Conversation[] (id, kind, members, consentState)
App->>App: 2. members → peerAddresses (excluding my inboxId)
App->>Dojang: 3. isVerified(peers) — cache misses only
Dojang-->>App: 4. Map<address, bool>
App->>App: 5. classifyConversation({kind, peers, spam})
App-->>U: 6. place into Main / Request / Spam tab
| # | Call | What comes back and why it matters |
|---|---|---|
| 1 | listConversations() |
The XMTP conversation list. consentState is the key field |
| 2 | — | Peer addresses from group members minus my inboxId. Only Ethereum identifiers are selected |
| 3-4 | GetVerificationStatus.execute(peers) |
Only addresses missing from the cache are actually queried. TTL 1 hour, reset on app refresh |
| 5 | classifyConversation |
Rules below |
| 6 | — | Placement per tab |
Routing rules, in priority order
- XMTP consent is
denied→ Spam inbox. The user set this explicitly, so it outranks Dojang - Every participant other than yourself is
verified→ Main inbox - Anything else (any
unverifiedorunknown) → Request inbox
A group with even one unverified member goes to the request inbox. Since this is the same criterion as evidence eligibility, "if it is in the main inbox you can evidence it" holds.
Flow 3 — New conversations and messaging (S4 → S3)¶
sequenceDiagram
participant U as User
participant App
participant XMTP
participant Peer as Peer app
U->>App: 1. enter address, "Start conversation"
App->>XMTP: 2. canMessage([address])
XMTP-->>App: 3. Map<address, bool>
App->>XMTP: 4. fetchDmByIdentifier / createDmWithIdentifier
XMTP-->>App: 5. Conversation {id}
U->>App: 6. open the conversation
App->>XMTP: 7. listMessages(conversationId)
XMTP-->>App: 8. Message[] (past history)
U->>App: 9. type and send a message
App->>XMTP: 10. conversation.sendText(text)
XMTP-->>App: 11. messageId
XMTP-->>Peer: 12. deliver MLS ciphertext
Peer->>Peer: 13. streamAllMessages onValue
XMTP-->>App: 14. echo of my message (same messageId)
| # | Call | What comes back and why it matters |
|---|---|---|
| 1 | — | Several addresses make a group; one makes a 1:1 |
| 2-3 | canMessage() |
Whether the peer has ever used XMTP. false raises UnreachablePeerError. Unrelated to Dojang verification — conversations open with unverified peers too |
| 4-5 | fetchDmByIdentifier, then createDmWithIdentifier if absent |
Reusing an existing DM comes first. Otherwise duplicate conversations pile up with the same peer |
| 7-8 | listMessages() |
The stream only yields messages after subscription. Past history is read from the local DB this way. A new installation cannot decrypt messages predating it |
| 10-11 | sendText() |
Returns the XMTP message ID. Evidence points at target messages by this ID |
| 12 | — | MLS group encryption. The server sees only ciphertext |
| 13 | streamAllMessages |
Reflected immediately on the recipient's screen |
| 14 | — | The sender receives an echo with the same ID. Without optimistic insert plus dedup by ID, the message appears twice |
Send failures are not swallowed. They are recorded as delivery: "failed"
with a retry button.
Flow 4 — On-chain evidence (S3 → S5 → S6)¶
The most involved flow. The proposer (A) and the signer (B) are different app instances.
sequenceDiagram
participant A as App A (proposer)
participant Dojang
participant API
participant PG
participant XMTP
participant B as App B (signer)
participant Registry
A->>Dojang: 1. isVerified(all participants) — bypass cache
Dojang-->>A: 2. bool × N
A->>A: 3. buildEvidenceManifest → contentHash
A->>API: 4. POST /v1/evidence-requests + Idempotency-Key
API->>PG: 5. INSERT evidence_requests + participants
API-->>A: 6. {id, status: PROPOSED, ...}
A->>XMTP: 7. sendText("gasok:evidence:v1:<base64url>")
XMTP-->>B: 8. control message received
A->>A: 9. toTypedData(request)
A->>API: 10. POST /:id/signatures {signature}
API->>PG: 11. SELECT FOR UPDATE → addSignature
API-->>A: 12. {status: SIGNING}
B->>API: 13. GET /v1/evidence-requests/:id (automatic on control message)
API-->>B: 14. {contentHash, participantsHash, ...}
B->>B: 15. select local messages by messageRefs → recompute hash
B->>API: 16. POST /:id/signatures {signature}
API->>PG: 17. everyone signed → status READY
API-->>B: 18. {status: READY}
B->>Registry: 19. simulateContract(recordEvidence)
Registry->>Dojang: 20. re-check isVerified(all participants)
B->>Registry: 21. writeContract(recordEvidence)
Registry-->>B: 22. txHash → receipt
B->>API: 23. POST /:id/submission {txHash, outcome, blockNumber}
API->>PG: 24. status CONFIRMED
| # | Call | What comes back and why it matters |
|---|---|---|
| 1-2 | GetVerificationStatus.execute(peers, {bypassCache: true}) |
The cache must be bypassed. Opening evidence off an hour-old cache would miss verification revoked in the meantime. If even one is not verified, it stops here without touching the API or XMTP |
| 3 | buildEvidenceManifest |
Sorts messages by (sentAtNs, messageId) and keccak256-hashes each plaintext. contentHash is the hash of the ABI-encoded version, conversation hash, participants hash, message hashes, count, and nonce. No plaintext remains in the manifest |
| 4 | POST /v1/evidence-requests |
Idempotency-Key header required. A repeat with the same key returns the existing request rather than creating one |
| 5 | INSERT |
Stores only hashes, addresses, and signatures. There is no message-plaintext column at all |
| 6 | {id, status: PROPOSED} |
Nobody has signed yet |
| 7-8 | gasok:evidence:v1:<base64url-json> |
A versioned text envelope. The payload carries requestId, contentHash, nonce, and messageRefs (the target message IDs). The ordinary chat UI hides this prefix and renders an evidence card |
| 9 | toTypedData(request) |
The EIP-712 struct. deadline derives from the request's expiresAt. If signers each picked their own value, the digests would diverge and the contract would reject every signature |
| 10-12 | POST /:id/signatures |
The signer is determined by the session address, not the request body. Nobody can submit someone else's signature |
| 13-14 | GET /v1/evidence-requests/:id |
Called automatically as soon as the stream handler decodes the control message, because the control message carries only hashes while the signable original lives in the backend. Non-participants get a 404 that hides existence. At sign-in, GET /v1/evidence-requests fetches existing requests in one go |
| 15 | Select by messageRefs, then rehash |
The crux. Hashing every message piled up locally would produce a different set from A's and raise EVIDENCE_CONTENT_HASH_MISMATCH. Only the messages the proposal points at are hashed, and on a mismatch the app does not even request a wallet signature |
| 16-18 | Everyone signed | The row is locked with SELECT FOR UPDATE. Two participants signing simultaneously still yield one final READY |
| 19-20 | simulateContract |
Simulation before the real send. Contract custom errors (ParticipantNotVerified and friends) come back verbatim without wasting gas. The contract re-queries Dojang — verification revoked after signing is caught here |
| 21-22 | writeContract → waitForTransactionReceipt |
Participants and signatures are passed in ascending address order. The contract checks ordering, duplicates, and participants-hash parity |
| 23-24 | POST /:id/submission |
Records the tx hash and block number. With outcome: "failed" the request becomes FAILED while keeping signatures, so it can be resubmitted |
Evidence state transitions
PROPOSED → SIGNING → READY → SUBMITTED → CONFIRMED
| | | |
+--→ REJECTED +--→ FAILED (resubmittable)
+--→ EXPIRED
The order the contract checks (recordEvidence)
- At least two participants, signature count matching
- Hashes non-zero, time range sane,
deadlinenot passed - Participant addresses ascending, no duplicates
participantsHash == keccak256(abi.encode(participants))evidenceIdunused (replay protection)DojangScroll.isVerifiedfor every participant- Each signature recovers its address from the same typed data digest
evidenceId = keccak256(abi.encode(digest, nonce)). Recording the same
conversation twice requires a different nonce.
Flow 5 — Marking spam (S3)¶
sequenceDiagram
participant U as User
participant App
participant XMTP
U->>App: 1. "Mark as spam"
App->>XMTP: 2. conversation.updateConsentState(Denied)
XMTP-->>App: 3. ok
App->>XMTP: 4. listConversations()
XMTP-->>App: 5. consentState: Denied
App-->>U: 6. move to the Spam tab
| # | Call | What comes back and why it matters |
|---|---|---|
| 2 | setConversationConsent(id, "denied") |
Nothing is stored on a separate server. XMTP consent is itself the store, so it syncs across devices |
| 3 | — | A failure surfaces as CONVERSATION_CONSENT_UPDATE_FAILED. It never quietly pretends to succeed |
| 5-6 | — | Spam marking outranks Dojang status. Even a verified peer goes to the spam inbox. Unmarking takes the same path |
Known limits¶
Only what has been confirmed.
| Limit | Impact | Workaround |
|---|---|---|
| Refreshing returns you to onboarding | The session cookie is still valid but app state lives only in memory | Reconnect the wallet. Session restoration is P1 |
| Peer signatures are detected by 5-second polling | The evidence screen reflects a peer's signature up to 5 seconds late | Announcing the signature over an XMTP control message would remove this |
| No evidence rejection UI | The backend and domain exist, but there is no button | Rejection is substituted by expiry (1 hour) |
| XMTP 10-installation limit | Repeated sign-ins from cleared storage exhaust slots and Client.create hangs |
Revoke with e2e/revoke-installations.spec.ts |
| Session cookie is not sent when the page and API hosts differ | SameSite=Lax makes every evidence API call 401 in a localhost ↔ 127.0.0.1 mix |
Align on the same host |
| A new installation cannot read messages predating it | Signing in on another device shows an empty history | Expected given MLS. See Message History |
Where data lives¶
| Data | Where | Plaintext? |
|---|---|---|
| Message plaintext | Browser XMTP local DB (OPFS) | Plaintext |
| Message ciphertext | XMTP network | Not decryptable |
| Session token | Browser cookie / SHA-256 in the DB | Hash only |
| Login nonce | SHA-256 in the DB | Hash only |
| Evidence hashes and signatures | PostgreSQL + GIWA Sepolia | Hash only |
| Dojang verification status | GIWA Sepolia (read-only) | Public |