Deploying and minting on Polygon with web3.py only

When we deployed the Quantum Genesis contract — our 100-piece collection generated from real quantum computers — we used Python end to end. Not Hardhat's JavaScript deploy scripts, not Remix's browser IDE. Just web3.py, a compiled contract, and a Polygon RPC endpoint.

This tutorial is the exact path we took: installing web3.py, connecting to Polygon, deploying the ERC-721 contract, minting tokens, and reading on-chain state. All of it is drawn from a production deployment, with the rough edges still visible. If you've been told you need a JavaScript framework to ship a smart contract, this should show you otherwise.

Quantum Genesis NFT #34 — minted via web3.py in Python

Prerequisites

Before you start you'll need:

  • Python 3.8+ installed
  • A Polygon wallet with some MATIC for gas (deployments cost a few cents of MATIC, even on Polygon)
  • Your wallet's private key, stored as an environment variable — never hardcode it
  • A compiled Solidity contract (ABI + bytecode). We used Hardhat for compilation; Remix or solc work too.
  • A Polygon RPC endpoint — free options include Alchemy, Infura, Ankr, or the public Polygon RPC.

Installing web3.py

pip install web3

That's it — web3.py has minimal dependencies. For our project we also added python-dotenv for environment variables:

pip install web3 python-dotenv

Verify the install:

python -c "from web3 import Web3; print(Web3.api)"
# Should print something like "6.x.x"

Connecting to Polygon

web3.py connects to any EVM chain through an RPC endpoint:

from web3 import Web3
import os

# Option 1: Public RPC (free, rate-limited)
POLYGON_RPC = "https://polygon-rpc.com"

# Option 2: Alchemy (free tier, 300M compute units/month)
# POLYGON_RPC = f"https://polygon-mainnet.g.alchemy.com/v2/{os.environ['ALCHEMY_KEY']}"

# Option 3: Infura
# POLYGON_RPC = f"https://polygon-mainnet.infura.io/v3/{os.environ['INFURA_KEY']}"

w3 = Web3(Web3.HTTPProvider(POLYGON_RPC))

# Verify connection
assert w3.is_connected(), "Failed to connect to Polygon"
print(f"Connected to chain ID: {w3.eth.chain_id}")  # 137 for Polygon mainnet
print(f"Latest block: {w3.eth.block_number}")

For Quantum Genesis we used the public RPC for deployment and Alchemy for minting, because batch minting hit rate limits on the public endpoint. The public RPC handles individual transactions fine but gets flaky under load.

> Always check w3.is_connected() before sending transactions. RPC endpoints go down without warning, and an unconnected web3 instance fails silently or throws confusing errors.

Loading a compiled contract

You need your contract's ABI (Application Binary Interface) and bytecode. Compiled with Hardhat:

import json

# Hardhat outputs compiled contracts to artifacts/
with open("nft-contracts/artifacts/contracts/QuantumGenesis.sol/QuantumGenesis.json") as f:
    contract_data = json.load(f)

ABI = contract_data["abi"]
BYTECODE = contract_data["bytecode"]

print(f"ABI has {len(ABI)} entries")
print(f"Bytecode length: {len(BYTECODE)} chars")

The ABI describes every function, event, and error; the bytecode is the compiled EVM instructions to deploy. You need both. With solc directly, the output format differs:

# From solc --combined-json abi,bin
with open("combined.json") as f:
    data = json.load(f)
contract_key = list(data["contracts"].keys())[0]
ABI = json.loads(data["contracts"][contract_key]["abi"])
BYTECODE = "0x" + data["contracts"][contract_key]["bin"]

Deploying the contract

Deployment is a special transaction that creates a contract on-chain:

import os
from web3 import Web3

PRIVATE_KEY = os.environ["PRIVATE_KEY"]
DEPLOYER = w3.eth.account.from_key(PRIVATE_KEY).address

print(f"Deploying from: {DEPLOYER}")
print(f"Balance: {w3.from_wei(w3.eth.get_balance(DEPLOYER), 'ether')} MATIC")

# Create contract object
Contract = w3.eth.contract(abi=ABI, bytecode=BYTECODE)

# Build constructor transaction
# Our QuantumGenesis constructor takes: name, symbol, maxSupply, royaltyBps
constructor_tx = Contract.constructor(
    "Quantum Genesis",   # name
    "QGEN",              # symbol
    100,                 # maxSupply
    500                  # royaltyBps (5% = 500 basis points)
).build_transaction({
    "from": DEPLOYER,
    "nonce": w3.eth.get_transaction_count(DEPLOYER),
    "gasPrice": w3.eth.gas_price,
    # Let web3 estimate gas, or set manually:
    # "gas": 3000000,
})

# Estimate gas
gas_estimate = w3.eth.estimate_gas(constructor_tx)
constructor_tx["gas"] = int(gas_estimate * 1.2)  # 20% buffer
print(f"Estimated gas: {gas_estimate} (using {constructor_tx['gas']} with buffer)")

# Sign the transaction
signed_tx = w3.eth.account.sign_transaction(constructor_tx, PRIVATE_KEY)

# Send it
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
print(f"Deploy TX sent: {tx_hash.hex()}")
print("Waiting for confirmation...")

# Wait for receipt
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)

if receipt.status == 1:
    CONTRACT_ADDRESS = receipt.contractAddress
    print(f"Contract deployed at: {CONTRACT_ADDRESS}")
    print(f"Gas used: {receipt.gasUsed}")
    print(f"Block: {receipt.blockNumber}")
else:
    print("DEPLOYMENT FAILED!")
    print(f"Receipt: {receipt}")

Our Quantum Genesis deployment cost about 0.08 MATIC (~$0.01). Deploying the same contract on Ethereum mainnet would have cost 50-100x more — a big part of why we chose Polygon. Our contract deployed to 0x488fCfaEA5fDf1cF6BAED5e8A34D7858033E1a27.

Interacting: setBaseTokenURI, mint, batchMint

Once deployed, create a contract instance to interact:

# Create contract instance at deployed address
contract = w3.eth.contract(address=CONTRACT_ADDRESS, abi=ABI)

# Read-only calls (no gas needed)
print(f"Name: {contract.functions.name().call()}")
print(f"Symbol: {contract.functions.symbol().call()}")
print(f"Max supply: {contract.functions.MAX_SUPPLY().call()}")

Setting the base token URI. Before minting, point the base URI at your IPFS metadata:

def send_transaction(func, gas_limit=None):
    """Helper to build, sign, and send a contract function call."""
    tx = func.build_transaction({
        "from": DEPLOYER,
        "nonce": w3.eth.get_transaction_count(DEPLOYER),
        "gasPrice": w3.eth.gas_price,
    })

    if gas_limit:
        tx["gas"] = gas_limit
    else:
        tx["gas"] = int(w3.eth.estimate_gas(tx) * 1.2)

    signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
    return receipt

# Set the base URI to your IPFS metadata folder
metadata_cid = "bafybeih...your_metadata_cid"
base_uri = f"ipfs://{metadata_cid}/"

receipt = send_transaction(
    contract.functions.setBaseTokenURI(base_uri)
)
print(f"Base URI set! Gas used: {receipt.gasUsed}")

Minting a single token:

# Mint token #1 to the deployer address
receipt = send_transaction(
    contract.functions.mint(DEPLOYER, 1)
)
print(f"Minted token #1! TX: {receipt.transactionHash.hex()}")

Batch minting. For a 100-piece collection, minting one at a time wastes gas on per-transaction overhead. Our contract has a batchMint:

def batch_mint(start_id, end_id, batch_size=20):
    """Mint tokens in batches to avoid gas limits."""
    current = start_id
    while current <= end_id:
        batch_end = min(current + batch_size - 1, end_id)
        count = batch_end - current + 1

        print(f"Minting #{current} to #{batch_end} ({count} tokens)...")

        try:
            receipt = send_transaction(
                contract.functions.batchMint(DEPLOYER, count),
                gas_limit=500000 * count  # rough estimate
            )
            print(f"  Success! Gas: {receipt.gasUsed}, TX: {receipt.transactionHash.hex()}")
            current = batch_end + 1
        except Exception as e:
            print(f"  Failed: {e}")
            # Reduce batch size and retry
            if batch_size > 5:
                batch_size = batch_size // 2
                print(f"  Reducing batch size to {batch_size}")
            else:
                raise

# Mint all 100 tokens in batches of 20
batch_mint(1, 100, batch_size=20)

We minted all 100 tokens in 5 batches of 20, at a total gas cost of about 0.5 MATIC.

Reading state

Read-only calls cost no gas and need no signing:

# Total minted tokens
total = contract.functions.totalSupply().call()
print(f"Total supply: {total}")  # 100

# Who owns token #42?
owner = contract.functions.ownerOf(42).call()
print(f"Token #42 owner: {owner}")

# Get metadata URI for token #42
uri = contract.functions.tokenURI(42).call()
print(f"Token #42 URI: {uri}")
# Output: ipfs://bafybeih.../42

# Check royalty info (EIP-2981)
royalty_address, royalty_amount = contract.functions.royaltyInfo(42, 10000).call()
print(f"Royalty: {royalty_amount} basis points to {royalty_address}")

# Iterate all tokens (useful for verification)
for i in range(1, total + 1):
    owner = contract.functions.ownerOf(i).call()
    uri = contract.functions.tokenURI(i).call()
    print(f"Token #{i}: owner={owner[:10]}..., uri={uri}")

Error handling

Blockchain transactions fail for many reasons. Here are the common ones and how to handle them:

from web3.exceptions import ContractLogicError, TimeExhausted

def safe_send(func, retries=3):
    """Send a transaction with retry logic."""
    for attempt in range(retries):
        try:
            receipt = send_transaction(func)
            if receipt.status == 1:
                return receipt
            else:
                print(f"TX reverted (attempt {attempt + 1})")

        except ContractLogicError as e:
            # Contract threw a require/revert
            print(f"Contract error: {e}")
            # Common: "Max supply reached", "Not authorized", "Token already minted"
            raise  # Don't retry contract logic errors

        except TimeExhausted:
            # TX didn't get mined in time
            print(f"TX timed out (attempt {attempt + 1}), retrying...")
            continue

        except ValueError as e:
            error_data = e.args[0] if e.args else {}
            if isinstance(error_data, dict):
                code = error_data.get("code", 0)
                if code == -32000:
                    # Nonce too low -- transaction already mined
                    print("Nonce conflict, refreshing...")
                    continue
                elif code == -32603:
                    # Internal RPC error -- try different endpoint
                    print("RPC error, switching endpoint...")
                    continue
            raise

    raise Exception(f"Failed after {retries} retries")

The errors we actually hit while minting:

ErrorCauseFix
nonce too lowPrevious TX was mined, nonce incrementedRe-fetch nonce with get_transaction_count
insufficient fundsNot enough MATIC for gasTop up MATIC balance
gas required exceeds allowanceBatch too largeReduce batch size
execution revertedContract require() failedCheck contract conditions (supply, auth, etc.)
replacement transaction underpricedPending TX with same nonceWait or resend with higher gas price

Gas estimation on Polygon

Polygon gas prices are typically 30-100 gwei (similar to Ethereum's 10-100+), but the real cost per transaction is far lower because Polygon's per-block gas limit is higher and the base fee is lower:

# Current gas price
gas_price = w3.eth.gas_price
print(f"Gas price: {w3.from_wei(gas_price, 'gwei')} gwei")

# Estimate gas for a function call
gas_estimate = contract.functions.mint(DEPLOYER, 1).estimate_gas({
    "from": DEPLOYER
})

# Calculate cost in MATIC
cost_wei = gas_estimate * gas_price
cost_matic = w3.from_wei(cost_wei, "ether")
print(f"Estimated cost: {cost_matic} MATIC")

# For batch operations, estimate per-token cost
batch_gas = contract.functions.batchMint(DEPLOYER, 20).estimate_gas({
    "from": DEPLOYER
})
per_token_gas = batch_gas / 20
print(f"Per-token gas (batch of 20): {per_token_gas}")
print(f"Per-token cost: {w3.from_wei(int(per_token_gas * gas_price), 'ether')} MATIC")

Our actual gas costs on Polygon:

OperationGas usedCost (MATIC)Cost (USD, approx)
Contract deployment~2,500,000~0.08~$0.01
setBaseTokenURI~45,000~0.002<$0.01
Single mint~85,000~0.003<$0.01
Batch mint (20)~1,200,000~0.04<$0.01
All 100 mints (5 batches)~6,000,000~0.20~$0.03

Total deployment plus minting: about 0.3 MATIC (~$0.04). On Ethereum mainnet the same operations would be roughly 0.1-0.3 ETH ($200-600).

Full deployment script

The complete script we used, consolidated into one file:

#!/usr/bin/env python3
"""
deploy_and_mint.py -- Deploy QuantumGenesis ERC-721 and mint all tokens
Used for the Quantum Genesis collection on Polygon
"""

import os
import json
import time
from web3 import Web3

# --- Configuration ---
POLYGON_RPC = os.environ.get("POLYGON_RPC", "https://polygon-rpc.com")
PRIVATE_KEY = os.environ["PRIVATE_KEY"]
CONTRACT_JSON = "nft-contracts/artifacts/contracts/QuantumGenesis.sol/QuantumGenesis.json"
METADATA_CID = os.environ.get("METADATA_CID", "YOUR_METADATA_CID_HERE")
BATCH_SIZE = 20
MAX_SUPPLY = 100

# --- Setup ---
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC))
assert w3.is_connected(), "Not connected to Polygon"

account = w3.eth.account.from_key(PRIVATE_KEY)
DEPLOYER = account.address
print(f"Deployer: {DEPLOYER}")
print(f"Balance: {w3.from_wei(w3.eth.get_balance(DEPLOYER), 'ether')} MATIC")

# Load contract
with open(CONTRACT_JSON) as f:
    data = json.load(f)
ABI, BYTECODE = data["abi"], data["bytecode"]

def send_tx(tx_dict):
    """Sign and send a transaction, return receipt."""
    signed = w3.eth.account.sign_transaction(tx_dict, PRIVATE_KEY)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    return w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)

def call_function(func, gas_buffer=1.2):
    """Build, estimate gas, sign, send a contract function."""
    tx = func.build_transaction({
        "from": DEPLOYER,
        "nonce": w3.eth.get_transaction_count(DEPLOYER),
        "gasPrice": w3.eth.gas_price,
    })
    tx["gas"] = int(w3.eth.estimate_gas(tx) * gas_buffer)
    return send_tx(tx)

# --- Deploy ---
print("\n=== DEPLOYING CONTRACT ===")
Contract = w3.eth.contract(abi=ABI, bytecode=BYTECODE)
constructor_tx = Contract.constructor(
    "Quantum Genesis", "QGEN", MAX_SUPPLY, 500
).build_transaction({
    "from": DEPLOYER,
    "nonce": w3.eth.get_transaction_count(DEPLOYER),
    "gasPrice": w3.eth.gas_price,
})
constructor_tx["gas"] = int(w3.eth.estimate_gas(constructor_tx) * 1.2)

receipt = send_tx(constructor_tx)
assert receipt.status == 1, "Deploy failed!"
CONTRACT_ADDRESS = receipt.contractAddress
print(f"Deployed at: {CONTRACT_ADDRESS}")

# --- Setup ---
contract = w3.eth.contract(address=CONTRACT_ADDRESS, abi=ABI)

print("\n=== SETTING BASE URI ===")
base_uri = f"ipfs://{METADATA_CID}/"
receipt = call_function(contract.functions.setBaseTokenURI(base_uri))
print(f"Base URI set! Gas: {receipt.gasUsed}")

# --- Mint ---
print(f"\n=== MINTING {MAX_SUPPLY} TOKENS ===")
minted = 0
while minted < MAX_SUPPLY:
    batch = min(BATCH_SIZE, MAX_SUPPLY - minted)
    print(f"Minting batch: {batch} tokens (total so far: {minted})...")

    receipt = call_function(
        contract.functions.batchMint(DEPLOYER, batch),
        gas_buffer=1.3
    )
    assert receipt.status == 1, f"Mint failed at token {minted + 1}!"
    minted += batch
    print(f"  Done! Gas: {receipt.gasUsed}")
    time.sleep(2)  # Be nice to the RPC

# --- Verify ---
print(f"\n=== VERIFICATION ===")
print(f"Total supply: {contract.functions.totalSupply().call()}")
print(f"Token #1 URI: {contract.functions.tokenURI(1).call()}")
print(f"Token #1 owner: {contract.functions.ownerOf(1).call()}")
print(f"\nContract: https://polygonscan.com/address/{CONTRACT_ADDRESS}")
print(f"OpenSea: https://opensea.io/assets/matic/{CONTRACT_ADDRESS}/1")

Quantum Genesis NFT #62 — Deployed and minted with web3.py end to end

web3.py gives you complete control over deployment and minting. No JavaScript required, no framework opinions — just Python talking directly to the blockchain. If you want to understand every step of what your tooling is doing, this is a good way to get there.

Comments

Popular posts from this blog

Getting Your Collection Visible on OpenSea, Step by Step

Polygon versus Ethereum for an NFT contract, from the gas bills up

Quantum Error Correction, or Why Your Qubits Forget What They Were Doing