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.
Table of Contents
- Prerequisites
- Installing web3.py
- Connecting to Polygon
- Loading a Compiled Contract
- Deploying the Contract
- Interacting: setBaseTokenURI, mint, batchMint
- Reading State: totalSupply, ownerOf, tokenURI
- Error Handling
- Gas Estimation on Polygon
- 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.
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:
| Error | Cause | Fix |
|---|---|---|
| nonce too low | Previous TX was mined, nonce incremented | Re-fetch nonce with get_transaction_count |
| insufficient funds | Not enough MATIC for gas | Top up MATIC balance |
| gas required exceeds allowance | Batch too large | Reduce batch size |
| execution reverted | Contract require() failed | Check contract conditions (supply, auth, etc.) |
| replacement transaction underpriced | Pending TX with same nonce | Wait 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:
| Operation | Gas Used | Cost (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.
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
Postar um comentário