Web3.py Tutorial: Deploy and Interact with Smart Contracts on Polygon

Web3.py Tutorial: Deploy and Interact with Smart Contracts on Polygon

When we deployed the smart contract for Quantum Genesis — our 100-piece NFT 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 walks through everything we did: installing web3.py, connecting to Polygon, deploying an ERC-721 contract, minting tokens, and reading on-chain state. All with real code from a production deployment.

Quantum Genesis NFT #19 — the first IBM Quantum piece in the collection

Table of Contents

  1. Prerequisites
  2. Installing web3.py
  3. Connecting to Polygon
  4. Loading a Compiled Contract
  5. Deploying the Contract
  6. Interacting: setBaseTokenURI, mint, batchMint
  7. Reading State: totalSupply, ownerOf, tokenURI
  8. Error Handling
  9. Gas Estimation on Polygon
  10. Full Deployment Script

1. Prerequisites

Before starting, you'll need:

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

2. Installing web3.py

pip install web3

That's it. web3.py has minimal dependencies. For our project, we also used python-dotenv for environment variable management:

pip install web3 python-dotenv

Verify the installation:

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

3. Connecting to Polygon

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

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 Polygon RPC for deployment and Alchemy for minting (because batch minting triggered rate limits on the public endpoint). The public RPC works fine for individual transactions but can be flaky under load.

Important: Always check w3.is_connected() before sending transactions. RPC endpoints can go down without warning, and an unconnected web3 instance will silently fail or throw confusing errors.

4. Loading a Compiled Contract

Before deploying, you need your contract's ABI (Application Binary Interface) and bytecode. If you 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 in your contract. The bytecode is the compiled EVM instructions that get deployed on-chain. You need both.

If you compiled with solc directly, the output format is different:

# 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"]

5. Deploying the Contract

Deployment is a special transaction that creates a new contract on-chain. Here's the complete deployment flow:

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}")

For Quantum Genesis, the deployment cost approximately 0.08 MATIC (~$0.01 USD). Polygon's low gas fees are one of the main reasons we chose it — deploying the same contract on Ethereum mainnet would have cost 50-100x more.

Our contract deployed to: 0x488fCfaEA5fDf1cF6BAED5e8A34D7858033E1a27

6. Interacting: setBaseTokenURI, mint, batchMint

Once deployed, you interact with the contract through its functions. First, create a contract instance:

# 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, set the base URI that points to 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 is slow and wastes gas on per-transaction overhead. Our contract includes a batchMint function:

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 Quantum Genesis tokens in 5 batches of 20. Total gas cost: approximately 0.5 MATIC.

Quantum Genesis NFT #100 — the final piece in the collection

7. Reading State: totalSupply, ownerOf, tokenURI

Read-only calls don't cost gas and don't require 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}")

8. Error Handling

Blockchain transactions can 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 most common errors we hit during Quantum Genesis 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

9. Gas Estimation on Polygon

Polygon gas prices are typically 30-100 gwei (compared to Ethereum's 10-100+ gwei), but the actual cost per transaction is much lower because Polygon's gas limit per block 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 for Quantum Genesis 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 + minting cost: approximately 0.3 MATIC (~$0.04 USD). On Ethereum mainnet, the same operations would have cost roughly 0.1-0.3 ETH ($200-600 USD).

10. Full Deployment Script

Here's 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")

web3.py gives you complete control over every aspect of the deployment and minting process. No JavaScript required, no framework opinions. Just Python talking directly to the blockchain.

Quantum Genesis NFT #55 — deployed and minted via web3.py on Polygon
@media (prefers-color-scheme: dark) { background:#1a1a2e; border-color:#2d2d4a; box-shadow:0 1px 3px rgba(0,0,0,0.3); }
Marcelo Santos
Marcelo Santos
Engenheiro Quântico • Artista Generativo • Founder Quantum Art Lab

Desenvolvo sistemas que usam hardware quântico real (IBM Quantum, Origin Quantum) para gerar arte e NFTs com proveniência verificável on-chain. Escrevo sobre computação quântica aplicada, criptografia, Web3 e arte generativa — tudo com código que roda em processadores quânticos de verdade.

Comentários

Postagens mais visitadas deste blog

How to List NFTs on OpenSea: The Complete 2026 Guide

Polygon vs Ethereum for NFTs: Why We Chose Polygon (and Saved $490)

Quantum Error Correction Explained: Why Your Qubits Need Backup