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

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
+ permanent-witness surcharge 40 per byte, only for non-elidable witnesses
+ Σ frame gas limits

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.successful; // overall
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.

The chain can tell you whether a transaction would be admitted, without submitting it:

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

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.