sendTransaction Solana Developer Guide

Send SOL from a connected wallet. Build the transfer in the browser, have the wallet sign it, and submit the signed bytes through FluxRPC with sendTransaction.

Reference documentation: sendTransaction

Before We Begin

This guide picks up where getBalance left off. In that guide, we let a user connect a wallet and fetched their balance for display. This guide takes the next step, letting the user spend from that wallet, for example to pay for a service on a website. For now we will just concern ourselves with sending the transaction, and storing the transaction signature.

(Spoiler Alert!) The next guide will cover how to listen for the transaction signature landing on-chain so you can display a "success" message to the user.

You will need a FluxRPC API key (a free plan one is enough). There is no need to copy-paste it. Sign in and come back to this page, and we will substitute the key you select into every snippet below.

In this guide, we will accomplish the following:

  1. Build a SOL transfer in the browser
  2. Hand it to the connected wallet for a signature
  3. Submit it through FluxRPC with sendTransaction

We will provide examples in JavaScript in this guide. We'll use Solana Kit, the current version of the library you may know as Solana Web3.js. It was renamed when version 2 was released.

This spends a small amount (0.001 by default) of real SOL! You can edit the example so that you send it to another wallet you control.

The Files

We'll start by creating two files as follows:

send-transaction/
├── index.html
└── app.js

Start with index.html. The IDs match the JavaScript that follows.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>FluxRPC Send Transaction</title>
  </head>
  <body>
    <main>
      <h1>FluxRPC Send Transaction</h1>

      <button id="connect-wallet-button" type="button">Connect Wallet</button>

      <p>
        <label for="recipient-input">Recipient</label>
        <input id="recipient-input" type="text" size="48" placeholder="Base-58 public key" />
      </p>

      <p>
        <label for="amount-input">Amount in SOL</label>
        <input id="amount-input" type="number" step="0.000000001" min="0" value="0.001" />
      </p>

      <button id="send-button" type="button" disabled>Send SOL</button>

      <p id="wallet-address"></p>
      <p id="status-message" role="status"></p>
      <pre id="result-output"></pre>
    </main>

    <script src="https://unpkg.com/@solana/kit@7.0.0/dist/index.production.min.js" defer></script>
    <script src="./app.js" defer></script>
  </body>
</html>

The Kit script tag is the one addition over getBalance. It exposes a global called solanaWeb3, the name kept from before the rename. It does two jobs here: turning a transfer into the exact bytes a validator expects, and making the RPC calls that surround it.

Note that the version is pinned. Before you ship an application, copy the file into your own project and serve it yourself.

The snippets below appear in the order they sit in app.js, or you can take the whole file at once first. Note that again, we use shielded API keys here, so that our real API key is not exposed to the frontend.

Set Up the Endpoints

app.js opens with the endpoints and the constants the rest of the file needs. Sign in and the values below are already set for you.

const FLUXRPC_SHIELD_KEY = "{{SHIELD_KEY}}";
const FLUXRPC_SHIELD_RPC_URL = "{{SHIELD_RPC_URL}}";
const LAMPORTS_PER_SOL = 1_000_000_000;
const SOLANA_CHAIN = "solana:mainnet";

const SYSTEM_PROGRAM_ADDRESS = "11111111111111111111111111111111";

const {
  AccountRole,
  address,
  appendTransactionMessageInstruction,
  compileTransaction,
  createSolanaRpc,
  createTransactionMessage,
  getBase64Decoder,
  getTransactionEncoder,
  getU32Encoder,
  getU64Encoder,
  pipe,
  setTransactionMessageFeePayer,
  setTransactionMessageLifetimeUsingBlockhash,
} = solanaWeb3;

SOLANA_CHAIN is the chain identifier the Wallet Standard uses. Wallets check it, and a mismatch will result in a rejected signature rather than a bad transaction, so it is worth naming rather than leaving to a default.

The rest of the opening is the element lookups:

const connectButton = document.getElementById("connect-wallet-button");
const sendButton = document.getElementById("send-button");
const recipientInput = document.getElementById("recipient-input");
const amountInput = document.getElementById("amount-input");
const addressOutput = document.getElementById("wallet-address");
const statusOutput = document.getElementById("status-message");
const resultOutput = document.getElementById("result-output");

let connectedWallet = null;
let connectedAccount = null;

getBalance only kept accounts[0].address, because a balance lookup only needs a public key. Signing needs the account object.

Connect a Wallet That Can Sign

discoverWallets() is unchanged from getBalance: the page dispatches one event announcing it is ready, every installed wallet answers by registering itself, and the solana: chain filter keeps the list limited to Solana wallets. Nothing about it changes for signing, so it is in the complete file above and not walked through again here. All this section needs from it is the list:

const solanaWallets = discoverWallets();

Connecting is where this guide diverges a little more from the last one. getBalance took the first wallet that turned up. Reading a balance works with any wallet; signing does not, so this filters on the solana:signTransaction feature instead.

async function connectWallet() {
  const wallet = solanaWallets.find(
    (candidate) => "solana:signTransaction" in candidate.features,
  );

  if (!wallet) {
    throw new Error(
      "No Solana wallet that can sign transactions was found. Install Solflare, Backpack, or Phantom then reload this page.",
    );
  }

  const { accounts } = await wallet.features["standard:connect"].connect();

  if (!accounts[0]) {
    throw new Error("The wallet connected but did not share an account.");
  }

  connectedWallet = wallet;
  connectedAccount = accounts[0];
  addressOutput.textContent = "Connected public key: " + connectedAccount.address;
  watchWalletChanges(wallet);
}

Connecting is still only a permission prompt, nothing is signed until the user presses "Send".

One problem at this point, is that the user can switch accounts or disconnect in their wallet extension at any point after this, and the page would never hear about it! It would keep the account it captured, and the next signature prompt would be for the wrong wallet. The Wallet Standard has an event for exactly this:

function watchWalletChanges(wallet) {
  const events = wallet.features["standard:events"];
  if (!events) return;

  events.on("change", (changes) => {
    if (!("accounts" in changes)) return;

    connectedAccount = changes.accounts[0] ?? null;
    resultOutput.textContent = "";

    if (!connectedAccount) {
      connectedWallet = null;
      addressOutput.textContent = "";
      statusOutput.textContent = "The wallet disconnected.";
      sendButton.disabled = true;
      return;
    }

    addressOutput.textContent = "Connected public key: " + connectedAccount.address;
    statusOutput.textContent = "The wallet switched accounts.";
  });
}

The event only carries the properties that changed, which is why it checks for accounts rather than reading it blindly. Clearing resultOutput is there because a signature on screen belongs to the account that sent it, and leaving it under a different address is confusing.

Build the Transfer

A Solana transaction is a list of instructions, the accounts they touch, and a recent blockhash. For a plain SOL transfer, the instruction comes from the System Program.

// System program instruction #2 is Transfer, and its data is 12 bytes: a u32
// discriminator followed by the amount as a u64, both little-endian.
function transferSolInstruction(fromAddress, toAddress, lamports) {
  const data = new Uint8Array(12);
  data.set(getU32Encoder().encode(2), 0);
  data.set(getU64Encoder().encode(lamports), 4);

  return {
    programAddress: address(SYSTEM_PROGRAM_ADDRESS),
    accounts: [
      { address: fromAddress, role: AccountRole.WRITABLE_SIGNER },
      { address: toAddress, role: AccountRole.WRITABLE },
    ],
    data,
  };
}

The sender's role is WRITABLE_SIGNER rather than WRITABLE: it is authorizing the transfer, not just having its balance changed.

The blockhash is one part worth understanding. Every transaction requires one, and it stays usable for about 150 blocks. That's about 60 seconds, because a slot takes around 400ms on Solana. After that, a validator would reject it. The page asks for a fresh one when the user presses "Send", so the transaction gets that full window to land.

Most guides will tell you to use commitment 'confirmed' or 'finalized'. However, that makes users wait a rather long time for their transaction confirmation. For a UI, it should be more responsive! It is sufficient to use 'processed' commitment in our case. This is true in nearly all cases: transactions on Solana are very rarely reversed, and in fact FluxRPC only supports 'processed' commitment. It means we can get you fresher data, much faster!

We fetch the blockhash with getLatestBlockhash, over the same client every HTTP call in this file uses:

const rpc = createSolanaRpc(
  FLUXRPC_SHIELD_RPC_URL + "?key=" + encodeURIComponent(FLUXRPC_SHIELD_KEY),
);

Now finally... the transaction itself:

async function buildTransferTransaction(recipient, amountSol) {
  const lamports = Math.round(amountSol * LAMPORTS_PER_SOL);

  if (!Number.isSafeInteger(lamports) || lamports <= 0) {
    throw new Error("Enter an amount greater than zero.");
  }

  let toAddress;
  try {
    toAddress = address(recipient);
  } catch {
    throw new Error("That recipient address is not a valid Solana public key.");
  }

  const { value } = await rpc.getLatestBlockhash({ commitment: "processed" }).send();

  const fromAddress = address(connectedAccount.address);

  const transaction = compileTransaction(
    pipe(
      createTransactionMessage({ version: 0 }),
      (message) => setTransactionMessageFeePayer(fromAddress, message),
      (message) => setTransactionMessageLifetimeUsingBlockhash(value, message),
      (message) =>
        appendTransactionMessageInstruction(
          transferSolInstruction(fromAddress, toAddress, BigInt(lamports)),
          message,
        ),
    ),
  );

  return getTransactionEncoder().encode(transaction);
}

getTransactionEncoder().encode() gives back the wire bytes as the Uint8Array the Wallet Standard asks for. Lamports are always integers, which is why the amount is rounded before it reaches the transfer instruction.

Ask the Wallet to Sign It

solana:signTransaction takes the serialized bytes and gives back the same transaction with the user's signature written into it. The wallet shows its own confirmation dialog first, decoding the bytes to display what is actually being approved, which is why the transaction is built in full before the user ever sees a prompt.

async function signTransaction(transactionBytes) {
  const [output] = await connectedWallet.features[
    "solana:signTransaction"
  ].signTransaction({
    account: connectedAccount,
    transaction: transactionBytes,
    chain: SOLANA_CHAIN,
  });

  return output.signedTransaction;
}

Why not signAndSendTransaction?

Wallets also offer solana:signAndSendTransaction, which signs and submits in one step. It is fewer lines, but the wallet then submits through whatever RPC endpoint it happens to be configured with, not through FluxRPC (we send transactions pretty fast!). You lose the endpoint and the send options, and you never see the signed bytes, which is what the confirmation guide reads the signature out of. Whenever you care about where the transaction is being sent, handle the signing and the sending in separate steps.

Submit It Through FluxRPC

sendTransaction takes the signed bytes as a string. Base64 is the encoding to use, and it has to be declared, because the parameter defaults to base58:

async function sendSignedTransaction(signedBytes) {
  return rpc
    .sendTransaction(getBase64Decoder().decode(signedBytes), {
      encoding: "base64",
      skipPreflight: true,
      maxRetries: 3,
      preflightCommitment: "processed",
    })
    .send();
}

skipPreflight skips any transaction simulation, resulting in faster transaction sending. Our transaction is very simple, and so we have no need to simulate it.

The call returns the transaction signature, base58-encoded. There is a crucial thing to be clear about here: that return value does not mean the transaction succeeded. Since we have skipped preflight, all it means is that you have sent the transaction to a validator. It may or may not be added on-chain!

That is why the status line below stops at "Submitted" rather than claiming anything more, and it is the whole subject of the next guide. It is also possible to poll for a transaction landing via the getSignatureStatuses RPC call, but polling is inefficient and slow compared to using a websocket!

Wire the Buttons

Connect enables Send. Send runs the three steps in order, and re-enables itself regardless of success or failure:

connectButton.addEventListener("click", async () => {
  statusOutput.textContent = "Opening the wallet...";

  try {
    await connectWallet();
    statusOutput.textContent = "Wallet connected.";
    sendButton.disabled = false;
  } catch (error) {
    statusOutput.textContent = error.message;
  }
});

sendButton.addEventListener("click", async () => {
  sendButton.disabled = true;
  resultOutput.textContent = "";

  try {
    statusOutput.textContent = "Building the transaction...";
    const transactionBytes = await buildTransferTransaction(
      recipientInput.value.trim(),
      Number(amountInput.value),
    );

    statusOutput.textContent = "Waiting for you to approve it in the wallet...";
    const signedBytes = await signTransaction(transactionBytes);

    statusOutput.textContent = "Sending through FluxRPC...";
    const signature = await sendSignedTransaction(signedBytes);

    statusOutput.textContent = "Submitted. The network has not confirmed it yet.";
    resultOutput.textContent = JSON.stringify(
      { signature, explorer: "https://solscan.io/tx/" + signature },
      null,
      2,
    );
  } catch (error) {
    statusOutput.textContent = error.message;
  } finally {
    sendButton.disabled = false;
  }
});

The three steps read in the order they happen: build, sign, send. The button is disabled for the whole handler and re-enabled in a finally, because every path out of here (success, a rejected signature, an expired blockhash) ends with the user able to try again.

Run It on localhost

Wallet extensions refuse to load from file://, so this needs a real server. Run one of these inside the folder holding your two files:

# if you have Python
python3 -m http.server 8000

# the same command, on Windows
py -m http.server 8000

# if you have Node.js instead
npx serve . --listen 8000

Then open http://localhost:8000 in your browser. Type the address yourself rather than clicking whatever the terminal printed. Wallets refuse to connect to a non-HTTPS site unless it is specifically localhost, and 0.0.0.0:8000 or [::]:8000 will not do.

Click Connect Wallet and approve the prompt. Paste a recipient address (a second wallet of your own is the sensible choice), leave the amount at 0.001, and click Send SOL. The wallet opens its confirmation dialog showing the transfer it is about to sign. Approve it, and the result appears:

{
  "signature": "5wHu1qwD4kLp1nH8mQTSDZ9E3rTJ9k5eYqk2K8LQnW1sN1Qm4d7ZzB2FyRxNaVYTgKcPjHM3AoLdEuXqTbBhZfSc",
  "explorer": "https://solscan.io/tx/5wHu1qwD4kLp1nH8mQTSDZ9E3rTJ9k5eYqk2K8LQnW1sN1Qm4d7ZzB2FyRxNaVYTgKcPjHM3AoLdEuXqTbBhZfSc"
}

Open that link. Within a second or two the explorer will show the transfer as confirmed. The fact that you had to leave your own page to find that out is exactly what the next guide fixes.

Troubleshooting

SymptomCause
"No Solana wallet that can sign transactions was found"Nothing answered the handshake, or what answered has no solana:signTransaction feature. Extensions load only on https:// and localhost, so check the address bar.
solanaWeb3 is not definedapp.js ran before the Kit script. Both tags need defer, which is what makes them execute in document order.
Insufficient funds, raised before the wallet dialog appearsThe sending wallet has no SOL on mainnet. It may be funded on devnet, which this endpoint does not serve. skipPreflight means the RPC never simulates, so this one reaches you from the wallet's own simulation rather than from FluxRPC.
"That recipient address is not a valid Solana public key"The string did not decode. In practice this is usually a truncated paste or a stray character.

Next Steps

Confirm the transaction over a WebSocket is the second half of this app: it takes the signed bytes you already have, subscribes to the signature before the transaction is submitted, and reports the real outcome. We will use a Web Worker, so the socket never touches the thread that draws your interface.

getBalance covers the read side of the same wallet connection, including the Shield keys this guide uses for its HTTP calls.

← All developer guides