콘텐츠 대표 이미지 - 파이썬으로 DAO 회의록을NFT로 자동 변환하기
🔗 블록체인 / Web3 · 프로그램개발

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

🐍 Python × 🗳️ DAO × 🖼️ NFT — 세 가지 키워드가 만나는 순간의 마법

야, 솔직히 말해봐. DAO 회의록 관리하다가 "이거 어디다 저장하지?" 하고 멍 때린 적 없어? 🤔
구글 독스에 올리면 누군가 수정할 수 있고, 서버에 올리면 해킹당할 수 있고... 탈중앙화 조직인데 정작 회의록은 중앙화된 서버에 있다는 아이러니. 진짜 웃기지 않아?

그래서 오늘은 파이썬(Python)을 이용해서 DAO 회의록을 NFT(Non-Fungible Token)로 자동 변환하는 방법을 같이 파헤쳐볼 거야. 이게 단순히 "오 신기하다~" 수준이 아니라, 실제로 불변성(Immutability), 투명성(Transparency), 소유권 증명이라는 세 마리 토끼를 한 번에 잡을 수 있는 진짜 실용적인 방법이거든.

💡 이 글에서 배울 것들 DAO 회의록 NFT화의 개념과 필요성 → IPFS 업로드 자동화 → ERC-721 스마트 컨트랙트 이해 → Python으로 민팅 자동화 → 메타데이터 설계 → 실전 코드까지!
DAO 회의록 → NFT 변환 흐름 📄 DAO 회의록 텍스트/마크다운 투표결과 포함 Python 처리 🌐 IPFS 업로드 분산 저장소 CID 해시 생성 메타데이터 생성 📋 NFT 메타데이터 JSON 형식 속성 정의 민팅 트랜잭션 🖼️ NFT 민팅 완료 블록체인 기록 영구 불변 저장 핵심 가치 🔒 불변성 보장 👁️ 완전한 투명성 ✅ 소유권 증명 ⚡ 자동화 처리 기술 스택 🐍 Python 3.10+ 🔗 Web3.py 📦 Pinata/IPFS ⛓️ Ethereum/Polygon 📝 Solidity ERC-721 모든 과정을 Python 스크립트 하나로 자동화!

🗳️ DAO 회의록, 왜 NFT로 만들어야 할까?

먼저 DAO가 뭔지 잠깐 짚고 가자. DAO(Decentralized Autonomous Organization)는 스마트 컨트랙트로 운영되는 탈중앙화 자율 조직이야. 중앙 관리자 없이 토큰 홀더들이 투표로 의사결정을 하는 구조지. 요즘 DeFi 프로토콜, NFT 프로젝트, 심지어 실물 자산 투자 클럽까지 DAO 형태로 운영되는 곳이 엄청 많아.

근데 여기서 웃긴 모순이 생겨. DAO는 블록체인 위에서 돌아가는데, 정작 회의록은 구글 독스나 노션 같은 중앙화 플랫폼에 저장되는 경우가 태반이야. 이게 왜 문제냐고?

문제점구체적 상황NFT화로 해결
🔓 수정 가능성관리자가 회의록 내용을 몰래 바꿀 수 있음블록체인에 기록되면 수정 불가
💀 서비스 종료노션/구글이 서비스 종료하면 데이터 소실IPFS + 블록체인으로 영구 보존
🔍 검증 불가"이 회의록이 진짜인지" 증명할 방법 없음트랜잭션 해시로 언제든 검증 가능
🏛️ 거버넌스 분쟁투표 결과 조작 의혹 발생 시 증거 없음온체인 기록으로 분쟁 해결
🌐 접근성특정 플랫폼 계정 없으면 접근 불가누구나 IPFS 해시로 접근 가능
⚠️ 실제 사례: 2022년 일부 DAO 프로젝트에서 회의록 조작 의혹이 제기됐을 때, 중앙화 플랫폼에 저장된 회의록은 "진짜인지 아닌지" 검증할 방법이 없었어. 만약 NFT로 민팅해뒀다면? 블록 타임스탬프와 트랜잭션 해시로 즉시 검증 가능했겠지.
NFT 회의록의 구체적 장점

1. 타임스탬프 불변성
블록체인에 기록된 순간의 타임스탬프는 절대 바꿀 수 없어. "이 회의는 2024년 3월 15일 오후 3시에 열렸고, 이 내용이 결정됐다"는 사실이 영원히 기록돼.

2. 멤버십 증명과 연계
회의에 참여한 멤버들의 지갑 주소를 메타데이터에 포함시키면, 누가 참여했는지도 블록체인으로 증명할 수 있어.

3. 거버넌스 히스토리 구축
NFT 컬렉션 형태로 회의록을 쌓아가면, DAO의 의사결정 역사가 자연스럽게 온체인 아카이브가 돼. 나중에 "우리 DAO가 어떻게 성장했는지" 투명하게 보여줄 수 있지.

4. 자동화의 힘
파이썬으로 자동화하면 회의 끝나고 5분 안에 NFT 민팅까지 완료할 수 있어. 사람이 수동으로 할 필요가 없다는 거야. 🚀


🛠️ 필요한 기술 스택 완전 정복

본격적으로 코드 짜기 전에, 우리가 사용할 도구들을 제대로 이해하고 가자. 도구를 모르면 코드 짜다가 막혔을 때 왜 막혔는지도 모르거든.

🐍 Web3.py — 파이썬의 블록체인 다리

Web3.py는 이더리움 블록체인과 파이썬을 연결해주는 라이브러리야. JavaScript 생태계의 ethers.js나 web3.js와 같은 역할을 파이썬에서 해주는 거지. 스마트 컨트랙트 호출, 트랜잭션 전송, 지갑 관리 등 블록체인과 관련된 거의 모든 작업을 파이썬 코드로 처리할 수 있어.

📦 IPFS와 Pinata — 분산 저장의 핵심

IPFS(InterPlanetary File System)는 파일을 분산 네트워크에 저장하는 프로토콜이야. 파일을 올리면 그 내용을 기반으로 CID(Content Identifier)라는 고유 해시값이 생성돼. 파일 내용이 1바이트라도 바뀌면 CID가 완전히 달라지기 때문에, 위변조 감지가 자동으로 돼.

근데 IPFS 노드를 직접 운영하는 건 복잡하잖아. 그래서 Pinata라는 서비스를 쓸 거야. Pinata는 IPFS 핀닝 서비스로, API를 통해 파일을 IPFS에 올리고 영구적으로 유지해주는 서비스야. 무료 플랜도 있어서 테스트하기 딱 좋아.

📝 ERC-721 — NFT의 표준 규격

ERC-721은 이더리움의 NFT 표준이야. 각 토큰이 고유한 ID를 가지고, 소유권을 추적할 수 있는 인터페이스를 정의해. 우리가 만들 DAO 회의록 NFT도 이 표준을 따를 거야.

⛓️ 어떤 체인을 쓸까?
체인가스비속도추천 용도
Ethereum Mainnet높음 💸보통고가치 DAO (MakerDAO 등)
Polygon (MATIC)매우 낮음 ✅빠름일반 DAO 회의록 (추천!)
Arbitrum낮음매우 빠름고빈도 회의 DAO
Sepolia Testnet무료 (테스트)보통개발/테스트 단계
✅ 추천: 처음 개발할 때는 Sepolia 테스트넷에서 테스트하고, 실제 배포는 Polygon 메인넷을 쓰는 게 가장 현실적이야. 가스비가 거의 없다시피 해서 회의록 하나 민팅하는 데 0.001달러도 안 들어.

⚙️ 개발 환경 세팅하기

자, 이제 진짜 시작이야. 환경 세팅부터 차근차근 해보자. 이 부분 대충 넘기면 나중에 에러 폭탄 맞으니까 꼼꼼하게 따라와!

필수 패키지 설치
# 가상환경 먼저 만들자 (강력 추천!)
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 파일 설정
# .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
⚠️ 보안 경고! PRIVATE_KEY는 절대 깃허브에 올리면 안 돼! .gitignore에 .env를 반드시 추가하고, 테스트용 지갑을 따로 만들어서 사용해. 실제 자산이 있는 지갑 프라이빗 키는 절대 코드에 넣지 마!

📝 스마트 컨트랙트 작성하기

파이썬 코드 짜기 전에 NFT를 민팅할 스마트 컨트랙트가 필요해. Solidity로 작성하는데, OpenZeppelin 라이브러리를 활용하면 안전하고 빠르게 만들 수 있어.

DAOMeetingNFT.sol
// 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);
    }
}
💡 컨트랙트 핵심 포인트 meetingExists 매핑으로 같은 회의록이 두 번 민팅되는 걸 방지해. 그리고 authorizedMinters로 민팅 권한을 관리하니까, 파이썬 스크립트가 사용하는 지갑 주소만 권한을 줘서 보안을 강화할 수 있어.
Remix IDE로 배포하기

컨트랙트 배포는 Remix IDE(remix.ethereum.org)에서 하면 제일 편해. 과정을 간단히 정리하면:

1
Remix IDE에서 새 파일 만들고 위 코드 붙여넣기
2
Compiler 탭에서 0.8.20 버전 선택 후 컴파일
3
Deploy 탭에서 Environment를 "Injected Provider - MetaMask"로 설정
4
MetaMask를 Sepolia 테스트넷으로 연결 후 Deploy 클릭
5
배포된 컨트랙트 주소를 .env의 CONTRACT_ADDRESS에 저장

📄 회의록 파싱 모듈 만들기

이제 파이썬 코드를 본격적으로 짜보자! 먼저 마크다운 형식의 회의록을 파싱하는 모듈부터 시작해.

회의록 마크다운 형식 정의

자동화를 위해서는 회의록 형식이 일관돼야 해. 아래 형식을 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
**주요 안건**: 파트너십 제안 검토
meeting_parser.py
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]}...")

파이썬 자동화 파이프라인 상세 📄 STEP 1 회의록 파싱 마크다운 읽기 YAML 프론트매터 안건 추출 투표결과 파싱 참석자 주소 추출 SHA-256 해시 meeting_parser.py 🖼️ STEP 2 이미지 생성 Pillow로 PNG 생성 회의 정보 시각화 투표 결과 차트 DAO 로고 삽입 해시값 표시 image_generator.py 🌐 STEP 3 IPFS 업로드 이미지 업로드 원본 MD 업로드 CID 해시 획득 메타데이터 JSON 메타데이터 업로드 ipfs_uploader.py ⛓️ STEP 4 NFT 민팅 Web3.py 연결 트랜잭션 생성 서명 및 전송 영수증 확인 결과 기록 저장 nft_minter.py ✅ 자동화 완료! 회의 종료 후 단 하나의 명령어로: python main.py meetings/sample_meeting.md

🌐 IPFS 업로드 모듈 만들기

회의록 파싱이 끝났으면 이제 IPFS에 올릴 차례야. Pinata API를 사용해서 파일을 업로드하고 CID를 받아오는 모듈을 만들어보자.

ipfs_uploader.py
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
✅ 메타데이터 설계 팁 NFT 메타데이터의 attributes 배열은 OpenSea 같은 마켓플레이스에서 필터링과 검색에 활용돼. display_type: 'number'로 설정하면 숫자형으로 표시되고, boost_percentage는 퍼센트 바 형태로 보여줘. 회의록 NFT를 나중에 거버넌스 대시보드에서 필터링할 때 유용해!

🎨 회의록 이미지 자동 생성하기

NFT에는 이미지가 있어야 하잖아. 회의록 내용을 시각화한 PNG 이미지를 Pillow 라이브러리로 자동 생성해보자. 이게 NFT 마켓플레이스에서 썸네일로 보이는 이미지야.

image_generator.py
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')

⛓️ NFT 민팅 모듈 — 드디어 블록체인으로!

이제 진짜 핵심이야. Web3.py를 사용해서 스마트 컨트랙트를 호출하고 NFT를 민팅하는 모듈을 만들어보자. 이 부분이 제일 짜릿한 순간이거든! 🚀

nft_minter.py
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]
        }

🎯 모든 걸 하나로 — main.py

자, 이제 모든 모듈을 하나로 엮는 메인 파일을 만들 차례야. 이 파일 하나만 실행하면 회의록 파싱부터 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 운영에서는 회의가 끝나면 자동으로 민팅이 되면 더 좋겠지? 몇 가지 심화 자동화 방법을 소개할게.

🕐 cron으로 주기적 실행
# 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
🤖 Discord 봇 연동

많은 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 노드 운영
⚡ 성능 최적화 팁
💡 가스비 절약 방법 1. EIP-1559 트랜잭션 사용: maxFeePerGas와 maxPriorityFeePerGas를 설정하면 가스비를 더 효율적으로 관리할 수 있어.
2. 배치 민팅: 여러 회의록을 한 번에 민팅하는 배치 함수를 컨트랙트에 추가하면 가스비를 절약할 수 있어.
3. 오프피크 시간 활용: 이더리움 메인넷은 새벽 시간대에 가스비가 낮아. Polygon은 거의 상관없지만.
🐛 자주 발생하는 에러와 해결법
에러 1: "insufficient funds for gas" → 지갑 잔액 부족. 테스트넷 faucet에서 토큰 받거나, 메인넷이면 MATIC/ETH 충전

에러 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 같은 멀티시그 지갑을 사용해서 여러 명의 승인이 있어야 민팅되도록 하기

✅ 재능넷 활용 팁: 이런 DAO NFT 자동화 시스템 구축이 어렵다면, 재능넷에서 Web3 개발 전문가를 찾아볼 수 있어. 블록체인 개발, 스마트 컨트랙트 작성, Python 자동화 등 다양한 전문가들이 활동하고 있거든. 프로젝트 규모에 맞게 협업하면 훨씬 빠르게 구현할 수 있어!

DAO 회의록 NFT 시스템 전체 아키텍처 👥 DAO 멤버 회의 진행 ✍️ 회의록 작성 마크다운 형식 🐍 Python 자동화 파싱 → 이미지 → IPFS → 민팅 자동 처리 ⛓️ 블록체인 기록 영구 불변 저장 🖼️ NFT 완성 소유권 증명 구성 모듈 📄 meeting_parser.py YAML 프론트매터 파싱 안건/투표 결과 추출 SHA-256 해시 생성 🎨 image_generator.py Pillow PNG 생성 회의 정보 시각화 투표 결과 차트 🌐 ipfs_uploader.py Pinata API 연동 파일/JSON 업로드 메타데이터 생성 ⛓️ nft_minter.py Web3.py 블록체인 연결 트랜잭션 서명/전송 ERC-721 민팅 실행 핵심 기술 스택 Python 3.10+ Web3.py 6.x Pinata IPFS ERC-721 NFT Polygon/Ethereum Pillow 이미지 python-dotenv eth-account PyYAML OpenZeppelin Solidity 0.8.x Remix IDE

🌍 실제 활용 사례와 미래 전망

이런 시스템이 실제로 어떻게 쓰일 수 있는지 구체적인 시나리오를 생각해보자.

🏛️ 실제 활용 시나리오

시나리오 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 기술과의 결합도 흥미로운 방향이야.

⚠️ 법적 고려사항: NFT 회의록이 법적 효력을 가지려면 각 국가의 전자문서법과 블록체인 관련 규정을 확인해야 해. 한국의 경우 전자서명법과 전자문서법이 관련되는데, 아직 NFT 회의록의 법적 지위가 명확하지 않아. 중요한 법적 결정은 반드시 법률 전문가와 상담하길 권해.

🎉 마무리 — 오늘 배운 것 정리

와, 진짜 긴 여정이었지? 😅 처음에 "DAO 회의록을 NFT로 만든다"는 개념이 좀 생소했을 수도 있는데, 이제는 왜 이게 필요하고 어떻게 구현하는지 감이 잡혔을 거야.

오늘 우리가 함께 만든 시스템을 정리해보면:

1
MeetingParser: 마크다운 회의록을 파싱해서 구조화된 데이터로 변환. YAML 프론트매터, 안건, 투표 결과, 참석자 주소까지 자동 추출.
2
MeetingImageGenerator: Pillow로 회의록 내용을 시각화한 NFT 이미지 자동 생성. 투표 결과, 안건 목록, 해시값까지 포함.
3
PinataUploader: 이미지, 원본 마크다운, 메타데이터 JSON을 IPFS에 업로드. ERC-721 표준 메타데이터 자동 생성.
4
NFTMinter: Web3.py로 스마트 컨트랙트를 호출해서 NFT 민팅. 중복 방지, 가스 최적화, 결과 저장까지.
5
DAOMeetingNFT.sol: ERC-721 기반 스마트 컨트랙트. 회의록 정보 온체인 저장, 중복 민팅 방지, 권한 관리.
💡 핵심 가치 요약 이 시스템의 핵심은 "신뢰를 코드로"야. DAO의 철학인 탈중앙화와 투명성을 회의록 관리에도 적용한 거지. 중앙화된 서버나 특정 플랫폼에 의존하지 않고, 블록체인과 IPFS라는 탈중앙화 인프라 위에서 회의록을 영구적으로 보존하는 것. 이게 진짜 Web3 정신이야.

이 시스템을 기반으로 더 발전시켜 나가고 싶다면, 재능넷에서 블록체인 개발 전문가들과 협업하는 것도 좋은 방법이야. 혼자 다 하려고 하면 지치거든. 잘하는 사람들과 함께하면 훨씬 빠르고 완성도 높은 결과물을 만들 수 있어.

자, 이제 너의 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
🐍 Python ⛓️ 블록체인 🖼️ NFT 🗳️ DAO 🌐 Web3 📦 IPFS 🔗 ERC-721 🤖 자동화 📝 거버넌스 🔒 불변성
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.