Skip to content

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

  1. XMTP consent is denied → Spam inbox. The user set this explicitly, so it outranks Dojang
  2. Every participant other than yourself is verified → Main inbox
  3. Anything else (any unverified or unknown) → 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)

  1. At least two participants, signature count matching
  2. Hashes non-zero, time range sane, deadline not passed
  3. Participant addresses ascending, no duplicates
  4. participantsHash == keccak256(abi.encode(participants))
  5. evidenceId unused (replay protection)
  6. DojangScroll.isVerified for every participant
  7. 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