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) {}
}| RPC | Shape | You get |
|---|---|---|
SubscribeEntries | Request → server stream | Serialized entries, entry batches, optional heartbeats |
SubscribeTransactions | Bidirectional | Decoded transactions plus the filter_ids that matched |
SubscribeBinaryTransactions | Bidirectional | Exact transaction wire bytes and the raw primary signature |
SubscribeSlots | Request → server stream | Slot 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.protoUse proto/ as the include root:
protoc -I proto \
--descriptor_set_out=arpc-v2.pb \
proto/arpc/common/types.proto \
proto/arpc/v2/service.protoControl 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: [],
},
},
],
},
});| Field | Match rule |
|---|---|
account_include | At least one listed key must be present |
account_required | Every listed key must be present |
account_exclude | No 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;
}signatureis the raw primary signature.transactionis the exact serialized Solana transaction.- Field numbering starts at 2; there is no field 1.
- There are no
filter_idshere. 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.
| Status | What aRPC observed |
|---|---|
SLOT_STATUS_ALIVE | It has started seeing the slot |
SLOT_STATUS_DEAD | It saw the slot go dead / skipped |
SLOT_STATUS_COMPLETE | It 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.
SubscribeEntriesis different. With a default request its counter increments normally, but as soon as anymetadata_filterfield is set away from its default, batches in which every entry passes the filter are emitted withsequence = 0, interleaved with heartbeats and partially-filtered batches that do increment. Treat the entry stream'ssequenceas 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:
| Level | Signal | Action |
|---|---|---|
| Filter | FilterValidationResult.accepted = false | Fix that filter only; the stream and other filters keep running |
| Filter | Expected result never arrives | That entry carried an empty filter_id; fix the ID and resend |
| Transport | UNAUTHENTICATED (16) | Fix x-token; retrying the same credential fails identically |
| Transport | PERMISSION_DENIED (7), detail starting PPS limit requirement not met | The PPS condition; the server closes the connection. See limits |
| Transport | INVALID_ARGUMENT (3) | Fix the request; do not retry unchanged |
| Transport | RESOURCE_EXHAUSTED (8), reason filters or subscribe_rate_limit | A filter cap was exceeded, or filter updates were sent too fast. See limits |
| Transport | UNAVAILABLE (14), or an unexpected end | Reconnect 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:
- Connect from an allowlisted egress IP, attaching
x-tokenonly if your endpoint uses one. - Open each RPC you need; they are independent streams.
- Reset that stream's
sequencebaseline. - Register your complete desired filter set in one
RegisterTransactionFiltersmessage. - Wait for a
FilterValidationResultperfilter_id. - Restore entry metadata filters and slot options; nothing is remembered server-side.
- 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.