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.
Summary
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.
Recipe
- Define Zod (or JSON Schema) inputs: destination, amount,
intentId, optional memo, cluster. - Implement
createTransactionto return serialized bytes or a human-readable summary without broadcasting. - Implement
transferSOLto validate, check idempotency, sign, send, and confirm. - Gate signing behind env/policy (
AGENT_CHAIN_WRITES=1) and amount caps. - Return structured results:
status,signatureortxBase64,intentId,cluster. - Register both tools with clear descriptions so the model prefers create-then-approve for large amounts.
- Test on devnet with duplicate
intentIdand insufficient-funds paths.
Working Example
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.Deep Dive
Why two tools instead of one
| Tool | Agent power | Risk | Typical use |
|---|---|---|---|
createTransaction | Propose bytes / summary | Low if no signing | HITL, Fireblocks, multisig |
transferSOL | Full auto spend path | High | Micropayments under caps |
Models default to the most powerful tool that looks relevant.
If only transferSOL exists, every tip becomes an immediate broadcast.
Keeping createTransaction lets you implement propose → approve → sign without rewriting the agent.
Lifecycle of a safe transfer
intentId born (user request / workflow id)
-> createTransaction (optional review)
-> policy / human approve
-> transferSOL or external signer + broadcast
-> confirm
-> persist intentId -> signature
-> agent narrates signature to userAmount units and caps
Accept SOL as a float only with a hard max, or accept integer lamports only.
Do not accept both in one field.
Auto-send caps should be lower than create-only caps.
Idempotency inside the tool
intentId must be supplied by the planner or workflow layer, not randomly regenerated inside the tool on each call.
In-memory maps die with the process; production needs Redis/Postgres with a proper state machine (idempotency article).
Simulation and preflight
sendAndConfirmTransaction preflights by default in common web3.js paths (verify behavior for your version).
Still run explicit simulation for multi-instruction transactions before asking a human to approve.
SPL transfers
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.
Gotchas
- Model regenerates intentId on retry. Fix in the orchestration layer: bind intent to the user task id.
- Blockhash expiry. Created transactions go stale; rebuild if
lastValidBlockHeightpasses. - Returning only "ok". Always return signature or a durable error code.
- Fee payer ≠ funded payer. Ensure the signing key holds SOL for fees and transfer amount.
- Parallel tool calls. Two concurrent
transferSOLwith different intent ids can both spend; use global rate and budget limits. - Memo / reference fields ignored. If commerce needs Solana Pay references, use that flow instead of ad-hoc memos alone.
- Logging full txBase64 in model context forever. Summarize for the LLM; store bytes in your DB.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
Single transferSOL tool | Simple demos | No review path; easy double-send |
| create + external signer | Strong control | More moving parts |
| Program-enforced escrow | On-chain rules | Engineering cost |
| Custody policy engine | Enterprise controls | Vendor lock-in |
| User wallet signature (session) | Non-custodial | Not fully autonomous |
FAQs
Should the model see txBase64?
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.
Related
Related: Setting Up @solana/web3.js and @solana/spl-token for Agent Tools
Related: Idempotency and Double-Spend Protection for On-Chain Agent Actions
Related: Solana Pay: Verifying Payments via Webhook or Polling
Related: Blockchain & On-Chain Agents Basics
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.