Send a transaction
Once your account exists, every subsequent transaction follows the same four steps.
- Build the transaction with an empty witness.
- Hash it — the sig-hash excludes elided witness bytes.
- Sign the 32-byte hash with your ML-DSA key.
- Attach the signature and submit.
Attaching the signature in step 4 does not change the hash from step 2. That is the whole point of the elision rule, and it is what makes signing a self-referential structure possible.
A transfer
Section titled “A transfer”import { createPublicClient, http, parseEther } from 'viem';import { buildSpendTransaction, frameActions, frost, serializeFrameTransaction, signFrameTransactionWitness,} from 'viem-8141';
const client = createPublicClient({ chain: frost, transport: http('https://rpc.frostfi.net'),}).extend(frameActions());
const tx = buildSpendTransaction({ sender: account, nonce: await client.getTransactionCount({ address: account }), recipient: '0xRECIPIENT', value: parseEther('0.1'),});
const signed = await signFrameTransactionWitness(tx, active);const hash = await client.sendFrameTransaction(serializeFrameTransaction(signed));
const receipt = await client.waitForFrameReceipt(hash);console.log(receipt.successful);from web3 import Web3, HTTPProviderfrom web3_8141 import attachfrom web3_8141.account import build_spend_txfrom web3_8141.codec import encode, sig_hash
w3 = attach(Web3(HTTPProvider("https://rpc.frostfi.net")))
tx = build_spend_tx( sender=account, nonce=w3.eth.get_transaction_count(account), recipient=recipient, value=10**17,)tx.signatures[0].signature = active.sign(sig_hash(tx))
tx_hash = w3.eip8141.send_frame_transaction(encode(tx))receipt = w3.eip8141.wait_for_frame_receipt(tx_hash)assert receipt.successfulframetx send -variant mldsa65 \ -seed 0xACTIVE -sender 0xACCOUNT \ -nonce 5 -recipient 0x… -value 100000000000000000Calling a contract
Section titled “Calling a contract”A contract call is the same shape with calldata on the SENDER frame. The
contract sees msg.sender as your account address, exactly as it would for an
EOA — a frame transaction is invisible to the contract being called.
import { encodeFunctionData } from 'viem';
const tx = buildSpendTransaction({ sender: account, nonce, recipient: tokenAddress, value: 0n, data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: ['0xRECIPIENT', 1_000_000n], }), sendGas: 120_000n, // raise for contract calls});tx = build_spend_tx( sender=account, nonce=nonce, recipient=token_address, data=contract.encode_abi("transfer", [recipient, 1_000_000]), send_gas=120_000,)Raise the SENDER frame’s gas limit for anything beyond a plain transfer. The default of 50,000 covers a transfer and nothing more.
A frame transaction’s total gas is:
15,000 intrinsic+ 475 × number of frames+ calldata gas EIP-7623, on everything including the witness+ permanent-witness surcharge 40 per byte, only for non-elidable witnesses+ Σ frame gas limitsTwo consequences worth internalising:
Calldata dominates. An ML-DSA-65 witness is 3,309 bytes. Calldata gas on that is a much larger number than the 16,000 gas the signature verification costs. If gas matters to you, an ML-DSA-44 account has a 2,420-byte witness and a 10,500-gas verifier.
The gas limit is signed. For a frame transaction, the transaction-level
gas_limit is covered by the sig-hash, must be at least the computed total, is
charged up front, and refunds the remainder. The SDKs size it for you, budgeting
witness bytes at their final length before hashing. If you build transactions by
hand, you must do the same, or the signature will not match the transaction you
end up sending.
Set maxPriorityFeePerGas to zero — Frost is
fee-blind and a tip buys nothing.
Receipts
Section titled “Receipts”A frame transaction’s receipt carries more than a standard one:
- an overall status,
- a per-frame receipt, so you can see which frame failed,
- and a
payerfield naming the account that paid.
const receipt = await client.waitForFrameReceipt(hash);receipt.successful; // overallreceipt.frameReceipts[0].status; // the VERIFY framereceipt.frameReceipts[1].status; // the SENDER frameA VERIFY frame that did not approve means the signature check failed. A SENDER frame that reverted means your call reverted — an ordinary contract failure.
Checking before you send
Section titled “Checking before you send”The chain can tell you whether a transaction would be admitted, without submitting it:
curl -s https://rpc.frostfi.net -H 'content-type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"frost_validateTransaction", "params":["0x06…"]}'# → {"valid":true,"sender":"0x…","nonce":5,"stateNonce":5}This runs your account’s real VERIFY frame under the submission-gate rules and returns a reason if it would be refused. It is advisory and never consensus-relevant, but it is the fastest way to debug a signature problem.
Common failures
Section titled “Common failures”| Symptom | Cause |
|---|---|
| Rejected at submission, signature-related reason | Sig-hash computed after attaching the witness, or signed with the wrong key. |
nonce too low / nonce gap |
Counted transactions instead of reading eth_getTransactionCount. Remember a fresh account starts at 3. |
| Rejected, payer balance | The funding transaction has not been mined yet. Admission reads head state. |
| VERIFY frame did not approve | The signature is valid ML-DSA but over the wrong message, or made with a key the account does not hold. |
| Transaction accepted, never appears | Submitted to a follower node rather than a validator. Use the public endpoint. |
- Rotate a key
- Transaction type
0x06— the exact wire format if you are building transactions by hand.