DEV Community

Cover image for Building a 'Completed Reading' NFT - The Story of Getting Blocked 6 Times in One Day
박준희
박준희

Posted on • Edited on • Originally published at aicoreutility.com

Building a 'Completed Reading' NFT - The Story of Getting Blocked 6 Times in One Day

After finishing a book, I built a feature that issues a non-transferable on-chain certificate (Soulbound NFT). I wrote the contract and passed all tests, but it took me an entire day and six hurdles just to successfully issue the first one. Fixing one issue would just reveal the next in a chain reaction.
This post will reveal all my know-how, from the EIP-712 signing structure, Privy wallet gas handling, to a seemingly invisible nonce precision bug, all at the actual code level.

I'm a solo developer in Korea building services. I only write about things I've personally experienced.## Design: Why a "Server Doesn't Pay Gas" Structure?

  • Completion Certificate = ERC-5192 Soulbound NFT. This standard adds "locked" functionality to ERC-721. Once issued, it cannot be transferred, making it a pure "proof" that cannot be traded.
  • Contract written with Foundry (forge) → 13/13 tests passed → Testnet (Amoy) → Mainnet (Polygon 0x33805F…).
  • Why testnet first? Initially, it was because I couldn't buy Polygon (POL) (the wall of Korean exchanges — next post). I built everything on the free testnet, secured POL, and then moved to mainnet. The core idea was to avoid gas pre-payment by the operator. Since the operator shouldn't have to pay gas for every issuance, the backend signs a voucher using EIP-712 and gives it to the user, who then mints it with their own wallet. The contract simply verifies that signature. Conceptual illustration of filling gas (fees) in a wallet

diagram

The voucher is essentially an EIP-712 typed data signature. The structure was as follows:

  • domain: { name, version, chainId: 137, verifyingContract: contract_address } — Binds the signature to "this chain, this contract" for validity (prevents replay).
  • message (struct): { recipient (wallet address), bookId, nonce, deadline }
  • Backend signs this typed data with the verifier's private key → sends the signature (65 bytes) to the user.
  • Contract mintWithVoucher(voucher, signature): Recovers the signer's address using ecrecover → verifies if it matches the verifier, if the nonce hasn't been used, and if the deadline is valid, then mints.

📊 See the full diagram in the original illustrated post.

In essence, the server issues a counterfeit-proof ticket, and the user covers the gas. It was clean up to this point — but actual issuance failed six times.## Blocked Six Times in One Day — A Chain of Issuance Gates

diagram

Breaking down each gate:

  1. 409 "Already Minted"exists_for_book was blocking even pending (minting not completed) entries, meaning a failed voucher would permanently block that book. → Changed to 409 only for completed mints with token_id set and added automatic deletion of stale pending records when reissuing vouchers.
  2. intrinsic gas too low: gas 0 — Privy's embedded wallet didn't use viem's automatic gas estimation, resulting in gas=0. → Had the frontend explicitly call estimateContractGas + estimateFeesPerGas to get values and pass them.
  3. insufficient funds — EIP-1559 reserves gasLimit × maxFeePerGas upfront. Even if the actual gas consumed is low, this reserved amount must be available in the balance. 0.05 POL was not enough to cover this reservation. → Increased automatic transfer for new wallets from 0.05 to 0.3 POL.
  4. voucher expired — The validity period (TTL) of 1 hour was too short; it expired during gas refueling and retries. Additionally, the frontend incorrectly logged reverted (status 0x0) transactions as "minted". → Increased TTL to 6 hours and confirmed only when receipt.status and CertMinted events indicated a true success.
  5. ★ bad signature (The Real Root Cause) — This was the hidden killer. nonce is a 256-bit integer (uint256), but JSON numbers are IEEE-754 doubles, with a safe integer limit of 2^53. Sending large nonces as JSON numbers silently caused precision loss, meaning the nonce signed by the backend ≠ nonce submitted by the frontendecrecover returned a different address → "Signature mismatch." → Serialized nonce/deadline as strings and restored them on the frontend using BigInt(). (The signature itself uses the original integer, so it remains valid.)
  6. DB Save 500 — After fixing #5, the stringified values flowed into the BIGINT/NUMERIC columns of create_pending, causing an asyncpg DataError. → Separately from JSON response stringification, converted back to int() right before saving to the database. Gate #5 was the core of this post, and to explain it visually:

diagram

Specifically, when debugging Gate #5, I manually succeeded in minting on-chain using the operator's wallet. This helped isolate the issue to "only the nonce serialization," proving that the signature, contract, and domain were all correct. This debugging approach of "forcing a success case to isolate a single variable" was crucial.## The Actually Issued NFT — A Completion Certificate Etched On-Chain
This is the actual result after breaking through all six gates. It's the Completion Certificate NFT (Token #2) that I minted after finishing The Art of War — permanently etched on the Polygon mainnet. Anyone can verify it on Polygonscan even now.
The Art of War Completion Certificate NFT (Token #2) — Gemini AI Art

▲ Completion NFT Art (Auto-generated by Gemini Nano Banana — different themed illustration for each book). The Art of War = Armored general crossing misty mountains.

🔗 On-Chain Proof (Polygon Mainnet · Chain 137)

· Book: The Art of War (Sun Tzu · 孫武) — Completed: 2026-05-29

· Standard: ERC-5192 Soulbound (Non-transferable = essence of proof of "having read")

· Token: #2 · Contract 0x62FE…4325

· Issued: 2026-05-30 · Owner Wallet 0x0137…8C28

Verify it yourself (viewable even without a wallet):

  • 🔍 View Token #2 on Polygonscan
  • 📜 Check Minting Transaction
  • 📄 ReadingCert Contract The art was automatically generated by Gemini (Nano Banana) based on the book's theme at the time of issuance, permanently stored in GCS. The contract's tokenURI then points to that metadata. This means this artwork is one-of-a-kind, born specifically for this token — a different book completion would yield a different themed illustration. If the abstract explanation of EIP-712 vouchers and nonces felt distant, this single image is the physical proof of all those hurdles. ## What I Gained Honestly — the technology was all there, but user demand was low. I deployed completion NFTs and member SBTs to mainnet, but very few people actually wanted them, and I eventually phased out the feature. However, I have no regrets. My mindset was this: > Just try it. If it doesn't work, find a workaround. If the best path is blocked, take the second best first. Technology keeps evolving, so record it and try again later. This experience taught me about real-world limitations, not just theoretical ones. Where EIP-712 signatures, Privy wallet gas, EIP-1559 reservations, and the JSON number's 2^53 trap actually fail in practice — this is knowledge that can't be absorbed just by reading. It's knowledge I gained by paying with an entire day. In the next post, I'll share what happened at the very beginning of all this: "What Happened When I Tried to Buy Polygon (POL) in Korea." I even got a call from customer support at the exchange.

💬 This is part of *Riel** — a full AI product I'm building solo, in public (failures and all). Read more build logs → · See the product →*

Top comments (0)