Bridging Python and Solidity: From Generated Art to On-Chain NFT

When I first started Quantum Genesis, I kept hitting the same wall. The art pipeline — quantum circuits, measurements, SHA-256 seeds, SVG rendering — lives entirely in Python. But the ownership side, the ERC-721 tokens and royalties, has to live on-chain in Solidity. Getting those two worlds to talk to each other turned out to be its own project.

This post is the bridge. Everything here is real code from our pipeline: compiling a Solidity contract with solcx, deploying it with web3.py, pointing it at IPFS metadata, and minting tokens — no JavaScript toolchain required. If you've ever wondered how to drive a smart contract from a Python script that already generates your art, this is the walkthrough I wish I'd had.

Quantum Genesis NFT #2

Two worlds, one pipeline

Before any code, you have to keep the separation clear:

LayerLanguageResponsibility
Off-chainPythonQuantum circuits, measurements, art generation, IPFS upload, contract interaction
On-chainSolidityToken ownership, supply cap, royalties, transfers, marketplace integration

The blockchain doesn't store images and 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 the whole thing: it generates the art, uploads it to IPFS, then calls the smart contract to mint a token pointing at that metadata.

Compiling Solidity from Python with solcx

The py-solc-x package lets you compile Solidity contracts directly from Python, so you never need Hardhat or Foundry sitting in a JavaScript project just to build a contract.

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 have to deal with imports. The cleanest route is allow_paths and base_path for local imports, or a flattened source file:

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 contract inherits from OpenZeppelin's ERC721, ERC721Enumerable, ERC721URIStorage, ERC2981, and Ownable. For Python compilation, the simplest approach is to flatten it 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 — a single 200-line file with the needed OpenZeppelin code inlined. Less elegant, but zero dependency issues.

Connecting to the blockchain with web3.py

web3.py is Python's gateway to any EVM chain. You need an RPC endpoint — a URL 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}")

A few RPC options, in order of what I'd reach for:

  • 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 you pay for infrastructure.

To send transactions (deploy, mint) you need a private key. Never hardcode it — read it from an environment variable:

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

Deploying the contract from Python

With the compiled ABI + bytecode and a connected wallet, deployment is straightforward:

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

Quantum Genesis NFT #55

Setting the baseURI to IPFS

Our contract uses a baseURI pattern: instead of storing per-token URIs, we set one base URI and each token's URI is baseURI + tokenId. For a large collection this is much cheaper on gas.

After uploading all 100 metadata JSON files to IPFS in a directory, Pinata gives us a CID for the directory. That CID becomes 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.

Minting: single, batch, and sequential

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 each transaction needs its own confirmation. Our contract includes a batch mint function to mint many at once:

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 a batch function, manage nonces manually so you don't wait for each confirmation before sending the next:

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

Reading on-chain state

After minting, verify everything looks right by reading the contract back:

# 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...

Verifying on the block explorer

After deployment, verify your contract source on PolygonScan so anyone can 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.

Quantum Genesis NFT #100

The full automated workflow

Here's how all the pieces connect — the actual flow we run to go from a quantum measurement to a 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}")

Lessons from building this pipeline

A few things cost me real time at first, so I'll save you the trip:

  • Gas estimation matters. Always add a buffer (I use 20%) to estimated gas. Running out of gas mid-transaction wastes what you already spent.
  • Nonce management is your friend — and your enemy. If a transaction fails while you're incrementing nonces manually, you either wait for the failed TX to drop or send a replacement with the same nonce.
  • Public RPCs throttle. During batch operations you may hit rate limits; use a paid provider or add delays between calls.
  • Test on testnet first. Deploy and test on Polygon Amoy before mainnet. Free test MATIC is available from faucets.
  • Save your ABI. You need it every time you interact with the contract. Save it to a JSON file during compilation.

The division of labor is what makes this work. Python handles everything the blockchain can't: quantum circuits, art generation, IPFS uploads, and orchestration. Solidity handles everything Python can't: trustless ownership, the enforced supply cap, and marketplace integration. Together they turn a physical quantum measurement into a token on Polygon — and that's exactly the pipeline every Quantum Genesis piece went through.

If you're curious how the metadata on the other end of that baseURI is structured, that's the ERC-721 metadata standard, and it's the layer that makes a URL into something a marketplace can actually render.

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