콘텐츠로 이동

데이터베이스

PostgreSQL 17을 사용합니다. 마이그레이션은 backend/migrations에 있고 서버 기동 시 migrate()가 버전 순서대로 적용합니다. 적용 이력은 umul_internal.schema_migrations에 버전, 이름, SHA-256 체크섬으로 남습니다. 이미 적용한 파일의 내용이 바뀌면 MIGRATION_CHECKSUM_MISMATCH로 중단합니다.

저장하지 않는 값

  • 일반 메시지 원문
  • XMTP 복호화 키와 지갑 개인키
  • 세션 토큰과 로그인 nonce의 원문 (SHA-256 해시만 저장)
  • 증빙 아카이브의 평문과 콘텐츠 키 (클라이언트가 잠근 암호문과 래핑된 키만 보관하며, 서버는 어느 쪽도 열 수 없다)

001_auth.sql

wallet_login_nonces

열 설명
id uuid 기본키
address 소문자 지갑 주소. CHECK (address = lower(address))
nonce_hash nonce의 SHA-256. UNIQUE
expires_at 발급 5분 후
consumed_at 사용 시각. 일회성 보장의 근거

sessions

열 설명
id uuid 기본키
address 소문자 지갑 주소
token_hash 세션 토큰의 SHA-256. UNIQUE
expires_at 발급 7일 후
revoked_at 로그아웃 시각

nonce 소비와 세션 생성은 하나의 CTE 문에서 원자적으로 처리합니다. 동시 요청이 와도 한 번만 소비됩니다.

002_evidence.sql

evidence_requests

열 설명
id uuid 기본키
idempotency_key UNIQUE. 중복 생성 차단
evidence_id 32바이트. UNIQUE
requester_address 소문자 주소
content_hash, conversation_id_hash, participants_hash 각 32바이트
manifest_version 해시 규칙 버전
message_count 1 이상
conversation_started_at, conversation_ended_at 종료 시각이 시작 이상
status 8개 상태만 허용하는 CHECK
transaction_hash 32바이트, UNIQUE, NULL 허용
block_number 0 이상, NULL 허용
rejected_at 거절 시각, NULL 허용
expires_at 생성 1시간 후. CHECK (expires_at > created_at)
created_at 생성 시각

evidence_participants

(request_id, address) 복합 기본키입니다. signature는 65바이트이고, signature와 signed_at은 함께 NULL이거나 함께 값이 있어야 한다는 CHECK가 있습니다. 주소 조회용 인덱스를 둡니다.

003_evidence_metadata.sql

증빙명과 증빙 대상 메시지 ID를 더합니다. 둘 다 온체인에 올라가지 않는 오프체인 메타데이터입니다. 컨트랙트는 contentHash만 받으므로 이름이 바뀌어도 온체인 기록과 서명은 영향을 받지 않습니다.

열 테이블 설명
name evidence_requests 증빙명. NULL 허용. CHECK (length BETWEEN 1 AND 100)
message_refs evidence_requests XMTP 메시지 ID 배열(text[]). 본문은 담지 않는다
saved_at evidence_participants 저장하기(즐겨찾기). 참여자별 상태라 한 사람이 저장해도 다른 참여자 목록에는 영향이 없습니다

message_refs는 증빙 제안 제어 메시지로 참여자에게 전달되는 값과 같습니다. 상세 화면이 이 ID로 각자의 로컬 MLS DB에서 본문을 찾습니다.

004_evidence_metadata_integrity.sql

003이 남긴 정합성 구멍을 막습니다. message_count는 컨트랙트에 기록되고 서명 대상 digest에도 들어가는 값이고 message_refs는 화면이 본문을 되찾는 데 씁니다. 둘이 어긋나면 화면은 "메시지 12개"라고 하면서 3건만 보여주게 됩니다.

제약 / 인덱스 내용
evidence_requests_message_refs_match_count cardinality(message_refs) = message_count
evidence_requests_message_refs_not_empty cardinality(message_refs) > 0
evidence_requests_created_at_idx (created_at DESC). 모아보기가 "저장한 것 먼저, 그다음 최신순"으로 읽습니다

003 이전 행은 message_refs가 NULL이라 두 CHECK 모두에서 빠집니다.

005_evidence_archive.sql

evidence_archives

증빙 원문의 클라이언트 측 암호문(AES-256-GCM, iv 12 + 본문 + 태그 16)입니다. request_id가 기본키라 증빙당 한 건이고, 최초 1회만 씁니다. 길이는 29바이트에서 2MB 사이라는 CHECK가 있습니다.

evidence_archive_keys

서명 참여자별 래핑된 콘텐츠 키입니다. (request_id, address) 기본키에 evidence_participants로 복합 외래키를 걸어 참여자가 아닌 주소의 행을 막습니다. wrapped_key는 정확히 60바이트(iv 12 + 키 32 + 태그 16)라는 CHECK가 있습니다. 서명하지 않은 참여자의 행은 애플리케이션이 만들지 않습니다.

006_evidence_content.sql

아직 어느 코드도 이 마이그레이션이 만든 객체를 쓰지 않습니다. 스키마에는 존재하므로 적어 두지만, 실제 증빙 원문 보관은 005의 evidence_archives / evidence_archive_keys를 지납니다. 005를 대체할 설계인지 별개 용도인지는 아직 정해지지 않았습니다.

evidence_contents

열 설명
request_id evidence_requests 참조 기본키. ON DELETE CASCADE
ciphertext 클라이언트가 AES-256-GCM으로 잠근 결과. 1바이트 이상 1MB 이하
iv 정확히 12바이트
algorithm 'AES-GCM-256'만 허용

1MB를 넘으면 대화 하나를 통째로 담은 것이므로 증빙 범위를 좁혀야 한다는 뜻의 상한입니다.

evidence_participants 확장

참여자가 서명할 때 자기 몫의 열쇠를 같이 올립니다. 서명하지 않은 참여자는 열쇠가 없어 본문을 열 수 없습니다.

열 설명
wrapped_key 정확히 48바이트 (CEK 32 + GCM 태그 16)
wrapped_key_iv 정확히 12바이트

둘은 함께 NULL이거나 함께 값이 있어야 한다는 CHECK가 있습니다. 005의 evidence_archive_keys.wrapped_key가 60바이트(iv를 포함)인 것과 달리 여기서는 iv를 별도 열로 뺐습니다.

키 유도는 EIP-712 멀티시그 서명(evidence_participants.signature)을 쓰지 않습니다. 그 값은 여기 저장되고 온체인 calldata로도 공개되므로, 그걸로 키를 만들면 서버가 복호화할 수 있게 됩니다. 전송되지 않는 별도 문구로 유도합니다.

동시성

상태 전이는 모두 PgEvidenceRepository.updateLocked를 지납니다. 트랜잭션 안에서 SELECT ... FOR UPDATE로 행을 잠그고, aggregate가 전이를 결정한 뒤 저장합니다. aggregate가 전이를 거부하면 트랜잭션 전체를 롤백해 부분 반영이 남지 않습니다.

두 참여자가 동시에 서명하면 둘 다 성공하고 최종 상태는 READY가 됩니다. 같은 참여자가 동시에 두 번 서명하면 하나만 성공하고 다른 하나는 EVIDENCE_SIGNATURE_ALREADY_SUBMITTED로 실패합니다.

테스트 격리

통합 테스트는 스키마를 초기화하므로 umul_test 또는 umul_test_ 접두사 데이터베이스만 허용합니다. 다른 이름이면 UNSAFE_TEST_DATABASE로 중단하며, 중단하더라도 열린 커넥션 풀은 모두 닫습니다. 개발용 umul과 테스트용 umul_test는 서로 다른 컨테이너(5432 / 5433)에서 실행합니다.