Skip to content

viem-8141

A companion package for viem. Stock viem stays untouched; the frame-transaction surface arrives as a client extension, a chain definition, and a set of pure functions.

@noble/post-quantum is the only runtime dependency. ML-DSA signing is native JavaScript, byte-identical to the reference implementation, and works in the browser.

Terminal window
npm install viem
# then viem-8141 from source
import { createPublicClient, http } from 'viem';
import { frameActions, frost } from 'viem-8141';
const client = createPublicClient({
chain: frost,
transport: http('https://rpc.frostfi.net'),
}).extend(frameActions());

frost is a viem chain definition with a frame-aware serializer and receipt/block formatters, so decoded blocks and receipts carry the extra fields. frameActions() adds six methods. Every one takes a single options object — there are no positional forms:

Method Purpose
sendFrameTransaction({ transaction }) Submit a type 0x06 transaction. Serialises for you; pass { serializedTransaction } if you already have bytes.
waitForFrameReceipt({ hash }) Poll until the receipt lands. Also takes timeout and pollingInterval.
getFrameReceipt({ hash }) The receipt now, or null.
validateTransaction({ transaction }) The submission-gate verdict, without submitting.
getSignatureSidecar({ blockNumber }) A block’s elided witness bytes, or null once pruned.
getStrippedTransaction({ hash }) The stripped record plus its inclusion proof, verified before it returns.
import { createMldsaSigner, mldsaPublicKey } from 'viem-8141';
const signer = createMldsaSigner({ seed: '0x…32 bytes' });
signer.publicKey; // 1,952 bytes (ML-DSA-65)
signer.publicKeyKeccak; // 32-byte commitment
await signer.sign(hash); // FIPS 204 signature over a 32-byte message

The 32-byte seed is the secret key. mldsaPublicKey derives a public key from a seed without constructing a signer.

For hardware, implement the WitnessSigner interface, or use connectRemoteWitnessSigner to talk to an out-of-process signer daemon such as an Apple Secure Enclave bridge. Anything that can return a signature over 32 bytes is a valid signer.

import { counterfactualAddress, accountSalt, AccountCodeId,
factoryAddress, saltDomain } from 'viem-8141';
const account = counterfactualAddress({
activePublicKey: active.publicKey,
backupPublicKeyHash: backup.publicKeyKeccak,
index: 0n, // optional, defaults to 0
});

Pure function, no network access. Also exported: create2Address, createAddress, rotatableInitcode, rotatableRuntime, and the mldsaVariants registry mapping parameter sets to their verifier precompiles.

Three builders cover the single-call account lifecycle:

import {
buildFirstSendTransaction, // materialise the account + do the first send
buildSpendTransaction, // ordinary spend or contract call
buildRecoveryTransaction, // rotate via the backup key
} from 'viem-8141';

Each returns an unsigned transaction with a correctly sized signed gas_limit and a zero-filled witness placeholder of the right length — so hashing, signing, and attaching never changes the hash.

Two more build atomic batches: several SENDER frames under one VERIFY, with the atomic-batch flag on every SENDER frame but the last, so a revert anywhere rolls the whole run back and the frames after it are skipped. This is what EIP-5792 wallet_sendCalls maps to on Frost.

import { buildBatchSpendTransaction, type BatchCall } from 'viem-8141';
const calls: BatchCall[] = [
{ to: token, data: approveCalldata }, // gas defaults to 50,000n
{ to: router, data: swapCalldata, gas: 200_000n }, // value defaults to 0n
];
const tx = buildBatchSpendTransaction({ sender: account, nonce, calls });

buildBatchSpendTransaction takes up to 63 calls (64 frames minus the VERIFY); buildBatchFirstSendTransaction({ activePublicKey, backupPublicKeyHash, calls }) prepends the deploy frame and takes up to 62. A one-call batch serialises identically to the single-call builder.

The package also exports builders for the slim hash-commit v3 account (code ID 0x04) — buildSlimFirstSendTransaction, buildSlimSpendTransaction, buildPromoteTransaction, and buildSlimRecoveryTransaction — but default onboarding remains the rotatable v2 account. See Account contracts.

  1. Build with an empty witness.
  2. signFrameTransactionWitness({ transaction, signer }) — hashes, signs, attaches, and returns a new transaction.
  3. client.sendFrameTransaction({ transaction }).
import { signFrameTransactionWitness } from 'viem-8141';
const signed = await signFrameTransactionWitness({ transaction: tx, signer: active });
const hash = await client.sendFrameTransaction({ transaction: signed });
const receipt = await client.waitForFrameReceipt({ hash });
receipt.status; // 1 on success
receipt.payer; // the account charged
receipt.frameReceipts; // per-frame outcomes

sendFrameTransaction serialises for you, so an explicit serializeFrameTransaction is only needed when you want the raw bytes themselves.

A hardware signer usually needs none of this. signFrameTransactionWitness accepts anything with a sign method, so an enclave bridge goes straight in:

const signed = await signFrameTransactionWitness({ transaction: tx, signer: hardware });

If you do need the pieces separately — a custom flow, or a signer you cannot wrap — the codec is exported directly:

import {
frameTransactionSigHash, // what you sign
frameTransactionHash, // the canonical txid
serializeFrameTransaction,
parseFrameTransaction,
intrinsicGas,
calldataGas,
signedGasLimit,
} from 'viem-8141';
const sigHash = frameTransactionSigHash(tx); // 32 bytes
const signature = await hardware.sign(sigHash);
tx.signatures[0].signature = signature;

Exported for building transactions by hand:

import {
FrameMode, // .default | .verify | .sender
FrameFlags, // .none | .approvePayment | .approveExecution | .atomicBatch
SignatureScheme, // .arbitrary | .secp256k1 | .p256
frameTxType, // 0x06
frameEntryPoint, // 0x…aa
maxFrames, // 64
maxSignatures, // 128
permanentSigByteGas,
txBaseGas,
perFrameGas,
type Frame,
type FrameTransaction,
type ParsedFrameTransaction,
} from 'viem-8141';

Client-side support for the chain’s witness segregation extension:

import {
calcStrippedTransactionsRoot,
strippedTransactionProof,
verifyStrippedTransactionProof,
extractSidecarEntries,
} from 'viem-8141';

These let a client fetch a stripped transaction record with a proof and verify it against the block header’s strippedTransactionsRoot — trustless historical queries against nodes that have pruned the witness bytes.

The package also implements Sign in with Frost — off-chain authentication for ML-DSA accounts. Build and parse the EIP-4361-shaped message, and verify a wallet’s reply:

import {
createSiwfMessage, // build the canonical message string
parseSiwfMessage, // parse it back
signedMessageDigest, // the personal-message digest that gets signed
verifySiwfMessage, // full server-side verification
} from 'viem-8141';

verifySiwfMessage checks the signature under the frost-siwf context and binds the public key to the account across the whole account family — counterfactual, fixed-key, and rotatable — using a viem public client for the chain reads. See Sign in with Frost for the flow and the security checklist.

For completeness, frame transactions signed by a classical key:

import { buildEoaFrameTransaction, signEoaFrameTransaction } from 'viem-8141';

Useful for testing and for sponsorship flows. Not quantum-safe.

The package ships examples/pq-account-lifecycle.ts, which runs the entire lifecycle against a live chain: generate keys, derive the counterfactual address, fund it, self-deploy on the first send, spend, rotate via the backup key, and spend again with the new key — all signed natively in JavaScript.

Terminal window
VIEM_8141_RPC_URL=https://rpc.frostfi.net npx tsx examples/pq-account-lifecycle.ts