Corvus Labs

aRPC quick start

Open a pre-execution Solana transaction stream over aRPC gRPC from Node.js, with account filters.

This gets you a working aRPC transaction stream on the legacy Subscribe RPC. If you end up on v2 instead, the endpoint, port and access rules are all the same.

You needWhere it comes from
Your assigned regionDashboard; see all nine endpoints
Your public egress IP registered on the allowlistDashboard
An x-token, only if your dedicated deployment has oneOnboarding
Node.js 20 or newer
export ARPC_ENDPOINT='http://arpc.fra.corvus-labs.io:20202'

# Only for a token-authenticated endpoint.
export ARPC_TOKEN=''

Download the proto

mkdir -p proto/arpc

curl -fsSLo proto/arpc/service.proto \
  https://docs.corvus-labs.io/proto/arpc/legacy/service.proto

For v2 you want both files, and the import paths have to survive the download, because service.proto imports arpc/common/types.proto:

mkdir -p proto/arpc/v2 proto/arpc/common

curl -fsSLo proto/arpc/v2/service.proto \
  https://docs.corvus-labs.io/proto/arpc/v2/service.proto

curl -fsSLo proto/arpc/common/types.proto \
  https://docs.corvus-labs.io/proto/arpc/common/types.proto

Install a gRPC client

npm install @grpc/grpc-js @grpc/proto-loader

The example loads the proto at runtime, so there's no code-generation step.

Subscribe to transactions

Create subscribe.js:

const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const endpoint = process.env.ARPC_ENDPOINT;
const token = process.env.ARPC_TOKEN;

if (!endpoint) {
  throw new Error('Set ARPC_ENDPOINT');
}

// grpc-js dials a bare host:port target, so take the host out of the URL.
const { host } = new URL(endpoint);

const definition = protoLoader.loadSync('./proto/arpc/service.proto', {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: false,
  oneofs: true,
});
const arpc = grpc.loadPackageDefinition(definition).arpc;

// aRPC always takes insecure channel credentials.
const client = new arpc.ARPCService(host, grpc.credentials.createInsecure());

const metadata = new grpc.Metadata();
if (token) {
  metadata.set('x-token', token);
}

const stream = client.Subscribe(metadata);

stream.on('data', (response) => {
  if (response.transaction) {
    const tx = response.transaction;
    console.log('transaction', {
      slot: tx.slot,
      matchedFilters: response.filters,
      // There is no singular `signature` field: the primary signature
      // is the first element of `signatures`.
      signature: tx.signatures?.[0]?.toString('hex'),
    });
  }

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

  if (response.ping_id !== undefined) {
    console.log('pong', response.ping_id);
  }
});

stream.on('error', (error) => {
  console.error('stream error', error.code, error.details);
});

stream.on('end', () => {
  console.log('stream ended');
});

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

Run it:

node subscribe.js

token-program is a name you picked, and response.filters echoes back every one of your filters that a transaction matched.

Verify the stream

SymptomCauseFix
No messages at all, no errorThe filter matches nothingWiden it, or subscribe to a busy program key like the one above
Immediate UNAUTHENTICATED (16)Token endpoint, missing or wrong x-tokenCorrect the credential; do not retry the same one
RPC fails right after connectingEgress IP not on the allowlistRegister the address traffic actually leaves from
INVALID_ARGUMENT (3), stream endsAn invalid Base58 key in the filterFix the key and open a new stream; legacy has no filter-validation message
PERMISSION_DENIED (7) with detail PPS limit requirement not metPlan RPS + TPS below 500; the server then closes the connectionSee limits

Reconnect

Neither API replays anything, so a reconnect is a fresh subscription:

  1. Reconnect from an allowlisted egress IP, re-attaching x-token only if your endpoint uses one.
  2. Send the complete filter map again, plus new_seen_slots; nothing is remembered for you.
  3. On v2, re-register every filter_id and wait for its FilterValidationResult; the new stream's sequence starts over.
  4. Reconcile anything you missed through Solana RPC.

For liveness you have a real number rather than a guess: the server heartbeats every 10 seconds, on both APIs. That's why the example above prints pong 0 every ten seconds without you ever writing a ping: the legacy heartbeat is a SubscribeResponse carrying only ping_id = 0. Tear the stream down yourself once nothing has arrived for a comfortable multiple of that.

On v2, sequence is a contiguous per-stream counter rather than a loss detector; limits covers it, including the one entry-stream exception.

Pre-execution data

A transaction on this stream hasn't executed yet. Confirm it by signature through Solana RPC before you treat it as done.

Next

On this page