web3-8141
A companion package for web3.py. Stock
web3.py stays untouched; frame-transaction support attaches as a w3.eip8141
module.
uv add web3# then web3-8141[mldsa] from sourceAttach
Section titled “Attach”from web3 import Web3, HTTPProviderfrom web3_8141 import attach
w3 = attach(Web3(HTTPProvider("https://rpc.frostfi.net")))Everything standard still works. The new surface is under w3.eip8141:
| Method | Purpose |
|---|---|
send_frame_transaction(tx) |
Submit a FrameTx or raw bytes. |
wait_for_frame_receipt(hash) |
Receipt with typed payer and per-frame receipts. |
validate_transaction(raw) |
The submission-gate verdict, without submitting. |
get_signature_sidecar(block) |
A block’s elided witness bytes, or None once pruned. |
verify_stripped_transaction(hash) |
Fetch a stripped record and prove it against the header. |
from web3_8141.signing.mldsa import MldsaSigner
signer = MldsaSigner(seed) # the 32-byte seed IS the secret keysigner.public_key # 1,952 bytes for ML-DSA-65signer.sign(sig_hash_bytes) # FIPS 204 signatureNative ML-DSA comes from dilithium-py — byte-identical to the reference
implementation, but pure Python and therefore slow. It is intended for
development, scripting, and testnet use. There is also a wrapper that shells out
to the reference frametx CLI when you want native
speed.
For classical signatures, web3_8141.signing.Secp256k1Signer.
Addresses
Section titled “Addresses”from web3_8141.account import ( CODE_ID_ROTATABLE_V2, FACTORY_ADDRESS, # 0x4e59b448…B4956C, the EIP-7997 predeploy SALT_DOMAIN, # b"frost/account/v1" account_salt, create2_address,)
salt = account_salt(CODE_ID_ROTATABLE_V2, index=0)account = create2_address(FACTORY_ADDRESS, salt, initcode)account_salt computes keccak256(SALT_DOMAIN ‖ code_id ‖ uint64_le(index)),
and create2_address the standard
keccak256(0xff ‖ deployer ‖ salt ‖ keccak256(initcode))[12:]. Both are pure
functions — no network access, so an address can be shown before the account
exists.
Transaction builders
Section titled “Transaction builders”from web3_8141.account import build_spend_tx, build_recovery_txfrom web3_8141.codec import encode, sig_hash
tx = build_spend_tx( sender=account, nonce=w3.eth.get_transaction_count(account), recipient=recipient, value=10**17, variant=ML_DSA_65, # default verify_gas=30_000, # VERIFY frame budget send_gas=50_000, # raise for contract calls)
tx.signatures[0].signature = signer.sign(sig_hash(tx))raw = encode(tx) # 0x06-prefixed bytestx_hash = w3.eip8141.send_frame_transaction(raw)The builders construct the transaction with a zero-filled witness placeholder of
the correct size and size the signed gas_limit over the final encoding, so
sig_hash is stable across attaching the real signature.
build_recovery_tx builds the rotation: a VERIFY frame
with data=0x01, a self-targeted SENDER frame carrying
new_active_pk ‖ keccak256(new_backup_pk), and two witnesses — the backup
signature and the backup public key blob.
Building by hand
Section titled “Building by hand”from web3_8141 import Frame, FrameMode, FrameFlags, FrameTx, TxSignature, SignatureSchemefrom web3_8141.codec import encode, sig_hash
tx = FrameTx( chain_id=8141, nonce=0, sender=account, frames=[ Frame(mode=FrameMode.VERIFY, flags=3, target=None, gas_limit=30_000), Frame(mode=FrameMode.SENDER, target=recipient, gas_limit=50_000, value=10**18), ], signatures=[TxSignature(scheme=SignatureScheme.ARBITRARY, signature=b"\x00" * 3309)], fee_cap=2 * 10**9, tip_cap=0,)tx.signatures[0].signature = signer.sign(sig_hash(tx))raw = encode(tx)A target of None on a VERIFY frame means the sender itself.
The builders default to tip_cap=0, because Frost is
fee-blind — a priority fee buys no position and no
latency. Pass tip_cap= explicitly only if you are targeting a chain that
prices priority.
from web3_8141.codec import intrinsic_gas, check_witness_limits
intrinsic_gas(tx) # incl. the permanent-witness surchargeintrinsic_gas(tx, permanent_sig_byte_gas=0) # vanilla EIP-8141 pricingcheck_witness_limits(tx) # pre-check the size capsARBITRARY entries with a non-empty msg are never elided, so their bytes are
permanent — the chain charges 40 gas per byte on top of calldata pricing and
caps them at 4,096 bytes.
Witness segregation
Section titled “Witness segregation”from web3_8141 import strip, reassemble, stripped_tx_root, verify_inclusion
stripped_raw, witnesses = strip(raw) # the sidecar's viewraw2 = reassemble(stripped_raw, witnesses, txid=txid) # txid check catches liesrecord = w3.eip8141.verify_stripped_transaction(tx_hash)Spec pin
Section titled “Spec pin”SPEC_PIN is exported as "eip-8141@2026-07-11". See
SDKs for what that covers and what is a Frost-local
extension.