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¶
- What problem this solves
- Design goals
- Core concepts
- Building contentHash
- Collecting signatures
- Reading the contract
- Dissecting a real transaction
- Verify it yourself
- What is and is not proven
- 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
conversationHashandcontentHashare non-zeromessageCountis non-zerostartedAt <= endedAtanddeadlinehas not passednonceis 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.
- Present the retained messages and recompute contentHash under the
gasok-evidence-v1rules - Compare against the on-chain contentHash via
getEvidence - Recover the participant addresses from the signatures in calldata
- 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.