---
title: "Validity Transactions"
description: "Reference for base_sendRawTransactionValidity — submitting a signed transaction with predicates over chain state, so Base holds it back until every condition matches."
source: https://basehub.org/api-reference/validity-transactions/
---
import { Aside } from '@astrojs/starlight/components';

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.

<Aside type="caution">
This is experimental. `base_sendRawTransactionValidity` is available on Vibenet only. Mainnet and Base Sepolia availability are unconfirmed, error codes and messages are not stable, and the production API contract is not settled. Arriving in the [Cobalt](/specifications/cobalt-overview/) upgrade.
</Aside>

## How it works

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.

## `base_sendRawTransactionValidity`

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

Every submission also needs a `block_number` upper bound. The example below pairs the condition it actually cares about with that mandatory bound:

```json
{
  "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

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

### Returns

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

<Aside type="note">
The hash confirms that Base accepted the submission. It does not confirm inclusion. Poll [`eth_getTransactionReceipt`](/api-reference/eth/) to find out whether the transaction actually landed.
</Aside>

### 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

| Type | Parameters |
| --- | --- |
| `balance` | `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.

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.

## Safety

<Aside type="caution">
A predicate is not a simulation. It reads a value and compares it, and that is all. Keep the checks your application actually depends on inside the contract call, where a failure reverts. A predicate that passes tells you nothing about whether the call will succeed.
</Aside>

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, 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`, `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.

<Aside type="caution">
Compute the bound at submission time, not at signing time. A bound derived from a stale head can fall behind the build target while you are still preparing the request, and the node rejects it rather than holding it.
</Aside>

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

<Aside type="caution">
Validity criteria travel beside the signed transaction, not inside it, and never appear in the resulting onchain transaction. That is a privacy property worth understanding rather than relying on. Keep secrets out of both calldata and predicate values.
</Aside>

## Worked example

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:

```ts
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:

```ts
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:

```ts
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

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:

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