A wallet monitor stops being simple when one transaction can match several customers, one customer can own several addresses, and a reconnect can replay an event already processed.

The answer is not one polling loop per address. Yellowstone gRPC carries the live path; resolve each transaction completely, store one canonical event, and attach every relevant wallet through a separate match record.

wallet-monitor-path.txt
wallet registry
    -> filter shards
    -> Yellowstone streams
    -> bounded queues
    -> transaction decoder
    -> canonical transaction + wallet-match edges
    -> notifications, balances, or strategy signals
                         ^
                         |
                 bounded reconciliation

Define what a wallet match means

"Watch this wallet" is not a complete requirement. A transaction can involve an address as:

  • Fee payer.
  • Transaction signer.
  • Writable account.
  • Read-only account.
  • Token-account owner discovered from state.
  • Authority referenced by an instruction.
  • Address loaded through an address lookup table.

Decide which roles the product exposes. A copy-trading product may care about signers and decoded swaps. A payment product may care about token-account owners and settlement. A risk monitor may care about any mention but label the role precisely.

Yellowstone's transaction filter can narrow traffic by accounts. In the upstream SubscribeRequestFilterTransactions, account_include, account_exclude, and account_required are separate fields. Inclusion is a transport filter. It is not proof that the included address signed or initiated the transaction.

Shard filters by measured load

Providers may cap accounts per filter, filters per request, streams per connection, message size, or replay depth. Do not embed one vendor's current limit into the architecture.

Start with a configurable shard size and keep stable wallet assignment. Stable shards make reconnect and incident traces easier to understand.

wallet-shards.ts
export type WalletShard = {
  id: string;
  wallets: readonly string[];
};
 
export function buildWalletShards(
  wallets: readonly string[],
  maxWalletsPerShard: number,
): WalletShard[] {
  if (!Number.isInteger(maxWalletsPerShard) || maxWalletsPerShard < 1) {
    throw new Error("maxWalletsPerShard must be a positive integer");
  }
 
  const unique = [...new Set(wallets)].sort();
  const shards: WalletShard[] = [];
 
  for (let start = 0; start < unique.length; start += maxWalletsPerShard) {
    const members = unique.slice(start, start + maxWalletsPerShard);
    shards.push({ id: `wallets-${start / maxWalletsPerShard}`, wallets: members });
  }
 
  return shards;
}

Sorted fixed-size chunks are sufficient for an initial deployment. High-volume wallets can later move to dedicated shards, but only after telemetry shows that one address dominates bytes or queue work.

For each shard, track:

  • Connected state and reconnect generation.
  • Last update receive time.
  • Highest observed slot by commitment.
  • Updates and bytes per second.
  • Queue depth and age of the oldest item.
  • Decode errors, duplicate observations, and dropped work.
  • Wallet count and the configuration version that produced the filter.

Wallet count alone is a weak capacity signal. Five hundred dormant addresses may be cheaper than a small group of active program or routing accounts.

Subscribe once, classify after receipt

One filter entry can carry the shard's wallet list:

wallet-subscription.ts
import { CommitmentLevel, type SubscribeRequest } from "@triton-one/yellowstone-grpc";
import type { WalletShard } from "./wallet-shards";
 
export function subscriptionFor(shard: WalletShard): SubscribeRequest {
  return {
    accounts: {},
    slots: {},
    transactions: {
      [shard.id]: {
        vote: false,
        failed: false,
        signature: undefined,
        accountInclude: [...shard.wallets],
        accountExclude: [],
        accountRequired: [],
      },
    },
    transactionsStatus: {},
    blocks: {},
    blocksMeta: {},
    entry: {},
    accountsDataSlice: [],
    commitment: CommitmentLevel.PROCESSED,
    ping: undefined,
  };
}

The upstream Yellowstone gRPC README documents the subscription client, filter model, pings, and replay-related interfaces. Confirm the generated client version and the provider's supported schema before deploying copied code.

processed gives the live path an early view. It also means the application needs a policy for commitment upgrades and fork reconciliation. Products that can wait for confirmed or finalized state should choose that commitment deliberately instead of inheriting the example.

Resolve the complete account list

Versioned Solana transactions can load addresses from lookup tables. Matching only static message keys can miss a monitored wallet or assign the wrong index to an instruction.

Build the effective key list in runtime order:

effective-account-keys.txt
static message keys
    + loaded writable addresses
    + loaded read-only addresses

Then derive roles from the message header and instruction account indices. Do not label every included wallet as a signer. The first static account is generally the fee payer, while the message header defines how many leading static keys are required signers. Loaded addresses are not transaction signers.

For token transfers, the token account in the transaction may differ from the customer wallet stored in your registry. Resolve and cache token-account ownership from account state, invalidate the cache when ownership or account lifecycle changes, and retain the evidence used for the match.

Store transactions once and wallet matches separately

A swap between two monitored wallets should create one transaction and two wallet relationships, not two copies of the transaction.

Model two durable records:

wallet-monitor-records.ts
type CanonicalTransaction = {
  signature: string;
  slot: bigint;
  commitment: "processed" | "confirmed" | "finalized";
  transactionBytes: Uint8Array;
  executionError: string | null;
};
 
type WalletMatch = {
  signature: string;
  wallet: string;
  role: "fee_payer" | "signer" | "writable" | "readonly" | "token_owner";
  sourceShard: string;
};

Make the canonical transaction key the signature. Make the match key at least (signature, wallet, role). Instruction-level events should also include the outer and inner instruction positions in that event key.

Upserts should allow a later observation to strengthen commitment without repeating downstream actions. Put notification or strategy execution behind its own idempotency key.

Bound the queue before it becomes an outage

The stream reader should not perform database writes, remote metadata requests, and webhooks inside the receive callback. Decode the envelope, add it to a bounded queue, and return to reading.

Backpressure policy must be explicit:

  1. Alert on queue age before memory pressure becomes critical.
  2. Slow or pause optional enrichments first.
  3. Preserve the raw observation or a recoverable cursor when possible.
  4. Reconnect only when the client or transport requires it, not as a queue-clearing strategy.
  5. Mark the uncertain slot interval for reconciliation if any update may have been lost.

Scaling consumers only helps when work can be partitioned without violating order. Route the same signature to one partition so duplicate observations serialize against the same idempotency key.

Our Yellowstone bandwidth guide shows how update size and rate turn into network cost. Record bytes after transport decoding as well as the size of stored and enriched records. All three affect the capacity plan.

Reconnect by shard and reconcile the uncertain interval

A reconnect creates a new observation generation. It does not create a new business event.

Persist per-shard state with the last observed slot, last processed signature, reconnect generation, disconnect time, and configuration version. Request replay from the earliest uncertain slot where the provider supports it. Deduplicate everything the replay returns.

RPC remains useful for repair. Solana's official getSignaturesForAddress returns confirmed signatures for an address in reverse chronological order. Apply it to a bounded wallet and time or slot interval, then fetch and compare the missing transactions.

Solana's payment indexing guide describes address-signature lookup followed by transaction retrieval for indexing payment activity. That is a reasonable repair primitive. Running it continuously for every wallet turns the live architecture back into a polling fleet.

Reconciliation should produce an auditable result:

  • Expected signatures in the interval.
  • Signatures already stored.
  • Missing records recovered.
  • Stored records invalidated or updated after stronger commitment.
  • Duplicate side effects prevented.
  • Any interval that remains unresolved.

Our production reconnect and replay guide covers the transport loop in more detail.

Protect the wallet registry

Solana addresses are public. The mapping between an address, a customer, notification preferences, and internal risk labels is not.

Provider filter logs should omit customer identifiers. Restrict registry access, encrypt secrets, and apply retention limits to raw payloads and enriched activity. Customer removal of an address must propagate the configuration change to streams, caches, backfill jobs, and notification routes.

Version the registry. Every stream observation should be attributable to the shard configuration that admitted it, especially when a wallet moves between shards during a rebalance.

Use the Wallet Stream Capacity Sheet

Fill one row per shard under a production-shaped load:

MeasureObserved valueAcceptance limitAction when exceeded
Wallets and active walletsSplit or isolate a hot wallet
Updates per second, p50 and peakAdd consumer capacity or reduce scope
Inbound bytes per secondReview filters, region, and plan
Oldest queue item agePause enrichment or add workers
Decode error rateQuarantine payload and page owner
Duplicate observation rateInspect reconnect or overlapping shards
Stream-to-store delayFind transport, queue, or database bottleneck
Reconciliation durationReduce interval or increase repair capacity
Missing records after repairKeep shard degraded and investigate

Run the sheet at average and burst traffic. Repeat after adding a new wallet cohort, changing a filter, moving regions, or altering decoded payloads. The right shard size is the one that stays inside the application's budgets with room for reconnect and repair.

To test this design against RPCEdge Yellowstone streams, choose an RPCEdge plan and bring a representative wallet set, expected activity mix, and acceptance sheet.

Frequently asked questions

How many Solana wallets can one Yellowstone gRPC stream monitor?
There is no provider-independent number. The usable count depends on filter limits, wallet activity, transaction overlap, selected commitment, update size, regional bandwidth, and consumer speed. Treat 500-plus wallets as an architecture scenario and measure the actual workload.
Does accountInclude mean a monitored wallet signed the transaction?
No. It means the transaction mentions at least one included account. Resolve the full account-key list and inspect the message header to determine signer and fee-payer roles.
Should I store one copy of a transaction for every matched wallet?
Store the canonical transaction once by signature, then store a separate edge for each matched wallet and role. This supports one transaction that mentions several monitored wallets without duplicating the payload.
How should a wallet monitor handle Yellowstone reconnects?
Give every shard a reconnect generation and durable progress record, request replay where the provider supports it, deduplicate before side effects, and reconcile the uncertain interval with bounded RPC backfill.
Is getSignaturesForAddress enough for production wallet monitoring?
It is useful for bounded reconciliation and lower-volume indexing. At larger scale, a polling loop per wallet increases request volume and can delay detection, so streaming should carry the live path while RPC repairs known gaps.