Skip to content

Send a transaction

Once your account exists, every subsequent transaction follows the same four steps.

  1. Build the transaction with an empty witness.
  2. Hash it — the sig-hash excludes elided witness bytes.
  3. Sign the 32-byte hash with your ML-DSA key.
  4. 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.

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 success

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
});

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 limits

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

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 payer field naming the account that paid.
const receipt = await client.waitForFrameReceipt({ hash });
receipt.status; // overall: 1 on success
receipt.payer; // the account charged
receipt.frameReceipts[0].status; // the VERIFY frame
receipt.frameReceipts[1].status; // the SENDER frame

A VERIFY frame that did not approve means the signature check failed. A SENDER frame that reverted means your call reverted — an ordinary contract failure.

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:

Terminal window
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.

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.