Umul P0 MVP Implementation Plan¶
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 지갑 주소 세 개가 XMTP dev에서 1:1 및 그룹 메시지를 실제로 주고받고, Dojang 상태를 표시하며, 참여자 전원 동의 대화를 GIWA Sepolia에 증빙하는 모바일형 웹 MVP를 만듭니다.
Architecture: 프론트엔드가 지갑, XMTP E2EE, Dojang 조회와 증빙 manifest를 담당합니다. 백엔드는 로그인 세션과 증빙 서명 상태만 PostgreSQL에 저장합니다. Solidity 컨트랙트는 참여자 서명과 Dojang 상태를 최종 검증한 뒤 대화 해시를 기록합니다.
Tech Stack: React, TypeScript, Vite, XMTP Browser SDK, wagmi, viem, Node.js, Fastify, PostgreSQL, Solidity, Foundry, Vitest, Testing Library, Playwright
실행 원칙¶
- 각 동작은
@test-driven-development에 따라 실패 테스트를 먼저 확인합니다. - UI 구현 전
@frontend-design을 읽고 기능 코드와 presentation 코드를 분리합니다. - PostgreSQL 스키마와 쿼리 구현 전
@postgres-best-practices를 읽습니다. - 버그나 예상 밖 SDK 동작은
@systematic-debugging으로 원인을 확인합니다. - 각 커밋 직전
@verification-before-completion에 따라 관련 테스트와 diff를 다시 검사합니다. - 제공된 테스트 개인키는 환경변수로만 전달합니다. 명령, 소스, fixture, 로그와 문서에 값을 넣지 않습니다.
- P0 단계 하나의 테스트가 실패하면 다음 단계로 넘어가지 않습니다.
Task 1: 저장소 기반과 시크릿 차단¶
Files:
- Create:
.gitignore - Create:
.editorconfig - Create:
.nvmrc - Create:
.env.example - Create:
README.md - Create:
compose.yaml - Create:
frontend/package.json - Create:
frontend/tsconfig.json - Create:
frontend/vite.config.ts - Create:
frontend/index.html - Create:
backend/package.json - Create:
backend/tsconfig.json - Create:
backend/vitest.config.ts - Create:
contracts/foundry.toml - Create:
mobile/README.md - Create:
scripts/check-secrets.sh
Step 1: 저장소 안전 설정 작성
.gitignore에 다음 범주를 포함합니다.
.env
.env.*
!.env.example
node_modules/
dist/
coverage/
playwright-report/
test-results/
.xmtp/
*.db
*.db3
backend/.postgres/
contracts/cache/
contracts/out/
contracts/broadcast/
contracts/lib/
.env.example에는 값이 없는 변수명만 둡니다.
GIWA_SEPOLIA_RPC_URL=https://sepolia-rpc.giwa.io
GIWA_SEPOLIA_EXPLORER_URL=https://sepolia-explorer.giwa.io
DOJANG_SCROLL_ADDRESS=0xd5077b67dcb56caC8b270C7788FC3E6ee03F17B9
DOJANG_ATTESTER_ID=
DATABASE_URL=postgres://umul:umul@localhost:5432/umul
TEST_DATABASE_URL=postgres://umul:umul@localhost:5433/umul_test
SESSION_SECRET=
VITE_API_URL=http://localhost:3000
VITE_REOWN_PROJECT_ID=
VITE_EVIDENCE_CONTRACT_ADDRESS=
UMUL_E2E_PRIVATE_KEY_A=
UMUL_E2E_PRIVATE_KEY_B=
UMUL_E2E_PRIVATE_KEY_C=
Step 2: 시크릿 검사 스크립트 작성
scripts/check-secrets.sh는 추적 파일에서 개인키 형식과 환경 파일을 검사합니다. 테스트 주소와 트랜잭션 해시는 허용하고 64자리 hex 후보는 명시적 allowlist 없이 실패시킵니다.
Step 3: 언어별 빈 프로젝트 설정
프론트엔드와 백엔드는 서로 독립된 package.json과 lockfile을 사용합니다. 루트 npm workspace는 만들지 않습니다. compose.yaml에는 개발용 PostgreSQL 17과 비영속 테스트용 PostgreSQL 17 서비스 및 healthcheck만 둡니다.
Step 4: 설정 검증
Run:
bash scripts/check-secrets.sh
docker compose config --quiet
cd frontend && npm install && npm run typecheck
cd ../backend && npm install && npm run typecheck
cd ../contracts && forge fmt --check
Expected: 모든 명령 exit 0. 기능 코드는 아직 없습니다.
Step 5: 커밋
git add .gitignore .editorconfig .nvmrc .env.example README.md compose.yaml frontend backend contracts mobile scripts
git commit -m "chore: Umul 프로젝트 기반 구성"
Task 2: 백엔드 애플리케이션과 health API¶
Files:
- Create:
backend/src/application/createApp.ts - Create:
backend/src/interfaces/http/server.ts - Create:
backend/src/interfaces/http/routes/health.ts - Create:
backend/src/application/createApp.test.ts
Step 1: 실패 테스트 작성
it("GET /health returns ok", async () => {
const app = await createApp({ clock: () => new Date("2026-07-26T00:00:00Z") });
const response = await app.inject({ method: "GET", url: "/health" });
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
status: "ok",
time: "2026-07-26T00:00:00.000Z",
});
});
Step 2: 실패 확인
Run:
cd backend
npm test -- src/application/createApp.test.ts
Expected: createApp 모듈이 없어 FAIL.
Step 3: 최소 구현
Fastify 인스턴스를 만드는 createApp과 /health 라우트를 구현합니다. server.ts만 포트와 환경변수를 읽습니다. 테스트는 서버 포트를 열지 않습니다.
Step 4: 통과 확인
Run:
cd backend
npm test -- src/application/createApp.test.ts
npm run typecheck
Expected: health 테스트 PASS, typecheck exit 0.
Step 5: 커밋
git add backend/src
git commit -m "feat: 백엔드 health API 추가"
Task 3: PostgreSQL 마이그레이션 기반¶
Prerequisite Skill: @postgres-best-practices
Files:
- Create:
backend/migrations/001_auth.sql - Create:
backend/migrations/002_evidence.sql - Create:
backend/src/infrastructure/postgres/Database.ts - Create:
backend/src/infrastructure/postgres/migrate.ts - Create:
backend/src/infrastructure/postgres/migrations.test.ts
Step 1: 실패 통합 테스트 작성
테스트용 PostgreSQL을 시작한 뒤 마이그레이션을 실행하고 다음 테이블과 제약을 검사합니다.
expect(await tableNames(db)).toEqual([
"evidence_participants",
"evidence_requests",
"sessions",
"wallet_login_nonces",
]);
expect(await hasUniqueConstraint(db, "sessions", "token_hash")).toBe(true);
expect(await hasCompositePrimaryKey(db, "evidence_participants", [
"request_id",
"address",
])).toBe(true);
Step 2: 실패 확인
Run:
docker compose up -d postgres-test
cd backend
TEST_DATABASE_URL=postgres://umul:umul@localhost:5433/umul_test \
npm test -- src/infrastructure/postgres/migrations.test.ts
Expected: 마이그레이션 파일이 없어 FAIL.
Step 3: 최소 스키마 작성
다음 테이블을 만듭니다.
CREATE TABLE wallet_login_nonces (
id uuid PRIMARY KEY,
address text NOT NULL,
nonce_hash bytea NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (address = lower(address))
);
CREATE TABLE sessions (
id uuid PRIMARY KEY,
address text NOT NULL,
token_hash bytea NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (address = lower(address))
);
증빙 테이블은 PROPOSED, SIGNING, READY, SUBMITTED, CONFIRMED, REJECTED, EXPIRED, FAILED 상태만 허용합니다. 참여자는 (request_id, address)를 기본키로 사용합니다. 주소, evidence ID, content hash, signature는 원문 메시지 없이 저장합니다.
Step 4: 통과 확인
Run:
cd backend
TEST_DATABASE_URL=postgres://umul:umul@localhost:5433/umul_test \
npm test -- src/infrastructure/postgres/migrations.test.ts
Expected: 마이그레이션 테스트 PASS.
통합 테스트는 스키마를 초기화하므로 개발 데이터베이스 umul과 분리된
umul_test만 사용합니다. 테스트 코드는 umul_test 또는 umul_test_
접두사가 아닌 데이터베이스에서 파괴적 초기화를 거부합니다.
Step 5: 커밋
git add backend/migrations backend/src/infrastructure/postgres
git commit -m "feat: 인증과 증빙 데이터베이스 스키마 추가"
Task 4: 지갑 서명 로그인 API¶
Files:
- Create:
backend/src/domain/auth/WalletSession.ts - Create:
backend/src/application/ports/AuthRepository.ts - Create:
backend/src/application/auth/IssueLoginNonce.ts - Create:
backend/src/application/auth/VerifyWalletLogin.ts - Create:
backend/src/infrastructure/postgres/PgAuthRepository.ts - Create:
backend/src/interfaces/http/routes/auth.ts - Create:
backend/src/application/auth/VerifyWalletLogin.test.ts - Create:
backend/src/interfaces/http/routes/auth.test.ts
Step 1: nonce 일회성 실패 테스트 작성
it("consumes a login nonce only once", async () => {
const nonce = await issueLoginNonce.execute(TEST_ADDRESS);
const signature = await testWallet.signMessage({ message: nonce.message });
await verifyWalletLogin.execute({ message: nonce.message, signature });
await expect(
verifyWalletLogin.execute({ message: nonce.message, signature }),
).rejects.toThrow("LOGIN_NONCE_ALREADY_USED");
});
Step 2: 실패 확인
Run:
cd backend
npm test -- src/application/auth/VerifyWalletLogin.test.ts
Expected: 유스케이스가 없어 FAIL.
Step 3: 최소 도메인 구현
- 주소를 lowercase checksum 검증 후 저장합니다.
- nonce는 5분 후 만료합니다.
- 서명 메시지에는 domain, URI, chain ID 91342, nonce, issued at, expiration을 포함합니다.
- nonce와 session token은 원문이 아닌 SHA-256 해시로 저장합니다.
- 서명에서 복구한 주소와 요청 주소가 다르면 거부합니다.
Step 4: API 테스트 작성
POST /v1/auth/nonce, POST /v1/auth/verify, DELETE /v1/auth/session을 검사합니다. 성공 응답은 HttpOnly 세션 쿠키를 포함해야 합니다.
Step 5: 전체 통과 확인
Run:
cd backend
npm test -- src/application/auth src/interfaces/http/routes/auth.test.ts
npm run typecheck
Expected: 인증 테스트 PASS, typecheck exit 0.
Step 6: 커밋
git add backend/src/domain/auth backend/src/application/auth backend/src/application/ports backend/src/infrastructure/postgres/PgAuthRepository.ts backend/src/interfaces/http/routes
git commit -m "feat: 지갑 서명 로그인 API 추가"
Task 5: 모바일 웹 셸과 화면 경계¶
Prerequisite Skill: @frontend-design
Files:
- Create:
frontend/src/main.tsx - Create:
frontend/src/app/App.tsx - Create:
frontend/src/presentation/layout/MobileShell.tsx - Create:
frontend/src/presentation/layout/MobileShell.test.tsx - Create:
frontend/src/presentation/styles/tokens.css - Create:
frontend/src/presentation/styles/global.css - Create:
frontend/src/presentation/screens/OnboardingScreen.tsx - Create:
frontend/src/presentation/screens/InboxScreen.tsx - Create:
frontend/src/presentation/screens/ConversationScreen.tsx - Create:
frontend/src/presentation/screens/NewConversationScreen.tsx - Create:
frontend/src/presentation/screens/EvidenceFlowScreen.tsx - Create:
frontend/src/presentation/screens/EvidenceDetailScreen.tsx - Create:
frontend/src/presentation/screens/ProfileScreen.tsx
Step 1: 실패 렌더 테스트 작성
it("renders one mobile viewport and safe-area content", () => {
render(
<MobileShell>
<div>Umul</div>
</MobileShell>,
);
expect(screen.getByText("Umul")).toBeVisible();
expect(screen.getByTestId("mobile-shell")).toHaveClass("mobileShell");
});
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/presentation/layout/MobileShell.test.tsx
Expected: 컴포넌트가 없어 FAIL.
Step 3: 디자인 토큰과 셸 구현
- 최대 콘텐츠 폭은 430px로 제한합니다.
- 데스크톱에서는 기기 프레임처럼 보이되 웹 접근성을 해치지 않습니다.
- 색, 간격, 타이포그래피, radius, shadow를 CSS 변수로 분리합니다.
- 컴포넌트는 비즈니스 SDK를 import하지 않습니다.
prefers-reduced-motion, 키보드 포커스와 safe area를 지원합니다.
Step 4: S1부터 S7 화면의 정적 골격 구현
각 화면은 view model과 callback props만 받습니다. SDK 호출과 데이터 가공은 넣지 않습니다.
Step 5: 통과 확인
Run:
cd frontend
npm test -- src/presentation
npm run typecheck
npm run build
Expected: presentation 테스트 PASS, typecheck와 build exit 0.
Step 6: 커밋
git add frontend/src
git commit -m "feat: 모바일 웹 화면 골격 추가"
Task 6: 프론트엔드 지갑 로그인 유스케이스¶
Files:
- Create:
frontend/src/domain/wallet/WalletAddress.ts - Create:
frontend/src/application/ports/WalletPort.ts - Create:
frontend/src/application/ports/SessionPort.ts - Create:
frontend/src/application/auth/ConnectWallet.ts - Create:
frontend/src/application/auth/ConnectWallet.test.ts - Create:
frontend/src/infrastructure/wallet/WagmiWalletAdapter.ts - Create:
frontend/src/infrastructure/api/HttpSessionAdapter.ts - Create:
frontend/src/presentation/features/auth/ConnectWalletView.tsx - Create:
frontend/src/presentation/features/auth/ConnectWalletView.test.tsx
Step 1: 실패 유스케이스 테스트 작성
it("connects a wallet, switches to GIWA Sepolia, and creates a session", async () => {
const wallet = new FakeWalletPort(TEST_ADDRESS, 1);
const session = new FakeSessionPort();
const useCase = new ConnectWallet(wallet, session);
const result = await useCase.execute();
expect(wallet.switchedChainId).toBe(91342);
expect(session.authenticatedAddress).toBe(TEST_ADDRESS.toLowerCase());
expect(result.status).toBe("authenticated");
});
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/application/auth/ConnectWallet.test.ts
Expected: 유스케이스가 없어 FAIL.
Step 3: 최소 구현
WalletPort와 SessionPort를 주입합니다. 연결, 체인 전환, nonce 요청, 메시지 서명, 세션 생성 순서를 application 계층에 둡니다.
Step 4: 지갑별 UI 연결
- GIWA Wallet 또는 호환 injected provider
- MetaMask
- WalletConnect,
VITE_REOWN_PROJECT_ID가 있을 때 활성화
환경변수가 없으면 WalletConnect를 실패시키지 말고 설정 필요 상태로 표시합니다.
Step 5: 통과 확인
Run:
cd frontend
npm test -- src/application/auth src/presentation/features/auth
npm run typecheck
Expected: 로그인 유스케이스와 화면 테스트 PASS.
Step 6: 커밋
git add frontend/src/domain/wallet frontend/src/application frontend/src/infrastructure frontend/src/presentation/features/auth
git commit -m "feat: 지갑 서명 로그인 연결"
Task 7: XMTP 클라이언트와 1:1 메시징¶
Files:
- Create:
frontend/src/domain/messaging/Conversation.ts - Create:
frontend/src/domain/messaging/Message.ts - Create:
frontend/src/application/ports/MessagingPort.ts - Create:
frontend/src/application/messaging/OpenDirectMessage.ts - Create:
frontend/src/application/messaging/SendMessage.ts - Create:
frontend/src/application/messaging/SubscribeMessages.ts - Create:
frontend/src/application/messaging/SendMessage.test.ts - Create:
frontend/src/infrastructure/xmtp/XmtpMessagingAdapter.ts - Create:
frontend/src/infrastructure/xmtp/XmtpSignerAdapter.ts - Create:
frontend/src/infrastructure/xmtp/XmtpMessagingAdapter.integration.test.ts - Create:
frontend/src/presentation/features/chat/ConversationView.tsx - Create:
frontend/src/presentation/features/chat/ConversationView.test.tsx
Step 1: 전송 실패 테스트 작성
it("keeps a failed outbound message available for retry", async () => {
const messaging = new FailingMessagingPort();
const useCase = new SendMessage(messaging);
const result = await useCase.execute({
conversationId: "dm-1",
clientMessageId: "local-1",
text: "hello",
});
expect(result).toMatchObject({
clientMessageId: "local-1",
delivery: "failed",
retryable: true,
});
});
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/application/messaging/SendMessage.test.ts
Expected: 메시징 유스케이스가 없어 FAIL.
Step 3: 포트와 최소 유스케이스 구현
application 계층은 XMTP 타입을 노출하지 않습니다. domain Message로 변환하고 optimistic, sent, failed 상태를 유지합니다.
Step 4: XMTP Browser SDK 어댑터 구현
- 환경은
dev로 고정합니다. - 브라우저 지속 DB를 사용합니다.
- 지갑 signer를 XMTP signer로 변환합니다.
- 주소 수신 가능 여부를 조회합니다.
- DM을 생성하거나 기존 DM을 엽니다.
- 모든 대화 메시지 스트림을 구독합니다.
- 스트림 종료 시 application 계층에 오류를 전달합니다.
Step 5: 실제 XMTP 통합 테스트
환경변수로 주입한 A와 B 테스트 지갑을 사용합니다. A가 고유 문자열을 전송하고 B가 제한 시간 안에 같은 XMTP message ID와 본문을 수신해야 합니다.
Run:
cd frontend
UMUL_RUN_XMTP_INTEGRATION=1 npm test -- src/infrastructure/xmtp/XmtpMessagingAdapter.integration.test.ts
Expected: 실제 XMTP dev 왕복 테스트 PASS. 개인키는 출력하지 않음.
Step 6: UI 연결과 검증
Run:
cd frontend
npm test -- src/application/messaging src/presentation/features/chat
npm run typecheck
npm run build
Expected: 단위 테스트 PASS, typecheck와 build exit 0.
Step 7: 커밋
git add frontend/src/domain/messaging frontend/src/application/messaging frontend/src/application/ports/MessagingPort.ts frontend/src/infrastructure/xmtp frontend/src/presentation/features/chat
git commit -m "feat: XMTP 1대1 메시징 추가"
Task 8: XMTP 그룹 메시징¶
Files:
- Create:
frontend/src/application/messaging/CreateGroup.ts - Create:
frontend/src/application/messaging/CreateGroup.test.ts - Modify:
frontend/src/application/ports/MessagingPort.ts - Modify:
frontend/src/infrastructure/xmtp/XmtpMessagingAdapter.ts - Create:
frontend/src/infrastructure/xmtp/XmtpGroup.integration.test.ts - Create:
frontend/src/presentation/features/chat/CreateConversationView.tsx - Create:
frontend/src/presentation/features/chat/CreateConversationView.test.tsx
Step 1: 참여자 정규화 실패 테스트 작성
it("creates a group with sorted unique peer addresses", async () => {
const messaging = new RecordingMessagingPort();
const useCase = new CreateGroup(messaging);
await useCase.execute([ADDRESS_C, ADDRESS_B, ADDRESS_B]);
expect(messaging.createdGroupPeers).toEqual([
ADDRESS_B.toLowerCase(),
ADDRESS_C.toLowerCase(),
]);
});
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/application/messaging/CreateGroup.test.ts
Expected: CreateGroup이 없어 FAIL.
Step 3: 그룹 유스케이스와 XMTP 어댑터 구현
중복 주소와 자기 주소를 제거합니다. XMTP 수신 불가능 주소가 있으면 어느 주소가 실패했는지 반환합니다. 그룹 생성 후 대화 목록을 동기화합니다.
Step 4: 실제 3인 그룹 통합 테스트
A가 A, B, C 그룹을 만듭니다. A, B, C가 각각 고유 메시지를 전송하고 나머지 참여자가 모두 수신하는지 확인합니다.
Run:
cd frontend
UMUL_RUN_XMTP_INTEGRATION=1 npm test -- src/infrastructure/xmtp/XmtpGroup.integration.test.ts
Expected: 실제 XMTP dev 3인 그룹 테스트 PASS.
Step 5: 전체 검증과 커밋
cd frontend
npm test
npm run typecheck
npm run build
cd ..
git add frontend/src
git commit -m "feat: XMTP 그룹 메시징 추가"
Task 9: Dojang 상태와 수신함 분류¶
Files:
- Create:
frontend/src/domain/trust/VerificationStatus.ts - Create:
frontend/src/domain/inbox/InboxCategory.ts - Create:
frontend/src/domain/inbox/classifyConversation.ts - Create:
frontend/src/domain/inbox/classifyConversation.test.ts - Create:
frontend/src/application/ports/VerificationPort.ts - Create:
frontend/src/application/trust/GetVerificationStatus.ts - Create:
frontend/src/application/trust/GetVerificationStatus.test.ts - Create:
frontend/src/infrastructure/dojang/ViemDojangAdapter.ts - Create:
frontend/src/infrastructure/dojang/MemoryVerificationCache.ts - Create:
frontend/src/infrastructure/dojang/ViemDojangAdapter.integration.test.ts - Create:
frontend/src/presentation/components/VerificationBadge.tsx - Create:
frontend/src/presentation/components/VerificationBadge.test.tsx - Create:
frontend/src/presentation/features/inbox/InboxTabs.tsx - Create:
frontend/src/presentation/features/inbox/InboxTabs.test.tsx
Step 1: 분류 실패 테스트 작성
it.each([
[{ kind: "dm", peers: ["verified"] }, "normal"],
[{ kind: "dm", peers: ["unverified"] }, "request"],
[{ kind: "group", peers: ["verified", "verified"] }, "normal"],
[{ kind: "group", peers: ["verified", "unverified"] }, "request"],
[{ kind: "dm", peers: ["unknown"] }, "request"],
])("classifies %o as %s", (conversation, expected) => {
expect(classifyConversation(conversation)).toBe(expected);
});
스팸 지정은 검증 상태보다 우선한다는 테스트도 작성합니다.
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/domain/inbox/classifyConversation.test.ts
Expected: 분류 함수가 없어 FAIL.
Step 3: 분류와 캐시 구현
verified,unverified,unknown을 별도 값으로 둡니다.- 캐시 TTL은 한 시간입니다.
- RPC 오류는 unknown으로 반환합니다.
- 증빙 사전 검사는 캐시를 우회합니다.
Step 4: 실제 GIWA Sepolia 조회 테스트
A, B, C 주소를 TESTNET_FAUCET attester ID로 조회합니다. 현재 기대값은 A true, B false, C false다. 온체인 상태가 바뀌면 테스트 실패 메시지에 실제 값을 표시하고 완료 기준 문서를 갱신합니다.
Run:
cd frontend
UMUL_RUN_GIWA_INTEGRATION=1 npm test -- src/infrastructure/dojang/ViemDojangAdapter.integration.test.ts
Expected: GIWA Sepolia 실조회 PASS.
Step 5: 배지와 탭 UI 검증
Run:
cd frontend
npm test -- src/domain/inbox src/application/trust src/presentation/components src/presentation/features/inbox
npm run typecheck
npm run build
Expected: 단위 테스트 PASS, typecheck와 build exit 0.
Step 6: 커밋
git add frontend/src
git commit -m "feat: Dojang 배지와 수신함 분류 추가"
Task 10: 스팸 지정과 XMTP consent¶
Files:
- Create:
frontend/src/application/messaging/MarkConversationSpam.ts - Create:
frontend/src/application/messaging/MarkConversationSpam.test.ts - Modify:
frontend/src/application/ports/MessagingPort.ts - Modify:
frontend/src/infrastructure/xmtp/XmtpMessagingAdapter.ts - Modify:
frontend/src/presentation/features/inbox/InboxTabs.tsx
Step 1: 실패 테스트 작성
대화를 스팸으로 지정하면 MessagingPort의 denied consent 상태를 호출하고 분류가 spam이 되는지 검사합니다.
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/application/messaging/MarkConversationSpam.test.ts
Expected: 유스케이스가 없어 FAIL.
Step 3: 최소 구현
XMTP consent 상태를 사용해 수동 스팸 지정을 동기화합니다. 스팸함에서도 사용자가 차단을 해제할 수 있습니다.
Step 4: 전체 검증과 커밋
cd frontend
npm test
npm run typecheck
npm run build
cd ..
git add frontend/src
git commit -m "feat: 대화 스팸 지정 기능 추가"
Task 11: 대화 증빙 컨트랙트¶
Files:
- Create:
contracts/src/interfaces/IDojangScroll.sol - Create:
contracts/src/ConversationEvidenceRegistry.sol - Create:
contracts/test/mocks/MockDojangScroll.sol - Create:
contracts/test/ConversationEvidenceRegistry.t.sol - Create:
contracts/test/ConversationEvidenceRegistry.fuzz.t.sol
Step 1: 실패 테스트 작성
function test_recordEvidence_requiresEveryParticipantSignature() public {
Evidence memory evidence = validEvidence();
address[] memory participants = sortedParticipants();
bytes[] memory signatures = signaturesFor(evidence, participants);
signatures[1] = bytes("");
vm.expectRevert(InvalidSignature.selector);
registry.recordEvidence(evidence, participants, signatures);
}
Verified 참여자 전원 서명 성공, 미검증 참여자, 중복 주소, 정렬 오류, 만료, replay 테스트를 각각 분리합니다. 참여자가 두 명 미만이면 거부하는 테스트도 포함합니다.
Step 2: 실패 확인
Run:
cd contracts
forge test --match-contract ConversationEvidenceRegistryTest -vvv
Expected: 컨트랙트가 없어 컴파일 FAIL.
Step 3: 최소 컨트랙트 구현
struct Evidence {
bytes32 conversationHash;
bytes32 contentHash;
bytes32 participantsHash;
uint32 messageCount;
uint64 startedAt;
uint64 endedAt;
bytes32 nonce;
uint64 deadline;
}
생성자에서 DojangScroll 주소와 attester ID를 immutable로 저장합니다. EIP-712 도메인은 이름 GasokConversationEvidence, 버전 1, 현재 chain ID와 컨트랙트 주소를 사용합니다.
recordEvidence는 참여자와 서명 길이를 비교하고 주소 오름차순과 중복을 검사합니다. 각 서명은 같은 typed data digest에서 해당 참여자 주소를 복구해야 합니다. 모든 참여자에 대해 Dojang isVerified가 true여야 합니다.
P0는 EOA 서명만 지원합니다. EIP-1271 스마트 계정은 P1 항목으로 문서화합니다.
Step 4: 퍼즈와 전체 테스트
Run:
cd contracts
forge fmt --check
forge test -vvv
Expected: 단위와 퍼즈 테스트 PASS.
Step 5: 커밋
git add contracts/src contracts/test
git commit -m "feat: 대화 다중 서명 증빙 컨트랙트 추가"
Task 12: 증빙 컨트랙트 배포와 fork 검증¶
Files:
- Create:
contracts/script/DeployConversationEvidence.s.sol - Create:
contracts/test/ConversationEvidenceRegistry.fork.t.sol - Create:
contracts/deployments/giwa-sepolia.json - Create:
docs/runbooks/deploy-evidence-contract.md - Create:
docs/runbooks/obtain-testnet-dojang.md
Step 1: fork 실패 테스트 작성
테스트 시작 시 A는 Verified이고 B와 C는 Unverified인 GIWA Sepolia 블록을 기록합니다. 이 블록을 fork해 실제 DojangScroll에서 A는 true, B와 C는 false인지 확인합니다. A와 B가 포함된 증빙은 ParticipantNotVerified로 실패해야 합니다.
Step 2: 실패 확인
Run:
cd contracts
GIWA_SEPOLIA_RPC_URL="$GIWA_SEPOLIA_RPC_URL" forge test --match-contract ConversationEvidenceRegistryForkTest -vvv
Expected: fork 테스트 또는 배포 스크립트가 없어 FAIL.
Step 3: 배포 스크립트와 runbook 작성
배포 개인키는 DEPLOYER_PRIVATE_KEY 환경변수에서 읽습니다. runbook에는 키 값을 넣지 않고 명령과 확인 절차만 적습니다.
obtain-testnet-dojang.md에는 GIWA 공식 테스트넷 인증 화면에서 B 주소가 TESTNET_FAUCET attestation을 받는 절차와 DojangScroll.isVerified() 재확인 명령을 적습니다. 인증 전에 A, B, C의 상태와 기준 블록을 먼저 기록합니다.
Step 4: B 테스트넷 인증과 GIWA Sepolia 배포
B만 TESTNET_FAUCET 인증을 받습니다. C는 Unverified로 유지합니다. B의 isVerified()가 true로 바뀐 것을 확인한 뒤 컨트랙트를 배포합니다.
Run:
cd contracts
forge script script/DeployConversationEvidence.s.sol:DeployConversationEvidence \
--rpc-url "$GIWA_SEPOLIA_RPC_URL" \
--broadcast \
--verify
Expected: 배포 트랜잭션 성공, Blockscout 검증 성공.
Step 5: 배포 산출물 검증
giwa-sepolia.json에 chain ID, 컨트랙트 주소, 배포 트랜잭션, 블록 번호, DojangScroll 주소, attester ID와 fork 기준 블록만 저장합니다. 개인키와 RPC 인증정보는 넣지 않습니다.
Step 6: 커밋
git add contracts/script contracts/test/ConversationEvidenceRegistry.fork.t.sol contracts/deployments docs/runbooks/deploy-evidence-contract.md docs/runbooks/obtain-testnet-dojang.md
git commit -m "feat: GIWA Sepolia 증빙 컨트랙트 배포"
Task 13: 백엔드 증빙 상태 머신과 API¶
Prerequisite Skill: @postgres-best-practices
Files:
- Create:
backend/src/domain/evidence/EvidenceRequest.ts - Create:
backend/src/domain/evidence/EvidenceRequest.test.ts - Create:
backend/src/application/ports/EvidenceRepository.ts - Create:
backend/src/application/evidence/CreateEvidenceRequest.ts - Create:
backend/src/application/evidence/SubmitEvidenceSignature.ts - Create:
backend/src/application/evidence/RejectEvidenceRequest.ts - Create:
backend/src/application/evidence/RecordEvidenceSubmission.ts - Create:
backend/src/application/evidence/ConfirmEvidence.ts - Create:
backend/src/application/evidence/EvidenceLifecycle.test.ts - Create:
backend/src/infrastructure/postgres/PgEvidenceRepository.ts - Create:
backend/src/infrastructure/postgres/PgEvidenceRepository.test.ts - Create:
backend/src/interfaces/http/routes/evidence.ts - Create:
backend/src/interfaces/http/routes/evidence.test.ts
Step 1: 상태 전이 실패 테스트 작성
it("becomes READY only after every participant signs", () => {
const request = proposedEvidence([ADDRESS_A, ADDRESS_B, ADDRESS_C]);
request.addSignature(ADDRESS_A, SIGNATURE_A);
request.addSignature(ADDRESS_B, SIGNATURE_B);
expect(request.status).toBe("SIGNING");
request.addSignature(ADDRESS_C, SIGNATURE_C);
expect(request.status).toBe("READY");
});
비참여자, 중복 서명, 거부 후 서명, 만료 후 서명과 잘못된 상태 전이를 각각 검사합니다.
Step 2: 실패 확인
Run:
cd backend
npm test -- src/domain/evidence src/application/evidence
Expected: 증빙 도메인이 없어 FAIL.
Step 3: 최소 도메인과 유스케이스 구현
상태 변경은 EvidenceRequest aggregate가 결정합니다. repository는 aggregate 저장과 idempotency key 조회를 제공합니다.
Step 4: PostgreSQL repository 통합 테스트
두 동시 서명 요청을 실행해 한 행만 저장되고 상태가 일관적인지 검사합니다. 상태 변경 시 SELECT FOR UPDATE 또는 같은 효과의 조건부 UPDATE를 사용합니다.
Step 5: HTTP API 구현
POST /v1/evidence-requests
GET /v1/evidence-requests/:id
GET /v1/evidence-requests
POST /v1/evidence-requests/:id/signatures
POST /v1/evidence-requests/:id/rejection
POST /v1/evidence-requests/:id/submission
생성 API는 Idempotency-Key를 요구합니다. 세션 주소가 참여자일 때만 조회와 서명을 허용합니다.
Step 6: 전체 검증과 커밋
cd backend
npm test
npm run typecheck
cd ..
git add backend/src
git commit -m "feat: 증빙 서명 상태와 API 추가"
Task 14: 증빙 manifest와 XMTP 제어 메시지¶
Files:
- Create:
frontend/src/domain/evidence/EvidenceManifest.ts - Create:
frontend/src/domain/evidence/hashEvidenceManifest.ts - Create:
frontend/src/domain/evidence/hashEvidenceManifest.test.ts - Create:
frontend/src/application/evidence/CreateEvidenceProposal.ts - Create:
frontend/src/application/evidence/CreateEvidenceProposal.test.ts - Create:
frontend/src/application/ports/EvidencePort.ts - Create:
frontend/src/infrastructure/api/HttpEvidenceAdapter.ts - Create:
frontend/src/infrastructure/xmtp/EvidenceControlMessage.ts - Create:
frontend/src/infrastructure/xmtp/EvidenceControlMessage.test.ts
Step 1: 정규화 실패 테스트 작성
같은 메시지를 다른 입력 순서로 제공해도 같은 content hash가 생성돼야 합니다.
expect(hashEvidenceManifest([MESSAGE_B, MESSAGE_A]))
.toBe(hashEvidenceManifest([MESSAGE_A, MESSAGE_B]));
본문 한 글자, 발신자, 시각, 참여자 또는 nonce가 바뀌면 해시가 달라져야 합니다.
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/domain/evidence/hashEvidenceManifest.test.ts
Expected: 해시 함수가 없어 FAIL.
Step 3: 최소 manifest 구현
viem ABI 인코딩과 keccak256을 사용합니다. 주소는 정렬하고 메시지는 sentAtNs, messageId 순으로 정렬합니다. 원문은 개별 해시로 바꾼 뒤 전체 manifest를 해시합니다.
Step 4: XMTP 제어 메시지 구현
P0는 별도 codec 대신 버전이 붙은 text envelope를 사용합니다.
gasok:evidence:v1:<base64url-json>
일반 채팅 UI는 prefix를 가진 메시지를 숨기고 증빙 요청 카드로 변환합니다. payload에는 요청 ID, manifest, message refs를 넣습니다. XMTP 암호화 밖으로 원문을 보내지 않습니다.
Step 5: Dojang 사전 검사와 API 생성 연결
참여자 상태는 캐시를 우회해 조회합니다. 한 명이라도 verified가 아니면 API 요청과 XMTP 제어 메시지를 만들지 않습니다.
Step 6: 검증과 커밋
cd frontend
npm test -- src/domain/evidence src/application/evidence src/infrastructure/xmtp/EvidenceControlMessage.test.ts
npm run typecheck
cd ..
git add frontend/src
git commit -m "feat: 증빙 manifest와 XMTP 요청 메시지 추가"
Task 15: 참여자 서명과 온체인 제출¶
Files:
- Create:
frontend/src/application/evidence/SignEvidenceProposal.ts - Create:
frontend/src/application/evidence/SignEvidenceProposal.test.ts - Create:
frontend/src/application/evidence/SubmitEvidenceOnchain.ts - Create:
frontend/src/application/evidence/SubmitEvidenceOnchain.test.ts - Create:
frontend/src/application/ports/EvidenceContractPort.ts - Create:
frontend/src/infrastructure/chain/ViemEvidenceContractAdapter.ts - Create:
frontend/src/infrastructure/chain/ViemEvidenceContractAdapter.integration.test.ts - Create:
frontend/src/infrastructure/chain/generated/conversationEvidenceAbi.ts
Step 1: 해시 불일치 실패 테스트 작성
it("refuses to sign when local messages produce a different content hash", async () => {
await expect(
signEvidence.execute(remoteProposalWithHash("0x1234")),
).rejects.toThrow("EVIDENCE_CONTENT_HASH_MISMATCH");
expect(wallet.signTypedData).not.toHaveBeenCalled();
});
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/application/evidence/SignEvidenceProposal.test.ts
Expected: 서명 유스케이스가 없어 FAIL.
Step 3: 서명 유스케이스 구현
- 로컬 메시지를 다시 읽습니다.
- manifest hash를 다시 계산합니다.
- 참여자와 증빙 요청 상태를 확인합니다.
- 컨트랙트 도메인에 chain ID 91342와 배포 주소를 포함합니다.
- typed data 서명 후 백엔드에 제출합니다.
Step 4: 온체인 제출 유스케이스 구현
READY 요청에서 정렬된 참여자와 서명을 가져와 컨트랙트를 호출합니다. receipt 성공 후 백엔드에 tx hash를 기록합니다.
Step 5: 실제 컨트랙트 통합 테스트
C가 포함된 제출은 실제 GIWA Sepolia에서 거부돼야 합니다. 성공 경로는 TESTNET_FAUCET 인증을 마친 A와 B의 1:1 대화로 실행합니다.
Run:
cd frontend
UMUL_RUN_GIWA_INTEGRATION=1 npm test -- src/infrastructure/chain/ViemEvidenceContractAdapter.integration.test.ts
Expected: C가 포함된 거부 경로 PASS. A와 B의 성공 트랜잭션 PASS.
Step 6: 검증과 커밋
cd frontend
npm test -- src/application/evidence src/infrastructure/chain
npm run typecheck
npm run build
cd ..
git add frontend/src
git commit -m "feat: 증빙 서명과 온체인 제출 연결"
Task 16: S5 증빙 플로우와 S6 상세 화면¶
Prerequisite Skill: @frontend-design
Files:
- Create:
frontend/src/presentation/features/evidence/EvidenceScopeStep.tsx - Create:
frontend/src/presentation/features/evidence/EvidencePreviewStep.tsx - Create:
frontend/src/presentation/features/evidence/EvidenceSignatureStep.tsx - Create:
frontend/src/presentation/features/evidence/EvidenceSubmissionStep.tsx - Create:
frontend/src/presentation/features/evidence/EvidenceCompleteStep.tsx - Create:
frontend/src/presentation/features/evidence/EvidenceFlow.test.tsx - Create:
frontend/src/presentation/features/evidence/EvidenceDetailView.tsx - Create:
frontend/src/presentation/features/evidence/EvidenceDetailView.test.tsx - Modify:
frontend/src/presentation/screens/EvidenceFlowScreen.tsx - Modify:
frontend/src/presentation/screens/EvidenceDetailScreen.tsx - Modify:
frontend/src/presentation/screens/ConversationScreen.tsx
Step 1: 미검증 참여자 UI 실패 테스트 작성
it("explains why evidence is disabled when a participant is unverified", () => {
renderEvidenceFlow({
participants: [
verifiedParticipant(ADDRESS_A),
unverifiedParticipant(ADDRESS_B),
],
});
expect(screen.getByRole("button", { name: "온체인에 기록" })).toBeDisabled();
expect(screen.getByText("모든 참여자의 Dojang 인증이 필요합니다")).toBeVisible();
});
Step 2: 실패 확인
Run:
cd frontend
npm test -- src/presentation/features/evidence
Expected: 증빙 UI가 없어 FAIL.
Step 3: 네 단계 플로우 구현
- 범위 선택
- manifest와 content hash 미리보기
- 참여자별 서명 상태
- 트랜잭션 제출과 완료
서명 거부, 만료, RPC 오류, 트랜잭션 실패와 재시도 상태를 각각 표현합니다.
Step 4: 증빙 상세 구현
체인에서 읽은 evidence ID, 시각, 메시지 수, 참여자, content hash, tx hash를 표시합니다. Explorer 링크는 https://sepolia-explorer.giwa.io/tx/<txHash> 형식으로 만듭니다.
Step 5: 검증과 커밋
cd frontend
npm test
npm run typecheck
npm run build
cd ..
git add frontend/src
git commit -m "feat: 온체인 증빙 화면 흐름 완성"
Task 17: 세 주소 실제 브라우저 E2E¶
Files:
- Create:
frontend/playwright.config.ts - Create:
frontend/e2e/support/TestWalletProvider.ts - Create:
frontend/e2e/support/createUserContext.ts - Create:
frontend/e2e/messaging-three-users.spec.ts - Create:
frontend/e2e/dojang-routing.spec.ts - Create:
frontend/e2e/evidence.spec.ts - Create:
docs/runbooks/e2e-test-wallets.md - Create:
docs/verification/2026-07-26-p0-e2e.md
Step 1: E2E 환경 검사 작성
세 개인키 환경변수 중 하나라도 없으면 키 값을 출력하지 않고 변수명만 포함한 명확한 오류로 중단합니다.
Step 2: 테스트 전용 EIP-1193 provider 작성
Playwright 각 context에 하나의 provider를 주입합니다. 개인키는 프로세스 환경에서만 읽습니다. 콘솔, trace, screenshot metadata에 키를 넣지 않습니다.
Step 3: 실제 1:1 E2E 작성
- A 로그인
- B 로그인
- A에서 B에게 고유 메시지 전송
- B에서 같은 message ID 수신
- B 답장
- A 수신
- B와 C도 같은 절차 반복
Step 4: 실제 3인 그룹 E2E 작성
A가 그룹을 생성하고 A, B, C가 각각 고유 메시지를 보냅니다. 각 메시지를 나머지 두 사용자가 스트림으로 수신해야 합니다. 새로고침 후 대화와 메시지를 다시 확인합니다.
Step 5: Dojang 분류 E2E 작성
테스트넷 인증 발급 전 A의 Verified 배지, B와 C의 Unverified 배지, 일반함과 요청함 위치를 기록합니다. 요청함에서 B와 C가 메시지를 보낼 수 있어야 합니다. B 인증 후에는 A와 B가 Verified이고 C가 Unverified인 상태로 다시 분류되는지 검사합니다.
Step 6: 증빙 E2E 작성
C가 포함된 3인 그룹은 증빙이 차단되지만 메시징은 계속돼야 합니다. TESTNET_FAUCET 인증을 마친 A와 B의 1:1 대화에서는 전체 서명, 트랜잭션, 상세 조회까지 검사합니다.
Step 7: 실제 실행
Run:
cd frontend
npm run e2e
Expected:
- 1:1 A와 B 왕복 PASS
- 1:1 B와 C 왕복 PASS
- A, B, C 그룹 송수신 PASS
- Dojang 배지와 라우팅 PASS
- 증빙 거부와 성공 경로 PASS
Step 8: 검증 기록 작성
문서에는 주소, XMTP message ID, conversation ID의 축약값, 송수신 시각, 증빙 tx hash와 테스트 결과를 기록합니다. 개인키, 전체 세션 토큰과 서명 원문은 기록하지 않습니다.
Step 9: 커밋
git add frontend/e2e frontend/playwright.config.ts docs/runbooks/e2e-test-wallets.md docs/verification/2026-07-26-p0-e2e.md
git commit -m "test: 세 사용자 실제 XMTP E2E 추가"
Task 18: 문서, CI와 최종 검증¶
Files:
- Modify:
README.md - Create:
docs/architecture/system.md - Create:
docs/api/http-api.md - Create:
docs/architecture/database.md - Create:
docs/architecture/evidence-contract.md - Create:
docs/runbooks/local-development.md - Create:
.github/workflows/ci.yml - Modify:
mobile/README.md
Step 1: 운영 문서 작성
README에서 로컬 실행, XMTP dev, GIWA Sepolia, 환경변수, 테스트 명령으로 이동할 수 있게 합니다. 문서는 메시지 원문 비저장, Dojang 정책, 증빙 한계와 모바일 이전 경계를 설명합니다.
Step 2: CI 작성
CI는 다음 독립 job을 실행합니다.
frontend: install, test, typecheck, build
backend: postgres service, migrate, test, typecheck
contracts: forge fmt --check, forge test
security: scripts/check-secrets.sh, git diff --check
실제 XMTP dev와 GIWA Sepolia E2E는 개인키가 필요한 수동 승인 job으로 분리합니다.
Step 3: 전체 로컬 검증
Run:
set -e
bash scripts/check-secrets.sh
docker compose up -d postgres
cd backend
npm ci
npm test
npm run typecheck
cd ../frontend
npm ci
npm test
npm run typecheck
npm run build
npm run e2e
cd ../contracts
forge fmt --check
forge test -vvv
cd ..
git diff --check
git status --short
Expected: 모든 명령 exit 0. git status --short에는 의도한 문서 변경만 표시됩니다.
Step 4: 요구사항 대조
docs/plans/2026-07-26-umul-mvp-design.md의 11절 완료 조건을 한 줄씩 대조합니다. 증거가 없는 항목은 완료로 표시하지 않습니다.
Step 5: 최종 커밋
git add README.md docs .github mobile
git commit -m "docs: Umul P0 실행과 검증 문서 완성"
Step 6: 브랜치 동기화
git push origin umul
git status --short --branch
git log --oneline --decorate -10
Expected: 로컬 umul과 origin/umul이 일치하고 작업트리가 깨끗합니다.