"이 동네에 버스 노선 하나 생겼으면 좋겠다" 를 블록체인으로 실현하는 프로젝트입니다.
주민들이 원하는 노선에 토큰을 모으고, 목표 금액을 넘기면 그 돈이 운영 주체에게 전달되어 노선이 개설됩니다. 그리고 돈을 낸 사람은 낸 만큼 그 노선의 탑승권(NFT) 을 받습니다.
이 저장소는 그중 스마트 컨트랙트(블록체인에 올라가는 코드) 부분입니다.
① 운영자가 노선을 등록합니다
"3번 노선 / 목표 10,000 토큰 / 돈 받을 곳: OO버스회사"
│
▼
② 주민들이 펀딩합니다
낸 토큰은 일단 컨트랙트가 금고처럼 보관합니다 (아직 아무도 못 가져감)
│
▼
③ 모금액이 목표를 넘으면? ──── 자동으로 아래 2가지가 한 번에 실행됩니다
│
├─ 모인 토큰 전액을 버스회사에게 전송 → 노선 개설 확정!
└─ 참여자들에게 탑승권 NFT를 기여한 비율대로 나눠줌
│
▼
④ 버스를 탈 때 탑승권 NFT를 사용(반납)합니다
핵심은 "목표를 넘기면 사람 손 안 거치고 자동으로 정산된다" 입니다. 중간에서 누가 돈을 들고 튈 수 없고, 목표를 못 넘기면 돈은 계속 금고에 남아있습니다.
ERC-1155라는 표준을 쓰면 한 개의 컨트랙트 안에서 여러 종류의 토큰을 번호(id)로 구분해 만들 수 있습니다. DBUS는 이 점을 이용해 아래처럼 나눴습니다.
| 토큰 번호 | 이름 | 성격 | 설명 |
|---|---|---|---|
0 |
포인트 토큰 | 서로 구분 없음 (화폐처럼) | 펀딩할 때 내는 돈 |
1, 2, 3 ... |
탑승권 NFT | 노선마다 다름 | 번호 = 노선 번호. 1번 노선 탑승권, 2번 노선 탑승권 ... |
즉 토큰 id가 곧 노선 id입니다. 새 노선이 생기면 새 종류의 탑승권이 자동으로 생깁니다.
탑승권의 이미지·노선 정보 같은 데이터는 블록체인에 직접 넣으면 비싸기 때문에, IPFS(분산 파일 저장소)에 올려두고 컨트랙트는 그 주소만 가리킵니다.
contracts/
├── DTicket.sol # 토큰 컨트랙트 (포인트 + 탑승권 NFT)
├── FundRegistry.sol # 핵심 로직 (펀딩 등록 / 모금 / 자동 정산 / 티켓 분배)
├── *_flattened.sol # Etherscan 코드 인증용으로 한 파일에 합쳐놓은 버전
scripts/
├── deploy_with_ethers.ts # 배포 스크립트 (ethers.js)
├── deploy_with_web3.ts # 배포 스크립트 (web3.js)
├── ethers-lib.ts # 배포 공통 함수
├── web3-lib.ts # 배포 공통 함수
└── bulk_mint_nfts.js # NFT를 100개씩 끊어서 한 번에 발행하는 스크립트
tests/
└── MyToken_test.sol # 초기 ERC-20 버전의 테스트 (현재는 사용하지 않음)
.deps/ # OpenZeppelin 라이브러리 (Remix가 자동으로 받아온 것)
개발 환경은 Remix IDE 기준입니다. (Hardhat / Foundry 아님)
| 함수 | 하는 일 |
|---|---|
mint(계정, 토큰번호, 수량) |
토큰을 새로 찍어냄 (운영자만) |
mintBatch(...) |
여러 종류를 한 번에 찍어냄 (가스비 절약) |
uri(토큰번호) |
해당 토큰의 정보가 담긴 IPFS 주소를 돌려줌 |
setURI(주소) |
메타데이터 위치 변경 (운영자만) |
노선 하나가 Fund라는 구조체 하나입니다.
struct Fund {
uint96 id; // 노선 번호
address owner; // 이 노선 정보를 수정할 수 있는 사람
address payee; // 모금이 성공하면 돈을 받을 주소
uint256 threshold; // 목표 금액
uint256 donationAmount; // 지금까지 모인 금액
bool isEnd; // 모금 완료 여부
uint48 createdAt; // 생성 시각
uint48 updatedAt; // 수정 시각
}| 함수 | 하는 일 |
|---|---|
createFund(owner, payee, 목표액) |
새 노선 펀딩을 엽니다 |
donate(유저, 노선번호, 금액) |
펀딩 참여. 토큰을 금고에 넣고 기록한 뒤, 목표 달성 여부를 즉시 검사합니다 |
updateFund(...) |
노선 정보 수정. 그 노선을 만든 사람만 가능합니다 |
burn(유저, 수량, 노선번호) |
탑승 시 탑승권을 회수합니다 |
getAllFunds() / getFunds(시작, 끝) |
노선 목록 조회 |
defaultMintToOwner(수량) |
운영자에게 포인트 토큰을 발행 |
내부에서만 도는 함수들:
createDonation()— 토큰을 금고로 옮기고 기여 내역을 저장validateFunds()— 목표를 넘었는지 확인하고, 넘었으면 정산 + 티켓 발행까지 처리mintDTiket()— 참여자별 기여 비율대로 탑승권 50장을 나눠줌 (예: 총 1,000 토큰이 모였고 내가 200을 냈다면 → 50 × 200 / 1000 = 10장)
블록체인은 "조회"가 느리고 비싸기 때문에, 변화가 생길 때마다 이벤트를 쏴서 서버나 앱이 그걸 받아 화면에 보여주는 구조를 씁니다.
| 이벤트 | 언제 발생 |
|---|---|
FundCreated |
새 노선 펀딩이 열렸을 때 |
FundUpdated |
노선 정보가 수정됐을 때 |
FundUserAdded |
새로운 참여자가 처음 펀딩했을 때 |
FundCompletion |
목표 달성 → 노선 개설 확정! |
순서를 지키지 않으면 티켓 자동 발행이 동작하지 않습니다.
1. DTicket 배포 (생성자 인자: 운영자 주소)
2. FundRegistry 배포 (생성자 인자: DTicket 주소, 운영자 주소)
3. DTicket.transferOwnership(FundRegistry 주소)
└ FundRegistry가 티켓을 발행할 권한을 가져야 하기 때문
4. 유저는 DTicket.setApprovalForAll(FundRegistry 주소, true)
└ 내 토큰을 펀딩에 쓸 수 있도록 미리 허락해두는 것
초기 버전에서 두 번 크게 방향을 바꿨습니다.
① Quadratic Funding → 목표 금액(threshold) 방식
처음에는 "소수의 큰손보다 다수의 소액 기여를 더 쳐주는" 이차 펀딩(Quadratic Funding) 공식
(∑√기여액)² 을 컨트랙트 안에 직접 구현했습니다.
솔리디티에는 소수점과 제곱근이 없어서 제곱근 함수도 직접 만들었습니다.
하지만 이 계산은 매번 전체 노선 × 전체 참여자 × 전체 기여내역을 3중으로 훑어야 해서 참여자가 늘어날수록 수수료가 감당이 안 되는 구조였습니다. 그래서 "목표 금액을 넘으면 정산" 이라는 단순하고 저렴한 방식으로 교체했습니다.
② ERC-20 + ERC-721 → ERC-1155 하나로 통합
처음엔 화폐용 토큰(ERC-20)과 탑승권을 따로 만들려 했지만, ERC-1155 하나로 두 역할을 모두 처리할 수 있어서 컨트랙트를 하나로 합쳤습니다. 배포 비용도 줄고, 토큰끼리 주고받는 로직도 단순해졌습니다.
솔직하게 적어둡니다. 개선 예정 항목입니다.
- 운영자 권한이 큽니다.
donate를 포함한 대부분의 함수가 운영자만 호출할 수 있습니다. (유저 대신 서버가 트랜잭션을 대신 보내주는 구조라 유저가 가스비를 안 내도 되는 장점은 있습니다) - 참여자가 아주 많아지면 정산이 실패할 수 있습니다. 티켓을 나눠줄 때 참여자 수만큼 반복문을 돌기 때문에, 각자 직접 받아가는(claim) 방식으로 바꾸는 게 안전합니다.
- 티켓 분배 시 나머지가 버려집니다. 정수 나눗셈이라 아주 적게 기여한 사람은 0장을 받을 수 있습니다.
- 목표액에 딱 맞게 도달하면 완료되지 않습니다. 비교 조건이
초과로 되어 있습니다. burn()함수가 잔액을 확인할 때와 실제로 옮길 때의 토큰 번호가 서로 다릅니다.- 환불 기능이 없습니다. 목표를 못 넘긴 펀딩의 토큰을 돌려받을 방법이 필요합니다.
- Solidity ^0.8.20
- OpenZeppelin Contracts v5.x (ERC1155, ERC1155Holder, Ownable)
- Remix IDE (개발 / 컴파일 / 배포)
- ethers.js, web3.js (배포 스크립트)
- IPFS (NFT 메타데이터 저장)