콘텐츠로 이동

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 배지와 수신함 분류 추가"

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이 일치하고 작업트리가 깨끗합니다.