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.public_key_keccak # 32-byte commitmentsigner.sign(sig_hash_bytes) # a TxSignature entry, not raw bytessigner.verify(sig_hash, sig) # what the precompile computesNote what sign returns: a complete TxSignature entry, so you assign it to a
slot rather than to a slot’s signature field.
tx.signatures[0] = signer.sign(sig_hash(tx)) # not .signatures[0].signatureNative ML-DSA comes from dilithium-py — byte-identical to the reference
implementation, but pure Python and therefore slow. 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 ML_DSA_65, 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] = 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.
There are two native builders — build_spend_tx and build_recovery_tx. The
self-deploying first send is not among them: it needs a DEPLOY frame
carrying the account’s initcode, which this package does not assemble. Build
that one through FrametxCLI — see the
Quickstart.
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, size_signed_gas_limit
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,)
# FrameTx.gas_limit defaults to 0, and validity requires gas_limit >= total_gas.# The builders call this for you; by hand you must, and it must happen BEFORE# sig_hash, because gas_limit is a signed field.size_signed_gas_limit(tx, [3309])
tx.signatures[0] = 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.