Python to Solidity: Building the Bridge Between Off-Chain Art and On-Chain NFTs
Table of Contents
- The Two Worlds: Python Off-Chain, Solidity On-Chain
- Compiling Solidity from Python with solcx
- Connecting to the Blockchain with web3.py
- Deploying the Contract from Python
- Setting the baseURI to IPFS
- Minting NFTs: Single and Batch
- Reading On-Chain State
- Verifying on Block Explorer
- The Full Automated Workflow
Quantum Genesis lives in two worlds. The art — quantum circuits, measurements, SHA-256 seeds, SVG generation, PNG rendering — all happens in Python. The ownership, scarcity, and marketplace integration — ERC-721 tokens, royalties, transfers — all happens in Solidity on the Polygon blockchain.
This post shows how to build the bridge between them. Everything here uses real code from our project: compiling Solidity contracts, deploying them, setting metadata URIs, and minting tokens — all from Python scripts.
1. The Two Worlds: Python Off-Chain, Solidity On-Chain
Understanding the separation is critical before writing any code:
| Layer | Language | Responsibility |
|---|---|---|
| Off-chain | Python | Quantum circuits, measurements, art generation, IPFS upload, contract interaction |
| On-chain | Solidity | Token ownership, supply cap, royalties, transfers, marketplace integration |
The blockchain doesn't store images. It doesn't run art algorithms. It stores a URL (tokenURI) pointing to metadata on IPFS, which in turn points to the image on IPFS. The smart contract is a registry — "token #42 is owned by address 0xABC and its metadata is at ipfs://QmXYZ."
Python orchestrates everything: it generates the art, uploads it to IPFS, then calls the smart contract to mint a token pointing to that IPFS metadata.
2. Compiling Solidity from Python with solcx
The py-solc-x package lets you compile Solidity contracts directly from Python, without needing Hardhat, Foundry, or any JavaScript toolchain.
pip install py-solc-x web3
First, install a Solidity compiler version:
import solcx
# Install Solidity compiler 0.8.20
solcx.install_solc('0.8.20')
solcx.set_solc_version('0.8.20')
Now compile your contract. For an ERC-721 with OpenZeppelin, you need to handle imports. The cleanest approach is to use the allow_paths and base_path parameters if your contract uses local imports, or use flattened source code:
import solcx
import json
def compile_contract(source_path: str) -> tuple:
"""
Compile a Solidity contract and return (abi, bytecode).
Assumes a flattened source file with no external imports.
"""
with open(source_path, 'r') as f:
source_code = f.read()
# Compile with optimization
compiled = solcx.compile_source(
source_code,
output_values=['abi', 'bin'],
solc_version='0.8.20',
optimize=True,
optimize_runs=200
)
# solcx returns a dict keyed by "ContractName"
# Find the main contract (last one, or specify by name)
contract_id = list(compiled.keys())[-1]
contract = compiled[contract_id]
abi = contract['abi']
bytecode = contract['bin']
# Save ABI for later use
with open('contract_abi.json', 'w') as f:
json.dump(abi, f, indent=2)
return abi, bytecode
Handling OpenZeppelin Imports
Our Quantum Genesis contract inherits from OpenZeppelin's ERC721, ERC721Enumerable, ERC721URIStorage, ERC2981, and Ownable. For Python compilation, the simplest approach is to flatten the contract first:
# Option 1: Use a pre-flattened file
# npx hardhat flatten contracts/QuantumGenesis.sol > QuantumGenesisFlat.sol
# Option 2: Install OpenZeppelin locally and set import paths
compiled = solcx.compile_files(
['contracts/QuantumGenesis.sol'],
output_values=['abi', 'bin'],
solc_version='0.8.20',
import_remappings=[
'@openzeppelin/=node_modules/@openzeppelin/'
]
)
For Quantum Genesis, we used a flattened source file. It's a single 200-line file that includes all needed OpenZeppelin code inlined. Less elegant, but zero dependency issues.
3. Connecting to the Blockchain with web3.py
web3.py is Python's gateway to any EVM blockchain. You need an RPC endpoint — a URL that connects you to a blockchain node.
from web3 import Web3
# Polygon Mainnet via public RPC
w3 = Web3(Web3.HTTPProvider('https://polygon-rpc.com'))
# Verify connection
print(f"Connected: {w3.is_connected()}")
print(f"Chain ID: {w3.eth.chain_id}") # 137 for Polygon
print(f"Latest block: {w3.eth.block_number}")
RPC Provider Options
- Public RPCs — Free but rate-limited. Fine for development and occasional minting. Examples:
polygon-rpc.com,rpc.ankr.com/polygon. - Alchemy/Infura — Free tier with higher limits. Best for production. Get an API key from alchemy.com.
- Your own node — Maximum control but requires infrastructure.
Wallet Setup
To send transactions (deploy, mint), you need a private key. Never hardcode it — use environment variables:
import os
private_key = os.environ['PRIVATE_KEY']
account = w3.eth.account.from_key(private_key)
wallet_address = account.address
print(f"Wallet: {wallet_address}")
print(f"Balance: {w3.from_wei(w3.eth.get_balance(wallet_address), 'ether')} MATIC")
4. Deploying the Contract from Python
With compiled ABI + bytecode and a connected wallet, you can deploy:
def deploy_contract(w3, abi, bytecode, private_key,
constructor_args=None):
"""
Deploy a smart contract and return the contract address.
"""
account = w3.eth.account.from_key(private_key)
# Create contract factory
Contract = w3.eth.contract(abi=abi, bytecode=bytecode)
# Build constructor transaction
if constructor_args:
tx = Contract.constructor(*constructor_args).build_transaction({
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address),
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id,
})
else:
tx = Contract.constructor().build_transaction({
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address),
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id,
})
# Estimate gas (add 20% buffer)
gas_estimate = w3.eth.estimate_gas(tx)
tx['gas'] = int(gas_estimate * 1.2)
# Sign and send
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
print(f"Deploy TX: {tx_hash.hex()}")
print("Waiting for confirmation...")
# Wait for receipt
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=300)
contract_address = receipt['contractAddress']
print(f"Contract deployed at: {contract_address}")
print(f"Gas used: {receipt['gasUsed']}")
return contract_address
For Quantum Genesis, the constructor takes an initial owner address, a name ("Quantum Genesis"), and a symbol ("QGEN"):
contract_address = deploy_contract(
w3, abi, bytecode, private_key,
constructor_args=[wallet_address] # initial owner
)
# Output: Contract deployed at: 0x488fCfaEA5fDf1cF6BAED5e8A34D7858033E1a27
5. Setting the baseURI to IPFS
Our contract uses a baseURI pattern: instead of storing individual URIs for each token, we set one base URI and each token's URI is baseURI + tokenId. This is gas-efficient for large collections.
After uploading all 100 metadata JSON files to IPFS in a directory, Pinata gives us a CID for the directory. We then set this as the baseURI:
def set_base_uri(w3, contract, private_key, base_uri: str):
"""Set the baseURI for token metadata."""
account = w3.eth.account.from_key(private_key)
tx = contract.functions.setBaseURI(base_uri).build_transaction({
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address),
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id,
})
gas_estimate = w3.eth.estimate_gas(tx)
tx['gas'] = int(gas_estimate * 1.2)
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"baseURI set. TX: {tx_hash.hex()}")
return receipt
# Usage — note the trailing slash!
ipfs_base = "ipfs://bafybeifges7tei5x7drj37f34yhzqofwlz2icbo7z67isg6g446k65yw3a/"
set_base_uri(w3, contract, private_key, ipfs_base)
Now tokenURI(1) returns ipfs://bafybei.../1, which resolves to the JSON metadata for token #1.
6. Minting NFTs: Single and Batch
Single Mint
def mint_single(w3, contract, private_key, to_address: str):
"""Mint the next token to an address."""
account = w3.eth.account.from_key(private_key)
tx = contract.functions.safeMint(to_address).build_transaction({
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address),
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id,
})
gas_estimate = w3.eth.estimate_gas(tx)
tx['gas'] = int(gas_estimate * 1.2)
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"Minted token. TX: {tx_hash.hex()}, Gas: {receipt['gasUsed']}")
return receipt
Batch Minting
Minting 100 NFTs one by one works but is slow (each transaction needs confirmation). Our contract includes a batch mint function:
def mint_batch(w3, contract, private_key, to_address: str, quantity: int):
"""Mint multiple tokens in one transaction."""
account = w3.eth.account.from_key(private_key)
tx = contract.functions.batchMint(
to_address, quantity
).build_transaction({
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address),
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id,
})
gas_estimate = w3.eth.estimate_gas(tx)
tx['gas'] = int(gas_estimate * 1.2)
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=600)
print(f"Batch minted {quantity} tokens. TX: {tx_hash.hex()}")
print(f"Gas used: {receipt['gasUsed']}")
return receipt
Nonce Management for Sequential Mints
If you're minting many tokens individually (without batch), manage nonces manually to avoid waiting for each confirmation:
def mint_sequential(w3, contract, private_key, to_address: str,
count: int):
"""Mint tokens sequentially with managed nonces."""
account = w3.eth.account.from_key(private_key)
nonce = w3.eth.get_transaction_count(account.address)
tx_hashes = []
for i in range(count):
tx = contract.functions.safeMint(to_address).build_transaction({
'from': account.address,
'nonce': nonce + i,
'gasPrice': w3.eth.gas_price,
'chainId': w3.eth.chain_id,
'gas': 200000, # Fixed gas limit for known function
})
signed_tx = w3.eth.account.sign_transaction(tx, private_key)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
tx_hashes.append(tx_hash)
print(f"Sent mint #{i+1}, TX: {tx_hash.hex()}")
# Wait for all to confirm
print(f"\nWaiting for {count} transactions to confirm...")
for i, tx_hash in enumerate(tx_hashes):
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=300)
status = "SUCCESS" if receipt['status'] == 1 else "FAILED"
print(f" Mint #{i+1}: {status}, Gas: {receipt['gasUsed']}")
7. Reading On-Chain State
After minting, verify everything looks correct by reading contract state:
# Load contract from address + ABI
contract = w3.eth.contract(
address='0x488fCfaEA5fDf1cF6BAED5e8A34D7858033E1a27',
abi=abi
)
# Read basic info
name = contract.functions.name().call()
symbol = contract.functions.symbol().call()
total_supply = contract.functions.totalSupply().call()
max_supply = contract.functions.MAX_SUPPLY().call()
print(f"Name: {name}") # Quantum Genesis
print(f"Symbol: {symbol}") # QGEN
print(f"Supply: {total_supply}/{max_supply}") # 100/100
# Check ownership of a specific token
owner_of_42 = contract.functions.ownerOf(42).call()
print(f"Owner of #42: {owner_of_42}")
# Get tokenURI
uri = contract.functions.tokenURI(42).call()
print(f"Token URI #42: {uri}")
# ipfs://bafybei.../42
# Check royalty info (EIP-2981)
receiver, amount = contract.functions.royaltyInfo(42, 10000).call()
print(f"Royalty: {amount/100}% to {receiver}")
# Royalty: 5.0% to 0xa198...
8. Verifying on Block Explorer
After deployment, verify your contract source code on PolygonScan. This lets anyone read the code and interact with it through the explorer UI:
# Using the polygonscan API (or do it manually on the website)
import requests
def verify_contract(contract_address, source_code, compiler_version,
constructor_args_encoded=""):
"""Verify contract source on PolygonScan."""
api_key = os.environ['POLYGONSCAN_API_KEY']
data = {
'apikey': api_key,
'module': 'contract',
'action': 'verifysourcecode',
'contractaddress': contract_address,
'sourceCode': source_code,
'codeformat': 'solidity-single-file',
'contractname': 'QuantumGenesis',
'compilerversion': f'v{compiler_version}',
'optimizationUsed': 1,
'runs': 200,
'constructorArguements': constructor_args_encoded,
'licenseType': 3, # MIT
}
response = requests.post(
'https://api.polygonscan.com/api',
data=data
)
result = response.json()
print(f"Verification: {result}")
return result
Once verified, anyone can view the contract at https://polygonscan.com/address/0x488fCfaEA5fDf1cF6BAED5e8A34D7858033E1a27#code.
9. The Full Automated Workflow
Here's how all the pieces connect in Quantum Genesis. This is the actual flow we run to go from quantum measurement to listed NFT:
"""
Full pipeline: Quantum Measurement → On-Chain NFT
"""
import os
from web3 import Web3
# --- Configuration ---
PRIVATE_KEY = os.environ['PRIVATE_KEY']
RPC_URL = 'https://polygon-rpc.com'
CONTRACT_ADDRESS = '0x488fCfaEA5fDf1cF6BAED5e8A34D7858033E1a27'
PINATA_JWT = os.environ['PINATA_JWT']
# --- Step 1: Generate Art (quantum_nft_generator.py) ---
# Run quantum circuit on IBM ibm_fez
# Get measurement counts
# SHA-256 → seed
# QuantumRNG → art parameters
# Generate SVG → render PNG
# Generate metadata JSON with certificate
# --- Step 2: Upload to IPFS (upload_to_ipfs.py) ---
# Upload PNG to Pinata → get image CID
# Update metadata JSON with image CID
# Upload metadata to Pinata → get metadata CID
# Upload all metadata to directory → get directory CID
# --- Step 3: Set baseURI (one-time, after all uploads) ---
w3 = Web3(Web3.HTTPProvider(RPC_URL))
contract = w3.eth.contract(address=CONTRACT_ADDRESS, abi=abi)
set_base_uri(w3, contract, PRIVATE_KEY,
f"ipfs://{directory_cid}/")
# --- Step 4: Mint tokens ---
wallet = w3.eth.account.from_key(PRIVATE_KEY).address
mint_batch(w3, contract, PRIVATE_KEY, wallet, quantity=100)
# --- Step 5: Verify ---
total = contract.functions.totalSupply().call()
print(f"Total minted: {total}") # 100
for token_id in [1, 42, 100]:
uri = contract.functions.tokenURI(token_id).call()
owner = contract.functions.ownerOf(token_id).call()
print(f" #{token_id}: owner={owner}, uri={uri}")
Key Lessons from Building This Pipeline
- Gas estimation is critical. Always add a buffer (20%) to estimated gas. Running out of gas mid-transaction wastes the gas already spent.
- Nonce management matters. If a transaction fails and you're incrementing nonces manually, you'll need to either wait for the failed TX to be dropped or send a replacement transaction with the same nonce.
- RPC rate limits exist. Public RPCs may throttle you during batch operations. Use a paid provider or add delays between calls.
- Test on testnet first. Always deploy and test on Polygon Amoy (testnet) before mainnet. Free test MATIC is available from faucets.
- Save your ABI. You need the ABI every time you interact with the contract. Save it to a JSON file during compilation.
Python handles everything the blockchain can't: quantum circuits, art generation, IPFS uploads, and orchestration. Solidity handles everything Python can't: trustless ownership, enforced scarcity, and marketplace integration. Together, they form a complete pipeline from physical quantum measurement to tradeable digital asset.
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