Skip to content

Conversation Evidence Contract Guide

This document explains what Umul's conversation evidence is, why it was built this way, and how it actually works. It uses values from a real transaction so that someone seeing the contract for the first time can read straight through and verify it themselves.

A terse reference is in Evidence Contract.

Contents

  1. What problem this solves
  2. Design goals
  3. Core concepts
  4. Building contentHash
  5. Collecting signatures
  6. Reading the contract
  7. Dissecting a real transaction
  8. Verify it yourself
  9. What is and is not proven
  10. FAQ

1. What problem this solves

Making arrangements over a messenger is ordinary. You settle contract terms or an amount in conversation, and then, if the other side later says "I never said that," there is no good way to prove otherwise. Screenshots can be forged, and logs held by a service operator require trusting the operator.

Umul solves it like this. Every participant signs, with their own wallet, "this is what we said," and those signatures are left on a blockchain. After that, nobody can alter the contents or deny having signed.

The catch is privacy. Putting conversation plaintext on a public chain lets the entire world read it permanently, which throws away the point of end-to-end encryption. So only a hash goes up, not the plaintext. The rest of this document is about how.

2. Design goals

Goal How it is achieved
Plaintext stays private Only hashes go on chain. Plaintext lives solely in participants' XMTP local storage
Tamper-evident Changing a single character changes the hash entirely
Non-repudiable Each participant signs under EIP-712 with their private key
No unilateral recording Nothing is recorded until every participant's signature is collected
Identity checked The contract re-queries Dojang verification at record time
No reuse The same evidence cannot be recorded twice

What is not a goal is worth stating too. The contract does not independently verify that the conversation actually took place. What the evidence says is that every participant signed the same content. This is the same character as a paper contract.

3. Core concepts

These four carry the rest.

A hash is a computation that turns text of any length into a short value of fixed length. Umul uses keccak256, widely used in Ethereum. Two properties matter: you cannot recover the original text from the result, and producing the same value from different text is effectively impossible. Just as fingerprints differ per person, hashes differ per content. That is why a hash serves as content's fingerprint.

A commitment is fixing "I hold this content" without revealing it. Publishing the hash lets you later present the plaintext and check that it produces the same hash. It is like sealing an answer in an envelope. Opening it later proves you settled on that answer in advance.

An EIP-712 signature is the Ethereum standard for signing a human-readable struct. A signature can only be produced with the wallet's private key, so it works like a seal that cannot be forged — and what it was stamped on is bound into the value as well. EIP-712 lets the wallet display "what you are signing" field by field, and the signature is bound to a chain ID and contract address so it cannot be reused elsewhere.

Dojang is GIWA's on-chain identity verification. You can query which addresses a given issuing authority (attester) has verified. Umul allows evidence only among verified participants.

4. Building contentHash

The heart of evidence. Here is, step by step, how conversation content compresses into a single 32-byte value with no plaintext. The implementation is in frontend/src/domain/evidence/hashEvidenceManifest.ts and runs entirely inside the user's browser.

The example is a two-sentence conversation.

A: Let's make the deposit 5 million won
B: Agreed

4.1 Step 1 — hash each message

A message is split into four pieces and hashed together.

messageHash = keccak256(abi.encode(
    keccak256(messageId),       // the message ID XMTP issued
    keccak256(senderInboxId),   // the sender's XMTP inbox
    sentAtNs,                   // send time in nanoseconds
    keccak256(text)             // the body
))

The body text becomes a hash at this point and never appears as plaintext again — not in the backend request, not in the XMTP control message, not in the transaction.

Feeding in the first sentence yields:

keccak256("계약금은 500만원으로 하시죠")
  = 0x0a96a630be14f15fab3d255d9f989732ab1b6534e0aeec6364a3a97179c20618

4.2 Step 2 — sort, then hash the whole array

Message hashes are sorted by (sentAtNs, messageId) and hashed in one go.

messagesHash = keccak256(abi.encode(bytes32[] sorted_message_hashes))

The sort matters. It makes two participants produce the same value even when they hold the messages in different orders.

4.3 Step 3 — the manifest hash

Finally the conversation's whole context is bound together.

contentHash = keccak256(abi.encode(
    keccak256("gasok-evidence-v1"),  // hash rule version
    conversationIdHash,              // which conversation
    participantsHash,                // who took part
    messagesHash,                    // the step 2 result
    messageCount,                    // how many
    nonce                            // a value unique to this evidence
))

The example conversation produces:

contentHash = 0x208fa2692ca1afd4c9380602971e95a8f574915da9ca96dbc7e2b3e16b4b5a2e

4.4 Checking the properties

Results of running the same code with varied conditions.

Condition contentHash
Original 0x208fa269...4b5a2e
500만원 → 600만원 0xa33dd9fa...8d1edf
Input order reversed 0x208fa269...4b5a2e (identical)

One changed character produces an entirely different value with not even partial similarity. Identical content, meanwhile, produces the same value regardless of input order.

The nonce is a fresh random value drawn per evidence — a single-use ticket number, so to speak. A short conversation has few possible contents, which would allow precomputing every hash and matching against them; mixing in a different ticket number each time defeats that.

5. Collecting signatures

This is where the "promise" is formed. A hash alone fixes the content; it does not create agreement.

5.1 The proposer

When the proposer selects the messages to evidence, the app computes the contentHash, creates an evidence request in the backend, and sends a proposal message over the XMTP conversation. The proposal carries the list of IDs (messageRefs) identifying the target messages. It carries no plaintext.

5.2 The signer

The crux is that the signer does not simply trust the proposer's hash.

1. Pull the messages from local XMTP storage using the proposal's messageRefs
2. Recompute contentHash from scratch on that copy
3. If it differs from the proposer's value → do not even request a wallet signature
4. Only when it matches → sign under EIP-712

So two signatures being on chain means that at signing time the two people's records matched byte for byte. A proposal where one side quietly altered the content never collects the other's signature.

5.3 What gets signed

Each party signs this struct.

struct Evidence {
    bytes32 conversationHash;   // which conversation
    bytes32 contentHash;        // the result of §4
    bytes32 participantsHash;   // the participant set
    uint32  messageCount;       // number of messages
    uint64  startedAt;          // first message time
    uint64  endedAt;            // last message time
    bytes32 nonce;              // unique random value
    uint64  deadline;           // signature validity deadline
}

deadline is the signature's expiry. If participants each set a different one, they sign different objects and the contract rejects every signature. So a single value, the backend request's expiresAt, is used in common. We hit this problem in practice and fixed it.

6. Reading the contract

The entry point in contracts/src/ConversationEvidenceRegistry.sol.

function recordEvidence(
    Evidence calldata evidence,
    address[] calldata participants,
    bytes[] calldata signatures
) external returns (bytes32 evidenceId)
{
    _validateShape(evidence, participants, signatures);              // ①
    if (evidence.participantsHash != hashParticipants(participants))
        revert ParticipantsHashMismatch(...);                        // ②
    bytes32 digest = hashEvidence(evidence);                         // ③
    evidenceId = keccak256(abi.encode(digest, evidence.nonce));      // ④
    if (evidenceExists[evidenceId]) revert EvidenceAlreadyRecorded(...);
    for (uint256 i; i < participants.length; ++i) {
        if (!dojangScroll.isVerified(participants[i], attesterId))
            revert ParticipantNotVerified(participants[i]);          // ⑤
        if (_recover(digest, signatures[i]) != participants[i])
            revert InvalidSignature(participants[i]);                // ⑥
    }
    evidenceExists[evidenceId] = true;
    _storeAndEmit(evidenceId, evidence, participants);               // ⑦
}

① Shape checks, in order.

  • At least two participants, and the signature count equals the participant count
  • conversationHash and contentHash are non-zero
  • messageCount is non-zero
  • startedAt <= endedAt and deadline has not passed
  • nonce is non-zero
  • The participant array is strictly ascending. The zero address and duplicates do not get through

Ordering is enforced so that the same participant set always produces the same participantsHash. participantsHash itself is not zero-checked here; the very next step recomputes it from the actual array and compares, so a zero would be caught there.

② Participant set comparison. hashParticipants is keccak256(abi.encode(participants)). It confirms that the "declared set" the signers signed matches the actual array now submitted. The frontend TypeScript and the contract must use byte-identical encoding, and this was measured against the deployment.

③ The EIP-712 digest.

structHash = keccak256(abi.encode(EVIDENCE_TYPEHASH, ...8 fields));
digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator(), structHash));

The digest is the final summary that presses everything being signed into one value, and it is what participants actually sign. domainSeparator() includes block.chainid and address(this). This signature is valid only on chain 91342 and only on this deployment. Redeploying invalidates every existing signature.

④ evidenceId derivation and replay protection. The digest already contains the nonce, but it is fed into the ID derivation once more to make the boundary explicit. Resubmitting the same evidence is blocked here.

⑤ Dojang re-check. For each participant, an external call re-queries verification at submission time. Even if the app pre-checked, verification revoked or expired while signatures were being collected is rejected here. The lookup target and the attester are fixed as immutables in the constructor.

⑥ Signature recovery. Anything other than 65 bytes fails. It also fails if s exceeds the secp256k1 half-curve bound or v is neither 27 nor 28, which prevents signature malleability. ecrecover is the built-in operation that recovers the signing wallet's address from a signature value, and the result must match that participant's address exactly. Since the participant array is sorted, the signatures must be passed in the same order.

⑦ Storage and event. It writes the following to EvidenceRecord and emits an EvidenceRecorded event with the same values.

struct EvidenceRecord {
    bytes32 evidenceId;
    bytes32 contentHash;
    bytes32 conversationHash;
    bytes32 participantsHash;
    address[] participants;
    uint32 messageCount;
    uint64 startedAt;
    uint64 endedAt;
    uint64 recordedAt;   // block.timestamp
    address submitter;   // msg.sender
}

All hashes and metadata. The signatures themselves are not written to storage. Having passed verification is itself the record, and the original signatures remain permanently in the block as transaction input (calldata). The event emits evidenceId, conversationHash, and contentHash as indexed fields so that an indexer — a program that collects and searches chain records — can find them per conversation.

7. Dissecting a real transaction

A real piece of evidence recorded on GIWA Sepolia.

transaction 0x677de178e5d99fdb38429206a2d8f50ba2a248e6c990b72df8e2ae3b6ae436ed
block       31766627
gas         383,998

Gas is the fee unit paid to ask the chain to record something. Decoding the input (calldata) carried by the transaction gives:

Field Value
conversationHash 0xaafb4be1...f292
contentHash 0xbd756882...b618
participantsHash 0x4f68b5db...286e
messageCount 2
startedAt 1785111682
endedAt 1785111712
nonce 0xf4f7f08e...21d2
deadline 1785115342
participants [0x532dA989...7778, 0xEf792c49...01f6]
signatures Two 65-byte signatures

What it contains is five hashes, four numbers, two addresses, and two signatures — that is all. Not one character of conversation plaintext. You can also see the participant array is ascending.

Calldata size and gas are constant regardless of message count. Evidencing 2 messages or 200 costs the same on chain.

8. Verify it yourself

Using only the calldata values above, you can reproduce off chain exactly what the contract verifies.

8.1 Recompute the digest

STRUCT=$(cast keccak $(cast abi-encode \
  'f(bytes32,bytes32,bytes32,bytes32,uint32,uint64,uint64,bytes32,uint64)' \
  $TYPEHASH $CONV $CONTENT $PARTS 2 1785111682 1785111712 $NONCE 1785115342))
# 0xde7000f6ced6a7854fb3c09a64edcc7065200d42462bb7c2aafbc7fb25628c2a

DIGEST=$(cast keccak "0x1901${DOMAIN:2}${STRUCT:2}")
# 0xc177aa98020390589dd6ae156cb7a407539196552b89d78b21cdb95a0858d200

8.2 Derive evidenceId and read the record

EVID=$(cast keccak $(cast abi-encode 'f(bytes32,bytes32)' $DIGEST $NONCE))
# 0xd6ed80cf1491136e8dcf21cb10a65e41ddc55e1850c42ef1d088f52898192ba3

cast call 0x675dB91aCEC52DCb44e6d20FB1F1299FFafBb51B \
  "getEvidence(bytes32)(...)" $EVID --rpc-url https://sepolia-rpc.giwa.io

The on-chain record comes back as is. recordedAt is 1785111743 and the submitter is A.

8.3 Recover the signers

await recoverAddress({ hash: digest, signature: signatures[0] });
// 0x532dA989489eaB825d484EAd09f117018da47778  → matches participants[0]
await recoverAddress({ hash: digest, signature: signatures[1] });
// 0xEf792c4973Cc8ef6aF433B3bd9f4461B701301f6  → matches participants[1]

Both addresses recover exactly. Since only the private key holder could have produced those values, having signed cannot be denied.

8.4 Procedure in a dispute

Whoever kept the plaintext proves the content by doing the following.

  1. Present the retained messages and recompute contentHash under the gasok-evidence-v1 rules
  2. Compare against the on-chain contentHash via getEvidence
  3. Recover the participant addresses from the signatures in calldata
  4. Confirm the time of existence from the block timestamp

If the other side says "I never said that," their wallet's signature is attached to the hash of that content. If they present a doctored conversation, its hash does not match the on-chain value.

9. What is and is not proven

Proven Not proven
Every participant signed this content Lose the plaintext and you lose the means to prove content. Retention is the participants' responsibility
The content has not been altered since signing The contract does not independently verify that the conversation actually happened over XMTP
It existed no later than the block time It cannot stop participants from colluding to sign a fabricated conversation
Every signer was Dojang-verified at that time Conversation context outside the selected range

The third item is a property of every contract. Content that both parties agreed to is precisely what evidence means.

On the fourth: because messageCount and the time range are recorded together, the fact that "only 2 of 12 were selected" is itself visible.

10. FAQ

Is there an option to put the conversation plaintext on chain?

No. Screen flow S5-b pins down "plaintext is never exposed outside the app," and the design document specifies that neither the contract nor the backend stores plaintext. Publishing plaintext would make E2EE meaningless and could not be undone.

How many messages can be evidenced?

The contract ceiling is the uint32 maximum, roughly 4.3 billion. On-chain cost is independent of the count. The practical constraint is the size of the control message that tells signers what the targets are. Because it carries every message ID, 1,000 messages is about 88 KB and 10,000 about 873 KB. The hashing itself handles 50,000 in under a second.

What if verification expires after signing?

Submission is rejected, because the contract re-queries Dojang at record time. Testnet faucet attestations expire 30 days after issuance.

Do smart account wallets work?

P0 supports only EOA signatures — ordinary wallets driven by a single private key. Contract-based smart accounts (EIP-1271) are deferred to P1.

How do I change the attester?

It is fixed as an immutable in the constructor, so it requires redeployment. Viewing against UPBIT_KOREA instead of TESTNET_FAUCET, for instance, needs a new deployment.