Corvus Labs

aRPC v2

Reference for aRPC v2 gRPC: Solana entry, decoded transaction, binary transaction, and slot streams with dynamic account filters.

aRPC v2 (package arpc.v2, service Service) splits the legacy single stream into four RPCs. The transaction streams take filter changes while they are running, without a reconnect, and acknowledge each registration with a FilterValidationResult.

service Service {
  rpc SubscribeEntries(SubscribeEntriesRequest) returns (stream SubscribeEntriesResponse) {}
  rpc SubscribeTransactions(stream SubscribeTransactionsRequest) returns (stream SubscribeTransactionsResponse) {}
  rpc SubscribeBinaryTransactions(stream SubscribeTransactionsRequest) returns (stream SubscribeBinaryTransactionsResponse) {}
  rpc SubscribeSlots(SubscribeSlotsRequest) returns (stream SubscribeSlotsResponse) {}
}
RPCShapeYou get
SubscribeEntriesRequest → server streamSerialized entries, entry batches, optional heartbeats
SubscribeTransactionsBidirectionalDecoded transactions plus the filter_ids that matched
SubscribeBinaryTransactionsBidirectionalExact transaction wire bytes and the raw primary signature
SubscribeSlotsRequest → server streamSlot lifecycle with status and current leader

Connect to http://arpc.<region>.corvus-labs.io:20202 with insecure channel credentials; all nine regions behave the same way. You get in on an allowlisted egress IP by default, and where a dedicated deployment has a token configured you attach x-token metadata to every RPC, because each stream is authenticated on its own.

Generate the code

You need both files, with the directory structure intact, because arpc/v2/service.proto imports arpc/common/types.proto:

proto/
└── arpc/
    ├── common/
    │   └── types.proto
    └── v2/
        └── service.proto

Use proto/ as the include root:

protoc -I proto \
  --descriptor_set_out=arpc-v2.pb \
  proto/arpc/common/types.proto \
  proto/arpc/v2/service.proto

Control the transaction streams

Both transaction RPCs accept the same client message:

message SubscribeTransactionsRequest {
  oneof payload {
    RegisterTransactionFilters   register_filters   = 1;
    UnregisterTransactionFilters unregister_filters = 2;
    Ping                         ping               = 3;
  }
}

message RegisterTransactionFilters {
  repeated TransactionFilter filters = 1;
}

message UnregisterTransactionFilters {
  repeated string filter_ids = 1;
}

message Ping {
  int32 ping_id = 1;
}

message TransactionFilter {
  string                             filter_id = 1;
  SubscribeRequestFilterTransactions filter    = 2;
}

message SubscribeRequestFilterTransactions {
  repeated string account_include  = 1;
  repeated string account_exclude  = 2;
  repeated string account_required = 3;
}

Batch filter changes

Send your complete filter change as one RegisterTransactionFilters message and match the FilterValidationResults by filter_id. Above 50 filter-update messages per 1,000 ms the stream is rejected with RESOURCE_EXHAUSTED (8), reason subscribe_rate_limit. See limits.

Register filters

Every filter needs a non-empty filter_id that you choose. Register an ID you are already using and it replaces that filter; the others stay active.

stream.write({
  register_filters: {
    filters: [
      {
        filter_id: 'token-program',
        filter: {
          account_include: [
            'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
          ],
          account_exclude: [],
          account_required: [],
        },
      },
    ],
  },
});
FieldMatch rule
account_includeAt least one listed key must be present
account_requiredEvery listed key must be present
account_excludeNo listed key may be present

Keys are Base58. Leaving include and required empty gives you a broad match, and an entirely empty filter matches every transaction.

The field numbers here are 1, 2, 3, where the legacy message's are 2, 3, 4.

Read the validation result

message FilterValidationResult {
  string          filter_id        = 1;
  bool            accepted         = 2;
  optional string rejection_reason = 3;
}
if (response.filter_validation) {
  const result = response.filter_validation;
  pending.delete(result.filter_id);
  if (!result.accepted) {
    console.error(`filter ${result.filter_id} rejected: ${result.rejection_reason}`);
  }
}

A rejection (a missing filter specification, an invalid public key) sets accepted: false and leaves the stream and all your other filters running.

Two cases get no FilterValidationResult: an entry with an empty filter_id is skipped, and UnregisterTransactionFilters is never acknowledged.

Unregister and ping

stream.write({ unregister_filters: { filter_ids: ['token-program'] } });

stream.write({ ping: { ping_id: 42 } });

The server answers a ping with pong.ping_id. It also sends an unsolicited Pong with ping_id = 0 every 10 seconds as the stream heartbeat, so matching pongs strictly against pings you issued throws that one away. Since nothing acknowledges an unregistration, keep the filter set you want in your own application state.

Stream decoded transactions

message SubscribeTransactionsResponse {
  google.protobuf.Timestamp created_at = 1;

  oneof payload {
    TransactionUpdate      transaction_update = 2;
    Pong                   pong               = 4;
    Error                  error              = 6;
    FilterValidationResult filter_validation  = 7;
  }

  uint64 sequence = 8;
}

message TransactionUpdate {
  repeated string            filter_ids  = 1;
  SubscribeUpdateTransaction transaction = 2;
}

message SubscribeUpdateTransaction {
  uint64      slot        = 1;
  Transaction transaction = 2;
  string      signature   = 3;
}

message Transaction {
  repeated bytes signatures = 1;
  Message        message    = 2;
  bool           is_vote    = 3;
  bytes          fee_payer  = 4;
}

message Message {
  MessageHeader                                  header                     = 1;
  repeated bytes                                 account_keys               = 2;
  bytes                                          recent_blockhash           = 3;
  repeated arpc.common.CompiledInstruction       instructions               = 4;
  google.protobuf.BoolValue                      versioned                  = 5;
  repeated arpc.common.MessageAddressTableLookup address_table_lookups      = 6;
  repeated bytes                                 loaded_writable_addresses  = 7;
  repeated bytes                                 loaded_readonly_addresses  = 8;
}

message MessageHeader {
  uint32 num_required_signatures        = 1;
  uint32 num_readonly_signed_accounts   = 2;
  uint32 num_readonly_unsigned_accounts = 3;
}

message Pong {
  int32 ping_id = 1;
}

filter_ids lists every active filter the update matched.

Take the primary signature from transaction.transaction.signatures[0], not from the string signature compatibility field on SubscribeUpdateTransaction. Signatures and account keys arrive as raw bytes.

For a versioned transaction, the accounts it touches are the static keys plus the addresses resolved from lookup tables, in this order:

const message = update.transaction.transaction.message;
const allAccountKeys = [
  ...message.account_keys,
  ...message.loaded_writable_addresses,
  ...message.loaded_readonly_addresses,
];

is_vote lets you drop vote traffic locally, and fee_payer arrives as its own field.

Stream binary transactions

SubscribeBinaryTransactions takes the same register / unregister / ping messages and returns the exact wire bytes for you to decode locally.

message SubscribeBinaryTransactionsResponse {
  google.protobuf.Timestamp created_at = 1;

  oneof payload {
    BinaryTransactionUpdate  transaction_update = 2;
    Pong                     pong               = 4;
    Error                    error              = 6;
    FilterValidationResult   filter_validation  = 7;
  }

  uint64 sequence = 8;
}

message BinaryTransactionUpdate {
  uint64 slot        = 2;
  bytes  signature   = 3;
  bytes  transaction = 4;
}
  • signature is the raw primary signature.
  • transaction is the exact serialized Solana transaction.
  • Field numbering starts at 2; there is no field 1.
  • There are no filter_ids here. If you need to know which filter matched, either use the decoded stream or keep one logical predicate per binary stream.
binaryStream.on('data', (response) => {
  if (!response.transaction_update) return;

  const update = response.transaction_update;
  console.log({
    slot: update.slot,
    signatureHex: update.signature.toString('hex'),
    transactionBytes: update.transaction.length,
    sequence: response.sequence,
  });
});

Stream entries

message SubscribeEntriesRequest {
  optional EntryMetadataFilter metadata_filter = 1;
  optional EntryStreamOptions  stream_options  = 2;
}

message EntryMetadataFilter {
  optional uint32 min_transaction_count    = 1;
  optional uint32 max_transaction_count    = 2;
  optional uint32 min_signature_count      = 3;
  optional uint32 max_signature_count      = 4;
  optional bool   include_tick_entries     = 5;
  optional bool   include_non_tick_entries = 6;
}

message EntryStreamOptions {
  optional bool include_heartbeat_updates = 1;
}

message SubscribeEntriesResponse {
  google.protobuf.Timestamp created_at = 1;

  oneof update {
    Entry      entry     = 2;
    EntryBatch batch     = 3;
    Heartbeat  heartbeat = 4;
    Error      error     = 5;
  }

  uint64 sequence = 6;
}

message EntryBatch {
  repeated Entry entries = 1;
}

message Heartbeat {
  uint64 slot = 1;
}

message Entry {
  uint64          slot              = 1;
  bytes           data              = 2;
  optional uint32 transaction_count = 3;
  optional uint32 signature_count   = 4;
  bool            tick_only         = 5;
  SlotStatus      slot_status       = 6;
  optional bytes  current_leader    = 7;
  uint32          entry_index       = 8;
  bool            is_last_in_slot   = 9;
}

Every metadata field is optional, and an unset one is no constraint on that dimension. Heartbeat updates are opt-in through stream_options.

const entries = client.SubscribeEntries(
  {
    metadata_filter: {
      min_transaction_count: 1,
      include_tick_entries: false,
      include_non_tick_entries: true,
    },
    stream_options: {
      include_heartbeat_updates: true,
    },
  },
  metadata,
);

Handle all four update arms, and don't assume one entry per message, since a response can carry an EntryBatch of several. Entry.data is the serialized entry, which you decode yourself.

COMPLETE is not finality

slot_status: SLOT_STATUS_COMPLETE and is_last_in_slot only mean aRPC observed the final entry of that slot. They tell you nothing about whether the slot was confirmed, rooted, or finalized, and nothing about whether its transactions succeeded. Confirm through Solana RPC.

Stream slot status

message SubscribeSlotsRequest {
  optional bool include_dead     = 1;
  optional bool include_complete = 2;
}

message SubscribeSlotsResponse {
  SlotUpdate slot     = 1;
  uint64     sequence = 2;
}

message SlotUpdate {
  uint64         slot           = 1;
  SlotStatus     status         = 2;
  optional bytes current_leader = 3;
}

enum SlotStatus {
  SLOT_STATUS_UNSPECIFIED = 0;
  SLOT_STATUS_ALIVE       = 1;
  SLOT_STATUS_DEAD        = 2;
  SLOT_STATUS_COMPLETE    = 3;
}

Set include_dead and include_complete yourself rather than relying on what happens when they are unset. There is no include_alive flag; alive updates are not opt-in.

StatusWhat aRPC observed
SLOT_STATUS_ALIVEIt has started seeing the slot
SLOT_STATUS_DEADIt saw the slot go dead / skipped
SLOT_STATUS_COMPLETEIt saw the slot's final entry

These are stream observations rather than Solana commitment levels; processed, confirmed and finalized don't appear anywhere in this API. Note that sequence doesn't sit at the same field number on every response. It is 8 on both transaction responses, 6 on SubscribeEntriesResponse, and 2 here.

Sequence numbers

Every v2 response carries sequence. On SubscribeTransactions, SubscribeBinaryTransactions and SubscribeSlots the server initialises it inside the stream handler and increments it immediately before emitting every message of every kind: transaction updates, pongs (including the 10-second heartbeat pong), validation results, slot updates. It is a contiguous per-stream message counter, so a gap is impossible and a discontinuity alert will only ever fire on your own bug.

  • SubscribeEntries is different. With a default request its counter increments normally, but as soon as any metadata_filter field is set away from its default, batches in which every entry passes the filter are emitted with sequence = 0, interleaved with heartbeats and partially-filtered batches that do increment. Treat the entry stream's sequence as unusable rather than as a counter.
  • It counts all messages, so it is not a transaction counter.
  • It is connection-local and starts again on every new stream. Four concurrent streams have four independent sequences.
  • It is not a cursor: this API has no replay or resume request.

What it is good for: ordering and correlating within one connection, and a stable per-message identifier in your logs. Reset your baseline on reconnect.

Handle errors

enum ErrorCode {
  ERROR_CODE_UNSPECIFIED        = 0;
  ERROR_CODE_UNKNOWN            = 1;
  ERROR_CODE_INVALID_FILTER     = 2;
  ERROR_CODE_FILTER_TOO_COMPLEX = 3;
  ERROR_CODE_RATE_LIMITED       = 4;
  ERROR_CODE_QUOTA_EXCEEDED     = 5;
  ERROR_CODE_INTERNAL           = 6;
  ERROR_CODE_UNAVAILABLE        = 7;
}

message Error {
  ErrorCode       code           = 1;
  string          message        = 2;
  optional uint32 retry_after_ms = 3;
}

These messages are defined in the proto, but the server doesn't currently emit an Error on any v2 stream; failures come through as gRPC trailers instead. Generate the types so your decoder is complete, but don't build logic that waits for an ErrorCode or a retry_after_ms. Failures reach you at three levels:

LevelSignalAction
FilterFilterValidationResult.accepted = falseFix that filter only; the stream and other filters keep running
FilterExpected result never arrivesThat entry carried an empty filter_id; fix the ID and resend
TransportUNAUTHENTICATED (16)Fix x-token; retrying the same credential fails identically
TransportPERMISSION_DENIED (7), detail starting PPS limit requirement not metThe PPS condition; the server closes the connection. See limits
TransportINVALID_ARGUMENT (3)Fix the request; do not retry unchanged
TransportRESOURCE_EXHAUSTED (8), reason filters or subscribe_rate_limitA filter cap was exceeded, or filter updates were sent too fast. See limits
TransportUNAVAILABLE (14), or an unexpected endReconnect and rebuild the subscription

For the full gRPC status and reason table, see Gateway & streaming errors.

Reconnect

v2 has no resume and no replay, so every new connection starts clean:

  1. Connect from an allowlisted egress IP, attaching x-token only if your endpoint uses one.
  2. Open each RPC you need; they are independent streams.
  3. Reset that stream's sequence baseline.
  4. Register your complete desired filter set in one RegisterTransactionFilters message.
  5. Wait for a FilterValidationResult per filter_id.
  6. Restore entry metadata filters and slot options; nothing is remembered server-side.
  7. Reconcile the disconnected interval through Solana RPC when completeness matters.

Use the server's 10-second heartbeat to size your liveness deadline. See Limits & operations.

On this page