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 need | Where it comes from |
|---|---|
| Your assigned region | Dashboard; see all nine endpoints |
| Your public egress IP registered on the allowlist | Dashboard |
An x-token, only if your dedicated deployment has one | Onboarding |
| 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.protoFor 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.protoInstall a gRPC client
npm install @grpc/grpc-js @grpc/proto-loaderThe 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.jstoken-program is a name you picked, and response.filters echoes back every one of your filters that a transaction matched.
Verify the stream
| Symptom | Cause | Fix |
|---|---|---|
| No messages at all, no error | The filter matches nothing | Widen it, or subscribe to a busy program key like the one above |
Immediate UNAUTHENTICATED (16) | Token endpoint, missing or wrong x-token | Correct the credential; do not retry the same one |
| RPC fails right after connecting | Egress IP not on the allowlist | Register the address traffic actually leaves from |
INVALID_ARGUMENT (3), stream ends | An invalid Base58 key in the filter | Fix the key and open a new stream; legacy has no filter-validation message |
PERMISSION_DENIED (7) with detail PPS limit requirement not met | Plan RPS + TPS below 500; the server then closes the connection | See limits |
Reconnect
Neither API replays anything, so a reconnect is a fresh subscription:
- Reconnect from an allowlisted egress IP, re-attaching
x-tokenonly if your endpoint uses one. - Send the complete filter map again, plus
new_seen_slots; nothing is remembered for you. - On v2, re-register every
filter_idand wait for itsFilterValidationResult; the new stream'ssequencestarts over. - 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.