대화 증빙 컨트랙트 가이드¶
이 문서는 Umul의 대화 증빙이 무엇이고, 왜 이렇게 만들었으며, 실제로 어떻게 동작하는지 설명합니다. 컨트랙트를 처음 보는 사람이 순서대로 읽고 직접 검증까지 해볼 수 있도록 실제 트랜잭션 값을 예시로 씁니다.
간결한 레퍼런스는 증빙 컨트랙트에 있습니다.
목차¶
- 무엇을 푸는 문제인가
- 설계 목표
- 핵심 개념
- contentHash 만들기
- 서명 모으기
- 컨트랙트 코드 읽기
- 실제 트랜잭션 해부
- 직접 검증하기
- 증명되는 것과 안 되는 것
- 자주 묻는 질문
1. 무엇을 푸는 문제인가¶
메신저로 약속을 잡는 일은 흔합니다. 계약 조건이나 금액을 대화로 정해 놓고도, 나중에 상대가 "그런 말 한 적 없다"고 하면 증명할 방법이 마땅치 않습니다. 스크린샷은 위조할 수 있고, 서비스 운영자가 보관한 로그는 운영자를 믿어야 합니다.
Umul은 이 문제를 이렇게 풉니다. 대화 참여자 전원이 "우리가 나눈 이 내용이 맞다"고 각자 자기 지갑으로 서명하고, 그 서명들을 블록체인에 남깁니다. 이후에는 누구도 내용을 바꾸거나 서명한 사실을 부인할 수 없습니다.
문제는 프라이버시입니다. 대화 원문을 퍼블릭 체인에 올리면 전 세계가 영구히 읽을 수 있게 되고, 종단간 암호화로 지킨 의미가 사라집니다. 그래서 원문 대신 해시만 올립니다. 이 문서의 나머지는 그 방법을 다룹니다.
2. 설계 목표¶
| 목표 | 어떻게 달성하는가 |
|---|---|
| 원문 비공개 | 체인에는 해시만 올립니다. 원문은 참여자의 XMTP 로컬 저장소에만 있습니다 |
| 변조 불가 | 내용이 한 글자만 달라져도 해시가 완전히 달라집니다 |
| 부인 불가 | 각 참여자가 개인키로 EIP-712 서명합니다 |
| 일방적 기록 방지 | 참여자 전원의 서명이 모여야 기록됩니다 |
| 신원 확인 | 기록 시점에 컨트랙트가 Dojang 인증을 다시 조회합니다 |
| 재사용 방지 | 같은 증빙을 두 번 기록할 수 없습니다 |
목표가 아닌 것도 적습니다. 대화가 실제로 오갔다는 사실 자체를 컨트랙트가 독립 검증하지는 않습니다. 참여자 전원이 같은 내용에 서명했다는 것이 증빙의 내용입니다. 이는 종이 계약서와 같은 성격입니다.
3. 핵심 개념¶
이 네 가지만 알면 나머지가 따라옵니다.
해시(hash)는 아무리 긴 글이든 항상 같은 길이의 짧은 값으로 바꾸는 계산입니다. Umul은 이더리움에서 널리 쓰는 keccak256이라는 해시 함수를 씁니다. 성질 두 개가 중요합니다. 결과값에서 원래 글을 되돌릴 수 없고, 다른 글로 같은 값을 만들어내는 것도 사실상 불가능합니다. 사람마다 지문이 다르듯 내용마다 해시가 다릅니다. 그래서 해시는 내용의 지문 역할을 합니다.
약정(commitment)은 내용을 밝히지 않은 채 "나는 이 내용을 갖고 있다"고 확정하는 것입니다. 해시를 공개하면 나중에 원문을 제시해 그 해시가 나오는지 대조할 수 있습니다. 답을 적어 봉인한 봉투와 같습니다. 나중에 봉투를 열어 보이면 그 답을 미리 정해 두었다는 사실이 증명됩니다.
EIP-712 서명은 사람이 읽을 수 있는 구조체에 서명하는 이더리움 표준입니다. 서명은 지갑의 개인키로만 만들 수 있어서 위조할 수 없는 도장과 비슷한데, 어떤 내용에 찍었는지까지 값에 함께 묶입니다. EIP-712 를 쓰면 지갑이 "무엇에 서명하는지"를 항목별로 보여줄 수 있고, 서명에 체인 ID와 컨트랙트 주소가 묶여 다른 곳에 재사용할 수 없습니다.
Dojang은 GIWA의 온체인 신원 인증입니다. 특정 발행 기관(attester)이 어떤 주소를 인증했는지 조회할 수 있습니다. Umul은 인증된 참여자끼리만 증빙을 남기게 합니다.
4. contentHash 만들기¶
증빙의 심장입니다. 대화 내용이 원문 없이 하나의 32바이트 값으로 압축되는 과정을
단계별로 봅니다. 구현은 frontend/src/domain/evidence/hashEvidenceManifest.ts에
있고, 전부 사용자 브라우저 안에서 실행됩니다.
예시로 두 문장짜리 대화를 씁니다.
A: 계약금은 500만원으로 하시죠
B: 동의합니다
4.1 1단계 - 메시지마다 해시¶
메시지 하나를 네 조각으로 나눠 함께 해싱합니다.
messageHash = keccak256(abi.encode(
keccak256(messageId), // XMTP가 발급한 메시지 ID
keccak256(senderInboxId), // 보낸 사람의 XMTP inbox
sentAtNs, // 나노초 단위 전송 시각
keccak256(text) // 본문
))
본문 text는 이 시점에 해시로 바뀌고, 이후 어디에도 원문으로 남지 않습니다.
백엔드 요청에도, XMTP 제어 메시지에도, 트랜잭션에도 들어가지 않습니다.
첫 문장을 넣으면 이런 값이 나옵니다.
keccak256("계약금은 500만원으로 하시죠")
= 0x0a96a630be14f15fab3d255d9f989732ab1b6534e0aeec6364a3a97179c20618
4.2 2단계 - 정렬 후 배열째 해시¶
메시지 해시들을 (sentAtNs, messageId) 순으로 정렬하고 한 번에 해싱합니다.
messagesHash = keccak256(abi.encode(bytes32[] 정렬된_메시지_해시들))
정렬이 중요합니다. 두 참여자가 메시지를 서로 다른 순서로 갖고 있어도 같은 값이 나오게 합니다.
4.3 3단계 - manifest 해시¶
마지막으로 대화 전체의 맥락을 함께 묶습니다.
contentHash = keccak256(abi.encode(
keccak256("gasok-evidence-v1"), // 해시 규칙 버전
conversationIdHash, // 어느 대화인지
participantsHash, // 누가 참여했는지
messagesHash, // 2단계 결과
messageCount, // 몇 개인지
nonce // 이 증빙 고유 난수
))
예시 대화를 넣으면 이 값이 나옵니다.
contentHash = 0x208fa2692ca1afd4c9380602971e95a8f574915da9ca96dbc7e2b3e16b4b5a2e
4.4 성질 확인¶
같은 코드로 조건을 바꿔가며 실행한 결과입니다.
| 조건 | contentHash |
|---|---|
| 원본 | 0x208fa269...4b5a2e |
500만원 → 600만원 |
0xa33dd9fa...8d1edf |
| 입력 순서 뒤집기 | 0x208fa269...4b5a2e (동일) |
한 글자만 바뀌어도 전혀 다른 값이 되고, 부분적 유사성조차 남지 않습니다. 반면 같은 내용이면 입력 순서와 무관하게 같은 값이 나옵니다.
nonce는 증빙을 만들 때마다 새로 뽑는 난수, 말하자면 일회용 번호표입니다. 짧은
대화는 나올 수 있는 내용이 몇 가지 안 되어 해시를 미리 다 만들어 두고 대조하는
공격이 가능한데, 매번 다른 번호표가 섞이면 그 방법이 통하지 않습니다.
5. 서명 모으기¶
여기가 "약속"이 성립하는 지점입니다. 해시 자체는 내용을 고정할 뿐, 동의를 만들지는 못합니다.
5.1 제안자¶
제안자가 증빙할 메시지를 고르면 앱이 contentHash를 계산하고, 백엔드에 증빙
요청을 만든 뒤, XMTP 대화로 제안 메시지를 보냅니다. 제안에는 어떤 메시지가
대상인지 알려주는 ID 목록(messageRefs)이 들어갑니다. 원문은 들어가지 않습니다.
5.2 서명자¶
핵심은 서명자가 제안자의 해시를 그냥 믿지 않는다는 것입니다.
1. 제안이 지목한 messageRefs 로 자기 로컬 XMTP 저장소에서 메시지를 꺼낸다
2. 그 사본으로 contentHash 를 처음부터 다시 계산한다
3. 제안자가 준 값과 다르면 → 지갑 서명 요청조차 하지 않는다
4. 같을 때만 → EIP-712 서명한다
그래서 온체인에 서명 두 개가 있다는 것은 서명 시점에 두 사람의 기록이 바이트 단위로 일치했다는 뜻이 됩니다. 한쪽이 몰래 내용을 바꾼 제안에는 상대 서명이 모이지 않습니다.
5.3 서명 대상¶
각자 다음 구조체에 서명합니다.
struct Evidence {
bytes32 conversationHash; // 어느 대화
bytes32 contentHash; // 4장의 결과
bytes32 participantsHash; // 참여자 집합
uint32 messageCount; // 메시지 개수
uint64 startedAt; // 첫 메시지 시각
uint64 endedAt; // 마지막 메시지 시각
bytes32 nonce; // 고유 난수
uint64 deadline; // 서명 유효 기한
}
deadline은 서명의 유효기한입니다. 참여자마다 다르게 정하면 서명한 대상이 서로
달라져 컨트랙트가 서명을 전부 거부합니다. 그래서 백엔드 요청의 expiresAt 하나를 공통으로 씁니다. 실제로
이 문제를 겪고 고친 이력이 있습니다.
6. 컨트랙트 코드 읽기¶
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); // ⑦
}
① 형태 검사. 순서대로 확인합니다.
- 참여자가 2명 이상이고 서명 개수가 참여자 수와 같다
conversationHash와contentHash가 0이 아니다messageCount가 0이 아니다startedAt <= endedAt이고deadline이 지나지 않았다nonce가 0이 아니다- 참여자 배열이 엄격한 오름차순입니다. 0 주소와 중복은 통과하지 못한다
정렬을 강제하는 이유는 같은 참여자 집합이 항상 같은 participantsHash를 만들게
하기 위해서입니다. participantsHash 자체는 여기서 0 검사를 하지 않습니다. 바로 다음
단계에서 실제 배열로 다시 계산해 대조하므로 0이면 그때 걸립니다.
② 참여자 집합 대조. hashParticipants는
keccak256(abi.encode(participants))다. 서명자들이 서명한 "선언된 집합"과 지금
제출된 실제 배열이 같은지 확인합니다. 프론트엔드 TypeScript와 컨트랙트가 바이트
단위로 같은 인코딩을 써야 하며, 배포본으로 실측 확인했습니다.
③ EIP-712 digest.
structHash = keccak256(abi.encode(EVIDENCE_TYPEHASH, ...8개 필드));
digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator(), structHash));
digest는 서명할 내용 전체를 한 값으로 눌러 담은 최종 요약값이고, 참여자들이
실제로 서명하는 값입니다. domainSeparator()에 block.chainid와 address(this)가
들어갑니다. 이 서명은
체인 91342의 이 배포본에서만 유효합니다. 재배포하면 기존 서명은 전부 무효가 됩니다.
④ evidenceId 파생과 리플레이 차단. digest가 이미 nonce를 포함하지만 ID 파생에 한 번 더 넣어 경계를 명시합니다. 같은 증빙의 재제출은 여기서 막힙니다.
⑤ Dojang 재확인. 참여자마다 제출 시점에 외부 호출로 인증을 다시 조회합니다. 앱이 사전 검사를 했더라도, 서명을 모으는 사이 인증이 철회되거나 만료됐으면 여기서 거부됩니다. 조회 대상과 attester는 생성자에서 immutable로 고정됩니다.
⑥ 서명 복구. 65바이트가 아니면 실패합니다. s가 secp256k1 반곡선 상한을
넘거나 v가 27 또는 28이 아니면 실패합니다. 서명 가변성(malleability)을 막기
위해서입니다. ecrecover는 서명값에서 서명한 지갑 주소를 되찾아 주는 내장 연산인데,
그 결과가 해당 참여자 주소와 정확히 일치해야 합니다. 참여자
배열이 정렬돼 있으므로 서명도 같은 순서로 넘겨야 합니다.
⑦ 저장과 이벤트. EvidenceRecord에 다음을 남기고 같은 값으로
EvidenceRecorded 이벤트를 냅니다.
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
}
전부 해시와 메타데이터입니다. 서명 자체는 스토리지에 저장하지 않습니다. 검증을
통과했다는 사실이 곧 기록이고, 서명 원본은 트랜잭션 입력값(calldata)으로 블록에
영구히 남습니다. 이벤트는 evidenceId, conversationHash, contentHash를
indexed 로 내보내, 체인 기록을 모아 검색해 주는 프로그램(인덱서)이 대화별로
찾을 수 있게 합니다.
7. 실제 트랜잭션 해부¶
GIWA Sepolia에 기록된 실제 증빙입니다.
트랜잭션 0x677de178e5d99fdb38429206a2d8f50ba2a248e6c990b72df8e2ae3b6ae436ed
블록 31766627
가스 383,998
가스(gas)는 체인에 기록을 부탁할 때 내는 수수료 단위입니다. 트랜잭션에 실어 보낸 입력값(calldata)을 풀어 보면 이렇습니다.
| 필드 | 값 |
|---|---|
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 |
65바이트 서명 2개 |
들어 있는 것은 해시 5개, 숫자 4개, 주소 2개, 서명 2개가 전부입니다. 대화 원문은 한 글자도 없습니다. 참여자 배열이 오름차순인 것도 확인할 수 있습니다.
메시지 개수와 무관하게 calldata 크기와 가스는 일정합니다. 2개를 증빙하든 200개를 증빙하든 온체인 비용은 같습니다.
8. 직접 검증하기¶
위 트랜잭션의 calldata 값만으로 컨트랙트가 하는 검증을 오프체인에서 그대로 재현할 수 있습니다.
8.1 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 evidenceId 파생과 기록 조회¶
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
온체인 기록이 그대로 반환됩니다. recordedAt은 1785111743, 제출자는 A다.
8.3 서명자 복구¶
await recoverAddress({ hash: digest, signature: signatures[0] });
// 0x532dA989489eaB825d484EAd09f117018da47778 → participants[0] 일치
await recoverAddress({ hash: digest, signature: signatures[1] });
// 0xEf792c4973Cc8ef6aF433B3bd9f4461B701301f6 → participants[1] 일치
두 주소가 정확히 복구됩니다. 개인키 보유자만 만들 수 있는 값이므로 서명한 사실을 부인할 수 없습니다.
8.4 분쟁 시 절차¶
원문을 보관한 사람이 다음을 수행하면 내용까지 입증됩니다.
- 보관한 메시지들을 제시하고
gasok-evidence-v1규칙으로 contentHash 재계산 getEvidence로 온체인 contentHash와 대조- calldata의 서명에서 참여자 주소 복구
- 블록 타임스탬프로 존재 시점 확인
상대가 "그런 말 한 적 없다"고 하면 그 지갑의 서명이 해당 내용의 해시에 붙어 있습니다. 상대가 조작된 대화를 제시하면 해시가 온체인 값과 맞지 않습니다.
9. 증명되는 것과 안 되는 것¶
| 증명됩니다 | 증명되지 않습니다 |
|---|---|
| 이 내용에 참여자 전원이 서명했습니다 | 원문을 잃으면 내용 입증 수단도 사라집니다. 보관 책임은 참여자에게 있습니다 |
| 내용이 서명 이후 변조되지 않았습니다 | 대화가 실제 XMTP에서 오갔다는 것을 컨트랙트가 독립 검증하지는 않습니다 |
| 늦어도 블록 시각에 존재했습니다 | 참여자들이 공모해 가짜 대화에 서명하는 것은 막지 못합니다 |
| 서명자 전원이 그 시점 Dojang 인증자였습니다 | 선택 범위 밖의 대화 맥락 |
세 번째 항목은 모든 계약서가 갖는 성질입니다. 쌍방이 합의한 내용이라는 것이 곧 증빙의 정의입니다.
네 번째와 관련해, messageCount와 시간 범위가 함께 기록되므로 "전체 12개 중
2개만 골랐다"는 사실 자체는 드러납니다.
10. 자주 묻는 질문¶
대화 원문을 올리는 옵션은 없나.
없습니다. 화면 흐름도 S5-b가 "원문은 앱 외부 노출 없음"으로 못박았고, 설계 문서도 컨트랙트와 백엔드가 원문을 저장하지 않는다고 규정합니다. 원문을 올리면 E2EE가 무의미해지고 삭제도 불가능합니다.
메시지를 몇 개까지 증빙할 수 있나.
컨트랙트 상한은 uint32 최대값인 약 43억 개입니다. 온체인 비용은 개수와 무관합니다.
실질적 제약은 서명자에게 대상을 알려주는 제어 메시지 크기입니다. 메시지 ID를 전부
싣기 때문에 1,000개면 약 88KB, 10,000개면 약 873KB가 됩니다. 해시 계산 자체는
50,000개도 1초 안쪽입니다.
서명 후 인증이 만료되면.
제출이 거부됩니다. 컨트랙트가 기록 시점에 Dojang을 다시 조회하기 때문입니다. 테스트넷 파우셋 attestation은 발급 30일 후 만료됩니다.
스마트 계정 지갑도 되나.
P0는 EOA, 즉 개인키 하나로 움직이는 일반 지갑의 서명만 지원합니다. 컨트랙트로 만든 스마트 계정(EIP-1271)은 P1로 미뤘습니다.
attester를 바꾸려면.
생성자에서 immutable로 고정되므로 재배포해야 합니다. 예를 들어 TESTNET_FAUCET 대신 UPBIT_KOREA 기준으로 보려면 새 배포가 필요합니다.