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

책을 다 읽으면 전송 불가능한 온체인 인증서(소울바운드 NFT)를 발급해주는 기능을 만들어봤습니다. 컨트랙트도 짜고 테스트도 통과했는데, 첫 발급 하나를 성공시키는 데 하루 종일, 관문을 6번 넘어야 했어요. 하나를 고치면 다음이 튀어나오는 연쇄였죠.
이 글은 노하우까지 다 공개합니다 — EIP-712 서명 구조, Privy 지갑 가스 처리, 그리고 눈에 안 보이던 nonce 정밀도 버그까지 실제 코드 레벨로.
한국에서 혼자 서비스 만드는 개발자입니다. 전부 실제로 겪은 것만 씁니다.
설계 — 왜 "서버가 가스를 안 내는" 구조였나
- 완독 인증 = ERC-5192 소울바운드 NFT. ERC-721에 "전송 잠금(locked)"을 더한 표준이에요. 발급 후
transfer가 막혀서, 사고팔 수 없는 순수 "증명"이 됩니다. - Foundry(
forge)로 컨트랙트 작성 → 테스트 13/13 → 테스트넷(Amoy) → 메인넷(Polygon0x33805F…). - 왜 테스트넷부터? 처음엔 폴리곤(POL)을 못 사서였어요(한국 거래소 벽 — 다음 글). 무료 테스트넷에서 다 만들고 POL 확보 후 메인넷으로 옮겼습니다.
핵심은 가스 대납을 피하는 것. 운영자가 모든 발급 가스를 내면 안 되니까, 백엔드가 EIP-712로 서명한 voucher를 사용자에게 주고 사용자가 자기 지갑으로 민팅합니다. 컨트랙트는 그 서명을 검증만 하고요.
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 유효를 검사한 뒤 민팅
즉 위조 불가능한 입장권을 서버가 발급하고, 가스는 사용자가 내는 구조. 여기까지는 깔끔했는데 — 실제 발급에서 6번 터졌습니다.
하루에 6번 막혔다 — 발급 관문 사슬
관문별로 실제로 뭘 했는지 풀면:
- 409 "이미 발급됨" —
exists_for_book가 pending(민팅 미완료) 행까지 차단해서, 한 번 실패한 voucher가 그 책을 영원히 막았어요. →token_id가 찍힌 완료건만 409로 바꾸고, voucher 재발급 시 stale pending을 자동 삭제. - intrinsic gas too low: gas 0 — Privy 임베디드 지갑은
viem의 자동 gas 추정을 안 해서 gas=0으로 나갔어요. → 프론트에서estimateContractGas+estimateFeesPerGas를 명시 호출해 값을 채워 전달. - insufficient funds — EIP-1559는
gasLimit × maxFeePerGas를 미리 예약해서, 실제 소모가 적어도 그 예약액이 잔액에 있어야 합니다. 0.05 POL로는 예약을 못 넘겼어요. → 신규 지갑 자동 송금액을 0.05 → 0.3 POL로. - voucher expired — 유효시간(TTL) 1h가 가스 충전·재시도하는 사이 지나버렸고, 게다가 프론트가 revert(status 0x0)된 tx를 "발급완료"로 오기록했어요. → TTL을 6h로, 그리고
receipt.status와CertMinted이벤트를 확인해 진짜 성공일 때만 confirm. - ★ bad signature (진짜 근본) — 이게 안 보였어요.
nonce는 256비트(uint256)인데, JSON의 숫자는 IEEE-754 double이라 안전정수 한계가 2^53입니다. 큰 nonce를 JSON number로 주고받으면 조용히 정밀도가 깨져서, 백엔드가 서명한 nonce ≠ 프론트가 제출한 nonce →ecrecover가 다른 주소를 뱉음 → "서명 불일치". → nonce/deadline를 문자열로 직렬화하고 프론트에서BigInt()로 복원. (서명은 정수 원본으로 하니 유효.) - DB 저장 500 — 5번을 고치며 문자열화한 값이 그대로
create_pending의 BIGINT/NUMERIC 컬럼에 흘러가 asyncpgDataError. → JSON 응답용 문자열화와 별개로, DB 저장 직전int()변환.
5번이 이 글의 핵심이라 그림으로 풀면:
특히 5번을 잡을 때, operator 지갑으로 직접 온체인 민팅을 성공시켜서 "서명·컨트랙트·도메인은 다 정상이고 문제는 nonce 직렬화뿐"임을 격리·입증하고 나서야 근본을 확정했어요. 이 "정상 케이스를 강제로 성공시켜 변수 하나만 남기는" 디버깅이 결정적이었습니다.
그래서 얻은 것
솔직히 — 기술은 다 됐는데 사용자 수요가 낮았어요. 완독 NFT·회원권 SBT까지 메인넷에 올렸지만 정작 원하는 사람은 많지 않았고, 얼마 뒤 기능을 접었습니다.
그런데 후회는 없어요. 제 마음가짐은 이랬거든요 —
일단 해본다. 안 되면 우회하고, 최선이 막히면 차선을 먼저 한다. 기술은 계속 발전하니 기록으로 남겨두고 나중에 다시 시도한다.
이 경험으로 허상이 아니라 현실적인 한계를 알게 됐어요. EIP-712 서명, Privy 지갑 가스, EIP-1559 선예약, 그리고 JSON 숫자의 2^53 함정이 실제로 어디서 터지는지 — 이건 글로만 읽어선 절대 안 박히는, 하루를 통째로 주고 산 지식입니다.
다음 글에서는 이 모든 것의 시작이었던 **"한국에서 폴리곤(POL)을 사려다 겪은 일"**을 풀어볼게요. 거래소에서 고객센터 전화까지 받았거든요.
태그