Corvus Labs

Legacy / v1 API

Reference for the legacy aRPC gRPC API: one bidirectional Subscribe RPC streaming decoded Solana transactions and observed slots.

The legacy API is one bidirectional RPC. Your transaction filters go up the same stream that decoded transactions, observed slots and ping responses come back down.

Proto: Download service.proto (package arpc, service ARPCService). You'll see this same shape called "v1" in some integrations.

service ARPCService {
  // Subscribe to decoded transactions
  rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse) {}
}

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; if your dedicated deployment is token-authenticated, attach the token as request metadata:

x-token: YOUR_API_TOKEN

Decoded transactions and slot numbers are the whole surface here. v2 adds raw transaction bytes, an entry stream, a dedicated slot stream with status, per-filter register/unregister, acknowledged filter validation, resolved lookup-table addresses and a sequence counter.

Request messages

message SubscribeRequest {
  map<string, SubscribeRequestFilterTransactions> transactions   = 1;
  optional int32                                  ping_id        = 2;
  optional bool                                   new_seen_slots = 3;
}

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

Watch the field numbers on the filter message: they start at 2, not at 1. This is the detail hand-rolled encoders get wrong.

Each map key is a filter name you choose; the values are Base58 Solana public keys.

stream.write({
  transactions: {
    'token-program': {
      account_include: [
        'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
      ],
      account_exclude: [],
      account_required: [],
    },
  },
  new_seen_slots: true,
});

Matching rules

Inside one named filter, all three conditions apply together:

FieldMatch rule
account_includeAt least one listed account must be present
account_requiredEvery listed account must be present
account_excludeNo listed account may be present
  • No include and no required accounts → broad match, minus anything excluded.
  • An entirely empty filter → every transaction.
  • When a transaction matches several named filters, SubscribeResponse.filters lists every matching name.

Updating filters

Every update carries the complete map you want, not a patch: each non-empty map replaces the previous one wholesale. An empty map won't clear anything either: it reads as "no change", and every previous filter stays active. If you want to end up with no filters at all, you need a new stream.

There is no unregister message here and no FilterValidationResult, so nothing ever acknowledges a filter change. An invalid Base58 key comes back as gRPC INVALID_ARGUMENT (3) and ends the stream, so validate keys on your side first.

Push past 50 filter-update messages per second and the stream is rejected outright with gRPC RESOURCE_EXHAUSTED (8), reason subscribe_rate_limit; see limits.

new_seen_slots is independent of the map, and leaving it out keeps whatever it was set to.

Response messages

message SubscribeResponse {
  google.protobuf.Timestamp             created_at  = 1;
  repeated string                       filters     = 2;
  optional SubscribeResponseTransaction transaction = 3;
  optional int32                        ping_id     = 4;
  optional uint64                       new_slot    = 5;
}

transaction, ping_id and new_slot are optional fields rather than a oneof, so you branch on whichever one is actually present in a given response.

message SubscribeResponseTransaction {
  uint64 slot                           = 1;
  uint32 num_required_signatures        = 2;
  uint32 num_readonly_signed_accounts   = 3;
  uint32 num_readonly_unsigned_accounts = 4;
  bytes  recent_blockhash               = 5;

  repeated bytes                     signatures            = 6;
  repeated bytes                     account_keys          = 7;
  repeated CompiledInstruction       instructions          = 8;
  repeated MessageAddressTableLookup address_table_lookups = 9;
  google.protobuf.BoolValue          versioned             = 10;
}

message MessageAddressTableLookup {
  bytes account_key      = 1;
  bytes writable_indexes = 2;
  bytes readonly_indexes = 3;
}

message CompiledInstruction {
  uint32 program_id_index = 1;
  bytes  accounts         = 2;
  bytes  data             = 3;
}
  • There is no transaction.signature field. Your primary signature is signatures[0].
  • Signatures, account_keys, recent_blockhash and the lookup-table fields are all raw bytes, not Base58 strings.
  • address_table_lookups carries the table and the indexes, not the resolved addresses. v2's loaded_writable_addresses / loaded_readonly_addresses carry the resolved ones.
  • versioned is a BoolValue wrapper, so it can be absent as well as true or false.
  • What you have is a decode of observed network data, not an execution receipt: no fee, no logs, no status.
stream.on('data', (response) => {
  if (!response.transaction) return;

  const tx = response.transaction;
  console.log({
    slot: tx.slot,
    matchedFilters: response.filters,
    primarySignature: tx.signatures?.[0]?.toString('hex'),
  });
});

Observe slots

Set new_seen_slots: true and the same stream delivers new_slot values.

if (response.new_slot !== undefined) {
  console.log('slot observed', response.new_slot);
}

new_slot is a slot aRPC has started seeing, and that is all it tells you: no status, no commitment, and no legacy equivalent of v2's SLOT_STATUS_DEAD / SLOT_STATUS_COMPLETE.

Ping

stream.write({ ping_id: 42 });
if (response.ping_id === 42) {
  console.log('pong');
}

Not every response ping_id corresponds to a ping you sent: the server also emits an unsolicited heartbeat every 10 seconds on this stream, a SubscribeResponse whose only populated field is ping_id = 0. Match by value when you care which pong you have. See limits.

Errors and reconnects

SignalMeaningAction
UNAUTHENTICATED (16)A token endpoint rejected x-tokenFix the credential; retrying unchanged fails identically
PERMISSION_DENIED (7), detail starting PPS limit requirement not metThe PPS condition on the streaming endpoint; the server then shuts the connection downSee limits; reconnecting alone does not clear it
INVALID_ARGUMENT (3)Invalid public key in a filterFix the filter, then open a new stream
RPC fails immediately after connectEgress IP is not allowlistedRegister the real public egress address

There is no resume and no replay, so every new stream starts from nothing: connect from an allowlisted IP, re-attach x-token if you use one, send the complete filter map again, restore new_seen_slots, recreate your ping timers, and reconcile the gap through Solana RPC if completeness matters.

Pre-execution data

Transactions on this stream haven't executed yet. Confirm by signature through Solana RPC before you do anything irreversible.

On this page