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, 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({ transaction: tx, signer: active });const hash = await client.sendFrameTransaction({ transaction: signed });
const receipt = await client.waitForFrameReceipt({ hash });console.log(receipt.status); // 1 on successfrom 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] = active.sign(sig_hash(tx)) # sign() returns the entry
tx_hash = w3.eip8141.send_frame_transaction(encode(tx))receipt = w3.eip8141.wait_for_frame_receipt(tx_hash)assert receipt.status == 1 # and receipt.frame_receipts per frameframetx 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+ per-signature verification 2,800 secp256k1 · 6,700 P256 · 0 ARBITRARY+ permanent-witness surcharge 40 per byte, only for non-elidable witnesses+ Σ frame gas limitsEach frame’s gas limit must also absorb the frame-entry access charge: before a frame dispatches, its target’s EIP-2929 account access — 2,600 gas cold, 100 warm — is charged from that frame’s limit, and a frame whose limit cannot cover it halts at entry with all its gas consumed. The SDK defaults (30,000 for VERIFY, 50,000 for SENDER) leave headroom for it.
A post-quantum witness is an ARBITRARY entry, so the verification line is zero
for the transactions on this page — the protocol does not check those bytes, your
account does. It is nonzero only if you also carry a protocol-verified entry.
Two 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.status; // overall: 1 on successreceipt.payer; // the account chargedreceipt.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.
Debugging a rejected transaction
Section titled “Debugging a rejected transaction”There is no dry-run method on the public endpoint. The submission gate runs at
eth_sendRawTransaction and refuses synchronously with a reason, so
submitting a transaction you are unsure about is the check:
curl -s https://rpc.frostfi.net -H 'content-type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x06…"]}'# → {"error":{"code":-32000,# "message":"rejected by validation: VERIFY frame 0 did not APPROVE"}}A refused transaction is never ordered, so a failed submission costs you
nothing but the round trip. Everything after rejected by validation: comes
from running your account’s real VERIFY frame under the submission-gate rules —
bounded gas, banned-opcode tracer — which is the same simulation a dry-run would
have performed. Resubmitting the identical bytes returns the cached verdict,
prefixed rejected by validation (cached):.
To reproduce a signature check without involving a transaction at all, call the
verifier precompile directly with eth_call, as in
Hardware custody.
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 | Ordered by consensus but skipped as invalid at execution: state moved between the submission gate and the block (typically a nonce or balance race). Both SDKs’ receipt waiters time out rather than return. Re-read the nonce and balance and resubmit. |
- Rotate a key
- Transaction type
0x06— the exact wire format if you are building transactions by hand.