# aRPC v2 (/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`.

```proto
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_id`s 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](/arpc/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`:

* [v2 service proto](/proto/arpc/v2/service.proto)
* [shared types proto](/proto/arpc/common/types.proto)

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

Use `proto/` as the include root:

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

```proto
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;
}
```

<Callout type="warn" title="Batch filter changes">
  Send your complete filter change as **one** `RegisterTransactionFilters` message and match the `FilterValidationResult`s 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](/arpc/limits#filter-update-rate).
</Callout>

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

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

```proto
message FilterValidationResult {
  string          filter_id        = 1;
  bool            accepted         = 2;
  optional string rejection_reason = 3;
}
```

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

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

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

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

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

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

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

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

<Callout type="warn" title="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.
</Callout>

## Stream slot status

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

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

```proto
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](/arpc/limits#the-pps-check)           |
| 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](/arpc/limits#filter-size) |
| Transport | `UNAVAILABLE` (14), or an unexpected end                                 | Reconnect and rebuild the subscription                                                                  |

For the full gRPC status and reason table, see [Gateway & streaming errors](/solana-rpc/errors#grpc-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](/arpc/limits).
