Skip to content

Umul P0 MVP Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a mobile-shaped web MVP where three wallet addresses really exchange 1:1 and group messages over XMTP dev, Dojang status is displayed, and conversations agreed to by every participant are recorded as evidence on GIWA Sepolia.

Architecture: The frontend owns the wallet, XMTP E2EE, Dojang lookups, and the evidence manifest. The backend stores only login sessions and evidence signature state in PostgreSQL. The Solidity contract makes the final check on participant signatures and Dojang status, then records the conversation hash.

Tech Stack: React, TypeScript, Vite, XMTP Browser SDK, wagmi, viem, Node.js, Fastify, PostgreSQL, Solidity, Foundry, Vitest, Testing Library, Playwright


Working principles

  • Every behaviour starts from a failing test, per @test-driven-development.
  • Read @frontend-design before UI work and keep functional code separate from presentation code.
  • Read @postgres-best-practices before writing PostgreSQL schema or queries.
  • Use @systematic-debugging to establish the cause of bugs or unexpected SDK behaviour.
  • Re-check the relevant tests and the diff before each commit, per @verification-before-completion.
  • The supplied test private keys are passed only as environment variables. Their values never appear in commands, source, fixtures, logs, or docs.
  • If a test in one P0 stage fails, do not move to the next stage.

Task 1: Repository foundation and secret blocking

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: Write repository safety settings

.gitignore covers these categories.

.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 holds variable names with no values.

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: Write the secret-scanning script

scripts/check-secrets.sh inspects tracked files for private key formats and environment files. It permits test addresses and transaction hashes, and fails any 64-hex candidate without an explicit allowlist entry.

Step 3: Empty per-language project setup

The frontend and backend use independent package.json files and lockfiles. No root npm workspace is created. compose.yaml holds only a development PostgreSQL 17 service, a non-persistent test PostgreSQL 17 service, and their healthchecks.

Step 4: Verify the setup

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: every command exits 0. There is no feature code yet.

Step 5: Commit

git add .gitignore .editorconfig .nvmrc .env.example README.md compose.yaml frontend backend contracts mobile scripts
git commit -m "chore: Umul 프로젝트 기반 구성"

Task 2: Backend application and 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: Write the failing test

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: Confirm the failure

Run:

cd backend
npm test -- src/application/createApp.test.ts

Expected: FAIL, because the createApp module does not exist.

Step 3: Minimal implementation

Implement createApp, which builds the Fastify instance, and the /health route. Only server.ts reads the port and environment variables. Tests never open a server port.

Step 4: Confirm it passes

Run:

cd backend
npm test -- src/application/createApp.test.ts
npm run typecheck

Expected: the health test PASSes and typecheck exits 0.

Step 5: Commit

git add backend/src
git commit -m "feat: 백엔드 health API 추가"

Task 3: PostgreSQL migration foundation

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: Write the failing integration test

Start the test PostgreSQL, run the migrations, and check the following tables and constraints.

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: Confirm the failure

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, because the migration files do not exist.

Step 3: Write the minimal schema

Create the following tables.

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))
);

The evidence tables permit only the states PROPOSED, SIGNING, READY, SUBMITTED, CONFIRMED, REJECTED, EXPIRED, and FAILED. Participants use (request_id, address) as the primary key. Addresses, evidence IDs, content hashes, and signatures are stored without any message plaintext.

Step 4: Confirm it passes

Run:

cd backend
TEST_DATABASE_URL=postgres://umul:umul@localhost:5433/umul_test \
  npm test -- src/infrastructure/postgres/migrations.test.ts

Expected: the migration test PASSes.

Integration tests reset the schema, so they use only umul_test, kept separate from the development database umul. The test code refuses destructive resets on any database not named umul_test or prefixed umul_test_.

Step 5: Commit

git add backend/migrations backend/src/infrastructure/postgres
git commit -m "feat: 인증과 증빙 데이터베이스 스키마 추가"

Task 4: Wallet signature login 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: Write the failing nonce single-use test

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: Confirm the failure

Run:

cd backend
npm test -- src/application/auth/VerifyWalletLogin.test.ts

Expected: FAIL, because the use case does not exist.

Step 3: Minimal domain implementation

  • Validate the checksum, then store the address lowercased.
  • Nonces expire after 5 minutes.
  • The signing message includes domain, URI, chain ID 91342, nonce, issued at, and expiration.
  • Nonces and session tokens are stored as SHA-256 hashes, not plaintext.
  • Reject when the address recovered from the signature differs from the requested address.

Step 4: Write the API tests

Cover POST /v1/auth/nonce, POST /v1/auth/verify, and DELETE /v1/auth/session. Successful responses must include an HttpOnly session cookie.

Step 5: Confirm everything passes

Run:

cd backend
npm test -- src/application/auth src/interfaces/http/routes/auth.test.ts
npm run typecheck

Expected: the auth tests PASS and typecheck exits 0.

Step 6: Commit

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: Mobile web shell and screen boundaries

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: Write the failing render test

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: Confirm the failure

Run:

cd frontend
npm test -- src/presentation/layout/MobileShell.test.tsx

Expected: FAIL, because the component does not exist.

Step 3: Implement design tokens and the shell

  • Cap content width at 430px.
  • On desktop it should look like a device frame without harming web accessibility.
  • Separate colour, spacing, typography, radius, and shadow into CSS variables.
  • Components never import business SDKs.
  • Support prefers-reduced-motion, keyboard focus, and safe areas.

Step 4: Implement static skeletons for screens S1 through S7

Each screen takes only a view model and callback props. No SDK calls or data processing.

Step 5: Confirm it passes

Run:

cd frontend
npm test -- src/presentation
npm run typecheck
npm run build

Expected: presentation tests PASS; typecheck and build exit 0.

Step 6: Commit

git add frontend/src
git commit -m "feat: 모바일 웹 화면 골격 추가"

Task 6: Frontend wallet sign-in use case

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: Write the failing use case test

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: Confirm the failure

Run:

cd frontend
npm test -- src/application/auth/ConnectWallet.test.ts

Expected: FAIL, because the use case does not exist.

Step 3: Minimal implementation

Inject WalletPort and SessionPort. Keep the sequence — connect, switch chain, request nonce, sign message, create session — in the application layer.

Step 4: Wire up per-wallet UI

  • GIWA Wallet or a compatible injected provider
  • MetaMask
  • WalletConnect, enabled when VITE_REOWN_PROJECT_ID is present

When the environment variable is absent, do not fail WalletConnect; show it as needing configuration.

Step 5: Confirm it passes

Run:

cd frontend
npm test -- src/application/auth src/presentation/features/auth
npm run typecheck

Expected: sign-in use case and screen tests PASS.

Step 6: Commit

git add frontend/src/domain/wallet frontend/src/application frontend/src/infrastructure frontend/src/presentation/features/auth
git commit -m "feat: 지갑 서명 로그인 연결"

Task 7: XMTP client and 1:1 messaging

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: Write the failing send test

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: Confirm the failure

Run:

cd frontend
npm test -- src/application/messaging/SendMessage.test.ts

Expected: FAIL, because the messaging use case does not exist.

Step 3: Implement ports and minimal use cases

The application layer never exposes XMTP types. Convert to the domain Message and maintain optimistic, sent, and failed states.

Step 4: Implement the XMTP Browser SDK adapter

  • Pin the environment to dev.
  • Use the persistent browser DB.
  • Convert the wallet signer into an XMTP signer.
  • Query address reachability.
  • Create a DM or open the existing one.
  • Subscribe to the message stream for every conversation.
  • Surface an error to the application layer when the stream ends.

Step 5: Real XMTP integration test

Use the A and B test wallets injected via environment variables. A sends a unique string, and B must receive the same XMTP message ID and body within the time limit.

Run:

cd frontend
UMUL_RUN_XMTP_INTEGRATION=1 npm test -- src/infrastructure/xmtp/XmtpMessagingAdapter.integration.test.ts

Expected: the real XMTP dev round trip PASSes. No private keys printed.

Step 6: Wire up the UI and verify

Run:

cd frontend
npm test -- src/application/messaging src/presentation/features/chat
npm run typecheck
npm run build

Expected: unit tests PASS; typecheck and build exit 0.

Step 7: Commit

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 group messaging

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: Write the failing participant normalization test

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: Confirm the failure

Run:

cd frontend
npm test -- src/application/messaging/CreateGroup.test.ts

Expected: FAIL, because CreateGroup does not exist.

Step 3: Implement the group use case and the XMTP adapter

Remove duplicate addresses and your own address. If any address cannot receive XMTP, return which ones failed. Sync the conversation list after creating the group.

Step 4: Real three-person group integration test

A creates an A/B/C group. A, B, and C each send a unique message, and every other participant must receive it.

Run:

cd frontend
UMUL_RUN_XMTP_INTEGRATION=1 npm test -- src/infrastructure/xmtp/XmtpGroup.integration.test.ts

Expected: the real XMTP dev three-person group test PASSes.

Step 5: Full verification and commit

cd frontend
npm test
npm run typecheck
npm run build
cd ..
git add frontend/src
git commit -m "feat: XMTP 그룹 메시징 추가"

Task 9: Dojang status and inbox routing

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: Write the failing routing test

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);
});

Also write a test asserting that a spam marking outranks verification status.

Step 2: Confirm the failure

Run:

cd frontend
npm test -- src/domain/inbox/classifyConversation.test.ts

Expected: FAIL, because the routing function does not exist.

Step 3: Implement routing and the cache

  • Keep verified, unverified, and unknown as distinct values.
  • Cache TTL is one hour.
  • RPC errors return unknown.
  • The evidence pre-check bypasses the cache.

Step 4: Real GIWA Sepolia lookup test

Query addresses A, B, and C under the TESTNET_FAUCET attester ID. The current expectation is A true, B false, C false. If on-chain state changes, surface the actual values in the failure message and update the completion criteria document.

Run:

cd frontend
UMUL_RUN_GIWA_INTEGRATION=1 npm test -- src/infrastructure/dojang/ViemDojangAdapter.integration.test.ts

Expected: the live GIWA Sepolia lookup PASSes.

Step 5: Verify badge and tab 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: unit tests PASS; typecheck and build exit 0.

Step 6: Commit

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: Write the failing test

Check that marking a conversation as spam calls the MessagingPort's denied consent state and that routing becomes spam.

Step 2: Confirm the failure

Run:

cd frontend
npm test -- src/application/messaging/MarkConversationSpam.test.ts

Expected: FAIL, because the use case does not exist.

Step 3: Minimal implementation

Use the XMTP consent state to sync manual spam marking. Users can also unblock from the spam inbox.

Step 4: Full verification and commit

cd frontend
npm test
npm run typecheck
npm run build
cd ..
git add frontend/src
git commit -m "feat: 대화 스팸 지정 기능 추가"

Task 11: The conversation evidence contract

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: Write the failing test

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);
}

Separate the tests for success with all verified participants signing, an unverified participant, duplicate addresses, ordering errors, expiry, and replay. Include a test rejecting fewer than two participants.

Step 2: Confirm the failure

Run:

cd contracts
forge test --match-contract ConversationEvidenceRegistryTest -vvv

Expected: compilation FAILs, because the contract does not exist.

Step 3: Minimal contract implementation

struct Evidence {
    bytes32 conversationHash;
    bytes32 contentHash;
    bytes32 participantsHash;
    uint32 messageCount;
    uint64 startedAt;
    uint64 endedAt;
    bytes32 nonce;
    uint64 deadline;
}

The constructor stores the DojangScroll address and attester ID as immutables. The EIP-712 domain uses the name GasokConversationEvidence, version 1, the current chain ID, and the contract address.

recordEvidence compares participant and signature lengths and checks ascending address order and duplicates. Each signature must recover the corresponding participant address from the same typed data digest. Dojang isVerified must be true for every participant.

P0 supports EOA signatures only. EIP-1271 smart accounts are documented as a P1 item.

Step 4: Fuzz and full tests

Run:

cd contracts
forge fmt --check
forge test -vvv

Expected: unit and fuzz tests PASS.

Step 5: Commit

git add contracts/src contracts/test
git commit -m "feat: 대화 다중 서명 증빙 컨트랙트 추가"

Task 12: Evidence contract deployment and fork verification

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: Write the failing fork test

At the start of testing, record a GIWA Sepolia block where A is Verified and B and C are Unverified. Fork that block and confirm that against the real DojangScroll A is true while B and C are false. Evidence containing A and B must fail with ParticipantNotVerified.

Step 2: Confirm the failure

Run:

cd contracts
GIWA_SEPOLIA_RPC_URL="$GIWA_SEPOLIA_RPC_URL" forge test --match-contract ConversationEvidenceRegistryForkTest -vvv

Expected: FAIL, because the fork test or the deploy script does not exist.

Step 3: Write the deploy script and runbooks

The deploy private key is read from the DEPLOYER_PRIVATE_KEY environment variable. The runbook contains commands and verification steps only, never key values.

obtain-testnet-dojang.md records the procedure for address B to receive a TESTNET_FAUCET attestation through GIWA's official testnet verification screen, plus the DojangScroll.isVerified() re-check command. The states of A, B, and C and the baseline block are recorded before verification is issued.

Step 4: Verify B on testnet and deploy to GIWA Sepolia

Only B receives TESTNET_FAUCET verification. C stays Unverified. Confirm B's isVerified() has flipped to true, then deploy the contract.

Run:

cd contracts
forge script script/DeployConversationEvidence.s.sol:DeployConversationEvidence \
  --rpc-url "$GIWA_SEPOLIA_RPC_URL" \
  --broadcast \
  --verify

Expected: deployment transaction succeeds and Blockscout verification succeeds.

Step 5: Verify the deployment artifact

giwa-sepolia.json stores only the chain ID, contract address, deployment transaction, block number, DojangScroll address, attester ID, and the fork baseline block. No private keys or RPC credentials.

Step 6: Commit

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: Backend evidence state machine and 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: Write the failing state transition test

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");
});

Separately cover non-participants, duplicate signatures, signing after rejection, signing after expiry, and invalid state transitions.

Step 2: Confirm the failure

Run:

cd backend
npm test -- src/domain/evidence src/application/evidence

Expected: FAIL, because the evidence domain does not exist.

Step 3: Minimal domain and use case implementation

The EvidenceRequest aggregate decides state changes. The repository provides aggregate persistence and idempotency key lookup.

Step 4: PostgreSQL repository integration test

Run two concurrent signature requests and check that only one row is stored and the state stays consistent. State changes use SELECT FOR UPDATE or an equivalent conditional UPDATE.

Step 5: Implement the 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

The creation API requires an Idempotency-Key. Reading and signing are allowed only when the session address is a participant.

Step 6: Full verification and commit

cd backend
npm test
npm run typecheck
cd ..
git add backend/src
git commit -m "feat: 증빙 서명 상태와 API 추가"

Task 14: The evidence manifest and XMTP control message

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: Write the failing normalization test

The same messages supplied in a different input order must produce the same content hash.

expect(hashEvidenceManifest([MESSAGE_B, MESSAGE_A]))
  .toBe(hashEvidenceManifest([MESSAGE_A, MESSAGE_B]));

Changing one character of the body, the sender, the time, the participants, or the nonce must change the hash.

Step 2: Confirm the failure

Run:

cd frontend
npm test -- src/domain/evidence/hashEvidenceManifest.test.ts

Expected: FAIL, because the hash function does not exist.

Step 3: Minimal manifest implementation

Use viem ABI encoding and keccak256. Sort addresses, and sort messages by sentAtNs then messageId. Convert plaintext into individual hashes, then hash the whole manifest.

Step 4: Implement the XMTP control message

P0 uses a versioned text envelope instead of a separate codec.

gasok:evidence:v1:<base64url-json>

The ordinary chat UI hides messages with this prefix and converts them into an evidence request card. The payload holds the request ID, manifest, and message refs. No plaintext leaves XMTP encryption.

Step 5: Wire the Dojang pre-check to API creation

Participant status is queried with the cache bypassed. If even one is not verified, neither the API request nor the XMTP control message is created.

Step 6: Verify and commit

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: Participant signing and on-chain submission

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: Write the failing hash mismatch test

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: Confirm the failure

Run:

cd frontend
npm test -- src/application/evidence/SignEvidenceProposal.test.ts

Expected: FAIL, because the signing use case does not exist.

Step 3: Implement the signing use case

  • Re-read local messages.
  • Recompute the manifest hash.
  • Check participants and evidence request state.
  • Include chain ID 91342 and the deployment address in the contract domain.
  • Submit to the backend after signing the typed data.

Step 4: Implement the on-chain submission use case

Take the sorted participants and signatures from a READY request and call the contract. Record the tx hash with the backend after a successful receipt.

Step 5: Real contract integration test

A submission including C must be rejected on live GIWA Sepolia. The success path runs on the A–B 1:1 conversation, both TESTNET_FAUCET verified.

Run:

cd frontend
UMUL_RUN_GIWA_INTEGRATION=1 npm test -- src/infrastructure/chain/ViemEvidenceContractAdapter.integration.test.ts

Expected: the rejection path including C PASSes. The A–B success transaction PASSes.

Step 6: Verify and commit

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: The S5 evidence flow and S6 detail screen

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: Write the failing unverified-participant UI test

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: Confirm the failure

Run:

cd frontend
npm test -- src/presentation/features/evidence

Expected: FAIL, because the evidence UI does not exist.

Step 3: Implement the four-step flow

  • Range selection
  • Manifest and content hash preview
  • Per-participant signature status
  • Transaction submission and completion

Represent declined signatures, expiry, RPC errors, transaction failure, and retry states separately.

Step 4: Implement the evidence detail view

Display the evidence ID, time, message count, participants, content hash, and tx hash read from the chain. Build the explorer link as https://sepolia-explorer.giwa.io/tx/<txHash>.

Step 5: Verify and commit

cd frontend
npm test
npm run typecheck
npm run build
cd ..
git add frontend/src
git commit -m "feat: 온체인 증빙 화면 흐름 완성"

Task 17: Real browser E2E with three addresses

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: Write the E2E environment check

If any of the three private key environment variables is missing, abort with a clear error naming only the variable, never printing a key value.

Step 2: Write the test-only EIP-1193 provider

Inject one provider per Playwright context. Private keys are read only from the process environment. No keys in the console, traces, or screenshot metadata.

Step 3: Write the real 1:1 E2E

  • A signs in
  • B signs in
  • A sends a unique message to B
  • B receives the same message ID
  • B replies
  • A receives it
  • Repeat the same procedure for B and C

Step 4: Write the real three-person group E2E

A creates a group and A, B, and C each send a unique message. The other two users must receive each message on the stream. Re-check conversations and messages after a refresh.

Step 5: Write the Dojang routing E2E

Before testnet verification is issued, record A's Verified badge, B's and C's Unverified badges, and their main/request inbox placement. B and C must be able to send messages from the request inbox. After B is verified, check that routing updates to A and B Verified with C Unverified.

Step 6: Write the evidence E2E

The three-person group including C must have evidence blocked while messaging continues. On the A–B 1:1 conversation, both TESTNET_FAUCET verified, check the full signing, transaction, and detail lookup.

Step 7: Real run

Run:

cd frontend
npm run e2e

Expected:

  • 1:1 A and B round trip PASS
  • 1:1 B and C round trip PASS
  • A, B, C group send and receive PASS
  • Dojang badges and routing PASS
  • Evidence rejection and success paths PASS

Step 8: Write the verification record

The document records addresses, XMTP message IDs, abbreviated conversation IDs, send and receive times, the evidence tx hash, and test results. It does not record private keys, full session tokens, or raw signatures.

Step 9: Commit

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: Documentation, CI, and final verification

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: Write the operational documentation

The README should lead to local execution, XMTP dev, GIWA Sepolia, environment variables, and test commands. The docs explain that message plaintext is never stored, the Dojang policy, evidence limits, and the mobile migration boundary.

Step 2: Write CI

CI runs these independent jobs.

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

The live XMTP dev and GIWA Sepolia E2E, which need private keys, is split into a manually approved job.

Step 3: Full local verification

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: every command exits 0. git status --short shows only the intended documentation changes.

Step 4: Requirements cross-check

Walk the completion criteria in §11 of docs/plans/2026-07-26-umul-mvp-design.md line by line. Any item without evidence is not marked complete.

Step 5: Final commit

git add README.md docs .github mobile
git commit -m "docs: Umul P0 실행과 검증 문서 완성"

Step 6: Branch sync

git push origin umul
git status --short --branch
git log --oneline --decorate -10

Expected: local umul matches origin/umul and the working tree is clean.