diff --git a/week-06/dev/README.md b/week-06/dev/README.md index 2bbad5cf..45fb0fc2 100644 --- a/week-06/dev/README.md +++ b/week-06/dev/README.md @@ -1,120 +1,111 @@ -# Week 6: 최종 프로젝트 - 나만의 dApp +# MilestoneFunding - 마일스톤 크라우드펀딩 dApp -6주간 배운 내용을 총동원하여 나만의 dApp을 만들어보세요! +> 후원자 투표로 자금을 단계별 릴리즈하는 크라우드펀딩 플랫폼 -## 개요 +## 프로젝트 소개 -**자유 주제**로 dApp을 개발합니다. 컨트랙트부터 프론트엔드까지 직접 구현하고, Sepolia 테스트넷에 배포합니다. +기존 크라우드펀딩의 가장 큰 문제는 "돈 받고 안 만듦"입니다. MilestoneFunding은 펀딩 금액을 한 번에 주지 않고, **마일스톤마다 후원자 투표를 통과해야 다음 자금이 풀리는** 구조로 이 문제를 해결합니다. -**목표:** -- 스마트 컨트랙트 설계 및 구현 -- Foundry로 테스트 작성 -- wagmi + RainbowKit으로 프론트엔드 연동 -- Sepolia 배포 및 검증 +### 핵심 플로우 +1. 크리에이터가 프로젝트 생성 (목표 금액 + 마일스톤 설정) +2. 후원자들이 ETH로 후원 (크리에이터는 자기 프로젝트에 후원 불가) +3. 크리에이터가 마일스톤 완료 신청 +4. 후원자들이 찬성/반대 투표 (기여금 비례 가중치) +5. 과반 찬성 → 해당 비율 자금 릴리즈 / 과반 반대 → 남은 자금 환불 -## 체크리스트 +## 기술 스택 -**반드시 [CHECKLIST.md](./CHECKLIST.md)의 모든 필수 항목을 충족해야 합니다.** +| 구분 | 기술 | +|------|------| +| Smart Contract | Solidity 0.8.26, Foundry | +| Frontend | Next.js 16, TypeScript, Tailwind CSS | +| Web3 | wagmi v2, viem, RainbowKit | +| Network | Sepolia Testnet | -주요 항목: -- Smart Contract: Solidity 0.8.26+, 상태 변수, public 함수, 이벤트, 테스트 5개+ -- Frontend: Next.js, wagmi, RainbowKit, 컨트랙트 연동, 에러 처리 -- Deployment: Sepolia 배포, 컨트랙트 주소 README 기재 +## 배포 정보 -## 아이디어 예시 +| 항목 | 값 | +|------|-----| +| Network | Sepolia | +| Factory | `0x63c5ED29c8BF61277542acB5f79672505382062d` | +| Etherscan | https://sepolia.etherscan.io/address/0x63c5ED29c8BF61277542acB5f79672505382062d | -아이디어가 떠오르지 않는다면 아래 예시를 참고하세요: +## 설치 및 실행 -### 1. 간단한 투표 시스템 -- 후보자 등록 -- 투표하기 (1인 1표) -- 결과 조회 +### 컨트랙트 테스트 -```solidity -// 핵심 기능 -mapping(address => bool) public hasVoted; -mapping(uint256 => uint256) public votes; -function vote(uint256 candidateId) external { ... } +```bash +forge test --match-path week-06/dev/test/MilestoneFunding.t.sol -vv ``` -### 2. 기부/펀딩 컨트랙트 -- 목표 금액 설정 -- ETH 기부하기 -- 목표 달성 시 수령 +### 프론트엔드 실행 -```solidity -// 핵심 기능 -uint256 public goal; -function donate() external payable { ... } -function withdraw() external { ... } +```bash +cd week-06/dev/frontend +npm install +npm run dev ``` -### 3. 메시지 저장소 -- 메시지 작성 (on-chain) -- 메시지 목록 조회 -- 작성자별 필터링 +`http://localhost:3000` 에서 확인 -```solidity -// 핵심 기능 -struct Message { address author; string content; uint256 timestamp; } -Message[] public messages; -function post(string calldata content) external { ... } -``` - -### 4. 간단한 NFT 민팅 -- ERC721 기본 구현 -- 민팅 기능 -- 소유자 확인 +## 프로젝트 구조 -```solidity -// 핵심 기능 (OpenZeppelin 사용 가능) -function mint() external { ... } -function tokenURI(uint256 tokenId) public view returns (string memory) { ... } ``` - -### 5. 에스크로 컨트랙트 -- 구매자가 ETH 예치 -- 판매자가 배송 후 확인 -- 구매자 확인 후 ETH 지급 - -```solidity -// 핵심 기능 -enum State { Created, Funded, Shipped, Completed } -State public state; -function fund() external payable { ... } -function confirmReceived() external { ... } +week-06/dev/ +├── src/ +│ ├── MilestoneFunding.sol # 개별 프로젝트 컨트랙트 +│ └── MilestoneFundingFactory.sol # 프로젝트 생성 팩토리 +├── test/ +│ └── MilestoneFunding.t.sol # Foundry 테스트 (9개) +├── script/ +│ └── Deploy.s.sol # 배포 스크립트 +├── frontend/ +│ ├── app/ +│ │ ├── page.tsx # 메인 (프로젝트 목록) +│ │ ├── create/page.tsx # 프로젝트 생성 +│ │ └── project/page.tsx # 프로젝트 상세 +│ ├── components/ +│ │ ├── ProjectCard.tsx # 프로젝트 카드 +│ │ ├── FundProject.tsx # 후원 폼 +│ │ ├── ActionPanel.tsx # 투표/확정/환불 +│ │ ├── Countdown.tsx # 마감 카운트다운 +│ │ └── MyContribution.tsx # 내 기여 정보 +│ └── config/ +│ ├── wagmi.ts # wagmi + Sepolia 설정 +│ └── contract.ts # ABI + 컨트랙트 주소 +└── README.md ``` -## 참고 자료 - -- [최종 프로젝트 상세 가이드](/eth-materials/week-06/dev/final-project.md) -- [wagmi 가이드](/eth-materials/week-04/dev/wagmi-basics.md) -- [RainbowKit 가이드](/eth-materials/week-05/dev/rainbowkit-guide.md) -- [프론트엔드 템플릿](/eth-materials/resources/frontend-template/) - -## 제출 방법 +## 컨트랙트 주요 함수 -1. `week-06/dev/` 폴더에 프로젝트 코드 작성 -2. README.md에 프로젝트 설명, 기술 스택, 컨트랙트 주소 기재 -3. [CHECKLIST.md](./CHECKLIST.md)를 PR 본문에 복사하고 완료 항목 체크 -4. PR 생성 +| 함수 | 설명 | 호출자 | +|------|------|--------| +| `fund()` | ETH 후원 | 후원자 (크리에이터 제외) | +| `submitMilestone()` | 마일스톤 완료 제출 | 크리에이터 | +| `vote(bool)` | 찬성/반대 투표 | 후원자 | +| `finalizeMilestone()` | 투표 결과 확정 | 누구나 | +| `claimRefund()` | 환불 청구 | 후원자 | -## 제출 마감 +## 보안 -**마감일: [TBD]** +- **CEI 패턴**: 모든 ETH 전송 함수에 Checks-Effects-Interactions 적용 +- **재진입 방지**: 상태 변경 후 외부 호출 (hasRefunded 플래그) +- **가중 투표**: 기여금 비례 투표로 시빌 공격 방지 +- **자기 후원 방지**: 크리에이터가 자기 프로젝트에 후원 불가 +- **비례 환불**: 이미 릴리즈된 자금을 제외한 잔여 금액 비례 분배 -마감 후에는 PR을 생성할 수 없습니다. 여유를 두고 미리 제출하세요! +## 테스트 결과 -## 발표 - -최종 발표에서 프로젝트를 시연합니다: -- 5분 발표 + 2분 Q&A -- 데모 시연 필수 -- 코드 설명 선택 - ---- - -> **응원의 말씀:** -> 6주간 열심히 달려왔습니다. 마지막 프로젝트는 여러분이 배운 모든 것을 보여줄 기회입니다. -> 완벽하지 않아도 괜찮습니다. 도전하고, 실패하고, 배우는 과정 자체가 가치 있습니다. -> 화이팅! +``` +9 tests passed (0 failed) + +- test_Fund_UpdatesContribution +- test_Fund_TransitionsToActiveWhenGoalMet +- test_Fund_EmitsEvent +- test_SubmitMilestone_SetsVotingState +- test_Vote_AndFinalize_ApproveMilestone +- test_Vote_AndFinalize_RejectMilestone +- test_ClaimRefund_AfterMilestoneRejection +- test_ClaimRefund_AfterDeadlineExpiry +- test_RevertWhen_ReentrancyOnClaimRefund +``` diff --git a/week-06/dev/frontend/.gitignore b/week-06/dev/frontend/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/week-06/dev/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/week-06/dev/frontend/AGENTS.md b/week-06/dev/frontend/AGENTS.md new file mode 100644 index 00000000..8bd0e390 --- /dev/null +++ b/week-06/dev/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/week-06/dev/frontend/CLAUDE.md b/week-06/dev/frontend/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/week-06/dev/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/week-06/dev/frontend/README.md b/week-06/dev/frontend/README.md new file mode 100644 index 00000000..e215bc4c --- /dev/null +++ b/week-06/dev/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/week-06/dev/frontend/app/create/page.tsx b/week-06/dev/frontend/app/create/page.tsx new file mode 100644 index 00000000..974d72a5 --- /dev/null +++ b/week-06/dev/frontend/app/create/page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useAccount, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { parseEther } from "viem"; +import { FACTORY_ADDRESS, FACTORY_ABI } from "@/config/contract"; + +interface MilestoneInput { + description: string; + percentage: string; +} + +export default function CreatePage() { + const router = useRouter(); + const { isConnected } = useAccount(); + + const [goal, setGoal] = useState(""); + const [daysFromNow, setDaysFromNow] = useState("7"); + const [milestones, setMilestones] = useState([ + { description: "", percentage: "30" }, + { description: "", percentage: "30" }, + { description: "", percentage: "40" }, + ]); + + const { writeContract, data: hash, isPending, error } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + const totalPercentage = milestones.reduce((sum, m) => sum + (parseInt(m.percentage) || 0), 0); + const isValid = goal && parseFloat(goal) > 0 && totalPercentage === 100 && milestones.every((m) => m.description.trim()); + + const addMilestone = () => { + setMilestones([...milestones, { description: "", percentage: "0" }]); + }; + + const removeMilestone = (index: number) => { + if (milestones.length <= 1) return; + setMilestones(milestones.filter((_, i) => i !== index)); + }; + + const updateMilestone = (index: number, field: keyof MilestoneInput, value: string) => { + const updated = [...milestones]; + updated[index] = { ...updated[index], [field]: value }; + setMilestones(updated); + }; + + const handleCreate = () => { + if (!isValid) return; + + const deadlineTimestamp = BigInt(Math.floor(Date.now() / 1000) + parseInt(daysFromNow) * 86400); + + writeContract({ + address: FACTORY_ADDRESS, + abi: FACTORY_ABI, + functionName: "createProject", + args: [ + parseEther(goal), + deadlineTimestamp, + milestones.map((m) => m.description), + milestones.map((m) => BigInt(parseInt(m.percentage))), + ], + }); + }; + + if (isSuccess) { + setTimeout(() => router.push("/"), 2000); + } + + return ( +
+ + +
+ + ← 목록으로 + + +

새 프로젝트 생성

+

마일스톤 기반 크라우드펀딩 프로젝트를 만들어보세요.

+ + {!isConnected ? ( +
+

지갑을 연결해야 프로젝트를 생성할 수 있습니다

+ +
+ ) : ( +
+ {/* 목표 금액 */} +
+

기본 정보

+
+ + setGoal(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-blue-500" + /> +
+
+ + setDaysFromNow(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-blue-500" + /> +
+
+ + {/* 마일스톤 설정 */} +
+
+

마일스톤 설정

+ + 합계: {totalPercentage}% + +
+ +
+ {milestones.map((ms, i) => ( +
+
+ 마일스톤 #{i + 1} + {milestones.length > 1 && ( + + )} +
+ updateMilestone(i, "description", e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-blue-500" + /> +
+ + updateMilestone(i, "percentage", e.target.value)} + className="w-20 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white text-center focus:outline-none focus:border-blue-500" + /> + % +
+
+ ))} +
+ + +
+ + {/* 생성 버튼 */} + + + {isSuccess && ( +
+

프로젝트가 생성되었습니다!

+

메인 페이지로 이동합니다...

+
+ )} + + {error && ( +
+

{error.message.split("\n")[0]}

+
+ )} +
+ )} +
+
+ ); +} diff --git a/week-06/dev/frontend/app/favicon.ico b/week-06/dev/frontend/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/week-06/dev/frontend/app/favicon.ico differ diff --git a/week-06/dev/frontend/app/globals.css b/week-06/dev/frontend/app/globals.css new file mode 100644 index 00000000..a2dc41ec --- /dev/null +++ b/week-06/dev/frontend/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/week-06/dev/frontend/app/layout.tsx b/week-06/dev/frontend/app/layout.tsx new file mode 100644 index 00000000..aeeb8e11 --- /dev/null +++ b/week-06/dev/frontend/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import Providers from "./providers"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "MilestoneFunding - 마일스톤 크라우드펀딩", + description: "후원자 투표로 자금을 단계별 릴리즈하는 크라우드펀딩 dApp", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/week-06/dev/frontend/app/page.tsx b/week-06/dev/frontend/app/page.tsx new file mode 100644 index 00000000..a18db178 --- /dev/null +++ b/week-06/dev/frontend/app/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Link from "next/link"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useAccount, useReadContract } from "wagmi"; +import { FACTORY_ADDRESS, FACTORY_ABI } from "@/config/contract"; +import ProjectCard from "@/components/ProjectCard"; + +export default function Home() { + const { isConnected } = useAccount(); + + const { data: projects, isLoading } = useReadContract({ + address: FACTORY_ADDRESS, + abi: FACTORY_ABI, + functionName: "getProjects", + }); + + const projectList = projects ?? []; + + return ( +
+ + +
+ {/* 히어로 */} +
+

+ 후원자가{" "} + 통제하는{" "} + 크라우드펀딩 +

+

+ 마일스톤마다 후원자 투표를 통과해야 자금이 릴리즈됩니다 +

+ {!isConnected && ( +
+ +
+ )} +
+ + {/* 프로젝트 목록 */} +
+

{projectList.length}개 프로젝트

+
+ + {isLoading ? ( +
+ {[1, 2].map((i) =>
)} +
+ ) : projectList.length === 0 ? ( +
+

아직 프로젝트가 없습니다

+ {isConnected && ( + + 첫 프로젝트 만들기 + + )} +
+ ) : ( +
+ {[...projectList].reverse().map((addr) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/week-06/dev/frontend/app/project/page.tsx b/week-06/dev/frontend/app/project/page.tsx new file mode 100644 index 00000000..1276558d --- /dev/null +++ b/week-06/dev/frontend/app/project/page.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useAccount, useReadContract } from "wagmi"; +import { formatEther } from "viem"; +import { + MILESTONE_FUNDING_ABI, + PROJECT_STATE, + PROJECT_STATE_KO, + MILESTONE_STATE, + MILESTONE_STATE_KO, +} from "@/config/contract"; +import Countdown from "@/components/Countdown"; +import FundProject from "@/components/FundProject"; +import ActionPanel from "@/components/ActionPanel"; + +function MilestoneCard({ contractAddress, index }: { contractAddress: `0x${string}`; index: number }) { + const { data } = useReadContract({ + address: contractAddress, + abi: MILESTONE_FUNDING_ABI, + functionName: "getMilestone", + args: [BigInt(index)], + }); + + if (!data) return
; + + const [description, percentage, milestoneState, votingDeadline, approveVotes, rejectVotes] = data; + const stateLabel = MILESTONE_STATE[milestoneState] ?? "Unknown"; + const totalVotes = approveVotes + rejectVotes; + const approveRate = totalVotes > 0n ? Number((approveVotes * 100n) / totalVotes) : 0; + const isVoting = milestoneState === 1; + const votingEnd = new Date(Number(votingDeadline) * 1000); + + const dotColor: Record = { + Pending: "bg-gray-600", Voting: "bg-yellow-400 animate-pulse", Approved: "bg-green-400", Rejected: "bg-red-400", + }; + + return ( +
+
+
+
+
+
+
+ {description} + {MILESTONE_STATE_KO[stateLabel]} · {Number(percentage)}% +
+ {isVoting &&

투표 마감 {votingEnd.toLocaleString("ko-KR")}

} + {totalVotes > 0n && ( +
+
+
+
+ {approveRate}% +
+ )} +
+
+ ); +} + +function RoleBanner({ contractAddress }: { contractAddress: `0x${string}` }) { + const { address } = useAccount(); + const { data: creatorAddr } = useReadContract({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "creator" }); + const { data: contribution } = useReadContract({ + address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "contributions", + args: address ? [address] : undefined, + }); + + if (!address) return null; + + const isCreator = address.toLowerCase() === creatorAddr?.toLowerCase(); + const isFunder = contribution ? contribution > 0n : false; + const { data: totalFunded } = useReadContract({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "totalFunded" }); + const votingPower = totalFunded && totalFunded > 0n && contribution ? Number((contribution * 10000n) / totalFunded) / 100 : 0; + + if (!isCreator && !isFunder) return null; + + return ( +
+ + {isCreator ? "이 프로젝트의 크리에이터입니다" : `후원자 · ${formatEther(contribution!)} ETH 기여`} + + {isFunder && !isCreator && 투표 가중치 {votingPower}%} +
+ ); +} + +function ProjectDetail() { + const searchParams = useSearchParams(); + const addr = searchParams.get("address") as `0x${string}` | null; + const { isConnected } = useAccount(); + + const { data: projectInfo, isLoading, error } = useReadContract({ + address: addr ?? "0x", abi: MILESTONE_FUNDING_ABI, functionName: "getProjectInfo", + }); + const { data: milestoneCount } = useReadContract({ + address: addr ?? "0x", abi: MILESTONE_FUNDING_ABI, functionName: "getMilestoneCount", + }); + + if (!addr) return

잘못된 주소입니다

; + if (isLoading) return
; + if (error || !projectInfo) return

프로젝트를 불러올 수 없습니다

; + + const [creator, goal, deadline, totalFunded, totalReleased, state, currentMilestone, msCount] = projectInfo; + const progress = goal > 0n ? Number((totalFunded * 100n) / goal) : 0; + const stateLabel = PROJECT_STATE[state] ?? "Unknown"; + const count = milestoneCount ? Number(milestoneCount) : 0; + + const stateColor: Record = { + Funding: "text-blue-400 bg-blue-500/10", Active: "text-green-400 bg-green-500/10", + Completed: "text-purple-400 bg-purple-500/10", Failed: "text-red-400 bg-red-500/10", + }; + + return ( +
+ + 목록 + + + {/* 역할 배너 */} + {isConnected && } + + {/* 상태 + 모금 */} +
+
+ + {PROJECT_STATE_KO[stateLabel]} + + {creator.slice(0, 6)}...{creator.slice(-4)} +
+ +
+
+
+ {formatEther(totalFunded)} + / {formatEther(goal)} ETH +
+ {Math.min(progress, 100)}% +
+
+
+
+
+ +
+
+

남은 시간

+

+
+
+

릴리즈됨

+

{formatEther(totalReleased)} ETH

+
+
+

마일스톤

+

{Number(currentMilestone)}/{Number(msCount)}

+
+
+
+ + {/* 구분선 */} +
+ + {/* 마일스톤 */} +
+

마일스톤

+ {Array.from({ length: count }, (_, i) => ( + + ))} +
+ + {/* 구분선 */} +
+ + {/* 액션 */} + {isConnected ? ( +
+ + +
+ ) : ( +
+

지갑을 연결하면 후원과 투표를 할 수 있습니다

+ +
+ )} +
+ ); +} + +export default function ProjectPage() { + return ( +
+ + }> + + +
+ ); +} diff --git a/week-06/dev/frontend/app/providers.tsx b/week-06/dev/frontend/app/providers.tsx new file mode 100644 index 00000000..f5a805e1 --- /dev/null +++ b/week-06/dev/frontend/app/providers.tsx @@ -0,0 +1,21 @@ +"use client"; + +import "@rainbow-me/rainbowkit/styles.css"; +import { RainbowKitProvider } from "@rainbow-me/rainbowkit"; +import { WagmiProvider } from "wagmi"; +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { config } from "@/config/wagmi"; + +const queryClient = new QueryClient(); + +export default function Providers({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/week-06/dev/frontend/components/ActionPanel.tsx b/week-06/dev/frontend/components/ActionPanel.tsx new file mode 100644 index 00000000..f5b39e14 --- /dev/null +++ b/week-06/dev/frontend/components/ActionPanel.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { MILESTONE_FUNDING_ABI } from "@/config/contract"; + +function ActionButton({ + label, loadingLabel, confirmingLabel, onClick, isPending, isConfirming, isSuccess, successLabel, error, variant = "blue", +}: { + label: string; loadingLabel: string; confirmingLabel: string; onClick: () => void; + isPending: boolean; isConfirming: boolean; isSuccess: boolean; successLabel: string; + error: Error | null; variant?: "blue" | "green" | "red" | "yellow"; +}) { + const colors = { blue: "bg-blue-600 hover:bg-blue-700", green: "bg-green-600 hover:bg-green-700", red: "bg-red-600 hover:bg-red-700", yellow: "bg-yellow-600 hover:bg-yellow-700" }; + return ( +
+ + {isSuccess &&

{successLabel}

} + {error &&

{error.message.split("\n")[0]}

} +
+ ); +} + +export default function ActionPanel({ contractAddress }: { contractAddress: `0x${string}` }) { + const { address } = useAccount(); + + const { data: projectInfo } = useReadContract({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "getProjectInfo" }); + const { data: creatorAddr } = useReadContract({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "creator" }); + const { data: contribution } = useReadContract({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "contributions", args: address ? [address] : undefined }); + + const { writeContract: submitWrite, data: submitHash, isPending: submitPending, error: submitError } = useWriteContract(); + const { isLoading: submitConfirming, isSuccess: submitSuccess } = useWaitForTransactionReceipt({ hash: submitHash }); + + const { writeContract: approveWrite, data: approveHash, isPending: approvePending, error: approveError } = useWriteContract(); + const { isLoading: approveConfirming, isSuccess: approveSuccess } = useWaitForTransactionReceipt({ hash: approveHash }); + + const { writeContract: rejectWrite, data: rejectHash, isPending: rejectPending, error: rejectError } = useWriteContract(); + const { isLoading: rejectConfirming, isSuccess: rejectSuccess } = useWaitForTransactionReceipt({ hash: rejectHash }); + + const { writeContract: finalizeWrite, data: finalizeHash, isPending: finalizePending, error: finalizeError } = useWriteContract(); + const { isLoading: finalizeConfirming, isSuccess: finalizeSuccess } = useWaitForTransactionReceipt({ hash: finalizeHash }); + + const { writeContract: refundWrite, data: refundHash, isPending: refundPending, error: refundError } = useWriteContract(); + const { isLoading: refundConfirming, isSuccess: refundSuccess } = useWaitForTransactionReceipt({ hash: refundHash }); + + if (!address || !projectInfo) return null; + + const [, , , , , state] = projectInfo; + const isCreator = address.toLowerCase() === creatorAddr?.toLowerCase(); + const isFunder = contribution ? contribution > 0n : false; + + const call = (write: typeof submitWrite, functionName: string, args?: readonly unknown[]) => { + write({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName, ...(args ? { args } : {}) } as Parameters[0]); + }; + + return ( +
+

액션

+ + {isCreator && state === 1 && ( + call(submitWrite, "submitMilestone")} + isPending={submitPending} isConfirming={submitConfirming} isSuccess={submitSuccess} + successLabel="마일스톤 제출 완료!" error={submitError} variant="yellow" /> + )} + + {isFunder && state === 1 && ( +
+

마일스톤 투표

+
+ call(approveWrite, "vote", [true])} + isPending={approvePending} isConfirming={approveConfirming} isSuccess={approveSuccess} + successLabel="찬성!" error={approveError} variant="green" /> + call(rejectWrite, "vote", [false])} + isPending={rejectPending} isConfirming={rejectConfirming} isSuccess={rejectSuccess} + successLabel="반대!" error={rejectError} variant="red" /> +
+
+ )} + + {state === 1 && ( + call(finalizeWrite, "finalizeMilestone")} + isPending={finalizePending} isConfirming={finalizeConfirming} isSuccess={finalizeSuccess} + successLabel="마일스톤 확정!" error={finalizeError} variant="blue" /> + )} + + {isFunder && (state === 3 || state === 0) && ( + call(refundWrite, "claimRefund")} + isPending={refundPending} isConfirming={refundConfirming} isSuccess={refundSuccess} + successLabel="환불 완료!" error={refundError} variant="red" /> + )} + + {state === 2 &&

프로젝트 완료!

} +
+ ); +} diff --git a/week-06/dev/frontend/components/Countdown.tsx b/week-06/dev/frontend/components/Countdown.tsx new file mode 100644 index 00000000..6b870877 --- /dev/null +++ b/week-06/dev/frontend/components/Countdown.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export default function Countdown({ deadline }: { deadline: bigint }) { + const [timeLeft, setTimeLeft] = useState(""); + + useEffect(() => { + const update = () => { + const now = Math.floor(Date.now() / 1000); + const end = Number(deadline); + const diff = end - now; + + if (diff <= 0) { + setTimeLeft("마감됨"); + return; + } + + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + const minutes = Math.floor((diff % 3600) / 60); + const seconds = diff % 60; + + if (days > 0) { + setTimeLeft(`${days}일 ${hours}시간 ${minutes}분`); + } else if (hours > 0) { + setTimeLeft(`${hours}시간 ${minutes}분 ${seconds}초`); + } else { + setTimeLeft(`${minutes}분 ${seconds}초`); + } + }; + + update(); + const interval = setInterval(update, 1000); + return () => clearInterval(interval); + }, [deadline]); + + const isExpired = timeLeft === "마감됨"; + + return ( + + {timeLeft} + + ); +} diff --git a/week-06/dev/frontend/components/FundProject.tsx b/week-06/dev/frontend/components/FundProject.tsx new file mode 100644 index 00000000..83ff6710 --- /dev/null +++ b/week-06/dev/frontend/components/FundProject.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useState } from "react"; +import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { parseEther } from "viem"; +import { MILESTONE_FUNDING_ABI } from "@/config/contract"; + +export default function FundProject({ contractAddress }: { contractAddress: `0x${string}` }) { + const [amount, setAmount] = useState(""); + const { writeContract, data: hash, isPending, error } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + + const handleFund = () => { + if (!amount || parseFloat(amount) <= 0) return; + writeContract({ + address: contractAddress, + abi: MILESTONE_FUNDING_ABI, + functionName: "fund", + value: parseEther(amount), + }); + }; + + return ( +
+

후원하기

+
+ setAmount(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-600 focus:outline-none focus:border-blue-500" + /> + +
+ {isSuccess &&

후원 완료!

} + {error &&

{error.message.split("\n")[0]}

} +
+ ); +} diff --git a/week-06/dev/frontend/components/MyContribution.tsx b/week-06/dev/frontend/components/MyContribution.tsx new file mode 100644 index 00000000..fc6c233d --- /dev/null +++ b/week-06/dev/frontend/components/MyContribution.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useAccount, useReadContract } from "wagmi"; +import { formatEther } from "viem"; +import { MILESTONE_FUNDING_ABI } from "@/config/contract"; + +export default function MyContribution({ contractAddress }: { contractAddress: `0x${string}` }) { + const { address } = useAccount(); + + const { data: contribution } = useReadContract({ + address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "contributions", + args: address ? [address] : undefined, + }); + const { data: totalFunded } = useReadContract({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "totalFunded" }); + const { data: hasRefunded } = useReadContract({ + address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "hasRefunded", + args: address ? [address] : undefined, + }); + const { data: creatorAddr } = useReadContract({ address: contractAddress, abi: MILESTONE_FUNDING_ABI, functionName: "creator" }); + + if (!address) return null; + + const myAmount = contribution ?? 0n; + const total = totalFunded ?? 0n; + const votingPower = total > 0n ? Number((myAmount * 10000n) / total) / 100 : 0; + const isCreator = address.toLowerCase() === creatorAddr?.toLowerCase(); + + return ( +
+

내 정보

+
+
+ 역할 +
+ {isCreator && 크리에이터} + {myAmount > 0n && 후원자} + {!isCreator && myAmount === 0n && 미참여} +
+
+ {myAmount > 0n && ( + <> +
+ 내 기여금 + {formatEther(myAmount)} ETH +
+
+ 투표 가중치 + {votingPower}% +
+ {hasRefunded && ( +
+ 환불 + 완료 +
+ )} + + )} +
+
+ ); +} diff --git a/week-06/dev/frontend/components/ProjectCard.tsx b/week-06/dev/frontend/components/ProjectCard.tsx new file mode 100644 index 00000000..4c8b3fc8 --- /dev/null +++ b/week-06/dev/frontend/components/ProjectCard.tsx @@ -0,0 +1,67 @@ +"use client"; + +import Link from "next/link"; +import { useReadContract } from "wagmi"; +import { formatEther } from "viem"; +import { MILESTONE_FUNDING_ABI, PROJECT_STATE, PROJECT_STATE_KO } from "@/config/contract"; + +export default function ProjectCard({ address }: { address: `0x${string}` }) { + const { data } = useReadContract({ + address, + abi: MILESTONE_FUNDING_ABI, + functionName: "getProjectInfo", + }); + + if (!data) return
; + + const [creator, goal, deadline, totalFunded, , state, currentMilestone, milestoneCount] = data; + const progress = goal > 0n ? Number((totalFunded * 100n) / goal) : 0; + const stateLabel = PROJECT_STATE[state] ?? "Unknown"; + const deadlineDate = new Date(Number(deadline) * 1000); + const isExpired = Date.now() > deadlineDate.getTime(); + + const stateColor: Record = { + Funding: "text-blue-400 bg-blue-500/10", + Active: "text-green-400 bg-green-500/10", + Completed: "text-purple-400 bg-purple-500/10", + Failed: "text-red-400 bg-red-500/10", + }; + + return ( + +
+ {/* 상단 */} +
+ + {PROJECT_STATE_KO[stateLabel] ?? stateLabel} + + + {creator.slice(0, 6)}...{creator.slice(-4)} + +
+ + {/* 진행률 */} +
+
+ {formatEther(totalFunded)} ETH + / {formatEther(goal)} ETH +
+
+
+
+
+ + {/* 하단 정보 */} +
+ 마일스톤 {Number(currentMilestone)}/{Number(milestoneCount)} + + {isExpired ? "마감됨" : `마감 ${deadlineDate.toLocaleDateString("ko-KR")}`} + +
+
+ + ); +} diff --git a/week-06/dev/frontend/config/contract.ts b/week-06/dev/frontend/config/contract.ts new file mode 100644 index 00000000..a2420268 --- /dev/null +++ b/week-06/dev/frontend/config/contract.ts @@ -0,0 +1,74 @@ +// Factory 컨트랙트 +export const FACTORY_ADDRESS = "0x63c5ED29c8BF61277542acB5f79672505382062d" as `0x${string}`; + +export const FACTORY_ABI = [ + { type: "function", name: "createProject", inputs: [ + { name: "_goal", type: "uint256" }, + { name: "_deadline", type: "uint256" }, + { name: "_descriptions", type: "string[]" }, + { name: "_percentages", type: "uint256[]" }, + ], outputs: [{ name: "", type: "address" }], stateMutability: "nonpayable" }, + { type: "function", name: "getProjects", inputs: [], outputs: [{ name: "", type: "address[]" }], stateMutability: "view" }, + { type: "function", name: "getProjectCount", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "getCreatorProjects", inputs: [{ name: "_creator", type: "address" }], outputs: [{ name: "", type: "address[]" }], stateMutability: "view" }, + { type: "event", name: "ProjectCreated", inputs: [ + { name: "project", type: "address", indexed: true }, + { name: "creator", type: "address", indexed: true }, + { name: "goal", type: "uint256", indexed: false }, + { name: "deadline", type: "uint256", indexed: false }, + ], anonymous: false }, +] as const; + +// 개별 프로젝트 컨트랙트 +export const MILESTONE_FUNDING_ABI = [ + { type: "function", name: "fund", inputs: [], outputs: [], stateMutability: "payable" }, + { type: "function", name: "submitMilestone", inputs: [], outputs: [], stateMutability: "nonpayable" }, + { type: "function", name: "vote", inputs: [{ name: "approve", type: "bool" }], outputs: [], stateMutability: "nonpayable" }, + { type: "function", name: "finalizeMilestone", inputs: [], outputs: [], stateMutability: "nonpayable" }, + { type: "function", name: "claimRefund", inputs: [], outputs: [], stateMutability: "nonpayable" }, + { type: "function", name: "getProjectInfo", inputs: [], outputs: [ + { name: "_creator", type: "address" }, + { name: "_goal", type: "uint256" }, + { name: "_deadline", type: "uint256" }, + { name: "_totalFunded", type: "uint256" }, + { name: "_totalReleased", type: "uint256" }, + { name: "_state", type: "uint8" }, + { name: "_currentMilestone", type: "uint256" }, + { name: "_milestoneCount", type: "uint256" }, + ], stateMutability: "view" }, + { type: "function", name: "getMilestone", inputs: [{ name: "index", type: "uint256" }], outputs: [ + { name: "description", type: "string" }, + { name: "percentage", type: "uint256" }, + { name: "milestoneState", type: "uint8" }, + { name: "votingDeadline", type: "uint256" }, + { name: "approveVotes", type: "uint256" }, + { name: "rejectVotes", type: "uint256" }, + ], stateMutability: "view" }, + { type: "function", name: "getMilestoneCount", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "contributions", inputs: [{ name: "", type: "address" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "hasVoted", inputs: [{ name: "", type: "uint256" }, { name: "", type: "address" }], outputs: [{ name: "", type: "bool" }], stateMutability: "view" }, + { type: "function", name: "hasRefunded", inputs: [{ name: "", type: "address" }], outputs: [{ name: "", type: "bool" }], stateMutability: "view" }, + { type: "function", name: "creator", inputs: [], outputs: [{ name: "", type: "address" }], stateMutability: "view" }, + { type: "function", name: "state", inputs: [], outputs: [{ name: "", type: "uint8" }], stateMutability: "view" }, + { type: "function", name: "totalFunded", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "goal", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "deadline", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, + { type: "function", name: "VOTING_DURATION", inputs: [], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }, +] as const; + +export const PROJECT_STATE = ["Funding", "Active", "Completed", "Failed"] as const; +export const MILESTONE_STATE = ["Pending", "Voting", "Approved", "Rejected"] as const; + +export const PROJECT_STATE_KO: Record = { + Funding: "모금 중", + Active: "진행 중", + Completed: "완료", + Failed: "실패", +}; + +export const MILESTONE_STATE_KO: Record = { + Pending: "대기 중", + Voting: "투표 진행 중", + Approved: "승인됨", + Rejected: "거부됨", +}; diff --git a/week-06/dev/frontend/config/wagmi.ts b/week-06/dev/frontend/config/wagmi.ts new file mode 100644 index 00000000..d37964ce --- /dev/null +++ b/week-06/dev/frontend/config/wagmi.ts @@ -0,0 +1,9 @@ +import { getDefaultConfig } from "@rainbow-me/rainbowkit"; +import { sepolia, hardhat } from "wagmi/chains"; + +export const config = getDefaultConfig({ + appName: "MilestoneFunding", + projectId: "YOUR_WALLETCONNECT_PROJECT_ID", // https://cloud.walletconnect.com + chains: [sepolia], + ssr: true, +}); diff --git a/week-06/dev/frontend/eslint.config.mjs b/week-06/dev/frontend/eslint.config.mjs new file mode 100644 index 00000000..05e726d1 --- /dev/null +++ b/week-06/dev/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/week-06/dev/frontend/next.config.ts b/week-06/dev/frontend/next.config.ts new file mode 100644 index 00000000..e9ffa308 --- /dev/null +++ b/week-06/dev/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/week-06/dev/frontend/package.json b/week-06/dev/frontend/package.json new file mode 100644 index 00000000..1e8678b4 --- /dev/null +++ b/week-06/dev/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@rainbow-me/rainbowkit": "^2.2.10", + "@tanstack/react-query": "^5.99.0", + "next": "16.2.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "viem": "^2.47.16", + "wagmi": "^2.19.5" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.3", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/week-06/dev/frontend/postcss.config.mjs b/week-06/dev/frontend/postcss.config.mjs new file mode 100644 index 00000000..61e36849 --- /dev/null +++ b/week-06/dev/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/week-06/dev/frontend/public/file.svg b/week-06/dev/frontend/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/week-06/dev/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/week-06/dev/frontend/public/globe.svg b/week-06/dev/frontend/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/week-06/dev/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/week-06/dev/frontend/public/next.svg b/week-06/dev/frontend/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/week-06/dev/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/week-06/dev/frontend/public/vercel.svg b/week-06/dev/frontend/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/week-06/dev/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/week-06/dev/frontend/public/window.svg b/week-06/dev/frontend/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/week-06/dev/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/week-06/dev/frontend/tsconfig.json b/week-06/dev/frontend/tsconfig.json new file mode 100644 index 00000000..15c7b97a --- /dev/null +++ b/week-06/dev/frontend/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/week-06/dev/script/Deploy.s.sol b/week-06/dev/script/Deploy.s.sol new file mode 100644 index 00000000..57182508 --- /dev/null +++ b/week-06/dev/script/Deploy.s.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "forge-std/Script.sol"; +import "../src/MilestoneFundingFactory.sol"; + +contract DeployFactory is Script { + function run() external { + vm.startBroadcast(); + + MilestoneFundingFactory factory = new MilestoneFundingFactory(); + console.log("Deployed Factory at:", address(factory)); + + vm.stopBroadcast(); + } +} diff --git a/week-06/dev/src/MilestoneFunding.sol b/week-06/dev/src/MilestoneFunding.sol new file mode 100644 index 00000000..451a7507 --- /dev/null +++ b/week-06/dev/src/MilestoneFunding.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @title MilestoneFunding - 마일스톤 기반 크라우드펀딩 +/// @notice 펀딩 금액을 한 번에 주지 않고, 마일스톤마다 후원자 투표로 자금을 릴리즈합니다. +/// @dev CEI 패턴 적용. 하나의 컨트랙트 = 하나의 프로젝트. +contract MilestoneFunding { + // ============================================================ + // 열거형 (Enums) + // ============================================================ + + enum ProjectState { Funding, Active, Completed, Failed } + enum MilestoneState { Pending, Voting, Approved, Rejected } + + // ============================================================ + // 구조체 (Structs) + // ============================================================ + + struct Milestone { + string description; + uint256 percentage; + MilestoneState state; + uint256 votingDeadline; + uint256 approveVotes; + uint256 rejectVotes; + } + + // ============================================================ + // 상태 변수 (State Variables) + // ============================================================ + + address public creator; + uint256 public goal; + uint256 public deadline; + uint256 public totalFunded; + uint256 public totalReleased; + ProjectState public state; + + Milestone[] public milestones; + uint256 public currentMilestone; + + mapping(address => uint256) public contributions; + mapping(uint256 => mapping(address => bool)) public hasVoted; + mapping(address => bool) public hasRefunded; + + uint256 public constant VOTING_DURATION = 3 days; + + // ============================================================ + // 이벤트 (Events) + // ============================================================ + + event ProjectCreated(address indexed creator, uint256 goal, uint256 deadline, uint256 milestoneCount); + event Funded(address indexed funder, uint256 amount); + event FundingCompleted(uint256 totalFunded); + event MilestoneSubmitted(uint256 indexed milestoneIndex); + event Voted(address indexed funder, uint256 indexed milestoneIndex, bool approve, uint256 weight); + event MilestoneApproved(uint256 indexed milestoneIndex, uint256 amountReleased); + event MilestoneRejected(uint256 indexed milestoneIndex); + event FundsReleased(address indexed creator, uint256 amount); + event Refunded(address indexed funder, uint256 amount); + + // ============================================================ + // 생성자 (Constructor) + // ============================================================ + + /// @param _creator 프로젝트 크리에이터 주소 + /// @param _goal 목표 금액 (wei) + /// @param _deadline 펀딩 마감 시각 (timestamp) + /// @param _descriptions 마일스톤 설명 배열 + /// @param _percentages 마일스톤별 자금 비율 (합계 100) + constructor( + address _creator, + uint256 _goal, + uint256 _deadline, + string[] memory _descriptions, + uint256[] memory _percentages + ) { + require(_creator != address(0), "Invalid creator"); + require(_goal > 0, "Goal must be > 0"); + require(_deadline > block.timestamp, "Deadline must be future"); + require(_descriptions.length == _percentages.length, "Length mismatch"); + require(_descriptions.length > 0, "Need at least 1 milestone"); + + uint256 totalPct; + for (uint256 i = 0; i < _percentages.length; i++) { + require(_percentages[i] > 0, "Percentage must be > 0"); + totalPct += _percentages[i]; + } + require(totalPct == 100, "Percentages must sum to 100"); + + creator = _creator; + goal = _goal; + deadline = _deadline; + state = ProjectState.Funding; + + for (uint256 i = 0; i < _descriptions.length; i++) { + milestones.push(Milestone({ + description: _descriptions[i], + percentage: _percentages[i], + state: MilestoneState.Pending, + votingDeadline: 0, + approveVotes: 0, + rejectVotes: 0 + })); + } + + emit ProjectCreated(msg.sender, _goal, _deadline, _descriptions.length); + } + + // ============================================================ + // 쓰기 함수 (State-Changing Functions) + // ============================================================ + + /// @notice ETH를 후원합니다 + function fund() external payable { + require(state == ProjectState.Funding, "Not in funding phase"); + require(block.timestamp < deadline, "Funding deadline passed"); + require(msg.value > 0, "Must send ETH"); + require(msg.sender != creator, "Creator cannot fund own project"); + + contributions[msg.sender] += msg.value; + totalFunded += msg.value; + + emit Funded(msg.sender, msg.value); + + if (totalFunded >= goal) { + state = ProjectState.Active; + emit FundingCompleted(totalFunded); + } + } + + /// @notice 크리에이터가 현재 마일스톤 완료를 제출합니다 + function submitMilestone() external { + require(msg.sender == creator, "Only creator"); + require(state == ProjectState.Active, "Not active"); + require(milestones[currentMilestone].state == MilestoneState.Pending, "Milestone not pending"); + + milestones[currentMilestone].state = MilestoneState.Voting; + milestones[currentMilestone].votingDeadline = block.timestamp + VOTING_DURATION; + + emit MilestoneSubmitted(currentMilestone); + } + + /// @notice 후원자가 현재 마일스톤에 찬성/반대 투표합니다 + /// @param approve true = 찬성, false = 반대 + function vote(bool approve) external { + require(state == ProjectState.Active, "Not active"); + require(milestones[currentMilestone].state == MilestoneState.Voting, "Not in voting"); + require(contributions[msg.sender] > 0, "Not a funder"); + require(!hasVoted[currentMilestone][msg.sender], "Already voted"); + require(block.timestamp < milestones[currentMilestone].votingDeadline, "Voting ended"); + + hasVoted[currentMilestone][msg.sender] = true; + uint256 weight = contributions[msg.sender]; + + if (approve) { + milestones[currentMilestone].approveVotes += weight; + } else { + milestones[currentMilestone].rejectVotes += weight; + } + + emit Voted(msg.sender, currentMilestone, approve, weight); + } + + /// @notice 투표 기간 종료 후 마일스톤 결과를 확정합니다 + function finalizeMilestone() external { + require(state == ProjectState.Active, "Not active"); + Milestone storage ms = milestones[currentMilestone]; + require(ms.state == MilestoneState.Voting, "Not in voting"); + require(block.timestamp >= ms.votingDeadline, "Voting not ended"); + + if (ms.approveVotes > ms.rejectVotes) { + // Checks-Effects-Interactions + ms.state = MilestoneState.Approved; + uint256 amount = (goal * ms.percentage) / 100; + totalReleased += amount; + + bool isLast = currentMilestone == milestones.length - 1; + if (isLast) { + state = ProjectState.Completed; + amount = address(this).balance; // 마지막엔 잔액 전부 (반올림 오차 방지) + } + currentMilestone++; + + emit MilestoneApproved(currentMilestone - 1, amount); + + (bool success, ) = creator.call{value: amount}(""); + require(success, "Transfer failed"); + + emit FundsReleased(creator, amount); + } else { + ms.state = MilestoneState.Rejected; + state = ProjectState.Failed; + + emit MilestoneRejected(currentMilestone); + } + } + + /// @notice 실패한 프로젝트에서 후원자가 환불을 청구합니다 + function claimRefund() external { + // Checks + bool fundingFailed = (state == ProjectState.Funding && block.timestamp >= deadline && totalFunded < goal); + bool milestoneRejected = (state == ProjectState.Failed); + require(fundingFailed || milestoneRejected, "Refund not available"); + require(contributions[msg.sender] > 0, "No contribution"); + require(!hasRefunded[msg.sender], "Already refunded"); + + uint256 refundAmount; + if (fundingFailed) { + refundAmount = contributions[msg.sender]; + } else { + uint256 remainingPool = totalFunded - totalReleased; + refundAmount = (contributions[msg.sender] * remainingPool) / totalFunded; + } + + // Effects + hasRefunded[msg.sender] = true; + + // Interactions + (bool success, ) = msg.sender.call{value: refundAmount}(""); + require(success, "Transfer failed"); + + emit Refunded(msg.sender, refundAmount); + } + + // ============================================================ + // 읽기 함수 (View Functions) + // ============================================================ + + /// @notice 프로젝트 요약 정보를 반환합니다 + function getProjectInfo() + external + view + returns ( + address _creator, + uint256 _goal, + uint256 _deadline, + uint256 _totalFunded, + uint256 _totalReleased, + ProjectState _state, + uint256 _currentMilestone, + uint256 _milestoneCount + ) + { + return (creator, goal, deadline, totalFunded, totalReleased, state, currentMilestone, milestones.length); + } + + /// @notice 특정 마일스톤 정보를 반환합니다 + function getMilestone(uint256 index) + external + view + returns ( + string memory description, + uint256 percentage, + MilestoneState milestoneState, + uint256 votingDeadline, + uint256 approveVotes, + uint256 rejectVotes + ) + { + require(index < milestones.length, "Invalid index"); + Milestone storage ms = milestones[index]; + return (ms.description, ms.percentage, ms.state, ms.votingDeadline, ms.approveVotes, ms.rejectVotes); + } + + /// @notice 마일스톤 개수를 반환합니다 + function getMilestoneCount() external view returns (uint256) { + return milestones.length; + } +} diff --git a/week-06/dev/src/MilestoneFundingFactory.sol b/week-06/dev/src/MilestoneFundingFactory.sol new file mode 100644 index 00000000..2c411276 --- /dev/null +++ b/week-06/dev/src/MilestoneFundingFactory.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {MilestoneFunding} from "./MilestoneFunding.sol"; + +/// @title MilestoneFundingFactory - 프로젝트 생성 및 목록 관리 +/// @notice 누구나 마일스톤 크라우드펀딩 프로젝트를 생성할 수 있습니다. +contract MilestoneFundingFactory { + // ============================================================ + // 상태 변수 + // ============================================================ + + address[] public projects; + mapping(address => address[]) public creatorProjects; + + // ============================================================ + // 이벤트 + // ============================================================ + + event ProjectCreated(address indexed project, address indexed creator, uint256 goal, uint256 deadline); + + // ============================================================ + // 쓰기 함수 + // ============================================================ + + /// @notice 새 크라우드펀딩 프로젝트를 생성합니다 + /// @param _goal 목표 금액 (wei) + /// @param _deadline 펀딩 마감 시각 (timestamp) + /// @param _descriptions 마일스톤 설명 배열 + /// @param _percentages 마일스톤별 자금 비율 (합계 100) + /// @return 생성된 프로젝트 컨트랙트 주소 + function createProject( + uint256 _goal, + uint256 _deadline, + string[] memory _descriptions, + uint256[] memory _percentages + ) external returns (address) { + MilestoneFunding project = new MilestoneFunding( + msg.sender, + _goal, + _deadline, + _descriptions, + _percentages + ); + + address addr = address(project); + projects.push(addr); + creatorProjects[msg.sender].push(addr); + + emit ProjectCreated(addr, msg.sender, _goal, _deadline); + + return addr; + } + + // ============================================================ + // 읽기 함수 + // ============================================================ + + /// @notice 전체 프로젝트 목록을 반환합니다 + function getProjects() external view returns (address[] memory) { + return projects; + } + + /// @notice 전체 프로젝트 수를 반환합니다 + function getProjectCount() external view returns (uint256) { + return projects.length; + } + + /// @notice 특정 크리에이터의 프로젝트 목록을 반환합니다 + function getCreatorProjects(address _creator) external view returns (address[] memory) { + return creatorProjects[_creator]; + } +} diff --git a/week-06/dev/test/MilestoneFunding.t.sol b/week-06/dev/test/MilestoneFunding.t.sol new file mode 100644 index 00000000..bb2a675b --- /dev/null +++ b/week-06/dev/test/MilestoneFunding.t.sol @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test, console} from "forge-std/Test.sol"; +import {MilestoneFunding} from "../src/MilestoneFunding.sol"; + +/// @title MilestoneFundingTest +/// @notice 마일스톤 크라우드펀딩 컨트랙트 테스트 +contract MilestoneFundingTest is Test { + // ============================================================ + // 상태 변수 + // ============================================================ + + MilestoneFunding public project; + + address public creator = makeAddr("creator"); + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + address public charlie = makeAddr("charlie"); + + uint256 public constant GOAL = 10 ether; + uint256 public constant FUNDING_DURATION = 7 days; + + // ============================================================ + // 이벤트 (검증용) + // ============================================================ + + event Funded(address indexed funder, uint256 amount); + event FundingCompleted(uint256 totalFunded); + event MilestoneSubmitted(uint256 indexed milestoneIndex); + event Voted(address indexed funder, uint256 indexed milestoneIndex, bool approve, uint256 weight); + event MilestoneApproved(uint256 indexed milestoneIndex, uint256 amountReleased); + event MilestoneRejected(uint256 indexed milestoneIndex); + event Refunded(address indexed funder, uint256 amount); + + // ============================================================ + // setUp + // ============================================================ + + function setUp() public { + string[] memory descriptions = new string[](3); + descriptions[0] = "Prototype"; + descriptions[1] = "Beta"; + descriptions[2] = "Launch"; + + uint256[] memory percentages = new uint256[](3); + percentages[0] = 30; + percentages[1] = 30; + percentages[2] = 40; + + vm.prank(creator); + project = new MilestoneFunding( + creator, + GOAL, + block.timestamp + FUNDING_DURATION, + descriptions, + percentages + ); + + vm.deal(alice, 20 ether); + vm.deal(bob, 20 ether); + vm.deal(charlie, 20 ether); + } + + // ============================================================ + // 헬퍼 함수 + // ============================================================ + + /// @dev 프로젝트를 Active 상태까지 진행 (Alice 6 ETH, Bob 4 ETH) + function _fundProject() internal { + vm.prank(alice); + project.fund{value: 6 ether}(); + vm.prank(bob); + project.fund{value: 4 ether}(); + } + + /// @dev 마일스톤 제출 + 과반 찬성 + 확정까지 진행 + function _approveMilestone() internal { + vm.prank(creator); + project.submitMilestone(); + + vm.prank(alice); + project.vote(true); + vm.prank(bob); + project.vote(true); + + vm.warp(block.timestamp + project.VOTING_DURATION()); + project.finalizeMilestone(); + } + + // ============================================================ + // 펀딩 테스트 + // ============================================================ + + /// @notice 후원 시 기여금이 올바르게 기록되는지 확인 + function test_Fund_UpdatesContribution() public { + // Arrange & Act + vm.prank(alice); + project.fund{value: 3 ether}(); + + // Assert + assertEq(project.contributions(alice), 3 ether, "Contribution should be recorded"); + assertEq(project.totalFunded(), 3 ether, "Total funded should update"); + } + + /// @notice 목표 달성 시 Active 상태로 전환되는지 확인 + function test_Fund_TransitionsToActiveWhenGoalMet() public { + // Arrange & Act + _fundProject(); + + // Assert + assertEq(uint256(project.state()), uint256(MilestoneFunding.ProjectState.Active)); + assertEq(project.totalFunded(), 10 ether); + } + + /// @notice Funded 이벤트가 발생하는지 확인 + function test_Fund_EmitsEvent() public { + vm.expectEmit(true, false, false, true); + emit Funded(alice, 5 ether); + + vm.prank(alice); + project.fund{value: 5 ether}(); + } + + // ============================================================ + // 마일스톤 제출 테스트 + // ============================================================ + + /// @notice 크리에이터가 마일스톤을 제출하면 Voting 상태로 전환 + function test_SubmitMilestone_SetsVotingState() public { + // Arrange + _fundProject(); + + // Act + vm.prank(creator); + project.submitMilestone(); + + // Assert + (, , MilestoneFunding.MilestoneState msState, uint256 votingDeadline, , ) = project.getMilestone(0); + assertEq(uint256(msState), uint256(MilestoneFunding.MilestoneState.Voting)); + assertGt(votingDeadline, 0, "Voting deadline should be set"); + } + + // ============================================================ + // 투표 & 확정 테스트 + // ============================================================ + + /// @notice 과반 찬성 시 마일스톤 승인 및 자금 릴리즈 + function test_Vote_AndFinalize_ApproveMilestone() public { + // Arrange + _fundProject(); + + vm.prank(creator); + project.submitMilestone(); + + // Act: 투표 + vm.prank(alice); + project.vote(true); // 6 ETH 가중치 + vm.prank(bob); + project.vote(true); // 4 ETH 가중치 + + // 투표 기간 종료 후 확정 + vm.warp(block.timestamp + project.VOTING_DURATION()); + + uint256 creatorBalanceBefore = creator.balance; + project.finalizeMilestone(); + + // Assert + (, , MilestoneFunding.MilestoneState msState, , , ) = project.getMilestone(0); + assertEq(uint256(msState), uint256(MilestoneFunding.MilestoneState.Approved)); + assertEq(creator.balance - creatorBalanceBefore, 3 ether, "Creator should receive 30% of 10 ETH"); + assertEq(project.currentMilestone(), 1, "Should advance to next milestone"); + } + + /// @notice 과반 반대 시 마일스톤 거부 및 프로젝트 실패 + function test_Vote_AndFinalize_RejectMilestone() public { + // Arrange + _fundProject(); + + vm.prank(creator); + project.submitMilestone(); + + // Act: Alice 반대 (6 ETH) > Bob 찬성 (4 ETH) + vm.prank(alice); + project.vote(false); + vm.prank(bob); + project.vote(true); + + vm.warp(block.timestamp + project.VOTING_DURATION()); + project.finalizeMilestone(); + + // Assert + (, , MilestoneFunding.MilestoneState msState, , , ) = project.getMilestone(0); + assertEq(uint256(msState), uint256(MilestoneFunding.MilestoneState.Rejected)); + assertEq(uint256(project.state()), uint256(MilestoneFunding.ProjectState.Failed)); + } + + // ============================================================ + // 환불 테스트 + // ============================================================ + + /// @notice 마일스톤 거부 후 후원자가 비례 환불을 받는지 확인 + function test_ClaimRefund_AfterMilestoneRejection() public { + // Arrange: 펀딩 완료 → 마일스톤 0 승인 (3 ETH 릴리즈) → 마일스톤 1 거부 + _fundProject(); + _approveMilestone(); // milestone 0 approved, 3 ETH to creator + + // 마일스톤 1 제출 및 거부 + vm.prank(creator); + project.submitMilestone(); + + vm.prank(alice); + project.vote(false); // 6 ETH 가중치로 반대 + // Bob 미투표 → 0 approve vs 6 reject → 거부 + + vm.warp(block.timestamp + project.VOTING_DURATION()); + project.finalizeMilestone(); + + // Assert: 프로젝트 실패, 남은 7 ETH 비례 환불 + assertEq(uint256(project.state()), uint256(MilestoneFunding.ProjectState.Failed)); + + // Act: Alice 환불 (6/10 * 7 = 4.2 ETH) + uint256 aliceBefore = alice.balance; + vm.prank(alice); + project.claimRefund(); + assertEq(alice.balance - aliceBefore, 4.2 ether, "Alice should get 60% of remaining 7 ETH"); + + // Act: Bob 환불 (4/10 * 7 = 2.8 ETH) + uint256 bobBefore = bob.balance; + vm.prank(bob); + project.claimRefund(); + assertEq(bob.balance - bobBefore, 2.8 ether, "Bob should get 40% of remaining 7 ETH"); + } + + /// @notice 펀딩 마감 후 목표 미달 시 전액 환불 + function test_ClaimRefund_AfterDeadlineExpiry() public { + // Arrange: 목표 미달 + vm.prank(alice); + project.fund{value: 3 ether}(); + + // 마감 시간 경과 + vm.warp(block.timestamp + FUNDING_DURATION); + + // Act + uint256 aliceBefore = alice.balance; + vm.prank(alice); + project.claimRefund(); + + // Assert + assertEq(alice.balance - aliceBefore, 3 ether, "Should get full refund"); + } + + /// @notice CEI 패턴으로 재진입 공격 방지 확인 + function test_RevertWhen_ReentrancyOnClaimRefund() public { + // Arrange: 공격자 컨트랙트로 펀딩 + ReentrancyAttacker attacker = new ReentrancyAttacker(address(project)); + vm.deal(address(attacker), 20 ether); + + vm.prank(address(attacker)); + project.fund{value: 5 ether}(); + vm.prank(bob); + project.fund{value: 5 ether}(); // 목표 달성 + + // 마일스톤 0 제출 → 거부 (Bob만 투표, 반대) + vm.prank(creator); + project.submitMilestone(); + vm.prank(bob); + project.vote(false); + + vm.warp(block.timestamp + project.VOTING_DURATION()); + project.finalizeMilestone(); + + // Act: 공격자가 재진입 시도 + uint256 vaultBefore = address(project).balance; + attacker.attackRefund(); + + // Assert: 공격자는 자기 몫(5 ETH)만 받음 + assertEq( + address(attacker).balance, + 20 ether, // 20 (deal) - 5 (fund) + 5 (refund) + "Attacker should only get their share" + ); + assertEq( + address(project).balance, + vaultBefore - 5 ether, + "Contract should only release attacker's share" + ); + } +} + +/// @dev 재진입 공격을 시뮬레이션하는 컨트랙트 +contract ReentrancyAttacker { + MilestoneFunding public target; + uint256 public attackCount; + + constructor(address _target) { + target = MilestoneFunding(_target); + } + + function fundProject() external payable { + target.fund{value: msg.value}(); + } + + function attackRefund() external { + target.claimRefund(); + } + + receive() external payable { + if (attackCount < 3) { + attackCount++; + try target.claimRefund() {} catch {} + } + } +} diff --git a/week-06/quiz/quiz-06-solution.md b/week-06/quiz/quiz-06-solution.md new file mode 100644 index 00000000..6cccd21c --- /dev/null +++ b/week-06/quiz/quiz-06-solution.md @@ -0,0 +1,227 @@ +# Week 6 Quiz: Beacon Chain/Finality + Final Project Integration + +> **제출 방법:** 이 파일을 복사하여 답변을 작성한 후, PR로 제출하세요. +> **평가 기준:** 개념 이해도 중심 - 6주간 배운 내용을 **통합**하여 설명하세요. + +--- + +## 문제 1: Beacon Chain 역할 (객관식) + +**답변:** +B) Beacon Chain은 이더리움의 **합의 계층(Consensus Layer)**으로, 검증자 등록/퇴장 관리, 블록 제안자 랜덤 선정, attestation 수집, 그리고 Casper FFG를 통한 블록 최종성(finality) 결정을 담당합니다. 스마트 컨트랙트 실행이나 상태 관리는 **실행 계층(Execution Layer)**의 역할입니다. 실행 계층은 EVM이 트랜잭션을 처리하고 상태 트리를 업데이트하며, 합의 계층은 "이 블록이 유효한가"를 검증자들의 투표로 확정합니다. 두 계층은 Engine API로 통신합니다. + +--- + +## 문제 2: Finality 개념 (객관식) + +**답변:** +C) Finality가 달성되면 해당 블록은 전체 검증자의 1/3 이상이 슬래싱되지 않는 한 절대 변경되지 않습니다. 이더리움 PoS에서 합의는 BFT(Byzantine Fault Tolerance) 기반으로, 전체 스테이킹의 2/3 이상이 특정 체크포인트에 동의하면 finalize됩니다. 이를 뒤집으려면 2/3 중 최소 1/3이 이전 attestation과 모순되는 투표를 해야 하므로, 해당 검증자들의 ETH가 슬래싱됩니다. 즉, finality를 뒤집는 비용이 전체 스테이킹의 1/3 이상(수십억 달러)이 되어 경제적으로 불가능합니다. + +--- + +## 문제 3: 왜 Finality가 중요한가 (단답형) + +**답변:** +1) 위 시나리오에서 거래소는 100 ETH를 내부 잔액에 반영했는데, 블록 재조직으로 입금 트랜잭션이 사라지면 거래소는 실제로 받지 않은 100 ETH를 사용자에게 크레딧한 셈이 됩니다. 사용자가 이 잔액으로 거래하거나 출금하면 거래소가 손실을 입습니다. 이것이 "이중 지불(double spend)" 공격의 핵심입니다. + +2) Finality가 달성된 블록은 재조직이 불가능하므로, 거래소는 finality 이후에 입금을 확정하면 이 문제가 완전히 해결됩니다. "이 트랜잭션은 절대 사라지지 않는다"는 수학적 보장이 생깁니다. + +3) 이더리움에서 finality까지 약 **2 Epoch = 12.8분**(1 Epoch = 32 Slots × 12초 = 6.4분)이 소요됩니다. 거래소들이 보통 "12~32 컨펌 대기"하는 이유가 이것입니다. + +--- + +## 문제 4: 포크 선택 규칙 (단답형) + +**답변:** +1) **Casper FFG**의 역할: 체크포인트(epoch 경계) 단위로 블록을 **finalize**합니다. 검증자 2/3 이상이 특정 체크포인트에 투표하면 해당 블록까지 영구 확정됩니다. "장기적 안전성(safety)"을 담당합니다. + +2) **LMD-GHOST**의 역할: 아직 finalize되지 않은 블록들 사이에서 "지금 당장 어느 포크를 따를 것인가"를 결정합니다. 각 검증자의 최신 attestation을 기준으로 가장 많은 지지를 받는 포크를 선택합니다. "단기적 활성(liveness)"을 담당합니다. + +3) Casper FFG만 있으면 finality 사이 구간(~12.8분)에 포크가 발생했을 때 어느 체인을 따를지 결정할 수 없어 네트워크가 멈춥니다. LMD-GHOST만 있으면 블록이 영구 확정되지 않아 아무리 오래된 블록도 재조직될 가능성이 남습니다. 둘을 결합해야 "빠른 포크 해결 + 영구 확정"이 모두 가능합니다. + +--- + +## 문제 5: dApp 아키텍처 설계 (코드/아키텍처 문제) + +**답변:** + +``` +1) 컴포넌트 구조: + - App (Root Layout + Providers) + - ConnectWallet (지갑 연결 버튼) + - VoteStatus (찬성/반대 현황 표시) + - VoteButton (찬성/반대 투표 실행) + - TxStatus (트랜잭션 상태 표시) + +2) 각 컴포넌트에서 사용할 hook: + - 지갑 연결: ConnectButton (RainbowKit) + useAccount (wagmi) + - 투표 현황 조회: useReadContract (functionName: 'yesVotes', 'noVotes') + - 투표 실행: useWriteContract (functionName: 'voteYes' 또는 'voteNo') + - 트랜잭션 확인: useWaitForTransactionReceipt (hash 추적) + +3) Provider 계층 구조: + WagmiProvider (config) + → QueryClientProvider (queryClient) + → RainbowKitProvider + → App Components +``` + +**왜 이렇게 설계했나요:** +- `useReadContract`로 view 함수를 호출하면 가스 없이 현재 상태를 읽을 수 있습니다. +- `useWriteContract`로 상태 변경 트랜잭션을 보내고, 반환된 `hash`를 `useWaitForTransactionReceipt`에 전달하면 확인 상태를 추적합니다. +- 투표 성공 후 `refetch`를 호출해야 화면에 최신 데이터가 반영됩니다. 블록체인 데이터는 React 상태와 자동 동기화되지 않기 때문입니다. +- Provider 순서는 의존성 방향을 따릅니다: RainbowKit → wagmi → React Query 순으로 의존하므로, 가장 바깥에 WagmiProvider가 위치해야 합니다. + +--- + +## 문제 6: 컨트랙트-프론트엔드 연동 (빈칸 채우기) + +**답변:** +```typescript +import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; + +const votingABI = [ + { name: 'yesVotes', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }, + { name: 'noVotes', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] }, + { name: 'voteYes', type: 'function', stateMutability: 'nonpayable', inputs: [], outputs: [] }, + { name: 'voteNo', type: 'function', stateMutability: 'nonpayable', inputs: [], outputs: [] }, +] as const; + +function VotingApp() { + const { data: yesCount, refetch: refetchYes } = useReadContract({ + address: '0x1234...5678', + abi: votingABI, + functionName: 'yesVotes', + }); + + const { data: noCount, refetch: refetchNo } = useReadContract({ + address: '0x1234...5678', + abi: votingABI, + functionName: 'noVotes', + }); + + const { writeContract, data: hash, isPending } = useWriteContract(); + + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + }); + + if (isSuccess) { + refetchYes(); + refetchNo(); + } + + const handleVoteYes = () => { + writeContract({ + address: '0x1234...5678', + abi: votingABI, + functionName: 'voteYes', + }); + }; + + return ( +
+

현재 투표 현황

+

찬성: {yesCount?.toString()}

+

반대: {noCount?.toString()}

+ + + + {isSuccess &&

투표 완료!

} +
+ ); +} +``` + +**데이터 흐름:** +1) 사용자가 "찬성 투표" 클릭 → `writeContract`가 MetaMask에 트랜잭션 서명 요청 → 사용자가 서명(isPending) → 트랜잭션이 네트워크에 제출 → 블록에 포함 대기(isConfirming) → 블록에 포함되어 실행 완료(isSuccess) → `refetch`로 최신 투표 수를 다시 읽어와 화면 업데이트 + +--- + +## 문제 7: 트랜잭션 흐름 디버깅 (취약점 찾기) + +**1) 발견한 문제점:** +트랜잭션이 성공해도(`isSuccess = true`) `useReadContract`가 자동으로 데이터를 다시 가져오지 않습니다. 블록체인에서 투표 수는 업데이트되었지만, React 컴포넌트는 이전에 캐싱된 데이터를 그대로 표시합니다. + +**2) 올바른 수정 방법:** +```typescript +import { useEffect } from 'react'; + +function FixedVoting() { + const { data: voteCount, refetch } = useReadContract({ + address: '0x...', + abi: votingABI, + functionName: 'yesVotes', + }); + + const { writeContract, data: hash } = useWriteContract(); + const { isSuccess } = useWaitForTransactionReceipt({ hash }); + + useEffect(() => { + if (isSuccess) { + refetch(); + } + }, [isSuccess, refetch]); + + const handleVote = () => { + writeContract({ + address: '0x...', + abi: votingABI, + functionName: 'voteYes', + }); + }; + + return ( +
+

찬성: {voteCount?.toString()}

+ + {isSuccess &&

투표 완료!

} +
+ ); +} +``` + +**3) refetch가 필요한 이유:** +블록체인 데이터는 서버의 실시간 데이터와 다릅니다. `useReadContract`는 한 번 호출하면 결과를 React Query 캐시에 저장하고, 일정 시간 동안 재요청하지 않습니다. 트랜잭션으로 온체인 상태가 바뀌어도 프론트엔드는 캐시된 데이터를 보여주므로, 명시적으로 `refetch()`를 호출하여 최신 상태를 가져와야 합니다. + +--- + +## 문제 8: Beacon Chain 구조 (다이어그램 해석) + +1) **합의 계층(CL)**은 검증자 관리, 블록 제안자 선정, attestation 수집, finality 결정 등 "누가 블록을 만들고, 어떤 체인이 정규 체인인가"를 결정합니다. **실행 계층(EL)**은 트랜잭션을 EVM에서 실행하고, 상태(계정 잔액, 컨트랙트 스토리지 등)를 업데이트합니다. CL은 "합의", EL은 "실행"을 각각 전담합니다. + +2) Engine API를 통해: CL → EL로 "이 트랜잭션 목록으로 블록을 실행하라"는 요청을 보내고, EL → CL로 실행 결과(상태 루트, 영수증 루트 등)를 반환합니다. 또한 CL이 새 블록을 제안할 때 EL에게 실행 페이로드(execution payload)를 요청합니다. + +3) 사용자가 트랜잭션을 전송하면: EL의 mempool에 트랜잭션이 들어갑니다. CL이 선정한 블록 제안자가 EL에게 실행 페이로드를 요청하면, EL이 mempool에서 트랜잭션을 선택하여 EVM으로 실행하고 결과를 CL에 반환합니다. CL은 이 페이로드를 포함한 블록을 네트워크에 제안하고, 검증자들이 attestation을 통해 블록을 확인합니다. + +--- + +## 문제 9: Slot/Epoch 관계 (다이어그램 해석) + +1) 1 Slot = **12초**, 1 Epoch = **32 Slots** = 384초 = **6.4분**입니다. 매 슬롯마다 1명의 검증자가 블록을 제안하고, 위원회(committee)가 attestation합니다. + +2) **Checkpoint**는 각 Epoch의 첫 번째 Slot(Slot 0) 블록에서 발생합니다. Checkpoint는 Casper FFG의 투표 대상이 되는 지점으로, 검증자들은 "이전 checkpoint에서 현재 checkpoint까지의 체인이 유효하다"고 투표합니다. 충분한 투표(2/3 이상)가 모이면 해당 checkpoint가 justified → finalized 상태로 전환됩니다. + +3) Finality가 달성되려면 **2 Epoch**이 필요하고, 시간으로는 약 **12.8분**입니다. 첫 번째 Epoch에서 checkpoint가 justified되고, 다음 Epoch에서 그 justified checkpoint가 finalized됩니다. + +--- + +## 문제 10: dApp 전체 아키텍처 (다이어그램 해석) + +1) 사용자가 "투표하기" 버튼 클릭 → React UI가 `useWriteContract`를 통해 wagmi에 트랜잭션 요청 → wagmi가 MetaMask(RainbowKit 커넥터)에 서명 요청 → 사용자가 서명 → wagmi가 RPC Provider(Alchemy/Infura)에 서명된 트랜잭션 전송 → RPC가 Full Node에 전파 → Full Node가 EVM에서 스마트 컨트랙트 함수 실행 → 상태 변경. + +2) RPC Provider는 사용자와 이더리움 노드 사이의 **통신 중개자**입니다. 사용자가 직접 Full Node를 운영하지 않아도 블록체인과 상호작용할 수 있게 해줍니다. 없다면 사용자가 직접 Full Node를 설치하고 동기화해야 하는데(수백 GB 다운로드, 수 일 소요), 일반 사용자에게는 불가능합니다. + +3) **전체 흐름:** 사용자가 트랜잭션에 서명하면 RPC를 통해 네트워크에 전파됩니다(전송). 블록 제안자가 이 트랜잭션을 mempool에서 선택하여 블록에 포함시키고, EVM이 실행합니다(실행). 다른 검증자들이 해당 블록에 attestation을 보내면 블록이 체인에 추가됩니다(블록 포함). 이후 2 Epoch(약 12.8분)이 지나 검증자 2/3 이상이 해당 checkpoint에 투표하면 블록이 finalized되어, 전체 스테이킹의 1/3 이상이 슬래싱되지 않는 한 절대 변경되지 않는 영구 상태가 됩니다(Finality). + +--- + +## 제출 전 체크리스트 + +- [x] 모든 문제에 답변을 작성했는가? +- [x] 객관식 문제: 정답 선택 **이유**를 설명했는가? +- [x] 단답형 문제: 2-3문장 이상으로 충분히 설명했는가? +- [x] 코드 문제: 완성된 코드와 **왜 그렇게 작성했는지** 설명했는가? +- [x] 다이어그램 문제: 6주간 배운 내용을 **연결**지어 설명했는가?