---
title: "Advanced: Manual Transactions, Direct RPC & Custom Plugins"
description: Manual transaction building with pipe composition, direct RPC client usage, RPC method reference, building custom plugins, and assembling domain-specific clients.
---

# Advanced: Manual Transactions, Direct RPC & Custom Plugins

This reference covers low-level patterns for when you need full control over the transaction lifecycle, direct RPC access, or want to build custom plugins and domain-specific clients.

For most use cases, prefer the plugin clients in [overview.md](overview.md) and [plugins.md](plugins.md) — with `transactionConfig: { version: 1 }` they build, budget, and send transaction v1 without any of the code below. Everything here uses `createTransactionMessage({ version: 1 })` (`@solana/kit` 8); `legacy` and `0` are historical formats you read, not ones you emit in new code.

---

## Contents

- [Manual Transaction Pipeline](#manual-transaction-pipeline)
- [Compute Budget](#compute-budget)
- [Signing](#signing)
- [Sending](#sending)
- [Complete Manual Example](#complete-manual-example)
- [Direct RPC Client](#direct-rpc-client)
- [Error Handling](#error-handling)
- [Building Custom Plugins](#building-custom-plugins)
- [Assembling Domain-Specific Clients](#assembling-domain-specific-clients)

## Manual Transaction Pipeline

### Transaction Flow

1. Create message → 2. Fee payer → 3. Lifetime → 4. Instructions → 5. Sign → 6. Send

### Pipe Composition

```ts
import {
  pipe, createTransactionMessage, setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstruction,
  fillTransactionMessageProvisoryResourceLimits,
  estimateResourceLimitsFactory, estimateAndSetResourceLimitsFactory,
} from '@solana/kit';

const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();

const draft = pipe(
  createTransactionMessage({ version: 1 }),
  m => setTransactionMessageFeePayerSigner(signer, m),
  m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
  m => appendTransactionMessageInstruction(instruction, m),
  fillTransactionMessageProvisoryResourceLimits,
);

const message = await estimateAndSetResourceLimitsFactory(
  estimateResourceLimitsFactory({ rpc }),
)(draft);
```

A v1 message allows 4096 bytes, no address lookup tables, and carries its whole compute budget in `message.config` rather than in ComputeBudget instructions. The plugin client does this step for you; on the manual path it is yours. See [transactions-v1.md](../transactions-v1.md).

### Fee Payer

```ts
// With signer (recommended) — enables signTransactionMessageWithSigners()
const msg = setTransactionMessageFeePayerSigner(signer, message);

// Address only — for multisig or when fee payer is a different party
const msg = setTransactionMessageFeePayer(feePayerAddress, message);
```

### Lifetime

```ts
// Blockhash
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
const msg = setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, message);

// Durable nonce — auto-adds AdvanceNonceAccount instruction
const msg = setTransactionMessageLifetimeUsingDurableNonce(nonceInfo, message);
```

### Instructions

```ts
// Append
const msg = appendTransactionMessageInstruction(instruction, message);
const msg = appendTransactionMessageInstructions([i1, i2, i3], message);

// Prepend (for compute budget)
const msg = prependTransactionMessageInstruction(computeBudgetIx, message);
```

### Creating Raw Instructions

```ts
import { AccountRole } from '@solana/instructions';

const instruction: Instruction = {
  programAddress: address('Token...'),
  accounts: [
    { address: source, role: AccountRole.WRITABLE_SIGNER },
    { address: dest, role: AccountRole.WRITABLE },
    { address: owner, role: AccountRole.READONLY_SIGNER },
  ],
  data: instructionData,
};
```

---

## Compute Budget

Estimate limits for production transactions. On v1 the budget lives in `message.config`; Kit measures both limits with one simulation.

### Setup Resource Limit Estimator

```ts
import {
  estimateResourceLimitsFactory,
  estimateAndSetResourceLimitsFactory,
  fillTransactionMessageProvisoryResourceLimits,
} from '@solana/kit';

const estimateAndSetLimits = estimateAndSetResourceLimitsFactory(
  estimateResourceLimitsFactory({ rpc })
);
```

### Full Pattern: Priority Fee + Limit Estimation + Blockhash Refresh

```ts
// 1. Build message with the priority fee (total lamports on v1) and provisory limits
let message = pipe(
  createTransactionMessage({ version: 1 }),
  m => setTransactionMessageFeePayerSigner(signer, m),
  m => setTransactionMessageLifetimeUsingBlockhash(blockhash, m),
  m => appendTransactionMessageInstruction(instruction, m),
  m => setTransactionMessagePriorityFeeLamports(lamports(5_000n), m),
  m => fillTransactionMessageProvisoryResourceLimits(m),
);

// 2. Estimate CU limit and loaded-accounts data size via one simulation
message = await estimateAndSetLimits(message);

// 3. REFRESH blockhash (simulation takes time, old one may expire)
const { value: freshBlockhash } = await rpc.getLatestBlockhash().send();
message = setTransactionMessageLifetimeUsingBlockhash(freshBlockhash, message);

// 4. Sign and send
await signAndSendTransactionMessageWithSigners(message);
```

The estimate has no margin and the data size is charged in 32 KiB pages — wrap the estimator to add headroom. See [Sizing the resource limits](../transactions-v1.md#sizing-the-resource-limits).

### Update Priority Fee Dynamically

```ts
import { setTransactionMessagePriorityFeeLamports, getTransactionMessagePriorityFeeLamports } from '@solana/kit';

const current = getTransactionMessagePriorityFeeLamports(message) ?? 0n;
const updated = setTransactionMessagePriorityFeeLamports(lamports(current === 0n ? 5_000n : current * 2n), message);
```

For legacy/v0 messages the equivalents are `@solana-program/compute-budget`'s `estimateAndUpdateProvisoryComputeUnitLimitFactory` and `updateOrAppendSetComputeUnitPriceInstruction`. See [programs/compute-budget.md](programs/compute-budget.md) for both paths.

---

## Signing

### With Embedded Signers (Recommended)

```ts
import { signTransactionMessageWithSigners } from '@solana/kit';

// Auto-discovers signers from fee payer + instruction accounts
const signed = await signTransactionMessageWithSigners(message);
```

### Sign and Send

```ts
import { signAndSendTransactionMessageWithSigners } from '@solana/kit';
const signature = await signAndSendTransactionMessageWithSigners(message);
```

### Manual: Compile + Sign Separately

```ts
import { compileTransaction, signTransaction, partiallySignTransaction } from '@solana/transactions';

const compiled = compileTransaction(message);
const signed = await signTransaction([keypair1, keypair2], compiled);

// Partial signing for multi-party flows
const partial = await partiallySignTransaction([keypair1], compiled);
```

---

## Sending

### Send and Confirm Factory

```ts
const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });
const signed = await signTransactionMessageWithSigners(message);

// Required type assertions before sending
assertIsTransactionWithBlockhashLifetime(signed);
assertIsTransactionWithinSizeLimit(signed);
await sendAndConfirm(signed, { commitment: 'confirmed' });
```

### Durable Nonce

```ts
const sendNonceTx = sendAndConfirmDurableNonceTransactionFactory({ rpc, rpcSubscriptions });
assertIsFullySignedTransaction(signed);
assertIsTransactionWithDurableNonceLifetime(signed);
assertIsTransactionWithinSizeLimit(signed);
await sendNonceTx(signed, { commitment: 'confirmed' });
```

### Utilities

```ts
import { getSignatureFromTransaction, getBase64EncodedWireTransaction } from '@solana/transactions';

const sig = getSignatureFromTransaction(signedTx);
const base64 = getBase64EncodedWireTransaction(signedTx);
```

---

## Complete Manual Example

```ts
import {
  pipe, createTransactionMessage, setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstruction,
  setTransactionMessagePriorityFeeLamports, fillTransactionMessageProvisoryResourceLimits,
  estimateResourceLimitsFactory, estimateAndSetResourceLimitsFactory,
  signTransactionMessageWithSigners, sendAndConfirmTransactionFactory,
  assertIsTransactionWithBlockhashLifetime, assertIsTransactionWithinSizeLimit, lamports,
} from '@solana/kit';

async function sendTx(rpc, rpcSubscriptions, signer, instruction) {
  const estimateAndSetLimits = estimateAndSetResourceLimitsFactory(
    estimateResourceLimitsFactory({ rpc })
  );

  const { value: simBlockhash } = await rpc.getLatestBlockhash().send();

  let message = pipe(
    createTransactionMessage({ version: 1 }),
    m => setTransactionMessageFeePayerSigner(signer, m),
    m => setTransactionMessageLifetimeUsingBlockhash(simBlockhash, m),
    m => appendTransactionMessageInstruction(instruction, m),
    m => setTransactionMessagePriorityFeeLamports(lamports(5_000n), m),
    m => fillTransactionMessageProvisoryResourceLimits(m),
  );

  message = await estimateAndSetLimits(message);

  // Refresh blockhash after estimation
  const { value: freshBlockhash } = await rpc.getLatestBlockhash().send();
  message = setTransactionMessageLifetimeUsingBlockhash(freshBlockhash, message);

  const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });
  const signed = await signTransactionMessageWithSigners(message);
  assertIsTransactionWithBlockhashLifetime(signed);
  assertIsTransactionWithinSizeLimit(signed); // 4096 bytes on v1
  await sendAndConfirm(signed, { commitment: 'confirmed' });
}
```

---

## Direct RPC Client

### Creating Clients

```ts
import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit';

const rpc = createSolanaRpc('https://api.devnet.solana.com');
const rpcSubs = createSolanaRpcSubscriptions('wss://api.devnet.solana.com');
```

### Custom Transport

```ts
const transport = createDefaultRpcTransport({
  url: 'https://my-rpc.example.com',
  headers: { 'Authorization': 'Bearer token' },
});
const rpc = createSolanaRpcFromTransport(transport);
```

### Making Calls

```ts
// All methods return pending request — call .send()
const { value: balance } = await rpc.getBalance(address).send();

// With abort
const controller = new AbortController();
await rpc.getBalance(address).send({ abortSignal: controller.signal });
```

### Return Types

Most methods return `{ value: T }`:
```ts
const { value: balance } = await rpc.getBalance(address).send();
const { value: blockhash } = await rpc.getLatestBlockhash().send();
```

Some return `T` directly:
```ts
const rentExempt = await rpc.getMinimumBalanceForRentExemption(80n).send();
const slot = await rpc.getSlot().send();
```

### Subscriptions

```ts
const sub = await rpcSubs.accountNotifications(address, {
  encoding: 'base64',
  commitment: 'confirmed',
}).subscribe();

for await (const notif of sub) {
  console.log('Changed:', notif);
}
```

### Commitment Levels

```ts
type Commitment = 'processed' | 'confirmed' | 'finalized';
// processed: seen by node
// confirmed: supermajority confirmed
// finalized: max lockout
```

### Airdrop (devnet/testnet)

```ts
import { airdropFactory, lamports } from '@solana/kit';

const airdrop = airdropFactory({ rpc, rpcSubscriptions });
await airdrop({
  recipientAddress: address('...'),
  lamports: lamports(1_000_000_000n),
  commitment: 'confirmed',
});
```

### RPC Method Reference

**Accounts**: `getAccountInfo`, `getMultipleAccounts`, `getBalance`, `getTokenAccountBalance`, `getTokenAccountsByOwner`, `getProgramAccounts`

**Transactions**: `sendTransaction`, `simulateTransaction`, `getTransaction`, `getSignatureStatuses`, `getSignaturesForAddress`, `getTransactionsForAddress`

`getTransactionsForAddress` (`@solana/kit` 7.1+) combines address-history discovery and per-transaction fetching into a single query, with server-side filtering, bidirectional sorting, and cursor-based pagination — replacing a `getSignaturesForAddress` + N× `getTransaction` fan-out. It supports a `signatures`-only mode and a `full` mode (`json` / `jsonParsed` / `base58` / `base64`). It is part of the upcoming solana-rpc spec (`solana-rpc/superbank`) and is already available from major RPC providers, but is not yet universally supported — check the target endpoint before relying on it. Transaction metadata (from `getTransactionsForAddress` and `getTransaction`) also gained an optional `meta.costUnits` field.

**Blocks**: `getBlock`, `getBlockHeight`, `getSlot`, `getLatestBlockhash`, `isBlockhashValid`

**Cluster**: `getClusterNodes`, `getEpochInfo`, `getHealth`, `getVersion`

**Misc**: `requestAirdrop`, `getMinimumBalanceForRentExemption`, `getFeeForMessage`

---

## Error Handling

```ts
import {
  isSolanaError,
  SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED,
  SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE,
} from '@solana/errors';

try {
  await sendAndConfirm(tx, { commitment: 'confirmed' });
} catch (e) {
  if (isSolanaError(e, SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED)) {
    console.error('Blockhash expired');
  }
  if (isSolanaError(e, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE)) {
    console.error('Preflight failed:', e.cause);
  }
}
```

---

## Building Custom Plugins

A plugin is a function that takes a client object and returns a new one (or a promise):

```ts
export type ClientPlugin<TInput extends object, TOutput extends Promise<object> | object> =
  (input: TInput) => TOutput;
```

### Basic Plugin

```ts
import { createClient } from '@solana/kit';

function apple() {
  return <T extends object>(client: T) => ({
    ...client,
    fruit: 'apple' as const,
  });
}

const client = createClient().use(apple());
client.fruit; // 'apple'
```

### Plugin with Requirements

Require that other plugins are installed first:

```ts
function appleTart() {
  return <T extends { fruit: 'apple' }>(client: T) => ({
    ...client,
    dessert: 'appleTart' as const,
  });
}

createClient().use(apple()).use(appleTart()); // ✅ Ok
createClient().use(appleTart());              // ❌ TypeScript error
```

### Async Plugin

```ts
function magicFruit() {
  return async <T extends object>(client: T) => {
    const fruit = await fetchSomeMagicFruit();
    return { ...client, fruit };
  };
}

// use() handles awaiting automatically
const client = await createClient().use(magicFruit()).use(apple());
```

---

## Assembling Domain-Specific Clients

The plugin system enables building purpose-built clients for specific domains. Here are real-world examples:

### Example: Kora (Gasless Transactions)

[Kora](https://github.com/solana-foundation/kora) builds a gasless payment client by composing standard plugins with a custom Kora plugin:

```ts
import { createClient } from '@solana/kit';
import { planAndSendTransactions, transactionPlanExecutor, transactionPlanner } from '@solana/kit-plugin-instruction-plan';
import { payer } from '@solana/kit-plugin-signer';
import { rpc } from '@solana/kit-plugin-rpc';

export async function createKitKoraClient(config) {
  return createClient()
    .use(rpc(config.rpcUrl))
    .use(koraPlugin({ apiKey: config.apiKey, endpoint: config.endpoint }))
    .use(payer(payerSigner))
    .use(transactionPlanner(koraTransactionPlanner))      // Custom planning logic
    .use(transactionPlanExecutor(koraTransactionExecutor)) // Custom execution via Kora API
    .use(planAndSendTransactions());
}

// Usage
const client = await createKitKoraClient({ endpoint, rpcUrl, feeToken, feePayerWallet });
await client.sendTransaction([myInstruction]); // Gasless!
```

Key pattern: Standard plugins (`rpc`, `payer`, `planAndSendTransactions`) combined with custom `transactionPlanner` and `transactionPlanExecutor` that route through Kora's gasless API.

### Example: Solana Pay

[Solana Pay](https://github.com/amilz/solana-pay) builds role-specific clients — a read-only merchant client and a full wallet client:

```ts
import { createClient } from '@solana/kit';
import { planAndSendTransactions } from '@solana/kit-plugin-instruction-plan';
import { payer } from '@solana/kit-plugin-signer';
import { rpc, rpcTransactionPlanExecutor, rpcTransactionPlanner } from '@solana/kit-plugin-rpc';

// Merchant: read-only, no payer needed
function createMerchantClient(config) {
  return createClient()
    .use(rpc(config.rpcUrl))
    .use(solanaPayMerchant()); // Adds client.pay.encodeURL, findReference, validateTransfer
}

// Wallet: full tx capabilities
function createWalletClient(config) {
  return createClient()
    .use(rpc(config.rpcUrl))
    .use(payer(config.payer))
    .use(rpcTransactionPlanner())
    .use(rpcTransactionPlanExecutor())
    .use(planAndSendTransactions())
    .use(solanaPayWallet()); // Adds client.pay.parseURL, createTransfer
}

// Usage
const merchant = createMerchantClient({ rpcUrl });
const url = merchant.pay.encodeURL({ recipient, amount: 1.5 });

const wallet = createWalletClient({ rpcUrl, payer: myWalletSigner });
const instructions = await wallet.pay.createTransfer({ recipient, amount: 1.5 });
await wallet.sendTransaction(instructions);
```

Key pattern: Same base plugins, different compositions for different roles. Domain logic added as custom plugins (`solanaPayMerchant`, `solanaPayWallet`).

### Pattern Summary

When building a domain-specific client:

1. Start with `createClient()` from `@solana/kit`
2. Add standard plugins for capabilities you need (`rpc`, `payer` from `@solana/kit-plugin-signer`, `planAndSendTransactions`)
3. Swap `transactionPlanner` / `transactionPlanExecutor` if you need custom tx lifecycle (like Kora)
4. Add your domain plugin(s) that extend the client with domain-specific methods
5. Export a factory function (`createMyClient(config)`) for consumers
