🍀블록체인

완독 인증 NFT를 만들다 — 발급이 하루에 6번 막힌 이야기

소울바운드 완독 인증서 NFT를 실제로 발급하기까지, 하루에 6번 막힌 관문과 해결을 코드 레벨로 — EIP-712 서명·Privy 지갑 가스·nonce 정밀도 버그까지 노하우 전부 공개.

📅 2026년 7월 8일·📖 10분 읽기·👁 5
완독 인증 NFT를 만들다 — 발급이 하루에 6번 막힌 이야기

책을 다 읽으면 전송 불가능한 온체인 인증서(소울바운드 NFT)를 발급해주는 기능을 만들어봤습니다. 컨트랙트도 짜고 테스트도 통과했는데, 첫 발급 하나를 성공시키는 데 하루 종일, 관문을 6번 넘어야 했어요. 하나를 고치면 다음이 튀어나오는 연쇄였죠.

이 글은 노하우까지 다 공개합니다 — EIP-712 서명 구조, Privy 지갑 가스 처리, 그리고 눈에 안 보이던 nonce 정밀도 버그까지 실제 코드 레벨로.

한국에서 혼자 서비스 만드는 개발자입니다. 전부 실제로 겪은 것만 씁니다.

설계 — 왜 "서버가 가스를 안 내는" 구조였나

  • 완독 인증 = ERC-5192 소울바운드 NFT. ERC-721에 "전송 잠금(locked)"을 더한 표준이에요. 발급 후 transfer가 막혀서, 사고팔 수 없는 순수 "증명"이 됩니다.
  • Foundry(forge)로 컨트랙트 작성 → 테스트 13/13 → 테스트넷(Amoy) → 메인넷(Polygon 0x33805F…).
  • 왜 테스트넷부터? 처음엔 폴리곤(POL)을 못 사서였어요(한국 거래소 벽 — 다음 글). 무료 테스트넷에서 다 만들고 POL 확보 후 메인넷으로 옮겼습니다.

핵심은 가스 대납을 피하는 것. 운영자가 모든 발급 가스를 내면 안 되니까, 백엔드가 EIP-712로 서명한 voucher를 사용자에게 주고 사용자가 자기 지갑으로 민팅합니다. 컨트랙트는 그 서명을 검증만 하고요.

지갑에 가스(수수료)를 채우는 개념 일러스트 EIP-712 voucher 민팅 흐름 백엔드 서버 voucher 서명(EIP-712) 티켓 사용자 지갑 Privy · 직접 mint 스마트컨트랙트 ecrecover 검증 NFT 발행 전송불가 SBT 🔑 서버는 가스를 안 낸다 — voucher만 서명해 건네고, mint 가스는 사용자가 부담 (신규 지갑엔 소량 가스를 자동 송금 — 관문 3 참고)

voucher의 실체 = EIP-712 typed data 서명. 구조는 이랬어요:

  • domain: { name, version, chainId: 137, verifyingContract: 컨트랙트주소 } — 서명이 "이 체인, 이 컨트랙트"에만 유효하게 묶음(리플레이 방지)
  • message(struct): { recipient(받는 지갑), bookId, nonce, deadline }
  • 백엔드가 verifier 개인키로 이 typed data를 서명 → signature(65바이트)를 사용자에게 전달
  • 컨트랙트 mintWithVoucher(voucher, signature): ecrecover로 서명자 주소 복원 → verifier와 일치하는지 + nonce 미사용 + deadline 유효를 검사한 뒤 민팅
EIP-712 typed data — 백엔드가 정확히 "무엇에" 서명하나 domain — 어디에 유효한가 name: "RielCert" version: "1" chainId: 137 (Polygon) verifyingContract: 0x33805F… → 다른 체인·컨트랙트에선 서명 무효 (리플레이 차단) message — 무엇을 인증하나 recipient: 받는 지갑 주소 bookId: 완독한 책 ID nonce: 1회용 난수 (uint256) ⚠ deadline: 만료 시각 → 이 nonce가 관문 5의 지뢰였다 (256비트 정수) verifier 개인키로 서명 → signature(65 bytes) → 컨트랙트가 ecrecover로 주소 복원·대조 서명자 == verifier 이고 nonce 미사용·deadline 유효 → 민팅 허가

위조 불가능한 입장권을 서버가 발급하고, 가스는 사용자가 내는 구조. 여기까지는 깔끔했는데 — 실제 발급에서 6번 터졌습니다.

하루에 6번 막혔다 — 발급 관문 사슬

발급 6관문 — 에러 → 원인 → 해결 (하루 안에 전부) 1 409 "이미 발급됨" 미완료(pending) 기록이 그 책을 영구 차단 → 해결: 민팅 완료건만 차단 + pending 자동 정리 2 intrinsic gas too low: gas 0 Privy 지갑이 viem으로 gas 자동추정 안 함 → 해결: estimateContractGas + estimateFeesPerGas 명시 3 insufficient funds EIP-1559가 gasLimit×maxFee를 선예약 → 잔액부족 → 해결: 자동 송금 0.05→0.3 POL 4 voucher expired TTL 1h가 짧고, revert된 tx를 "발급완료"로 오기록 → 해결: TTL 6h + receipt.status/이벤트 검증 5 bad signature ★ 진짜 근본 원인 nonce(uint256)를 JSON 숫자로 보내 정밀도 손실 → 서명≠제출 → 해결: 문자열 직렬화 + BigInt 6 DB 저장 500 문자열화한 nonce가 숫자 컬럼에 들어가 타입 에러 → 해결: 저장 직전 int() 변환 🎉 첫 온체인 발급 성공

관문별로 실제로 뭘 했는지 풀면:

  1. 409 "이미 발급됨"exists_for_bookpending(민팅 미완료) 행까지 차단해서, 한 번 실패한 voucher가 그 책을 영원히 막았어요. → token_id가 찍힌 완료건만 409로 바꾸고, voucher 재발급 시 stale pending을 자동 삭제.
  2. intrinsic gas too low: gas 0 — Privy 임베디드 지갑은 viem의 자동 gas 추정을 안 해서 gas=0으로 나갔어요. → 프론트에서 estimateContractGas + estimateFeesPerGas를 명시 호출해 값을 채워 전달.
  3. insufficient funds — EIP-1559는 gasLimit × maxFeePerGas미리 예약해서, 실제 소모가 적어도 그 예약액이 잔액에 있어야 합니다. 0.05 POL로는 예약을 못 넘겼어요. → 신규 지갑 자동 송금액을 0.05 → 0.3 POL로.
  4. voucher expired — 유효시간(TTL) 1h가 가스 충전·재시도하는 사이 지나버렸고, 게다가 프론트가 revert(status 0x0)된 tx를 "발급완료"로 오기록했어요. → TTL을 6h로, 그리고 receipt.statusCertMinted 이벤트를 확인해 진짜 성공일 때만 confirm.
  5. ★ bad signature (진짜 근본) — 이게 안 보였어요. nonce는 256비트(uint256)인데, JSON의 숫자는 IEEE-754 double이라 안전정수 한계가 2^53입니다. 큰 nonce를 JSON number로 주고받으면 조용히 정밀도가 깨져서, 백엔드가 서명한 nonce ≠ 프론트가 제출한 nonceecrecover가 다른 주소를 뱉음 → "서명 불일치". → nonce/deadline를 문자열로 직렬화하고 프론트에서 BigInt()로 복원. (서명은 정수 원본으로 하니 유효.)
  6. DB 저장 500 — 5번을 고치며 문자열화한 값이 그대로 create_pending의 BIGINT/NUMERIC 컬럼에 흘러가 asyncpg DataError. → JSON 응답용 문자열화와 별개로, DB 저장 직전 int() 변환.

5번이 이 글의 핵심이라 그림으로 풀면:

★ 관문 5 — nonce 정밀도 버그 (숫자값은 이해용 예시) ① 백엔드가 서명한 nonce (256비트 정수 원본) 90071992547409929312 JSON 숫자(number)로 전송 — 여기가 지뢰 ② JS는 숫자를 IEEE-754 double로 파싱 — 안전정수 한계 2^53 ≈ 9,007,199,254,740,992 …409929312 → …409936000 한계를 넘는 뒷자리가 조용히 반올림·소실 (예외도 안 남 = 안 보임) ③ 서명한 nonce ≠ 제출된 nonce → ecrecover가 딴 주소 복원 → "bad signature" 서명·컨트랙트·도메인은 전부 정상. 오직 숫자 직렬화 한 곳 때문에 몇 시간을 헤맴 ✅ 해결: nonce·deadline를 문자열로 직렬화 → 프론트에서 BigInt()로 복원 (서명은 정수 원본이라 유효) 단, DB 저장 직전엔 다시 int() — 관문 6 (문자열이 숫자 컬럼에 들어가 500)

특히 5번을 잡을 때, operator 지갑으로 직접 온체인 민팅을 성공시켜서 "서명·컨트랙트·도메인은 다 정상이고 문제는 nonce 직렬화뿐"임을 격리·입증하고 나서야 근본을 확정했어요. 이 "정상 케이스를 강제로 성공시켜 변수 하나만 남기는" 디버깅이 결정적이었습니다.

그래서 얻은 것

솔직히 — 기술은 다 됐는데 사용자 수요가 낮았어요. 완독 NFT·회원권 SBT까지 메인넷에 올렸지만 정작 원하는 사람은 많지 않았고, 얼마 뒤 기능을 접었습니다.

그런데 후회는 없어요. 제 마음가짐은 이랬거든요 —

일단 해본다. 안 되면 우회하고, 최선이 막히면 차선을 먼저 한다. 기술은 계속 발전하니 기록으로 남겨두고 나중에 다시 시도한다.

이 경험으로 허상이 아니라 현실적인 한계를 알게 됐어요. EIP-712 서명, Privy 지갑 가스, EIP-1559 선예약, 그리고 JSON 숫자의 2^53 함정이 실제로 어디서 터지는지 — 이건 글로만 읽어선 절대 안 박히는, 하루를 통째로 주고 산 지식입니다.

다음 글에서는 이 모든 것의 시작이었던 **"한국에서 폴리곤(POL)을 사려다 겪은 일"**을 풀어볼게요. 거래소에서 고객센터 전화까지 받았거든요.

태그

#블록체인#NFT#EIP-712#Polygon#스마트컨트랙트