Skip to content

web3-8141

A companion package for web3.py. Stock web3.py stays untouched; frame-transaction support attaches as a w3.eip8141 module.

Terminal window
uv add web3
# then web3-8141[mldsa] from source
from web3 import Web3, HTTPProvider
from 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 key
signer.public_key # 1,952 bytes for ML-DSA-65
signer.sign(sig_hash_bytes) # FIPS 204 signature

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

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.

from web3_8141.account import build_spend_tx, build_recovery_tx
from 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 bytes
tx_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.

from web3_8141 import Frame, FrameMode, FrameFlags, FrameTx, TxSignature, SignatureScheme
from 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 surcharge
intrinsic_gas(tx, permanent_sig_byte_gas=0) # vanilla EIP-8141 pricing
check_witness_limits(tx) # pre-check the size caps

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

from web3_8141 import strip, reassemble, stripped_tx_root, verify_inclusion
stripped_raw, witnesses = strip(raw) # the sidecar's view
raw2 = reassemble(stripped_raw, witnesses, txid=txid) # txid check catches lies
record = w3.eip8141.verify_stripped_transaction(tx_hash)

SPEC_PIN is exported as "eip-8141@2026-07-11". See SDKs for what that covers and what is a Frost-local extension.