This is the code-first companion to Copy-Trading on Solana: The Infrastructure Behind It. That guide explains the detect, decide, and send architecture. This one builds the detection worker and the state machine around it.

A production bot still needs a program-specific decoder, quote and route construction, signing, secure key management, and a transaction sender. Those boundaries stay explicit below. The code does not pretend that a generic transaction can be converted safely into a mirror trade.

The smallest safe architecture

Keep the first version in five parts:

  1. Watcher: one Yellowstone gRPC stream filtered to a target wallet.
  2. Decoder: a strict adapter for each supported swap program.
  3. Policy: allowlists, sizing, liquidity, slippage, and exposure limits.
  4. Executor: builds, simulates where appropriate, signs, and submits your transaction.
  5. Reconciler: verifies target and mirror outcomes and repairs uncertain state.
copy-trading-pipeline.txt
Yellowstone update -> verify role -> decode swap -> risk policy -> submit mirror
        |                                                       |
        +------------ durable idempotency record ---------------+
                                |
                      confirmed/finalized RPC

Do not put all five jobs inside one stream callback. A burst of updates can create unbounded async work, memory growth, and duplicate submissions. The callback should normalize the event and hand it to a bounded queue or durable worker.

Install the client

The examples use Triton One's maintained Node client and bs58 to render Solana signatures:

terminal
npm install @triton-one/yellowstone-grpc bs58

Yellowstone is a bidirectional gRPC subscription built on Solana's Geyser interface. Its official repository documents transaction filters for included, excluded, and required accounts, plus the processed, confirmed, and finalized commitment levels. See the Yellowstone repository and the subscription protobuf for the wire contract.

Store the endpoint, API key, and target wallet outside source control:

.env.example
YELLOWSTONE_ENDPOINT=https://grpc.rpcedge.com:443
YELLOWSTONE_API_KEY=YOUR_UUID_KEY
TARGET_WALLET=TARGET_BASE58_PUBKEY

Subscribe to transactions that mention the wallet

The following worker opens one stream and writes one named transaction filter. accountInclude matches a transaction when any listed account is present. It does not prove that the wallet signed the transaction or initiated a swap. That verification belongs in the decoder.

watch-wallet.ts
import Client, { CommitmentLevel } from "@triton-one/yellowstone-grpc";
import bs58 from "bs58";
 
const endpoint = required("YELLOWSTONE_ENDPOINT");
const apiKey = required("YELLOWSTONE_API_KEY");
const targetWallet = required("TARGET_WALLET");
 
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}
 
const request = {
  accounts: {},
  slots: {},
  transactions: {
    watchedWallet: {
      vote: false,
      failed: false,
      signature: undefined,
      accountInclude: [targetWallet],
      accountExclude: [],
      accountRequired: [],
    },
  },
  transactionsStatus: {},
  blocks: {},
  blocksMeta: {},
  entry: {},
  accountsDataSlice: [],
  commitment: CommitmentLevel.PROCESSED,
};
 
type Candidate = {
  targetSignature: string;
  observedSlot: bigint;
  update: unknown;
};
 
async function subscribeOnce(onCandidate: (item: Candidate) => Promise<void>) {
  const client = new Client(endpoint, apiKey, undefined);
  const stream = await client.subscribe();
 
  let queue = Promise.resolve();
 
  stream.on("data", (update) => {
    const tx = update.transaction?.transaction;
    if (!tx?.signature) return;
 
    const item: Candidate = {
      targetSignature: bs58.encode(Buffer.from(tx.signature)),
      observedSlot: BigInt(update.transaction.slot),
      update,
    };
 
    queue = queue
      .then(() => onCandidate(item))
      .catch((error) => console.error("candidate failed", error));
  });
 
  await new Promise<void>((resolve, reject) => {
    stream.on("error", reject);
    stream.on("end", resolve);
    stream.on("close", resolve);
    stream.write(request, (error: Error | null | undefined) => {
      if (error) reject(error);
    });
  });
}

The promise chain is the simplest bounded behavior for a tutorial because it processes one update at a time. A production system should use a queue with an explicit capacity, processing deadline, and overflow policy. If the queue age rises, stop opening risk rather than acting on stale signals.

For more filter shapes, see the Yellowstone gRPC documentation and Yellowstone gRPC vs standard RPC.

Decode intent, not account presence alone

An included wallet may be a signer, fee payer, token-account authority, lookup-table account, or merely an account referenced by an instruction. A safe decoder needs to prove the role you care about and recognize the exact program instruction.

Define a narrow output contract:

decode.ts
type SwapIntent = {
  programId: string;
  inputMint: string;
  outputMint: string;
  inputAmount: bigint;
  minimumOutput: bigint | null;
};
 
function decodeTargetSwap(update: unknown, targetWallet: string): SwapIntent | null {
  // 1. Read static and address-lookup-table account keys.
  // 2. Verify targetWallet has the signer or authority role your policy requires.
  // 3. Find an instruction for one allowlisted swap program.
  // 4. Decode its discriminant and accounts with that program's current layout.
  // 5. Reject transfers, liquidity changes, unknown versions, and partial decodes.
  return null;
}

Returning null is a normal outcome. Most transactions that mention a watched wallet should not be mirrored. Maintain one decoder per program and test it against captured transactions, program upgrades, versioned transactions, address lookup tables, and failed executions.

Never infer a swap only from token balance changes. Fees, wrapped SOL, account creation, routed swaps, and unrelated transfers can produce similar balance patterns.

Put policy between detection and execution

The target wallet's action is an input, not your risk policy. Before building anything, require:

  • An allowlisted program, input mint, and output mint.
  • A maximum notional and maximum open exposure per token.
  • A minimum liquidity or route-quality threshold.
  • A price-impact and slippage ceiling based on a fresh quote.
  • A cool-down or duplicate-position rule.
  • A kill switch for stale blockhashes, stale quotes, queue lag, or reconciliation failure.
policy.ts
type Decision =
  | { action: "skip"; reason: string }
  | { action: "mirror"; inputAmount: bigint; maxSlippageBps: number };
 
function decide(intent: SwapIntent, portfolio: PortfolioState): Decision {
  if (!portfolio.allowedPrograms.has(intent.programId)) {
    return { action: "skip", reason: "program_not_allowed" };
  }
  if (!portfolio.allowedMints.has(intent.outputMint)) {
    return { action: "skip", reason: "mint_not_allowed" };
  }
  if (portfolio.hasPendingPosition(intent.outputMint)) {
    return { action: "skip", reason: "position_pending" };
  }
 
  return {
    action: "mirror",
    inputAmount: min(intent.inputAmount / 10n, portfolio.maxInputAmount),
    maxSlippageBps: portfolio.maxSlippageBps,
  };
}
 
function min(a: bigint, b: bigint) {
  return a < b ? a : b;
}

Paper trade this policy against recorded stream data before it can sign transactions. Copy-trading does not remove strategy risk. It adds dependency on another wallet whose intent, inventory, and risk tolerance you do not know.

Make each target signature idempotent

Use the target signature as a durable key. A reconnect, retry, or process restart must find the same record instead of opening a second trade.

candidate-worker.ts
async function handleCandidate(candidate: Candidate) {
  const claimed = await store.claim(candidate.targetSignature, candidate.observedSlot);
  if (!claimed) return;
 
  const intent = decodeTargetSwap(candidate.update, targetWallet);
  if (!intent) return store.skip(candidate.targetSignature, "not_supported_swap");
 
  const decision = decide(intent, await portfolio.read());
  if (decision.action === "skip") {
    return store.skip(candidate.targetSignature, decision.reason);
  }
 
  try {
    const mirrorSignature = await executor.buildSignAndSubmit(intent, decision);
    await store.submitted(candidate.targetSignature, mirrorSignature);
    await reconciliation.enqueue(candidate.targetSignature, mirrorSignature);
  } catch (error) {
    await store.uncertain(candidate.targetSignature, String(error));
  }
}

An executor timeout is uncertain, not failed. The sender may have accepted the bytes before the response was lost. Query the signature before rebuilding or resubmitting. If you cannot derive or persist the mirror signature before submission, fix that contract first.

Test a wallet-filtered Yellowstone stream

Compare your current detection path with RPCEdge using the same wallet set, commitment, decoder, and measurement window.

View plans and pricing →

Reconnect without turning gaps into trades

Streams end. Networks reset. Deployments happen. Reconnect with capped exponential backoff and jitter, then resend the complete subscription request.

run.ts
let attempt = 0;
 
for (;;) {
  try {
    await subscribeOnce(handleCandidate);
    attempt = 0;
  } catch (error) {
    console.error("stream disconnected", error);
  }
 
  const capMs = 30_000;
  const baseMs = Math.min(capMs, 500 * 2 ** Math.min(attempt++, 6));
  const jitterMs = Math.floor(Math.random() * 250);
  await new Promise((resolve) => setTimeout(resolve, baseMs + jitterMs));
}

Track the last observed slot and the disconnect interval. A resumed live stream does not prove you received everything that happened during the gap. Depending on your provider and retention policy, you may be able to request replay from a prior slot. Otherwise, query canonical RPC history for the watched wallet and mark recovered transactions as late. Do not submit time-sensitive mirror trades from a catch-up scan.

The official Yellowstone documentation also describes subscription pings for intermediaries that close idle connections. Follow your provider's client and keepalive contract rather than inventing application messages.

Reconcile target and mirror transactions

At processed commitment, a transaction has been observed by a node but is not final. Your ledger needs separate states for detected, submitted, confirmed, finalized, failed on-chain, expired, and unknown.

Use getSignatureStatuses to check batches of signatures. Fetch getTransaction when you need the canonical transaction metadata for accounting or decoder verification. Solana documents both in the official RPC reference:

The last valid block height returned with a recent blockhash gives the executor an explicit expiry boundary. Do not label a transaction expired from wall-clock time alone.

Reconciliation should answer four independent questions:

QuestionEvidence
Did the target transaction survive?Target signature status at confirmed or finalized
Did the mirror reach the network?Mirror signature status, not only sender acknowledgement
Did the mirror execute successfully?Status error and transaction metadata
What position do we hold?Canonical token accounts and balances after settlement

If the target disappears but the mirror lands, the bot owns an unmatched position. That is a risk event with a predefined response, not an edge case to handle later.

Failure modes to test before funding

FailureRequired behavior
Same update delivered twiceOne durable claim, one possible submission
Stream disconnects for several slotsRecord gap, recover for accounting, do not copy stale trades
Unknown program versionReject and alert
Wallet mentioned but not the required actorReject
Decoder succeeds but quote is staleReject before signing
Sender times out after accepting bytesMark uncertain and reconcile signature
Target drops while mirror landsTrigger unmatched-position policy
Mirror lands but confirmation worker restartsResume from durable submitted state
Queue grows beyond the action deadlineShed work and stop new exposure
RPC and stream disagree temporarilyPreserve both observations and wait for commitment policy

Add metrics for stream reconnects, last observed slot, queue age, decode rejection reasons, candidate-to-submit count, uncertain submissions, confirmation duration, and unmatched positions. For a broader measurement model, see Observability for a Solana Trading Stack.

Production checklist

  • Keep API keys and signing keys separate. The stream worker should not hold signing material.
  • Verify the watched wallet's role, not only its presence in the account list.
  • Version every decoder and retain the raw event needed to reproduce a decision.
  • Use a durable idempotency key before any external side effect.
  • Bound queue depth, quote age, blockhash validity, slippage, size, and total exposure.
  • Treat sender timeouts as uncertain until signature reconciliation resolves them.
  • Reconcile both target and mirror transactions at a stronger commitment.
  • Test reconnects, duplicates, forks, program upgrades, and partial outages deliberately.
  • Start in record-only mode, then paper mode, before enabling signing.

The takeaway

A Yellowstone subscription is the cleanest part of a copy-trading bot. The difficult work is proving what the watched wallet did, deciding whether it fits your own risk, ensuring one signal can cause at most one submission, and repairing state after uncertainty.

Start with one wallet, one allowlisted swap program, one strict decoder, and one durable state machine. Use processed updates for detection only when your risk model allows it. Use confirmed or finalized RPC evidence for reconciliation. That separation keeps the bot understandable when the network, provider, program, or your own process behaves differently from the happy path.

Frequently asked questions

How do I monitor a Solana wallet in real time?
Open a Yellowstone gRPC transaction subscription with the wallet in accountInclude. The server then sends transactions that mention that account. Exclude vote and failed transactions, keep the filter narrow, and decode only the programs your application supports.
Should a copy-trading bot use processed or confirmed commitment?
Processed is useful for early detection, but it is not final. Treat it as a candidate signal, cap exposure, and reconcile the target and mirror signatures at confirmed or finalized commitment. Use confirmed directly if your strategy values stronger state over earlier notification.
Does accountInclude prove that the watched wallet made the trade?
No. It only proves that the transaction mentions the wallet. Your decoder must verify that the wallet is a signer or otherwise has the exact role your policy requires, identify the supported swap instruction, and reject unrelated transfers or account maintenance.
How do I prevent a reconnect from copying the same transaction twice?
Use the target signature as an idempotency key in durable storage. Record a candidate before submitting a mirror transaction, update the record with the mirror signature, and make every retry read that record before acting.
Can Yellowstone gRPC guarantee that a copied trade is profitable?
No. Yellowstone provides a filtered stream, not a trading outcome. Profitability depends on the target, decoder correctness, market movement, fees, slippage, execution, and risk controls. Backtest and paper trade before using capital.