Building transferSOL and createTransaction Agent Tools
Wrap transferSOL (sign + send a system transfer) and createTransaction (build an unsigned or partially prepared payload) so agents can move value without owning every low-level web3 call in the prompt.
Expose two tools with strict schemas: one that performs a capped, idempotent SOL transfer end-to-end, and one that only constructs transaction messages for review, simulation, or external signing.
import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, LAMPORTS_PER_SOL,} from "@solana/web3.js";import { z } from "zod";const pubkey = z.string().refine((s) => { try { new PublicKey(s); return true; } catch { return false; }}, "invalid public key");const CreateTxIn = z.object({ to: pubkey, sol: z.number().positive().max(1), intentId: z.string().min(8).max(128), memo: z.string().max(128).optional(),});const TransferIn = CreateTxIn.extend({ sol: z.number().positive().max(0.05), // tighter cap for auto-send});// Demo memo program note: real memo uses the Memo program id; keep optional.function maybeMemoIx(payer: PublicKey, memo?: string): TransactionInstruction[] { if (!memo) return []; // Prefer @solana/spl-memo in production; omitted here to keep the example small. return [];}export async function createTransactionTool( raw: unknown, connection: Connection, feePayer: PublicKey,) { const args = CreateTxIn.parse(raw); const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("confirmed"); const tx = new Transaction({ feePayer, blockhash, lastValidBlockHeight, }).add( SystemProgram.transfer({ fromPubkey: feePayer, toPubkey: new PublicKey(args.to), lamports: Math.round(args.sol * LAMPORTS_PER_SOL), }), ...maybeMemoIx(feePayer, args.memo), ); const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false, }); return { status: "created", intentId: args.intentId, to: args.to, sol: args.sol, blockhash, lastValidBlockHeight, txBase64: Buffer.from(serialized).toString("base64"), note: "Unsigned/partially signed payload for review or external signer", };}const intentStore = new Map<string, { status: string; signature?: string }>();export async function transferSOLTool( raw: unknown, connection: Connection, payer: Keypair,) { if (process.env.AGENT_CHAIN_WRITES !== "1") { return { status: "blocked", reason: "AGENT_CHAIN_WRITES not enabled" }; } const args = TransferIn.parse(raw); const existing = intentStore.get(args.intentId); if (existing?.status === "confirmed" && existing.signature) { return { status: "duplicate", intentId: args.intentId, signature: existing.signature }; } if (existing?.status === "pending") { return { status: "in_flight", intentId: args.intentId }; } intentStore.set(args.intentId, { status: "pending" }); try { const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: new PublicKey(args.to), lamports: Math.round(args.sol * LAMPORTS_PER_SOL), }), ); const signature = await sendAndConfirmTransaction(connection, tx, [payer], { commitment: "confirmed", }); intentStore.set(args.intentId, { status: "confirmed", signature }); return { status: "confirmed", intentId: args.intentId, signature, to: args.to, sol: args.sol, }; } catch (err) { intentStore.set(args.intentId, { status: "failed" }); return { status: "error", intentId: args.intentId, message: err instanceof Error ? err.message : String(err), }; }}
Agent-facing descriptions (illustrative):
createTransaction: Build a SOL transfer transaction for intentId without broadcasting. Use when a human or custody service must approve signing.transferSOL: Sign and send a small SOL transfer for intentId on the pinned cluster. Always reuse the same intentId when retrying the same user request.
Mirror the same pattern with @solana/spl-token transfer instructions, ATA creation as a separate gated step, and raw token amounts tied to mint decimals.
Prefer a short summary (to, amount, fee payer, intentId) in the model context. Keep base64 in application storage for the signer.
When do I skip createTransaction?
For tiny, allowlisted, auto-approved payments with strong idempotency and monitoring. Still keep the code path available for larger amounts.
How do I bind this to LangGraph or similar?
Register both as tools/nodes; put a conditional edge to human approval when sol exceeds a threshold or destination is new.
Can createTransaction include arbitrary instructions?
Yes, but then you need allowlists of program ids and simulation. Open-ended "run any instruction" tools are shell-equivalent on-chain.
What commitment should transferSOL wait for?
Match finance risk: many apps use confirmed for UX and re-check finalized for settlement. Document the choice in the tool result.
How do I test duplicate protection?
Call transferSOL twice with the same intentId; the second call must return duplicate with the same signature, not a new one.
Where do versioned transactions fit?
Use versioned transactions (v0) when you need address lookup tables or newer runtime features. Keep the same tool split and idempotency rules (verify APIs at build).
Is SystemProgram.transfer enough for production payroll?
It is enough for native SOL movement. Payroll usually also needs batching, accounting ids, compliance holds, and custody approvals beyond this recipe.