Files

533 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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).
---
## 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,
prependTransactionMessageInstruction,
} from '@solana/kit';
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
const message = pipe(
createTransactionMessage({ version: 0 }),
m => setTransactionMessageFeePayerSigner(signer, m),
m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
m => appendTransactionMessageInstruction(instruction, m),
);
```
For a `version: 1` message (4096-byte transactions, SIMD-0385) the pipeline is identical, plus one `setTransactionMessageConfig` step — and it is currently the **only** way to send v1, since the plugin client's planner rejects `version: 1`. Requires `@solana/kit` 8. 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
Should be used for production transactions.
### Setup CU Estimator
```ts
import {
getSetComputeUnitPriceInstruction,
estimateComputeUnitLimitFactory,
estimateAndUpdateProvisoryComputeUnitLimitFactory,
} from '@solana-program/compute-budget';
const estimateAndUpdateCU = estimateAndUpdateProvisoryComputeUnitLimitFactory(
estimateComputeUnitLimitFactory({ rpc })
);
```
### Full Pattern: Priority Fee + CU Estimation + Blockhash Refresh
```ts
// 1. Build message with priority fee
let message = pipe(
createTransactionMessage({ version: 0 }),
m => setTransactionMessageFeePayerSigner(signer, m),
m => setTransactionMessageLifetimeUsingBlockhash(blockhash, m),
m => appendTransactionMessageInstruction(instruction, m),
m => prependTransactionMessageInstruction(
getSetComputeUnitPriceInstruction({ microLamports: 1000n }), m
),
);
// 2. Estimate CU via simulation
message = await estimateAndUpdateCU(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);
```
### Update Priority Fee Dynamically
```ts
import { updateOrAppendSetComputeUnitPriceInstruction } from '@solana-program/compute-budget';
const updated = updateOrAppendSetComputeUnitPriceInstruction(
(current) => current === null ? 1000n : current * 2n,
message
);
```
See [programs/compute-budget.md](programs/compute-budget.md) for the full CU reference.
---
## 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,
prependTransactionMessageInstruction, signTransactionMessageWithSigners,
sendAndConfirmTransactionFactory, assertIsTransactionWithBlockhashLifetime,
assertIsTransactionWithinSizeLimit,
} from '@solana/kit';
import {
getSetComputeUnitPriceInstruction,
estimateComputeUnitLimitFactory,
estimateAndUpdateProvisoryComputeUnitLimitFactory,
} from '@solana-program/compute-budget';
async function sendTx(rpc, rpcSubscriptions, signer, instruction) {
const estimateAndUpdateCU = estimateAndUpdateProvisoryComputeUnitLimitFactory(
estimateComputeUnitLimitFactory({ rpc })
);
const { value: simBlockhash } = await rpc.getLatestBlockhash().send();
let message = pipe(
createTransactionMessage({ version: 0 }),
m => setTransactionMessageFeePayerSigner(signer, m),
m => setTransactionMessageLifetimeUsingBlockhash(simBlockhash, m),
m => appendTransactionMessageInstruction(instruction, m),
m => prependTransactionMessageInstruction(
getSetComputeUnitPriceInstruction({ microLamports: 1000n }), m
),
);
message = await estimateAndUpdateCU(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);
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