Ver3.5 파이썬으로 DAO 회의록을NFT로 자동 변환하기

파이썬으로 DAO 회의록을
NFT로 자동 변환하기
🐍 Python × 🗳️ DAO × 🖼️ NFT — 세 가지 키워드가 만나는 순간의 마법
야, 솔직히 말해봐. DAO 회의록 관리하다가 "이거 어디다 저장하지?" 하고 멍 때린 적 없어? 🤔
구글 독스에 올리면 누군가 수정할 수 있고, 서버에 올리면 해킹당할 수 있고... 탈중앙화 조직인데 정작 회의록은 중앙화된 서버에 있다는 아이러니. 진짜 웃기지 않아?
그래서 오늘은 파이썬(Python)을 이용해서 DAO 회의록을 NFT(Non-Fungible Token)로 자동 변환하는 방법을 같이 파헤쳐볼 거야. 이게 단순히 "오 신기하다~" 수준이 아니라, 실제로 불변성(Immutability), 투명성(Transparency), 소유권 증명이라는 세 마리 토끼를 한 번에 잡을 수 있는 진짜 실용적인 방법이거든.
먼저 DAO가 뭔지 잠깐 짚고 가자. DAO(Decentralized Autonomous Organization)는 스마트 컨트랙트로 운영되는 탈중앙화 자율 조직이야. 중앙 관리자 없이 토큰 홀더들이 투표로 의사결정을 하는 구조지. 요즘 DeFi 프로토콜, NFT 프로젝트, 심지어 실물 자산 투자 클럽까지 DAO 형태로 운영되는 곳이 엄청 많아.
근데 여기서 웃긴 모순이 생겨. DAO는 블록체인 위에서 돌아가는데, 정작 회의록은 구글 독스나 노션 같은 중앙화 플랫폼에 저장되는 경우가 태반이야. 이게 왜 문제냐고?
| 문제점 | 구체적 상황 | NFT화로 해결 |
|---|---|---|
| 🔓 수정 가능성 | 관리자가 회의록 내용을 몰래 바꿀 수 있음 | 블록체인에 기록되면 수정 불가 |
| 💀 서비스 종료 | 노션/구글이 서비스 종료하면 데이터 소실 | IPFS + 블록체인으로 영구 보존 |
| 🔍 검증 불가 | "이 회의록이 진짜인지" 증명할 방법 없음 | 트랜잭션 해시로 언제든 검증 가능 |
| 🏛️ 거버넌스 분쟁 | 투표 결과 조작 의혹 발생 시 증거 없음 | 온체인 기록으로 분쟁 해결 |
| 🌐 접근성 | 특정 플랫폼 계정 없으면 접근 불가 | 누구나 IPFS 해시로 접근 가능 |
1. 타임스탬프 불변성
블록체인에 기록된 순간의 타임스탬프는 절대 바꿀 수 없어. "이 회의는 2024년 3월 15일 오후 3시에 열렸고, 이 내용이 결정됐다"는 사실이 영원히 기록돼.
2. 멤버십 증명과 연계
회의에 참여한 멤버들의 지갑 주소를 메타데이터에 포함시키면, 누가 참여했는지도 블록체인으로 증명할 수 있어.
3. 거버넌스 히스토리 구축
NFT 컬렉션 형태로 회의록을 쌓아가면, DAO의 의사결정 역사가 자연스럽게 온체인 아카이브가 돼. 나중에 "우리 DAO가 어떻게 성장했는지" 투명하게 보여줄 수 있지.
4. 자동화의 힘
파이썬으로 자동화하면 회의 끝나고 5분 안에 NFT 민팅까지 완료할 수 있어. 사람이 수동으로 할 필요가 없다는 거야. 🚀
본격적으로 코드 짜기 전에, 우리가 사용할 도구들을 제대로 이해하고 가자. 도구를 모르면 코드 짜다가 막혔을 때 왜 막혔는지도 모르거든.
Web3.py는 이더리움 블록체인과 파이썬을 연결해주는 라이브러리야. JavaScript 생태계의 ethers.js나 web3.js와 같은 역할을 파이썬에서 해주는 거지. 스마트 컨트랙트 호출, 트랜잭션 전송, 지갑 관리 등 블록체인과 관련된 거의 모든 작업을 파이썬 코드로 처리할 수 있어.
IPFS(InterPlanetary File System)는 파일을 분산 네트워크에 저장하는 프로토콜이야. 파일을 올리면 그 내용을 기반으로 CID(Content Identifier)라는 고유 해시값이 생성돼. 파일 내용이 1바이트라도 바뀌면 CID가 완전히 달라지기 때문에, 위변조 감지가 자동으로 돼.
근데 IPFS 노드를 직접 운영하는 건 복잡하잖아. 그래서 Pinata라는 서비스를 쓸 거야. Pinata는 IPFS 핀닝 서비스로, API를 통해 파일을 IPFS에 올리고 영구적으로 유지해주는 서비스야. 무료 플랜도 있어서 테스트하기 딱 좋아.
ERC-721은 이더리움의 NFT 표준이야. 각 토큰이 고유한 ID를 가지고, 소유권을 추적할 수 있는 인터페이스를 정의해. 우리가 만들 DAO 회의록 NFT도 이 표준을 따를 거야.
| 체인 | 가스비 | 속도 | 추천 용도 |
|---|---|---|---|
| Ethereum Mainnet | 높음 💸 | 보통 | 고가치 DAO (MakerDAO 등) |
| Polygon (MATIC) | 매우 낮음 ✅ | 빠름 | 일반 DAO 회의록 (추천!) |
| Arbitrum | 낮음 | 매우 빠름 | 고빈도 회의 DAO |
| Sepolia Testnet | 무료 (테스트) | 보통 | 개발/테스트 단계 |
자, 이제 진짜 시작이야. 환경 세팅부터 차근차근 해보자. 이 부분 대충 넘기면 나중에 에러 폭탄 맞으니까 꼼꼼하게 따라와!
# 가상환경 먼저 만들자 (강력 추천!)
python -m venv dao-nft-env
source dao-nft-env/bin/activate # Mac/Linux
# dao-nft-env\Scripts\activate # Windows
# 필수 패키지 설치
pip install web3==6.11.3
pip install requests==2.31.0
pip install python-dotenv==1.0.0
pip install Pillow==10.1.0
pip install markdown==3.5.1
pip install eth-account==0.10.0
dao-nft-minter/
├── .env # 환경변수 (절대 깃에 올리지 마!)
├── .gitignore
├── main.py # 메인 실행 파일
├── config.py # 설정 관리
├── modules/
│ ├── __init__.py
│ ├── meeting_parser.py # 회의록 파싱
│ ├── ipfs_uploader.py # IPFS 업로드
│ ├── metadata_builder.py # NFT 메타데이터 생성
│ ├── nft_minter.py # 블록체인 민팅
│ └── image_generator.py # 회의록 이미지 생성
├── contracts/
│ └── DAOMeetingNFT.sol # 스마트 컨트랙트
├── meetings/ # 회의록 파일 저장 폴더
│ └── sample_meeting.md
└── output/ # 결과물 저장
└── minted_records.json
# .env 파일 내용
PINATA_API_KEY=your_pinata_api_key_here
PINATA_SECRET_KEY=your_pinata_secret_key_here
PRIVATE_KEY=your_wallet_private_key_here # 0x 포함
RPC_URL=https://polygon-mumbai.g.alchemy.com/v2/your_key
CONTRACT_ADDRESS=0x_your_deployed_contract_address
CHAIN_ID=80001 # Polygon Mumbai testnet
파이썬 코드 짜기 전에 NFT를 민팅할 스마트 컨트랙트가 필요해. Solidity로 작성하는데, OpenZeppelin 라이브러리를 활용하면 안전하고 빠르게 만들 수 있어.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract DAOMeetingNFT is ERC721, ERC721URIStorage, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
// 회의록 NFT 구조체
struct MeetingRecord {
string meetingId; // 회의 고유 ID
uint256 timestamp; // 민팅 타임스탬프
address mintedBy; // 민팅한 주소
string ipfsHash; // IPFS CID
uint256 voteCount; // 투표 참여자 수
bool isValid; // 유효성 플래그
}
// tokenId => MeetingRecord 매핑
mapping(uint256 => MeetingRecord) public meetingRecords;
// meetingId => tokenId 매핑 (중복 방지)
mapping(string => uint256) public meetingIdToToken;
mapping(string => bool) public meetingExists;
// 이벤트 정의
event MeetingMinted(
uint256 indexed tokenId,
string meetingId,
address mintedBy,
string ipfsHash,
uint256 timestamp
);
// 민팅 권한 주소 관리
mapping(address => bool) public authorizedMinters;
modifier onlyAuthorized() {
require(
authorizedMinters[msg.sender] || msg.sender == owner(),
"Not authorized to mint"
);
_;
}
constructor() ERC721("DAO Meeting Record", "DAOMR") Ownable(msg.sender) {
authorizedMinters[msg.sender] = true;
}
// 민팅 권한 부여
function addMinter(address minter) external onlyOwner {
authorizedMinters[minter] = true;
}
// 회의록 NFT 민팅
function mintMeetingRecord(
address recipient,
string memory tokenURI,
string memory meetingId,
string memory ipfsHash,
uint256 voteCount
) external onlyAuthorized returns (uint256) {
// 동일 회의 중복 민팅 방지
require(!meetingExists[meetingId], "Meeting already minted");
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(recipient, tokenId);
_setTokenURI(tokenId, tokenURI);
// 회의록 정보 저장
meetingRecords[tokenId] = MeetingRecord({
meetingId: meetingId,
timestamp: block.timestamp,
mintedBy: msg.sender,
ipfsHash: ipfsHash,
voteCount: voteCount,
isValid: true
});
meetingIdToToken[meetingId] = tokenId;
meetingExists[meetingId] = true;
emit MeetingMinted(tokenId, meetingId, msg.sender, ipfsHash, block.timestamp);
return tokenId;
}
// 회의록 조회
function getMeetingRecord(uint256 tokenId)
external view returns (MeetingRecord memory) {
require(_exists(tokenId), "Token does not exist");
return meetingRecords[tokenId];
}
// 총 민팅된 회의록 수
function totalMeetings() external view returns (uint256) {
return _tokenIdCounter.current();
}
// ERC721URIStorage override 필수
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage)
returns (string memory) {
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC721URIStorage)
returns (bool) {
return super.supportsInterface(interfaceId);
}
}
컨트랙트 배포는 Remix IDE(remix.ethereum.org)에서 하면 제일 편해. 과정을 간단히 정리하면:
이제 파이썬 코드를 본격적으로 짜보자! 먼저 마크다운 형식의 회의록을 파싱하는 모듈부터 시작해.
자동화를 위해서는 회의록 형식이 일관돼야 해. 아래 형식을 DAO 내에서 표준으로 정하면 돼:
# meetings/sample_meeting.md
---
meeting_id: DAO-2024-0315-001
title: Q1 예산 배분 및 신규 프로젝트 승인
date: 2024-03-15
time: 15:00 KST
location: Discord #governance-meeting
facilitator: 0x1234...abcd
secretary: 0x5678...efgh
quorum: 75
total_members: 120
attending: 89
---
## 참석자 지갑 주소
- 0x1234567890abcdef1234567890abcdef12345678
- 0xabcdef1234567890abcdef1234567890abcdef12
- 0x9876543210fedcba9876543210fedcba98765432
## 안건 1: Q1 마케팅 예산 50,000 USDC 승인
**제안자**: 0x1234...abcd
**내용**: 커뮤니티 성장을 위한 마케팅 예산 배정
**투표 결과**: 찬성 67 / 반대 12 / 기권 10
**결과**: ✅ 가결 (찬성률 74.4%)
## 안건 2: 신규 개발자 보조금 프로그램 도입
**제안자**: 0x5678...efgh
**내용**: 오픈소스 기여자에게 월 500 USDC 지원
**투표 결과**: 찬성 81 / 반대 5 / 기권 3
**결과**: ✅ 가결 (찬성률 91.0%)
## 안건 3: 트레저리 다중서명 지갑 변경
**제안자**: 0x9876...3210
**내용**: 3/5 멀티시그에서 4/7 멀티시그로 변경
**투표 결과**: 찬성 45 / 반대 38 / 기권 6
**결과**: ❌ 부결 (찬성률 50.6%, 가결 기준 60% 미달)
## 다음 회의
**일시**: 2024-04-01 15:00 KST
**주요 안건**: 파트너십 제안 검토
import re
import yaml
import hashlib
from datetime import datetime
from pathlib import Path
class MeetingParser:
"""DAO 회의록 마크다운 파일을 파싱하는 클래스"""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self.raw_content = self._read_file()
self.frontmatter = {}
self.body = ""
self._parse()
def _read_file(self) -> str:
"""파일 읽기"""
with open(self.file_path, 'r', encoding='utf-8') as f:
return f.read()
def _parse(self):
"""프론트매터와 본문 분리 파싱"""
# YAML 프론트매터 추출 (--- 사이의 내용)
frontmatter_pattern = r'^---\n(.*?)\n---\n'
match = re.match(frontmatter_pattern, self.raw_content, re.DOTALL)
if match:
self.frontmatter = yaml.safe_load(match.group(1))
self.body = self.raw_content[match.end():]
else:
raise ValueError("회의록에 YAML 프론트매터가 없습니다!")
def get_meeting_id(self) -> str:
"""회의 고유 ID 반환"""
return str(self.frontmatter.get('meeting_id', ''))
def get_title(self) -> str:
"""회의 제목 반환"""
return str(self.frontmatter.get('title', '제목 없음'))
def get_date(self) -> str:
"""회의 날짜 반환"""
date_val = self.frontmatter.get('date', '')
if isinstance(date_val, datetime):
return date_val.strftime('%Y-%m-%d')
return str(date_val)
def get_attendees(self) -> list:
"""참석자 지갑 주소 목록 추출"""
attendee_section = re.search(
r'## 참석자 지갑 주소\n(.*?)(?=\n##|\Z)',
self.body, re.DOTALL
)
if not attendee_section:
return []
addresses = re.findall(
r'0x[a-fA-F0-9]{40}',
attendee_section.group(1)
)
return addresses
def get_agenda_items(self) -> list:
"""안건 목록 파싱"""
agenda_pattern = r'## (안건 \d+: .+?)\n(.*?)(?=\n## |\Z)'
matches = re.findall(agenda_pattern, self.body, re.DOTALL)
agenda_items = []
for title, content in matches:
item = {
'title': title.strip(),
'proposer': self._extract_field(content, '제안자'),
'description': self._extract_field(content, '내용'),
'vote_result': self._extract_vote_result(content),
'outcome': self._extract_field(content, '결과')
}
agenda_items.append(item)
return agenda_items
def _extract_field(self, content: str, field_name: str) -> str:
"""특정 필드 값 추출"""
pattern = rf'\*\*{field_name}\*\*: (.+?)(?:\n|$)'
match = re.search(pattern, content)
return match.group(1).strip() if match else ''
def _extract_vote_result(self, content: str) -> dict:
"""투표 결과 파싱"""
vote_text = self._extract_field(content, '투표 결과')
result = {'for': 0, 'against': 0, 'abstain': 0}
for_match = re.search(r'찬성 (\d+)', vote_text)
against_match = re.search(r'반대 (\d+)', vote_text)
abstain_match = re.search(r'기권 (\d+)', vote_text)
if for_match:
result['for'] = int(for_match.group(1))
if against_match:
result['against'] = int(against_match.group(1))
if abstain_match:
result['abstain'] = int(abstain_match.group(1))
return result
def get_content_hash(self) -> str:
"""회의록 내용의 SHA-256 해시 생성 (무결성 증명용)"""
content_bytes = self.raw_content.encode('utf-8')
return hashlib.sha256(content_bytes).hexdigest()
def get_summary(self) -> dict:
"""파싱된 회의록 요약 반환"""
agenda_items = self.get_agenda_items()
total_votes = sum(
item['vote_result']['for'] +
item['vote_result']['against'] +
item['vote_result']['abstain']
for item in agenda_items
)
passed = sum(
1 for item in agenda_items
if '✅' in item.get('outcome', '')
)
return {
'meeting_id': self.get_meeting_id(),
'title': self.get_title(),
'date': self.get_date(),
'time': str(self.frontmatter.get('time', '')),
'facilitator': str(self.frontmatter.get('facilitator', '')),
'total_members': int(self.frontmatter.get('total_members', 0)),
'attending': int(self.frontmatter.get('attending', 0)),
'quorum': int(self.frontmatter.get('quorum', 0)),
'attendees': self.get_attendees(),
'agenda_count': len(agenda_items),
'agenda_items': agenda_items,
'passed_count': passed,
'rejected_count': len(agenda_items) - passed,
'total_votes_cast': total_votes,
'content_hash': self.get_content_hash(),
'raw_content': self.raw_content
}
# 테스트
if __name__ == "__main__":
parser = MeetingParser("meetings/sample_meeting.md")
summary = parser.get_summary()
print(f"회의 ID: {summary['meeting_id']}")
print(f"제목: {summary['title']}")
print(f"참석자: {summary['attending']}명")
print(f"안건 수: {summary['agenda_count']}개")
print(f"가결: {summary['passed_count']}개")
print(f"내용 해시: {summary['content_hash'][:16]}...")
회의록 파싱이 끝났으면 이제 IPFS에 올릴 차례야. Pinata API를 사용해서 파일을 업로드하고 CID를 받아오는 모듈을 만들어보자.
import requests
import json
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
class PinataUploader:
"""Pinata를 통한 IPFS 업로드 클래스"""
PINATA_BASE_URL = "https://api.pinata.cloud"
IPFS_GATEWAY = "https://gateway.pinata.cloud/ipfs/"
def __init__(self):
self.api_key = os.getenv('PINATA_API_KEY')
self.secret_key = os.getenv('PINATA_SECRET_KEY')
if not self.api_key or not self.secret_key:
raise ValueError("Pinata API 키가 설정되지 않았습니다!")
self.headers = {
'pinata_api_key': self.api_key,
'pinata_secret_api_key': self.secret_key
}
# 연결 테스트
self._test_connection()
def _test_connection(self):
"""Pinata API 연결 테스트"""
response = requests.get(
f"{self.PINATA_BASE_URL}/data/testAuthentication",
headers=self.headers
)
if response.status_code != 200:
raise ConnectionError(f"Pinata 인증 실패: {response.text}")
print("✅ Pinata 연결 성공!")
def upload_file(self, file_path: str, name: str = None) -> dict:
"""파일을 IPFS에 업로드"""
file_path = Path(file_path)
if not file_path.exists():
raise FileNotFoundError(f"파일을 찾을 수 없습니다: {file_path}")
pin_name = name or file_path.name
with open(file_path, 'rb') as f:
files = {
'file': (file_path.name, f, self._get_mime_type(file_path))
}
payload = {
'pinataMetadata': json.dumps({'name': pin_name}),
'pinataOptions': json.dumps({'cidVersion': 1})
}
response = requests.post(
f"{self.PINATA_BASE_URL}/pinning/pinFileToIPFS",
files=files,
data=payload,
headers=self.headers
)
if response.status_code != 200:
raise Exception(f"파일 업로드 실패: {response.text}")
result = response.json()
cid = result['IpfsHash']
print(f"📦 파일 업로드 완료: {pin_name}")
print(f" CID: {cid}")
print(f" URL: {self.IPFS_GATEWAY}{cid}")
return {
'cid': cid,
'url': f"{self.IPFS_GATEWAY}{cid}",
'ipfs_uri': f"ipfs://{cid}",
'name': pin_name,
'size': result.get('PinSize', 0)
}
def upload_json(self, data: dict, name: str) -> dict:
"""JSON 데이터를 IPFS에 업로드"""
payload = {
'pinataContent': data,
'pinataMetadata': {'name': name},
'pinataOptions': {'cidVersion': 1}
}
response = requests.post(
f"{self.PINATA_BASE_URL}/pinning/pinJSONToIPFS",
json=payload,
headers=self.headers
)
if response.status_code != 200:
raise Exception(f"JSON 업로드 실패: {response.text}")
result = response.json()
cid = result['IpfsHash']
print(f"📋 JSON 업로드 완료: {name}")
print(f" CID: {cid}")
return {
'cid': cid,
'url': f"{self.IPFS_GATEWAY}{cid}",
'ipfs_uri': f"ipfs://{cid}",
'name': name
}
def _get_mime_type(self, file_path: Path) -> str:
"""파일 확장자에 따른 MIME 타입 반환"""
mime_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.md': 'text/markdown',
'.txt': 'text/plain',
'.json': 'application/json',
'.pdf': 'application/pdf'
}
return mime_types.get(file_path.suffix.lower(), 'application/octet-stream')
def upload_meeting_package(self,
image_path: str,
markdown_path: str,
meeting_summary: dict) -> dict:
"""
회의록 전체 패키지 업로드
1. 이미지 업로드
2. 원본 마크다운 업로드
3. 메타데이터 JSON 생성 및 업로드
"""
meeting_id = meeting_summary['meeting_id']
print(f"\n🚀 회의록 패키지 업로드 시작: {meeting_id}")
print("=" * 50)
# 1. 이미지 업로드
print("\n[1/3] 회의록 이미지 업로드 중...")
image_result = self.upload_file(
image_path,
f"{meeting_id}_image.png"
)
# 2. 원본 마크다운 업로드
print("\n[2/3] 원본 회의록 업로드 중...")
markdown_result = self.upload_file(
markdown_path,
f"{meeting_id}_original.md"
)
# 3. NFT 메타데이터 생성 및 업로드
print("\n[3/3] NFT 메타데이터 업로드 중...")
metadata = self._build_nft_metadata(
meeting_summary,
image_result['ipfs_uri'],
markdown_result['ipfs_uri']
)
metadata_result = self.upload_json(
metadata,
f"{meeting_id}_metadata.json"
)
print(f"\n✅ 패키지 업로드 완료!")
print(f" 메타데이터 URI: {metadata_result['ipfs_uri']}")
return {
'image': image_result,
'markdown': markdown_result,
'metadata': metadata_result,
'token_uri': metadata_result['ipfs_uri']
}
def _build_nft_metadata(self,
summary: dict,
image_uri: str,
document_uri: str) -> dict:
"""ERC-721 표준 NFT 메타데이터 생성"""
# 안건별 속성 생성
agenda_attributes = []
for i, item in enumerate(summary.get('agenda_items', []), 1):
vote = item.get('vote_result', {})
total = vote.get('for', 0) + vote.get('against', 0) + vote.get('abstain', 0)
approval_rate = round(vote.get('for', 0) / total * 100, 1) if total > 0 else 0
agenda_attributes.append({
'trait_type': f'안건{i} 찬성률',
'value': f"{approval_rate}%"
})
agenda_attributes.append({
'trait_type': f'안건{i} 결과',
'value': '가결' if '✅' in item.get('outcome', '') else '부결'
})
metadata = {
'name': f"DAO 회의록 #{summary['meeting_id']}",
'description': (
f"DAO 거버넌스 회의록 NFT\n"
f"회의 제목: {summary['title']}\n"
f"회의 일시: {summary['date']} {summary['time']}\n"
f"참석자: {summary['attending']}명 / {summary['total_members']}명\n"
f"처리 안건: {summary['agenda_count']}건 "
f"(가결 {summary['passed_count']}건, "
f"부결 {summary['rejected_count']}건)\n\n"
f"이 NFT는 DAO 회의록의 불변 기록입니다."
),
'image': image_uri,
'external_url': f"https://your-dao.xyz/meetings/{summary['meeting_id']}",
'attributes': [
{
'trait_type': '회의 ID',
'value': summary['meeting_id']
},
{
'trait_type': '회의 날짜',
'value': summary['date']
},
{
'trait_type': '참석자 수',
'value': summary['attending'],
'display_type': 'number'
},
{
'trait_type': '전체 멤버 수',
'value': summary['total_members'],
'display_type': 'number'
},
{
'trait_type': '참석률',
'value': round(summary['attending'] / summary['total_members'] * 100, 1),
'display_type': 'boost_percentage'
},
{
'trait_type': '처리 안건 수',
'value': summary['agenda_count'],
'display_type': 'number'
},
{
'trait_type': '가결 안건 수',
'value': summary['passed_count'],
'display_type': 'number'
},
{
'trait_type': '총 투표 수',
'value': summary['total_votes_cast'],
'display_type': 'number'
},
{
'trait_type': '원본 문서 해시',
'value': summary['content_hash'][:32] + '...'
},
{
'trait_type': '원본 문서 URI',
'value': document_uri
},
*agenda_attributes
],
'properties': {
'meeting_id': summary['meeting_id'],
'content_hash': summary['content_hash'],
'document_uri': document_uri,
'attendees': summary.get('attendees', []),
'category': 'DAO Governance',
'version': '1.0'
}
}
return metadata
attributes 배열은 OpenSea 같은 마켓플레이스에서 필터링과 검색에 활용돼. display_type: 'number'로 설정하면 숫자형으로 표시되고, boost_percentage는 퍼센트 바 형태로 보여줘. 회의록 NFT를 나중에 거버넌스 대시보드에서 필터링할 때 유용해!
NFT에는 이미지가 있어야 하잖아. 회의록 내용을 시각화한 PNG 이미지를 Pillow 라이브러리로 자동 생성해보자. 이게 NFT 마켓플레이스에서 썸네일로 보이는 이미지야.
from PIL import Image, ImageDraw, ImageFont
import os
from datetime import datetime
class MeetingImageGenerator:
"""회의록 NFT 이미지 생성 클래스"""
# 이미지 크기 (정사각형이 NFT에 적합)
WIDTH = 1000
HEIGHT = 1000
# 색상 팔레트
COLORS = {
'bg_top': (30, 42, 56), # 다크 네이비
'bg_bottom': (45, 62, 82), # 미디엄 네이비
'accent': (91, 155, 213), # 블루
'text_primary': (201, 216, 232), # 라이트 블루
'text_secondary': (138, 168, 192), # 그레이 블루
'success': (107, 191, 107), # 그린
'danger': (213, 107, 107), # 레드
'white': (255, 255, 255),
'card_bg': (38, 54, 72), # 카드 배경
}
def __init__(self, font_path: str = None):
"""
font_path: 한글 폰트 경로 (없으면 기본 폰트 사용)
한글 표시를 위해 NanumGothic 등의 폰트 권장
"""
self.font_path = font_path
self._setup_fonts()
def _setup_fonts(self):
"""폰트 설정"""
try:
if self.font_path and os.path.exists(self.font_path):
self.font_large = ImageFont.truetype(self.font_path, 48)
self.font_medium = ImageFont.truetype(self.font_path, 32)
self.font_small = ImageFont.truetype(self.font_path, 24)
self.font_tiny = ImageFont.truetype(self.font_path, 18)
else:
# 시스템 폰트 시도
font_candidates = [
'/System/Library/Fonts/AppleSDGothicNeo.ttc', # Mac
'/usr/share/fonts/truetype/nanum/NanumGothic.ttf', # Linux
'C:/Windows/Fonts/malgun.ttf', # Windows
]
font_loaded = False
for font_path in font_candidates:
if os.path.exists(font_path):
self.font_large = ImageFont.truetype(font_path, 48)
self.font_medium = ImageFont.truetype(font_path, 32)
self.font_small = ImageFont.truetype(font_path, 24)
self.font_tiny = ImageFont.truetype(font_path, 18)
font_loaded = True
break
if not font_loaded:
# 기본 폰트 (한글 미지원)
self.font_large = ImageFont.load_default()
self.font_medium = ImageFont.load_default()
self.font_small = ImageFont.load_default()
self.font_tiny = ImageFont.load_default()
print("⚠️ 한글 폰트를 찾지 못했습니다. 기본 폰트를 사용합니다.")
except Exception as e:
print(f"폰트 로드 오류: {e}")
self.font_large = ImageFont.load_default()
self.font_medium = ImageFont.load_default()
self.font_small = ImageFont.load_default()
self.font_tiny = ImageFont.load_default()
def generate(self, meeting_summary: dict, output_path: str) -> str:
"""회의록 NFT 이미지 생성"""
# 배경 그라디언트 생성
img = self._create_gradient_background()
draw = ImageDraw.Draw(img)
# 상단 헤더 영역
self._draw_header(draw, meeting_summary)
# 구분선
draw.line([(50, 220), (950, 220)],
fill=self.COLORS['accent'], width=2)
# 회의 기본 정보
self._draw_meeting_info(draw, meeting_summary)
# 투표 결과 시각화
self._draw_vote_summary(draw, meeting_summary)
# 안건 목록
self._draw_agenda_list(draw, meeting_summary)
# 하단 해시 정보
self._draw_footer(draw, meeting_summary)
# 저장
img.save(output_path, 'PNG', quality=95)
print(f"🎨 이미지 생성 완료: {output_path}")
return output_path
def _create_gradient_background(self) -> Image:
"""그라디언트 배경 생성"""
img = Image.new('RGB', (self.WIDTH, self.HEIGHT))
draw = ImageDraw.Draw(img)
top_r, top_g, top_b = self.COLORS['bg_top']
bot_r, bot_g, bot_b = self.COLORS['bg_bottom']
for y in range(self.HEIGHT):
ratio = y / self.HEIGHT
r = int(top_r + (bot_r - top_r) * ratio)
g = int(top_g + (bot_g - top_g) * ratio)
b = int(top_b + (bot_b - top_b) * ratio)
draw.line([(0, y), (self.WIDTH, y)], fill=(r, g, b))
return img
def _draw_header(self, draw: ImageDraw, summary: dict):
"""헤더 영역 그리기"""
# DAO 배지
draw.rounded_rectangle(
[(50, 40), (200, 80)],
radius=20,
fill=self.COLORS['accent']
)
draw.text((125, 60), "DAO 회의록",
fill=self.COLORS['white'],
font=self.font_tiny,
anchor='mm')
# NFT 배지
draw.rounded_rectangle(
[(210, 40), (310, 80)],
radius=20,
fill=(107, 91, 213)
)
draw.text((260, 60), "NFT",
fill=self.COLORS['white'],
font=self.font_tiny,
anchor='mm')
# 회의 제목 (긴 제목은 줄임)
title = summary['title']
if len(title) > 20:
title = title[:20] + '...'
draw.text((500, 140), title,
fill=self.COLORS['text_primary'],
font=self.font_large,
anchor='mm')
# 회의 ID
draw.text((500, 195), f"#{summary['meeting_id']}",
fill=self.COLORS['accent'],
font=self.font_small,
anchor='mm')
def _draw_meeting_info(self, draw: ImageDraw, summary: dict):
"""회의 기본 정보 그리기"""
y_start = 250
info_items = [
("📅 날짜", summary['date']),
("⏰ 시간", summary.get('time', '-')),
("👥 참석", f"{summary['attending']}명 / {summary['total_members']}명"),
("📊 참석률", f"{round(summary['attending']/summary['total_members']*100, 1)}%"),
]
for i, (label, value) in enumerate(info_items):
x = 80 + (i % 2) * 460
y = y_start + (i // 2) * 70
draw.text((x, y), label,
fill=self.COLORS['text_secondary'],
font=self.font_tiny)
draw.text((x, y + 28), str(value),
fill=self.COLORS['text_primary'],
font=self.font_small)
def _draw_vote_summary(self, draw: ImageDraw, summary: dict):
"""투표 결과 요약 시각화"""
y = 430
draw.text((500, y), "안건 처리 결과",
fill=self.COLORS['text_secondary'],
font=self.font_small,
anchor='mm')
# 가결/부결 큰 숫자 표시
passed = summary['passed_count']
rejected = summary['rejected_count']
total = summary['agenda_count']
# 가결
draw.rounded_rectangle(
[(80, y + 30), (440, y + 130)],
radius=15,
fill=self.COLORS['card_bg']
)
draw.text((260, y + 65), str(passed),
fill=self.COLORS['success'],
font=self.font_large,
anchor='mm')
draw.text((260, y + 110), "✅ 가결",
fill=self.COLORS['success'],
font=self.font_small,
anchor='mm')
# 부결
draw.rounded_rectangle(
[(560, y + 30), (920, y + 130)],
radius=15,
fill=self.COLORS['card_bg']
)
draw.text((740, y + 65), str(rejected),
fill=self.COLORS['danger'],
font=self.font_large,
anchor='mm')
draw.text((740, y + 110), "❌ 부결",
fill=self.COLORS['danger'],
font=self.font_small,
anchor='mm')
# 진행 바
bar_y = y + 150
bar_width = 840
bar_x = 80
draw.rounded_rectangle(
[(bar_x, bar_y), (bar_x + bar_width, bar_y + 20)],
radius=10,
fill=self.COLORS['card_bg']
)
if total > 0:
passed_width = int(bar_width * passed / total)
if passed_width > 0:
draw.rounded_rectangle(
[(bar_x, bar_y), (bar_x + passed_width, bar_y + 20)],
radius=10,
fill=self.COLORS['success']
)
def _draw_agenda_list(self, draw: ImageDraw, summary: dict):
"""안건 목록 그리기"""
y = 640
draw.text((500, y), "안건 목록",
fill=self.COLORS['text_secondary'],
font=self.font_small,
anchor='mm')
agenda_items = summary.get('agenda_items', [])[:4] # 최대 4개
for i, item in enumerate(agenda_items):
item_y = y + 40 + i * 55
is_passed = '✅' in item.get('outcome', '')
color = self.COLORS['success'] if is_passed else self.COLORS['danger']
icon = '✓' if is_passed else '✗'
# 아이콘 원
draw.ellipse(
[(80, item_y + 5), (110, item_y + 35)],
fill=color
)
draw.text((95, item_y + 20), icon,
fill=self.COLORS['white'],
font=self.font_tiny,
anchor='mm')
# 안건 제목 (줄임)
title = item['title']
if len(title) > 28:
title = title[:28] + '...'
draw.text((125, item_y + 20), title,
fill=self.COLORS['text_primary'],
font=self.font_tiny,
anchor='lm')
# 투표 수
vote = item.get('vote_result', {})
vote_text = f"찬성 {vote.get('for', 0)} / 반대 {vote.get('against', 0)}"
draw.text((920, item_y + 20), vote_text,
fill=self.COLORS['text_secondary'],
font=self.font_tiny,
anchor='rm')
def _draw_footer(self, draw: ImageDraw, summary: dict):
"""하단 해시 정보 그리기"""
draw.line([(50, 900), (950, 900)],
fill=self.COLORS['card_bg'], width=1)
# 내용 해시 (앞 32자만)
hash_short = summary['content_hash'][:32] + '...'
draw.text((500, 930), f"SHA-256: {hash_short}",
fill=self.COLORS['text_secondary'],
font=self.font_tiny,
anchor='mm')
draw.text((500, 965), "Powered by DAO NFT Minter",
fill=(60, 80, 100),
font=self.font_tiny,
anchor='mm')
이제 진짜 핵심이야. Web3.py를 사용해서 스마트 컨트랙트를 호출하고 NFT를 민팅하는 모듈을 만들어보자. 이 부분이 제일 짜릿한 순간이거든! 🚀
import os
import json
import time
from web3 import Web3
from eth_account import Account
from dotenv import load_dotenv
load_dotenv()
# 컨트랙트 ABI (민팅에 필요한 함수만 포함)
CONTRACT_ABI = [
{
"inputs": [
{"internalType": "address", "name": "recipient", "type": "address"},
{"internalType": "string", "name": "tokenURI", "type": "string"},
{"internalType": "string", "name": "meetingId", "type": "string"},
{"internalType": "string", "name": "ipfsHash", "type": "string"},
{"internalType": "uint256", "name": "voteCount", "type": "uint256"}
],
"name": "mintMeetingRecord",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [{"internalType": "string", "name": "meetingId", "type": "string"}],
"name": "meetingExists",
"outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalMeetings",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}],
"name": "getMeetingRecord",
"outputs": [
{
"components": [
{"internalType": "string", "name": "meetingId", "type": "string"},
{"internalType": "uint256", "name": "timestamp", "type": "uint256"},
{"internalType": "address", "name": "mintedBy", "type": "address"},
{"internalType": "string", "name": "ipfsHash", "type": "string"},
{"internalType": "uint256", "name": "voteCount", "type": "uint256"},
{"internalType": "bool", "name": "isValid", "type": "bool"}
],
"internalType": "struct DAOMeetingNFT.MeetingRecord",
"name": "",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
}
]
class NFTMinter:
"""DAO 회의록 NFT 민팅 클래스"""
def __init__(self):
# 환경변수 로드
self.rpc_url = os.getenv('RPC_URL')
self.private_key = os.getenv('PRIVATE_KEY')
self.contract_address = os.getenv('CONTRACT_ADDRESS')
self.chain_id = int(os.getenv('CHAIN_ID', '80001'))
# 유효성 검사
self._validate_config()
# Web3 연결
self.w3 = Web3(Web3.HTTPProvider(self.rpc_url))
if not self.w3.is_connected():
raise ConnectionError(f"RPC 연결 실패: {self.rpc_url}")
print(f"✅ 블록체인 연결 성공!")
print(f" 체인 ID: {self.w3.eth.chain_id}")
print(f" 최신 블록: {self.w3.eth.block_number}")
# 지갑 설정
self.account = Account.from_key(self.private_key)
self.wallet_address = self.account.address
print(f" 지갑 주소: {self.wallet_address}")
# 잔액 확인
balance = self.w3.eth.get_balance(self.wallet_address)
balance_eth = self.w3.from_wei(balance, 'ether')
print(f" 잔액: {balance_eth:.6f} ETH/MATIC")
if balance_eth < 0.001:
print("⚠️ 잔액이 부족할 수 있습니다. 가스비를 확인하세요!")
# 컨트랙트 인스턴스
self.contract = self.w3.eth.contract(
address=Web3.to_checksum_address(self.contract_address),
abi=CONTRACT_ABI
)
# 총 민팅 수 확인
total = self.contract.functions.totalMeetings().call()
print(f" 현재까지 민팅된 회의록: {total}개")
def _validate_config(self):
"""설정 유효성 검사"""
required = {
'RPC_URL': self.rpc_url,
'PRIVATE_KEY': self.private_key,
'CONTRACT_ADDRESS': self.contract_address
}
for key, value in required.items():
if not value:
raise ValueError(f"환경변수 {key}가 설정되지 않았습니다!")
def check_meeting_exists(self, meeting_id: str) -> bool:
"""이미 민팅된 회의록인지 확인"""
return self.contract.functions.meetingExists(meeting_id).call()
def mint_meeting_nft(self,
meeting_summary: dict,
token_uri: str,
ipfs_cid: str,
recipient: str = None) -> dict:
"""
회의록 NFT 민팅
Args:
meeting_summary: 파싱된 회의록 요약
token_uri: IPFS 메타데이터 URI
ipfs_cid: IPFS CID (컨트랙트 저장용)
recipient: NFT 수령 주소 (None이면 민팅 지갑으로)
Returns:
민팅 결과 딕셔너리
"""
meeting_id = meeting_summary['meeting_id']
# 중복 확인
if self.check_meeting_exists(meeting_id):
raise ValueError(f"이미 민팅된 회의록입니다: {meeting_id}")
# 수령 주소 설정
if recipient is None:
recipient = self.wallet_address
recipient = Web3.to_checksum_address(recipient)
print(f"\n⛓️ NFT 민팅 시작: {meeting_id}")
print(f" 수령 주소: {recipient}")
print(f" 토큰 URI: {token_uri}")
# 총 투표 수 계산
total_votes = meeting_summary.get('total_votes_cast', 0)
# 가스 추정
try:
gas_estimate = self.contract.functions.mintMeetingRecord(
recipient,
token_uri,
meeting_id,
ipfs_cid,
total_votes
).estimate_gas({'from': self.wallet_address})
gas_limit = int(gas_estimate * 1.2) # 20% 여유
print(f" 예상 가스: {gas_estimate:,} (한도: {gas_limit:,})")
except Exception as e:
print(f"⚠️ 가스 추정 실패, 기본값 사용: {e}")
gas_limit = 300000
# 현재 가스 가격
gas_price = self.w3.eth.gas_price
gas_price_gwei = self.w3.from_wei(gas_price, 'gwei')
print(f" 가스 가격: {gas_price_gwei:.2f} Gwei")
# 예상 비용
estimated_cost = self.w3.from_wei(gas_limit * gas_price, 'ether')
print(f" 예상 비용: {estimated_cost:.6f} ETH/MATIC")
# 트랜잭션 구성
nonce = self.w3.eth.get_transaction_count(self.wallet_address)
transaction = self.contract.functions.mintMeetingRecord(
recipient,
token_uri,
meeting_id,
ipfs_cid,
total_votes
).build_transaction({
'chainId': self.chain_id,
'gas': gas_limit,
'gasPrice': gas_price,
'nonce': nonce,
'from': self.wallet_address
})
# 트랜잭션 서명
signed_txn = self.w3.eth.account.sign_transaction(
transaction,
private_key=self.private_key
)
# 트랜잭션 전송
print("\n📡 트랜잭션 전송 중...")
tx_hash = self.w3.eth.send_raw_transaction(signed_txn.rawTransaction)
tx_hash_hex = tx_hash.hex()
print(f" 트랜잭션 해시: {tx_hash_hex}")
print(" ⏳ 컨펌 대기 중...")
# 영수증 대기 (최대 5분)
receipt = self.w3.eth.wait_for_transaction_receipt(
tx_hash,
timeout=300,
poll_latency=2
)
if receipt['status'] == 1:
print(f"\n✅ 민팅 성공!")
print(f" 블록 번호: {receipt['blockNumber']}")
print(f" 실제 가스 사용: {receipt['gasUsed']:,}")
# 이벤트에서 tokenId 추출
token_id = self._extract_token_id(receipt)
result = {
'success': True,
'meeting_id': meeting_id,
'token_id': token_id,
'tx_hash': tx_hash_hex,
'block_number': receipt['blockNumber'],
'gas_used': receipt['gasUsed'],
'token_uri': token_uri,
'ipfs_cid': ipfs_cid,
'recipient': recipient,
'minted_at': time.time(),
'explorer_url': self._get_explorer_url(tx_hash_hex)
}
print(f" 토큰 ID: {token_id}")
print(f" 탐색기: {result['explorer_url']}")
return result
else:
raise Exception(f"트랜잭션 실패! 해시: {tx_hash_hex}")
def _extract_token_id(self, receipt) -> int:
"""트랜잭션 영수증에서 토큰 ID 추출"""
try:
# MeetingMinted 이벤트 파싱
meeting_minted_abi = {
"anonymous": False,
"inputs": [
{"indexed": True, "name": "tokenId", "type": "uint256"},
{"indexed": False, "name": "meetingId", "type": "string"},
{"indexed": False, "name": "mintedBy", "type": "address"},
{"indexed": False, "name": "ipfsHash", "type": "string"},
{"indexed": False, "name": "timestamp", "type": "uint256"}
],
"name": "MeetingMinted",
"type": "event"
}
# 로그에서 토큰 ID 추출
for log in receipt['logs']:
if len(log['topics']) > 1:
token_id = int(log['topics'][1].hex(), 16)
return token_id
except Exception as e:
print(f"⚠️ 토큰 ID 추출 실패: {e}")
return -1
def _get_explorer_url(self, tx_hash: str) -> str:
"""체인별 탐색기 URL 생성"""
explorers = {
1: f"https://etherscan.io/tx/{tx_hash}",
137: f"https://polygonscan.com/tx/{tx_hash}",
80001: f"https://mumbai.polygonscan.com/tx/{tx_hash}",
11155111: f"https://sepolia.etherscan.io/tx/{tx_hash}",
42161: f"https://arbiscan.io/tx/{tx_hash}"
}
return explorers.get(self.chain_id, f"https://etherscan.io/tx/{tx_hash}")
def get_meeting_record(self, token_id: int) -> dict:
"""온체인 회의록 정보 조회"""
record = self.contract.functions.getMeetingRecord(token_id).call()
return {
'meeting_id': record[0],
'timestamp': record[1],
'minted_by': record[2],
'ipfs_hash': record[3],
'vote_count': record[4],
'is_valid': record[5]
}
자, 이제 모든 모듈을 하나로 엮는 메인 파일을 만들 차례야. 이 파일 하나만 실행하면 회의록 파싱부터 NFT 민팅까지 전부 자동으로 돌아가!
#!/usr/bin/env python3
"""
DAO 회의록 NFT 자동 민터
사용법: python main.py <회의록_파일.md> [--recipient 0x주소]
"""
import sys
import json
import argparse
import os
from datetime import datetime
from pathlib import Path
from modules.meeting_parser import MeetingParser
from modules.image_generator import MeetingImageGenerator
from modules.ipfs_uploader import PinataUploader
from modules.nft_minter import NFTMinter
def save_minting_record(result: dict, output_dir: str = "output"):
"""민팅 결과를 JSON 파일로 저장"""
Path(output_dir).mkdir(exist_ok=True)
records_file = Path(output_dir) / "minted_records.json"
# 기존 기록 로드
if records_file.exists():
with open(records_file, 'r', encoding='utf-8') as f:
records = json.load(f)
else:
records = []
# 새 기록 추가
records.append(result)
# 저장
with open(records_file, 'w', encoding='utf-8') as f:
json.dump(records, f, ensure_ascii=False, indent=2)
print(f"\n💾 민팅 기록 저장: {records_file}")
def print_banner():
"""시작 배너 출력"""
banner = """
╔══════════════════════════════════════════════╗
║ DAO 회의록 NFT 자동 민터 v1.0 ║
║ Python × Web3 × IPFS × ERC-721 ║
╚══════════════════════════════════════════════╝
"""
print(banner)
def main():
print_banner()
# 인자 파싱
parser = argparse.ArgumentParser(
description='DAO 회의록을 NFT로 자동 변환합니다'
)
parser.add_argument('meeting_file', help='회의록 마크다운 파일 경로')
parser.add_argument('--recipient', help='NFT 수령 지갑 주소 (기본: 민팅 지갑)')
parser.add_argument('--font', help='한글 폰트 파일 경로')
parser.add_argument('--dry-run', action='store_true',
help='실제 민팅 없이 테스트만 실행')
args = parser.parse_args()
# 파일 존재 확인
if not Path(args.meeting_file).exists():
print(f"❌ 파일을 찾을 수 없습니다: {args.meeting_file}")
sys.exit(1)
start_time = datetime.now()
print(f"🕐 시작 시간: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"📄 처리 파일: {args.meeting_file}")
try:
# ═══════════════════════════════════════
# STEP 1: 회의록 파싱
# ═══════════════════════════════════════
print("\n" + "="*50)
print("STEP 1: 회의록 파싱")
print("="*50)
parser_obj = MeetingParser(args.meeting_file)
summary = parser_obj.get_summary()
print(f"✅ 파싱 완료!")
print(f" 회의 ID: {summary['meeting_id']}")
print(f" 제목: {summary['title']}")
print(f" 날짜: {summary['date']}")
print(f" 참석자: {summary['attending']}명")
print(f" 안건: {summary['agenda_count']}개")
print(f" 내용 해시: {summary['content_hash'][:16]}...")
# ═══════════════════════════════════════
# STEP 2: 이미지 생성
# ═══════════════════════════════════════
print("\n" + "="*50)
print("STEP 2: NFT 이미지 생성")
print("="*50)
output_dir = Path("output") / summary['meeting_id']
output_dir.mkdir(parents=True, exist_ok=True)
image_path = str(output_dir / "nft_image.png")
generator = MeetingImageGenerator(font_path=args.font)
generator.generate(summary, image_path)
# ═══════════════════════════════════════
# STEP 3: IPFS 업로드
# ═══════════════════════════════════════
print("\n" + "="*50)
print("STEP 3: IPFS 업로드")
print("="*50)
uploader = PinataUploader()
upload_result = uploader.upload_meeting_package(
image_path=image_path,
markdown_path=args.meeting_file,
meeting_summary=summary
)
token_uri = upload_result['token_uri']
ipfs_cid = upload_result['metadata']['cid']
# ═══════════════════════════════════════
# STEP 4: NFT 민팅
# ═══════════════════════════════════════
print("\n" + "="*50)
print("STEP 4: NFT 민팅")
print("="*50)
if args.dry_run:
print("🔍 DRY RUN 모드 - 실제 민팅 건너뜀")
mint_result = {
'success': True,
'dry_run': True,
'meeting_id': summary['meeting_id'],
'token_uri': token_uri,
'ipfs_cid': ipfs_cid
}
else:
minter = NFTMinter()
mint_result = minter.mint_meeting_nft(
meeting_summary=summary,
token_uri=token_uri,
ipfs_cid=ipfs_cid,
recipient=args.recipient
)
# ═══════════════════════════════════════
# 결과 저장 및 출력
# ═══════════════════════════════════════
final_result = {
**mint_result,
'upload_result': upload_result,
'meeting_summary': {
k: v for k, v in summary.items()
if k != 'raw_content' # 원본 내용은 제외 (용량)
},
'processed_at': datetime.now().isoformat()
}
save_minting_record(final_result)
# 결과 요약 출력
elapsed = (datetime.now() - start_time).total_seconds()
print("\n" + "="*50)
print("🎉 완료! 결과 요약")
print("="*50)
print(f"⏱️ 소요 시간: {elapsed:.1f}초")
print(f"📄 회의 ID: {summary['meeting_id']}")
if not args.dry_run:
print(f"🖼️ 토큰 ID: {mint_result.get('token_id', 'N/A')}")
print(f"🔗 트랜잭션: {mint_result.get('tx_hash', 'N/A')}")
print(f"🌐 탐색기: {mint_result.get('explorer_url', 'N/A')}")
print(f"📦 IPFS CID: {ipfs_cid}")
print(f"🔗 메타데이터: {token_uri}")
print(f"🖼️ 이미지: {upload_result['image']['url']}")
print(f"📄 원본 문서: {upload_result['markdown']['url']}")
print("\n✅ DAO 회의록이 성공적으로 NFT로 변환되었습니다!")
except KeyboardInterrupt:
print("\n\n⚠️ 사용자에 의해 중단되었습니다.")
sys.exit(1)
except Exception as e:
print(f"\n❌ 오류 발생: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
python main.py meetings/sample_meeting.md — 기본 실행python main.py meetings/sample_meeting.md --dry-run — 민팅 없이 테스트python main.py meetings/sample_meeting.md --recipient 0x1234...abcd — 특정 주소로 민팅
지금까지 만든 건 "수동으로 실행하는 자동화"야. 근데 진짜 DAO 운영에서는 회의가 끝나면 자동으로 민팅이 되면 더 좋겠지? 몇 가지 심화 자동화 방법을 소개할게.
# crontab -e 에 추가
# 매일 자정에 meetings/ 폴더의 새 파일 확인 및 민팅
0 0 * * * /path/to/dao-nft-env/bin/python /path/to/dao-nft-minter/auto_mint.py
# auto_mint.py 핵심 로직
import os
import json
from pathlib import Path
from modules.meeting_parser import MeetingParser
def find_unminted_meetings():
"""아직 민팅되지 않은 회의록 파일 찾기"""
meetings_dir = Path("meetings")
output_file = Path("output/minted_records.json")
# 이미 민팅된 회의 ID 목록
minted_ids = set()
if output_file.exists():
with open(output_file) as f:
records = json.load(f)
minted_ids = {r['meeting_id'] for r in records}
# 새 회의록 파일 찾기
new_meetings = []
for md_file in meetings_dir.glob("*.md"):
try:
parser = MeetingParser(str(md_file))
meeting_id = parser.get_meeting_id()
if meeting_id and meeting_id not in minted_ids:
new_meetings.append(str(md_file))
except Exception as e:
print(f"파싱 오류 {md_file}: {e}")
return new_meetings
많은 DAO가 Discord를 사용하잖아. Discord 봇을 만들어서 회의 채널에 회의록을 올리면 자동으로 NFT 민팅이 되도록 할 수도 있어!
import discord
from discord.ext import commands
import tempfile
import os
bot = commands.Bot(command_prefix='!', intents=discord.Intents.all())
@bot.command(name='mint-meeting')
async def mint_meeting(ctx, *, meeting_id: str = None):
"""
Discord 명령어: !mint-meeting
첨부파일로 회의록 MD 파일을 올리면 자동 민팅
"""
if not ctx.message.attachments:
await ctx.send("❌ 회의록 파일을 첨부해주세요! (.md 형식)")
return
attachment = ctx.message.attachments[0]
if not attachment.filename.endswith('.md'):
await ctx.send("❌ 마크다운(.md) 파일만 지원합니다!")
return
await ctx.send("⏳ 회의록 NFT 민팅을 시작합니다...")
# 임시 파일로 저장
with tempfile.NamedTemporaryFile(
suffix='.md',
delete=False,
mode='wb'
) as tmp:
content = await attachment.read()
tmp.write(content)
tmp_path = tmp.name
try:
# 민팅 실행 (subprocess로 main.py 호출)
import subprocess
result = subprocess.run(
['python', 'main.py', tmp_path],
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
# 성공 임베드 메시지
embed = discord.Embed(
title="✅ 회의록 NFT 민팅 완료!",
color=0x5b9bd5
)
# 결과에서 정보 추출해서 임베드에 추가
embed.add_field(name="상태", value="성공", inline=True)
await ctx.send(embed=embed)
else:
await ctx.send(f"❌ 민팅 실패:\n```{result.stderr[:500]}```")
finally:
os.unlink(tmp_path) # 임시 파일 삭제
bot.run(os.getenv('DISCORD_BOT_TOKEN'))
"""
간단한 CLI 대시보드 - 민팅 현황 확인
python dashboard.py
"""
import json
from pathlib import Path
from datetime import datetime
def show_dashboard():
records_file = Path("output/minted_records.json")
if not records_file.exists():
print("아직 민팅된 회의록이 없습니다.")
return
with open(records_file) as f:
records = json.load(f)
print("\n" + "="*60)
print("📊 DAO 회의록 NFT 대시보드")
print("="*60)
print(f"총 민팅 수: {len(records)}개\n")
for record in records[-10:]: # 최근 10개
summary = record.get('meeting_summary', {})
print(f"📄 {summary.get('meeting_id', 'N/A')}")
print(f" 제목: {summary.get('title', 'N/A')[:30]}")
print(f" 날짜: {summary.get('date', 'N/A')}")
print(f" 토큰 ID: {record.get('token_id', 'N/A')}")
print(f" TX: {record.get('tx_hash', 'N/A')[:20]}...")
print()
if __name__ == "__main__":
show_dashboard()
코드는 다 짰는데, 실제로 운영하다 보면 예상치 못한 상황들이 생겨. 미리 알아두면 삽질을 줄일 수 있는 팁들을 정리해봤어.
| 항목 | 위험도 | 대처법 |
|---|---|---|
| 프라이빗 키 노출 | 🔴 치명적 | .env 파일 사용, .gitignore 필수, 테스트 전용 지갑 사용 |
| Pinata API 키 노출 | 🟡 높음 | 환경변수로 관리, IP 화이트리스트 설정 |
| 가스비 고갈 | 🟡 높음 | 잔액 모니터링, 최소 잔액 알림 설정 |
| 중복 민팅 | 🟢 낮음 | 컨트랙트의 meetingExists 체크로 방지됨 |
| IPFS 핀 해제 | 🟡 높음 | Pinata 유료 플랜 사용, 자체 IPFS 노드 운영 |
maxFeePerGas와 maxPriorityFeePerGas를 설정하면 가스비를 더 효율적으로 관리할 수 있어.2. 배치 민팅: 여러 회의록을 한 번에 민팅하는 배치 함수를 컨트랙트에 추가하면 가스비를 절약할 수 있어.
3. 오프피크 시간 활용: 이더리움 메인넷은 새벽 시간대에 가스비가 낮아. Polygon은 거의 상관없지만.
에러 2: "nonce too low"
→ 이전 트랜잭션이 아직 처리 중. 잠시 기다렸다가 재시도
에러 3: "execution reverted: Meeting already minted"
→ 이미 민팅된 회의록. meeting_id가 중복되지 않는지 확인
에러 4: Pinata "UNAUTHORIZED"
→ API 키 확인. Pinata 대시보드에서 키가 활성화 상태인지 체크
에러 5: "Web3 connection failed"
→ RPC URL 확인. Alchemy/Infura 무료 플랜 한도 초과 여부 체크
기본 기능을 완성했다면 이런 것들도 추가해볼 수 있어:
🗳️ 온체인 투표 연동: Snapshot.org API를 연동해서 실제 온체인 투표 결과를 자동으로 가져와 회의록에 포함시키기
📧 이메일/슬랙 알림: 민팅 완료 시 DAO 멤버들에게 자동 알림 발송
🔍 검색 인덱싱: The Graph 프로토콜을 사용해서 민팅된 회의록 NFT를 인덱싱하고 검색 가능하게 만들기
📱 모바일 앱 연동: WalletConnect를 통해 모바일 지갑으로도 민팅 승인 가능하게 하기
🤝 멀티시그 민팅: Gnosis Safe 같은 멀티시그 지갑을 사용해서 여러 명의 승인이 있어야 민팅되도록 하기
이런 시스템이 실제로 어떻게 쓰일 수 있는지 구체적인 시나리오를 생각해보자.
시나리오 1: DeFi 프로토콜 거버넌스
Uniswap, Compound 같은 DeFi 프로토콜들은 수백억 달러의 자산을 거버넌스 투표로 관리해. 이런 중요한 의사결정 회의록이 NFT로 기록된다면, 나중에 "그때 왜 그런 결정을 했지?"라는 질문에 투명하게 답할 수 있어.
시나리오 2: DAO 투자 클럽
여러 명이 공동으로 투자 결정을 내리는 DAO 투자 클럽에서, 각 투자 결정 회의록을 NFT로 남기면 나중에 법적 분쟁이 생겼을 때 "우리가 이런 결정을 했다"는 증거로 활용할 수 있어.
시나리오 3: 오픈소스 프로젝트 거버넌스
Linux Foundation이나 Apache Foundation 같은 오픈소스 재단들도 DAO 형태로 전환하는 추세야. 회의록 NFT화로 의사결정 과정의 투명성을 높일 수 있어.
시나리오 4: 지역 커뮤니티 DAO
동네 주민 자치 조직이나 소규모 협동조합도 DAO 형태로 운영할 수 있어. 회의록 NFT는 조직의 역사를 영구적으로 기록하는 디지털 아카이브가 돼.
AI 연동: GPT-4 같은 LLM을 연동해서 회의 내용을 자동으로 요약하고, 핵심 결정사항만 추출해서 메타데이터에 포함시키는 것도 가능해. 회의 녹음 파일을 Whisper로 텍스트 변환 → GPT로 요약 → NFT 민팅까지 완전 자동화!
크로스체인 지원: 현재는 하나의 체인에만 민팅하지만, 여러 체인에 동시에 기록해서 더 강력한 불변성을 보장하는 방향으로 발전할 수 있어.
ZK 프루프 연동: 회의 내용의 일부는 비공개로 유지하면서도, 회의가 실제로 열렸다는 사실은 증명하는 Zero-Knowledge Proof 기술과의 결합도 흥미로운 방향이야.
와, 진짜 긴 여정이었지? 😅 처음에 "DAO 회의록을 NFT로 만든다"는 개념이 좀 생소했을 수도 있는데, 이제는 왜 이게 필요하고 어떻게 구현하는지 감이 잡혔을 거야.
오늘 우리가 함께 만든 시스템을 정리해보면:
이 시스템을 기반으로 더 발전시켜 나가고 싶다면, 재능넷에서 블록체인 개발 전문가들과 협업하는 것도 좋은 방법이야. 혼자 다 하려고 하면 지치거든. 잘하는 사람들과 함께하면 훨씬 빠르고 완성도 높은 결과물을 만들 수 있어.
자, 이제 너의 DAO 회의록을 영원히 블록체인에 새겨넣을 준비가 됐어? 🚀
코드 짜다가 막히는 부분 있으면 언제든지 파고들어봐. 에러 메시지는 항상 답을 알고 있거든. 화이팅! 💪
공식 문서
- Web3.py 공식 문서: docs.web3.py
- OpenZeppelin 컨트랙트: docs.openzeppelin.com
- Pinata API 문서: docs.pinata.cloud
- IPFS 공식 문서: docs.ipfs.tech
표준 및 스펙
- ERC-721 표준: eips.ethereum.org/EIPS/eip-721
- NFT 메타데이터 표준: docs.opensea.io/docs/metadata-standards
개발 도구
- Remix IDE: remix.ethereum.org
- Alchemy (RPC): alchemy.com
- Polygon Faucet: faucet.polygon.technology
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.