Validity Transactions
A validity transaction is an ordinary signed transaction handed to Base together with a list of conditions on chain state. Base keeps it out of a block until every condition matches, then lets it compete for inclusion on the usual fee rules. The pattern suits conditional swaps, conditional withdrawals, and anything else that should only fire once the chain reaches a particular state.
The method accepts legacy, EIP-2930, and EIP-1559 transactions. EIP-1559 is the recommended type.
How it works
Section titled “How it works”Submit two things: a signed raw transaction, and an object carrying a non-empty validity array. Base checks the predicates against the state as it stands just before the transaction would run, not against the state at submission time. A transaction whose predicates do not all match stays pending rather than failing.
That waiting behaviour is the useful part. An earlier transaction can move the storage value a pending transaction is watching, and the pending transaction then becomes eligible — possibly within the same Flashblock. No keeper has to watch the chain and race to submit.
Eligibility is not a reservation. A matching predicate set earns a place in the ordinary competition for block space, nothing more.
base_sendRawTransactionValidity
Section titled “base_sendRawTransactionValidity”Parameters
Section titled “Parameters”| Position | Value |
|---|---|
| 1 | A signed, serialized transaction (legacy, EIP-2930, or EIP-1559) |
| 2 | An object containing a non-empty validity array |
The second parameter takes validity and nothing else. An unrecognised key fails the whole request, so a misspelled field name surfaces as a rejection rather than as a silently ignored setting. From v1.5.0-rc.7 the object also accepts an optional validity_signature, covered under Signed predicates.
Every submission also needs a block_number upper bound. The example below pairs the condition it actually cares about with that mandatory bound:
{ "jsonrpc": "2.0", "id": 1, "method": "base_sendRawTransactionValidity", "params": [ "0x<signed-raw-transaction>", { "validity": [ { "type": "storage", "params": { "address": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "slot": "0x8", "mask": "0xff", "op": "=", "value": "0x2a" } }, { "type": "block_number", "params": { "op": "<=", "value": "0x1e8493" } } ] } ]}Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Predicates per submission | 1 to 64 |
| Lifetime window | 60 seconds by default |
| Unknown top-level fields | Rejected |
The predicate ceiling is a wire limit rather than a policy an operator can raise. Decoding stops at the 65th entry, so an oversized batch is refused before the node allocates room for it. An operator can lower the ceiling with --validity-max-predicates (builders use --builder.validity-max-predicates), but a value above 64 is refused at startup instead of being quietly clamped.
An empty validity array is also rejected. A submission with no conditions carries nothing to enforce, so it is not accepted as a plain private transaction.
Returns
Section titled “Returns”A 32-byte transaction hash, as a 0x-prefixed string.
Errors
Section titled “Errors”Failures come back as JSON-RPC errors. Handle at least three cases: the method being unavailable on the endpoint, invalid parameters, and a missing transaction hash in the response. Do not pattern-match on codes or message text yet, because neither is stable.
Predicate types
Section titled “Predicate types”| Type | Parameters |
|---|---|
balance | address, op, value |
nonce | address, op, value |
storage | address, slot, op, value, optional mask |
block_number | op, value |
flashblock_index | op, value |
Operators are <, <=, =, !=, >, and >=. Every value is a 0x-prefixed hexadecimal string.
For a storage predicate Base compares (storage[address][slot] & mask) against value. Leave mask out to compare the whole storage word; it defaults to all ones.
A balance predicate reads the native ETH balance of address, in wei, and compares it with value. An address with no account reads as zero. It takes no slot and no mask.
A nonce predicate works the same way, but on the account’s protocol nonce (#5637). That is the ordinary transaction count of address, not an EIP-8130 channel nonce. An address with no account reads as zero, and an account deleted during the block also reads as zero. Like balance, it takes no slot and no mask. Use it to make one transaction wait until another account has sent a given number of transactions, or to stop waiting once it has sent too many.
A nonce condition that has been passed does not evict the transaction. Suppose you write =, <, or <= and the nonce then moves above your value. Nonces only go up, so the condition can never match again, but the node still parks the transaction and does not drop it. Only your mandatory block_number bound ends its life. Keep that bound tight when you rely on a nonce ceiling.
Use block_number to pin a target block and flashblock_index to pin a position inside it. Both conditions need their own predicate — one entry cannot express both. The request sample in the Cobalt launch post folds both into a single object with repeated type and params keys, and it is not valid JSON as printed. Give each predicate its own entry in the validity array, as the examples on this page do.
A usable flashblock_index runs from 1 to 10 inclusive, which is 0x1 to 0xa in hex. Index 0 is the fallback block. It holds only the sequencer’s own transactions, so no pooled transaction can ever match there. The node refuses at ingress any flashblock_index predicate whose largest reachable value is below 1, for example <= 0x0 or < 0x1, because such a transaction would wait forever.
Signed predicates
Section titled “Signed predicates”The relayer problem above now has an opt-in fix (#5645). A sender can sign the predicate list itself, so nobody else can bolt different conditions onto the transaction. The motivating attack is a sandwich: someone holding your raw transaction adds a storage check that keeps it waiting until their own trade has moved a pool’s reserves. Your calldata is untouched, and the damage stays inside your onchain slippage limit, which is why it is easy to miss.
To sign, pass validity_signature next to validity in the second parameter. It is an Alloy signature object with r, s, and yParity as hex quantities. Produce it with eth_signTypedData_v4, not personal_sign, over this EIP-712 data:
- Domain:
{ name: "Base Transaction Validity", version: "1", chainId }, with the transaction’s own chain ID and no verifying contract. - Types:
ValidityPredicate(uint8 kind,uint8 operator,address account,uint256 slot,uint256 mask,uint256 value)ValidityAuthorization(bytes32 transactionHash,ValidityPredicate[] validity)transactionHash is the hash of the full signed EIP-2718 envelope. kind is balance = 0, storage = 1, block number = 2, flashblock index = 3, nonce = 4. operator is < = 0, <= = 1, = = 2, != = 3, > = 4, >= = 5. Fields a predicate does not use are zero, and a storage predicate with no mask signs U256::MAX. Before signing, sort the predicates by their EIP-712 struct hashes in ascending byte order and keep any duplicates. Reordering your list then gives the same digest, while any added, dropped, or edited predicate gives a different one.
Only a secp256k1 key at the sender address can sign. Contract wallets and EIP-8130 actors without that key cannot use this, and neither can deposits or unprotected legacy transactions, which have no chain-bound domain. The node also refuses high-s signatures.
Node operators control enforcement with --validity-signature-mode off|verify-if-present|required, on forwarding ingress nodes and on builders:
| Mode | Unsigned predicates | Supplied signatures |
|---|---|---|
off (default) | Accepted | Kept and forwarded, never checked |
verify-if-present | Accepted | Checked at raw ingress |
required | Rejected for any non-empty batch | Checked at raw ingress |
The intended rollout is verify-if-present across every ingress node and builder first, then wallets move to signing, then the fleet flips to required. The txpool.validity_signature.accepted{site,signature} and txpool.validity_signature.rejected{site,reason} metrics show how far that migration has got.
This is on main and first ships in v1.5.0-rc.7. v1.4.2 does not have it, and rejects validity_signature as an unknown key. The flag defaults to off, so until Base announces otherwise, assume the public sequencer does not enforce signatures.
Safety
Section titled “Safety”Three further limits are worth internalising before writing one:
- Storage predicates read raw slots. They cannot call a view function or evaluate anything computed. You need the target contract’s storage layout, and that layout is an implementation detail the contract author can change under an upgrade.
- Only the state at execution counts. The check runs against the state right before your transaction would execute. A condition true when you submit can be false by then, and the reverse. Treat the predicate as a filter, not a guarantee.
- A zero-valued slot proves nothing. An uninitialized contract, a proxy before initialization, and a CREATE2 address that has not been deployed yet all read as zero. None of them is safe to call on that basis.
Do not use a transaction’s position as a randomness source.
Fees, ordering, and lifecycle
Section titled “Fees, ordering, and lifecycle”Fees follow the normal rules for the transaction type. For EIP-1559 set maxFeePerGas and maxPriorityFeePerGas on the signed transaction as usual.
A transaction that never becomes eligible costs nothing onchain. One that is included pays normal execution fees, and it pays them even if it reverts.
Expiry is mandatory, and it is bounded on both sides. Every submission has to carry a block_number predicate with an upper bound, written with <, <=, or =. Leave it out and the node refuses the transaction outright. Pair it with flashblock_index to tighten the bound to a position within the block.
Only an upper bound counts. A >, >=, or != comparison on block_number does not establish expiry, and neither does a balance, nonce, storage, or flashblock_index predicate. State conditions can recover after failing, and the flashblock index restarts every block, so none of them puts a ceiling on how long a transaction can wait. When several block_number predicates carry upper bounds, the tightest one decides the lifetime.
Two further checks apply once a canonical head exists. A bound already behind the block being built is rejected, because every block it still permits has been sealed. A bound past the node’s configured window is rejected as well. That window is 60 seconds of wall-clock time by default, which the node converts into a block count using the current full-block cadence: roughly 30 blocks on two-second blocks, and roughly 300 once Denim’s 200ms cadence is active. Operators set it with --validity-max-expiry-secs.
A bound equal to the block currently being built is fine. The transaction can still land in a later Flashblock of that same block.
Once the block named by the bound is committed, the node drops the transaction from its pool, because no later block can include it. Before #5333 the node kept it for one more block. During that block the node still forwarded it and still counted it against admission capacity. The fix is not in v1.4.2. It first ships in v1.5.0-rc.1.
A flashblock_index upper bound now expires sooner too (#5477). The node drops the transaction as soon as the last Flashblock it allows is published, instead of waiting for the whole block to seal. This frees the nonce at once. An unbumped replacement with the same nonce can land in a later Flashblock of the same block, which an expired entry used to block. A failed publication does not trigger the drop. This change also first ships in v1.5.0-rc.1.
Replacement works the way it does for ordinary transactions: sign again with the same sender and nonce. If the node answers with an underpriced-replacement error, raise the fee fields, re-sign, and retry.
Worked example
Section titled “Worked example”Build the predicate list first. This one waits for a storage slot to reach a value, and bounds the result to a block and a Flashblock position:
import type { Address, Hex } from 'viem';
const validity = [ { type: 'storage', params: { address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72' as Address, slot: '0x8' as Hex, op: '=', value: '0x2a' as Hex, }, }, { type: 'block_number', params: { op: '<=', value: '0x11a6a1' as Hex }, }, { type: 'flashblock_index', params: { op: '<=', value: '0x2' as Hex }, },] as const;Sign an EIP-1559 transaction against the sender’s next nonce. The signed payload becomes the first RPC parameter:
import { createPublicClient, http, type Chain } from 'viem';import { privateKeyToAccount } from 'viem/accounts';
const client = createPublicClient({ chain, transport: http(rpcUrl) });const account = privateKeyToAccount(privateKey);const fees = await client.estimateFeesPerGas();const nonce = await client.getTransactionCount({ address: account.address, blockTag: 'latest',});
const rawTransaction = await account.signTransaction({ chainId: (chain as Chain).id, type: 'eip1559', nonce, to: '0x...' as Address, data: '0x...' as Hex, value: 0n, gas: 100_000n, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas,});Then submit both parameters. Viem has no client method for this yet, so call the endpoint directly:
const response = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'base_sendRawTransactionValidity', params: [rawTransaction, { validity }], }),});
const body = await response.json();if (body.error) throw new Error(body.error.message);const hash = body.result;When a transaction does not land
Section titled “When a transaction does not land”Start by separating “not accepted” from “accepted but not included”. A returned hash means the submission was accepted, so anything after that is an inclusion question.
The method is unavailable. The endpoint does not serve base_sendRawTransactionValidity. Today that means Vibenet or, on Base Sepolia, https://sepolia-sequencer.base.org; the regular sepolia.base.org endpoint returns Method not found.
Submission fails. Check that the first parameter really is a signed legacy, EIP-2930, or EIP-1559 transaction, and that the second parameter carries a non-empty validity array and no other key (apart from validity_signature on v1.5.0-rc.7 and later). Then check each predicate’s type, operator, and that every value is 0x-prefixed hex.
Four rejections come from the expiry and batch rules rather than from malformed input. Each arrives as an invalid-parameters error naming the predicate’s position in the array:
| Message | Cause |
|---|---|
validity transactions require a block-number predicate with an upper bound | No block_number predicate uses <, <=, or = |
block-number predicate at index N already expired | The bound sits behind the block being built |
block-number predicate at index N expires too far in the future | The bound is past the node’s lifetime window |
too many validity predicates | The batch is over the 64-entry ceiling |
The first is the one to check first when a request that previously worked starts failing. A bounded lifetime is required on every submission, so a predicate set that only describes state conditions is no longer sufficient on its own.
It stays pending. Four ordinary explanations: a predicate is still false, other transactions are outbidding it, a replacement superseded it, or its block_number or flashblock_index bound has passed.
It expired. Sign and submit a fresh transaction with later bounds. An expired one does not revive.
Replacement is underpriced. Raise the fee fields and retry with the same sender and nonce.
When escalating, record the network, RPC provider, transaction hash, predicate type, submission time, and the full JSON-RPC error. Never paste private keys, authentication data, or complete signed transactions into a public channel.