Skip to content
BaseHub by wbnns Updated

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.

Submit two things: a signed raw transaction, and an object carrying a non-empty validity array. Base evaluates the predicates before inclusion. 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 balance or 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.

PositionValue
1A signed, serialized transaction (legacy, EIP-2930, or EIP-1559)
2An 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.

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"
}
}
]
}
]
}
LimitValue
Predicates per submission1 to 64
Lifetime window60 seconds by default
Unknown top-level fieldsRejected

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

A 32-byte transaction hash, as a 0x-prefixed string.

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.

TypeParameters
balanceaddress, op, value
storageaddress, slot, op, value, optional mask
block_numberop, value
flashblock_indexop, 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.

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.

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.
  • State moves during block building. A condition true when you submit can be false at inclusion, 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 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, 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 --experimental-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.

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.

Build the predicate list first. This one waits for an account to hold a balance, 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: 'balance',
params: {
address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72' as Address,
op: '>=',
value: '0x1' as Hex,
},
},
{
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;

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 support base_sendRawTransactionValidity. Vibenet is currently the only network that does.

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

MessageCause
validity transactions require a block-number predicate with an upper boundNo block_number predicate uses <, <=, or =
block-number predicate at index N already expiredThe bound sits behind the block being built
block-number predicate at index N expires too far in the futureThe bound is past the node’s lifetime window
too many validity predicatesThe 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.