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:
- Watcher: one Yellowstone gRPC stream filtered to a target wallet.
- Decoder: a strict adapter for each supported swap program.
- Policy: allowlists, sizing, liquidity, slippage, and exposure limits.
- Executor: builds, simulates where appropriate, signs, and submits your transaction.
- Reconciler: verifies target and mirror outcomes and repairs uncertain state.
Yellowstone update -> verify role -> decode swap -> risk policy -> submit mirror
| |
+------------ durable idempotency record ---------------+
|
confirmed/finalized RPCDo 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:
npm install @triton-one/yellowstone-grpc bs58Yellowstone 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:
YELLOWSTONE_ENDPOINT=https://grpc.rpcedge.com:443
YELLOWSTONE_API_KEY=YOUR_UUID_KEY
TARGET_WALLET=TARGET_BASE58_PUBKEYSubscribe 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.
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:
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.
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.
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.
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.
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:
| Question | Evidence |
|---|---|
| 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
| Failure | Required behavior |
|---|---|
| Same update delivered twice | One durable claim, one possible submission |
| Stream disconnects for several slots | Record gap, recover for accounting, do not copy stale trades |
| Unknown program version | Reject and alert |
| Wallet mentioned but not the required actor | Reject |
| Decoder succeeds but quote is stale | Reject before signing |
| Sender times out after accepting bytes | Mark uncertain and reconcile signature |
| Target drops while mirror lands | Trigger unmatched-position policy |
| Mirror lands but confirmation worker restarts | Resume from durable submitted state |
| Queue grows beyond the action deadline | Shed work and stop new exposure |
| RPC and stream disagree temporarily | Preserve 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.