Documentation menu

Private Orderflow

Private Orderflow delivers transactions received by the TPU (Transaction Processing Unit) of validators in our network. Transactions are captured at the validator's ingress, before execution and block inclusion, and delivered to you without delay. Our relayer does not hold or delay transactions. You typically have a window of about 148 ms per leader to react before the transactions are processed.

Delivery

PropertyValue
gRPC (main path)Our relayer pushes transactions to a gRPC server you run, using Jito's relayer and block engine protocol. See gRPC push
WebSocketYou connect to our stream and receive one JSON message per transaction. See WebSocket
EndpointsgRPC: your own servers. WebSocket: provided with your credentials, see Regions & endpoints
AuthenticationgRPC: our relayer signs in to your server with its identity key. WebSocket: your API key
WindowAbout 148 ms per leader to react. Our relayer does not hold or delay transactions
RegionsAll 7 regions, see Regions & endpoints
CoverageThe validators in your stake pool, see Coverage and stake pool
FilteringgRPC: by the accounts and programs of interest your server sends. WebSocket: none, you filter on your side

What the stream contains

  • Transactions as they arrive at the TPU of the validators in your stake pool, before the validators execute them.
  • Transactions exactly as the sender serialized them, in the legacy or a versioned format.
  • Over gRPC, packets carry flags. Skip packets flagged as discarded (discard) and vote transactions (simple_vote_tx).
  • Only transactions that reach those validators, not every pending transaction on Solana. See Coverage and stake pool.
  • No transactions sent through Private TPU Send. See Privacy.

Coverage and stake pool

Your offer includes a stake pool: the validators in our network whose TPU traffic you receive. Here the term means that set of validators, not an SPL stake pool. The size of the stake pool, measured in stake, is part of your personalized offer and affects its price.

How coverage works

Solana has no global mempool. Senders deliver transactions directly to the current and upcoming leaders, and each validator is the leader for a number of slots proportional to its stake. It follows that:

  • A larger stake pool covers more of the network. More stake means more leader slots, and more transactions sent to those leaders reach you.
  • The stream is never a complete mempool. Transactions sent only to validators outside your stake pool do not appear in it. No single source on Solana sees every pending transaction.
  • Volume follows the leader schedule. Expect the most transactions shortly before and during the leader slots of validators in your stake pool, and fewer in between. A drop in volume between those slots is normal, not an outage.

Measuring your coverage

  • Over WebSocket, the validator field shows which validator received each transaction. It holds the validator's identity public key, the same key as in the leader schedule from your RPC (getLeaderSchedule), so you can see when the validators in your stake pool are leaders.
  • To estimate the share of the network you see, compare the signatures you receive with the transactions that later land in blocks, for example through Shreds or your RPC.

Changing your stake pool

You can increase your stake pool at any time. Contact our support: @ZoriDev.

gRPC push

Over gRPC the usual direction is reversed: you run the gRPC server, and our relayer connects to it as a client and streams transactions into it. The protocol is Jito's relayer and block engine protocol from jito-labs/mev-protos, unchanged. If you already run a Jito-style block engine, it works with our relayer as it is.

Setting up

  1. Run a gRPC server that implements the BlockEngineRelayer service from block_engine.proto and the AuthService from auth.proto. Both can run on the same address.
  2. Keep it reachable from our relayer around the clock. Transactions are not queued for a server that cannot be reached.
  3. Send our support the address of your server, or of several servers, and of your auth service if it runs separately: @ZoriDev.
  4. Allow our relayer's identity key in your auth service. You receive the key from our support. Accept no other key.
  5. Verify. Once signed in, our relayer calls StartExpiringPacketStream and transactions start to arrive.

Connection and load

  • Volume. During congestion, the stream can reach several thousand transactions per second in bursts, before your accounts and programs of interest narrow it down. Size your servers for that peak.
  • Reconnects. Our relayer connects to your servers and reconnects automatically after an interruption.
  • No buffering for slow servers. Our relayer does not wait for your server. If it falls behind, messages are dropped. Keep the stream handler fast, queue the work and process it on separate workers.
  • Several servers. You can register more than one server. Our relayer splits the stream between them, so each server receives part of the transactions. Use this to spread the load. If your strategy needs the full view, combine the streams on your side, and deduplicate there: copies of the same transaction can reach different servers.

Authentication

Authentication follows Jito's AuthService flow, with our relayer as the client:

  1. The relayer calls GenerateAuthChallenge with the role RELAYER and its public key. Your server returns a challenge.
  2. The relayer signs the challenge, prefixed with its public key as described in auth.proto, and calls GenerateAuthTokens. Your server checks the key and the signature, then returns an access token and a refresh token.
  3. The relayer sends the access token with its calls to BlockEngineRelayer and renews it with RefreshAccessToken. Reject calls without a valid token.

Accounts and programs of interest

Your server streams AccountsOfInterestUpdate and ProgramsOfInterestUpdate messages to the relayer through SubscribeAccountsOfInterest and SubscribeProgramsOfInterest. As defined in Jito's protocol, the relayer forwards the transactions that write-lock any of your accounts of interest or use any of your programs of interest. Send the pools and programs your strategy trades, and update the lists when they change. This is the server-side filter of the gRPC path.

Messages

An excerpt from block_engine.proto, packet.proto and shared.proto:

protobuf
// block_engine.proto
service BlockEngineRelayer {
  rpc SubscribeAccountsOfInterest (AccountsOfInterestRequest) returns (stream AccountsOfInterestUpdate) {}
  rpc SubscribeProgramsOfInterest (ProgramsOfInterestRequest) returns (stream ProgramsOfInterestUpdate) {}
  rpc StartExpiringPacketStream (stream PacketBatchUpdate) returns (stream StartExpiringPacketStreamResponse) {}
}

message PacketBatchUpdate {
  oneof msg {
    ExpiringPacketBatch batches = 1;
    shared.Heartbeat heartbeat = 2;
  }
}

message ExpiringPacketBatch {
  shared.Header header = 1;
  packet.PacketBatch batch = 2;
  uint32 expiry_ms = 3;
}

// packet.proto
message Packet {
  bytes data = 1;
  Meta meta = 2;
}

message Meta {
  uint64 size = 1;
  string addr = 2;
  uint32 port = 3;
  PacketFlags flags = 4;
  uint64 sender_stake = 5;
}

message PacketFlags {
  bool discard = 1;
  bool forwarded = 2;
  bool repair = 3;
  bool simple_vote_tx = 4;
  bool tracer_packet = 5;
  bool from_staked_node = 6;
}
FieldWhat to do with it
data, meta.sizeThe transaction in the Solana wire format. Decode the first meta.size bytes
meta.flags.discard, meta.flags.simple_vote_txSkip discarded packets and vote transactions
header.tsThe time the relayer created the batch. Compare it with your clock to measure delivery time
expiry_msPart of Jito's protocol, where it gives the time until the relayer forwards the packets to the validator. Our relayer does not hold packets, so plan with the window of about 148 ms, not with this field
heartbeatArrives on the same stream. Send StartExpiringPacketStreamResponse heartbeats back at a regular interval

Rust: decoding a batch

Compile block_engine.proto, auth.proto and the files they import with tonic-build. The function below turns one update from our relayer into the transactions it carries. Call it from your StartExpiringPacketStream handler, put the result in a queue for your workers and keep the handler fast.

rust
use solana_sdk::transaction::VersionedTransaction;

// Generated with tonic-build from Jito's mev-protos.
use crate::pb::block_engine::{packet_batch_update::Msg, PacketBatchUpdate};

/// Returns the transactions in one update from our relayer. Heartbeats yield nothing.
pub fn transactions(update: PacketBatchUpdate) -> Vec<VersionedTransaction> {
    let Some(Msg::Batches(expiring)) = update.msg else {
        return Vec::new();
    };
    let Some(batch) = expiring.batch else {
        return Vec::new();
    };
    batch
        .packets
        .into_iter()
        .filter_map(|packet| {
            let meta = packet.meta?;
            let flags = meta.flags.unwrap_or_default();
            if flags.discard || flags.simple_vote_tx {
                return None;
            }
            let len = (meta.size as usize).min(packet.data.len());
            bincode::deserialize(&packet.data[..len]).ok()
        })
        .collect()
}

Dependencies: tonic and prost for the server (the examples follow tonic 0.12 and prost 0.13), tonic-build as a build dependency, solana-sdk 2.x and bincode 1.3.

The jito-relayer repository shows how a relayer uses this protocol from the client side.

WebSocket

If you prefer not to run a gRPC server, connect to our WebSocket stream with your API key. The endpoint comes with your credentials; the examples read it from MEV_ORDERFLOW_WS. After the connection opens, the server sends one message per transaction:

json
{
  "signature": "4Rq7Zx...base58",
  "transaction": "AV8x...base64-encoded transaction...AAA=",
  "slot": 372418559,
  "received_at_us": 1761490800118302,
  "validator": "MevA1...base58"
}
FieldTypeDescription
signaturestringFirst signature of the transaction, base58-encoded. It identifies the transaction, so use it for deduplication
transactionstring, base64The transaction in the Solana wire format, exactly as received
slotnumberThe slot in progress on the receiving validator when the transaction arrived
received_at_usnumberTime the transaction arrived at the TPU, Unix epoch in microseconds, from the validator's clock
validatorstringIdentity public key of the validator that received the transaction, base58-encoded

slot and received_at_us stay well within the safe integer range of JavaScript numbers, so JSON.parse reads them exactly.

TypeScript consumer

The example deduplicates by signature, keeps a bounded queue for your workers and reconnects with backoff.

ts
import WebSocket from "ws";

const ENDPOINT = process.env.MEV_ORDERFLOW_WS!;
const DEDUP_SLOTS = 300; // longer than a transaction's blockhash stays valid
const MAX_QUEUE = 10_000;

interface PendingTransaction {
  signature: string;
  transaction: string; // base64
  slot: number;
  received_at_us: number;
  validator: string;
}

const seen = new Map<string, number>(); // signature -> slot when first seen
export const queue: PendingTransaction[] = []; // drained by your strategy workers
let latestSlot = 0;

function connect(attempt = 0): void {
  const ws = new WebSocket(ENDPOINT, {
    headers: { "x-api-key": process.env.MEV_ALPHA_KEY! },
  });

  // Reconnect if the connection goes silent: no message and no ping for 30 seconds.
  let lastSeen = Date.now();
  const watchdog = setInterval(() => {
    if (Date.now() - lastSeen > 30_000) ws.terminate();
  }, 5_000);

  // Reset the backoff only after the connection has been stable for a minute.
  let stable: NodeJS.Timeout | undefined;
  ws.on("open", () => {
    stable = setTimeout(() => (attempt = 0), 60_000);
  });

  ws.on("ping", () => {
    lastSeen = Date.now();
  });

  ws.on("message", (data) => {
    lastSeen = Date.now();
    const msg: PendingTransaction = JSON.parse(data.toString());
    latestSlot = Math.max(latestSlot, msg.slot);
    if (seen.has(msg.signature)) return;
    seen.set(msg.signature, msg.slot);
    queue.push(msg); // decode and evaluate elsewhere: keep this handler fast
    if (queue.length > MAX_QUEUE) queue.shift(); // drop the oldest: stale data is worth less
  });

  ws.on("close", (code, reason) => {
    clearInterval(watchdog);
    clearTimeout(stable);
    console.log("closed", code, reason.toString());
    const delay = Math.min(30_000, 500 * 2 ** attempt) + Math.random() * 250;
    setTimeout(() => connect(attempt + 1), delay);
  });

  ws.on("error", (err) => console.error("error", err.message)); // "close" follows
}

// Forget signatures once their transactions can no longer land.
// A Map iterates in insertion order, so the oldest entries come first.
setInterval(() => {
  for (const [signature, slot] of seen) {
    if (slot > latestSlot - DEDUP_SLOTS) break;
    seen.delete(signature);
  }
}, 1_000);

connect();

The server sends WebSocket ping frames every 10 seconds. The ws library answers them automatically.

Decoding transactions

Transactions use the standard Solana wire format, so standard libraries decode them. Over gRPC, each packet holds the raw bytes. Over WebSocket, transaction is base64-encoded.

ts
import { VersionedTransaction } from "@solana/web3.js";

const tx = VersionedTransaction.deserialize(Buffer.from(msg.transaction, "base64"));
const instructions = tx.message.compiledInstructions;

In Rust, bincode::deserialize::<VersionedTransaction>(bytes) does the same, as in the gRPC example above.

Programs and accounts

The program called by each top-level instruction is always among the transaction's static account keys (tx.message.staticAccountKeys), never loaded from an address lookup table. You can filter on those programs right after decoding, without any RPC call.

This does not cover programs called through cross-program invocation (CPI). When a swap goes through an aggregator such as Jupiter, the top-level instruction calls the aggregator, and the AMM it routes through is only one of the accounts, which may come from a lookup table. To catch those swaps, check the aggregator programs as well, or resolve the lookup tables and check every account.

Version 0 transactions can reference accounts through address lookup tables. To see every account a transaction touches, resolve its lookup tables through your RPC (getAddressLookupTable) and cache them. If a lookup index is out of range, the table was extended after you cached it: fetch it again.

Priority fee

The Compute Budget program sets the compute unit price, which is the priority fee per compute unit, and the compute unit limit. Read them to see how hard a pending transaction is competing for its place in the block, and to place your backrun behind it.

ts
import { PublicKey, VersionedTransaction } from "@solana/web3.js";

const COMPUTE_BUDGET = new PublicKey("ComputeBudget111111111111111111111111111111");

// Compute unit price (micro-lamports per compute unit) and limit, if the transaction sets them.
export function computeBudget(tx: VersionedTransaction) {
  const keys = tx.message.staticAccountKeys;
  let unitPrice: bigint | undefined;
  let unitLimit: number | undefined;
  for (const ix of tx.message.compiledInstructions) {
    if (!keys[ix.programIdIndex].equals(COMPUTE_BUDGET)) continue;
    const data = Buffer.from(ix.data);
    if (data[0] === 2 && data.length >= 5) unitLimit = data.readUInt32LE(1); // SetComputeUnitLimit
    if (data[0] === 3 && data.length >= 9) unitPrice = data.readBigUInt64LE(1); // SetComputeUnitPrice
  }
  return { unitPrice, unitLimit };
}

The priority fee in lamports is the unit price multiplied by the unit limit, divided by 1,000,000. If a transaction sets no limit, a default limit applies, for most programs 200,000 compute units per instruction.

Placing your backrun

You typically have a window of about 148 ms per leader to decide, build and send your backrun. Our relayer does not hold the target transaction, so the sooner you send, the better.

There are three ways to place your backrun right after its target. A bundle through Private TPU Send is the only one of them that guarantees the order in our leader slots.

Bundle through Private TPU Send

Private TPU Send accepts atomic bundles. Put the target transaction from the stream first and your backrun after it. We deliver the bundle to the TPU of our validator in its leader slots, and the validator executes it as a whole and in order, or not at all.

  • Your backrun lands directly behind its target, or it does not land. It never executes before the target.
  • The target also reaches the leader on its own, because our relayer does not hold it. If the leader executes the target before your bundle arrives, the bundle fails as a whole. Build and send it as early as you can.
  • Bundles land in the leader slots of validators in our network.
  • The method and format for submitting bundles come with your access details from our support. See Bundles.

Priority just below the target

You can also send your backrun as a separate transaction. Leaders running the Agave client schedule conflicting transactions by priority. If your backrun write-locks an account that the target also write-locks, and its priority is slightly lower than the target's, the leader executes the target first and your backrun right after it.

  • Agave computes priority as the fee the validator earns from the transaction, multiplied by 1,000,000 and divided by the transaction's estimated cost plus one. The fee is the priority fee plus the validator's share of the base fee. The cost depends on the compute unit limit you request, not on the units you use. Check the scheduler code of the Agave release the leaders run for the exact formula.
  • Set the compute unit limit and price of your backrun so that its priority is one unit below the target's. Read the target's values as shown in Priority fee.
  • Send the backrun through Private TPU Send, so that it reaches the leader that the target is waiting for.

This placement is best effort:

  • It works only when both transactions reach the same leader's scheduler and write-lock a common account.
  • If your backrun reaches the leader before the target, it can execute first. Make it fail safely, for example with a minimum-profit check, so that a misplaced backrun costs only its fee.
  • Leaders running other clients, such as Firedancer, may schedule differently.

Bundles through another service

You can also submit bundles through a service such as Jito, BAM or Harmonic and use Private Orderflow as the signal. Those bundles carry a tip and follow the rules of the service you use.

Whichever way you choose, confirm the outcome of both transactions through your RPC or Shreds. With the Orderflow + TPU Combo, the signal and the sending path run within the same infrastructure.

Processing guidelines

  • Deduplicate by signature. The same transaction can arrive more than once, for example when the sender retries it or when it reaches more than one validator in your stake pool. Over gRPC, take the signature from the decoded transaction (its first signature). Keep signatures for a few hundred slots: by then the transaction's blockhash has expired and it can no longer land.
  • Treat messages as a signal, not a result. These transactions have not been executed yet. They may fail, be dropped or never be included. Verify outcomes via Shreds or your RPC.
  • Expect failures. Many transactions that reach the TPU fail when executed, for example arbitrage attempts that lose the race for the same opportunity. Weigh a pending transaction before you act on it.
  • Keep the receive path fast. Queue messages and decode them on separate workers, whether they arrive at your gRPC server or over WebSocket. See Limits.
  • Measure latency. With a well-synchronized clock (PTP, or NTP with low offset), compare header.ts with your clock over gRPC, or compute Date.now() * 1000 - received_at_us over WebSocket. See Latency & benchmarking.

Privacy

Transactions submitted by Clients through Private TPU Send are excluded from the Private Orderflow stream.

Transactions you send through other paths, for example public RPC nodes, can reach validators in our network and appear in the stream like any other transaction. To keep your own transactions out of the stream, send them through Private TPU Send.

Need help or access? Contact our support on Telegram.

Contact