Skip to main content

Protect Solana Transactions with Lighthouse Assertion Guards

Updated on
Feb 28, 2025

14 min read

Overview

Protecting yourself and your users from malicious transactions is a top priority when building in Web3. Lighthouse, "The Assertion Protocol", is an open-source security enhancement for Solana transactions. By adding assertion instructions to your transaction flow, Lighthouse ensures transactions will fail if certain on-chain states—like token balances or oracle price feeds—do not match your defined expectations. This can protect users from scams like wallet-draining and other malicious activities where a transaction might unintentionally manipulate account state in an unexpected way.

In this guide, you will learn how Lighthouse Assertions work as transaction guards, what they can do to bolster security, and how to integrate them into your Solana workflow. Let's get started!

What You Will Do

  • Understand how Assertion Transaction Guards protect against malicious or unexpected state changes
  • Learn the core Lighthouse concepts: assertion instructions and transaction flow
  • Integrate Lighthouse instructions into your Solana transactions
  • Explore potential real-world use cases for DeFi, NFT marketplaces, or general dApp security

What You Will Need

DependencyVersion
node23.3.0
@solana/web3.js^2.0.0
@solana-program/system^0.6.2
@solana-program/memo^0.6.1
lighthouse-sdk^2.0.1
typescript^5.7.3

Understanding Lighthouse Assertion Protocol

Before we start coding, let's break down the core concepts behind Lighthouse's Assertion and why they are an important development for the Solana ecosystem.

Transaction-Level Assertions

Lighthouse allows developers to attach assertion instructions at the end of a transaction, which effectively serve as transaction guards to prevent unwanted/unexpected activity in the transaction. These assertions can check specific conditions at runtime, such as:

  1. Token Account Balances - “Ensure this user's token account still has at least 90 tokens after the swap.”
  2. Oracle Prices - “Verify the oracle price is within my expected range before proceeding.”
  3. Other On-Chain Conditions - “Check that a certain account data has not changed between the start and end of this transaction.”

If any of these conditions fail, the entire transaction reverts—this prevents partial or malicious executions.

How Lighthouse Works

Transaction guards work by defining a set of conditions that must be met for a transaction to succeed. These conditions are added as assertion instructions to the transaction. An assertion instruciton includes an assertion type and a set of parameters that define how the condition should be checked. When the transaction is executed, the Lighthouse program evaluates these assertions. If all assertions pass, the transaction completes as expected. If any assertion fails, the transaction is atomically aborted, ensuring no partial execution occurs.

The assertion is appended to the end of the transaction instructions:

Lighthouse Assertion Example

We will walk through an example in just a second, but programmatically, here's how this might look:

// ...prepare transaction message
return pipe(
createTransactionMessage({ version: 0 }),
(msg) => setTransactionMessageFeePayerSigner(signer, msg),
(msg) => setTransactionMessageLifetimeUsingBlockhash(blockhash, msg),
(msg) => appendTransactionMessageInstruction(drainerInstruction, msg),
(msg) => appendTransactionMessageInstruction(
getAssertAccountInfoInstruction({
targetAccount: signer, // account to protect
assertion: accountInfoAssertion("Lamports", { // type of protection
value: initialLamports - BASE_TRANSACTION_FEE, // expected value param
operator: IntegerOperator.GreaterThanOrEqual, // operation to evaluate on expected account-value
}),
}),
msg,
)
);

Types of Assertions

Assertion TypeDescription
Account InfoCheck lamports, executable, owner, rent epoch, writable, signer status
Account DeltaCheck Account Data/Info by comparing state of two different accounts
Account DataCheck that arbitrary account data matches a specific condition
Token MintCheck information about a token mint (e.g., authority, supply, freeze status, etc.)
Token AccountCheck information about a token account (e.g., balance, delegate, owner, mint, etc.)
Stake AccountCheck information about a stake account (e.g., stake authorities, lockup, stake, etc.)
Upgradeable Loader AccountCheck information about an upgradeable loader account (e.g., program data, upgrade authority, etc.)
Merkle TreeA wrapper around the spl-account-compression verify_leaf instruction

Additional details on each assertion and their implementation can be found at the Lighthouse Documentation.

Example Use Cases

Below are a couple of real-world scenarios where Lighthouse Transaction Guards can improve security and user trust:

Use CaseExample
Wallet Draining ScamEnsure final SOL or token balances match expectations.
Oracle Price IntegrityA DeFi protocol requires that the price feed not deviate beyond a certain threshold.
Validator BlacklistUtilize a sysvar slot assertion and getLeaderSchedule to avoid slots when the leader is a known bad actor (check out our guide on Solana MEV)

Key Considerations

When integrating transaction guards, keep these points in mind:

  • Transaction Size: Assertions add instructions, increasing your overall transaction size. Ensure you stay within Solana's 1,232-byte limit.
  • Documentation: Lighthouse is a relatively new protocol. Make sure to check out the GitHub repository/docs for up-to-date details.
  • Testing: Assertions can fail transactions-—test thoroughly with edge cases to avoid unexpected rejections.
  • Performance: Assertions add minor computation overhead--though these are relatively small, added compute can negatively impact your transaction landing rates and increase priority fee costs. Check out our Guide on Transaction Optimization, and make sure you know the implications for your application.
  • For large/complex operations, Lighthouse assertions can be used with Jito Bundles
  • For asserting account data against a snapshot of some historical data, Lighthouse offers Memory accounts to store info and run AssertDelta* checks against it.

Demonstration

Let's demonstrate Transaction Guards by demonstrating a desired transaction sent under various conditions. Our goal will be for a user to sign and execute a simple transaction that logs a message using Solana's memo program. We will run the transaction under four conditions:


  1. The memo transaction is sent as-is with no assertions
  2. The memo transaction is replaced with a SOL drainer instruction with an assertion that the user's SOL balance has not decreased
  3. The memo transaction is sent with an assertion that the user's SOL balance has not decreased
  4. The memo transaction is replaced with a SOL drainer instruction with no assertions

In this example, we will expect to see the assertion guard protect against the malicious instruction when it is included in the transaction, and we will expect to see the malicious instruction execute when the assertion guard is not included.

Let's jump into a step-by-step approach for integrating them with a simple TypeScript script.

Project Setup

Create and initialize a new project folder. Then install the required dependencies such as @solana/web3.js (or your chosen Solana library) and potentially the Lighthouse client (if available) or placeholders for it.

mkdir lighthouse-transaction-guards && cd lighthouse-transaction-guards
npm init -y
npm install @solana/web3.js@2 @solana-program/memo @solana-program/system lighthouse-sdk dotenv

You may also need dev dependencies for TypeScript if you plan to write your code in .ts:

npm install --save-dev typescript ts-node @types/node

Initialize your tsconfig:

tsc --init --target ES2020

Add a start script to your package.json:

{
"scripts": {
"start": "ts-node index.ts"
}
}

Create your script file and a .env file for your environment variables:

echo > index.ts && echo > .env

Go ahead and set your environment variables in the .env file:

SOLANA_ENDPOINT=https://example.solana.quiknode.pro/012345 
SOLANA_WS_ENDPOINT=wss://example.solana.quiknode.pro/012345
WALLET_SECRET=[0,0,0....YOUR_SECRET_KEY....0,0,0]

Make sure to grab your Solana Devnet endpoint (HTTPS and WSS) from your QuickNode dashboard and replace the placeholders above. If you do not already have one, you can create one free here. Alternatively, if you prefer to run locally, you can check our guide on local Solana development, here.

Make sure to add your wallet secret as an array of numbers--if you do not have one you can generate one using the solana-keygen new command in your terminal with the Solana CLI.

Make sure that you have some devnet SOL in your wallet to run the tests. You can get some at the QuickNode Faucet.

Imports

In a file called index.ts, import your Solana and Lighthouse dependencies:

import {
appendTransactionMessageInstruction,
createSolanaRpc,
createTransactionMessage,
setTransactionMessageLifetimeUsingBlockhash,
SolanaRpcApi,
pipe,
Address,
generateKeyPairSigner,
Blockhash,
lamports,
IInstruction,
createKeyPairSignerFromBytes,
sendAndConfirmTransactionFactory,
Lamports,
signTransactionMessageWithSigners,
getSignatureFromTransaction,
createSolanaRpcSubscriptions,
RpcSubscriptions,
SolanaRpcSubscriptionsApi,
KeyPairSigner,
setTransactionMessageFeePayerSigner,
isSolanaError,
type Rpc,
} from "@solana/web3.js";
import { getTransferSolInstruction } from "@solana-program/system";
import { getAddMemoInstruction } from "@solana-program/memo";
import {
accountInfoAssertion,
getAssertAccountInfoInstruction,
IntegerOperator,
isLighthouseError,
LIGHTHOUSE_ERROR__ASSERTION_FAILED
} from "lighthouse-sdk";
import { config } from "dotenv";
config();

const BASE_TRANSACTION_FEE = lamports(5000n);

If you have utilized Solana Web3.js 2.0, you will know there are a lot of type guards to ensure we catch errors in development rather than after sending transactions to the validator network. That's what we have here--a lot of types and helper functions we will use to build and send our transactions (including a few from the Lighthouse SDK, which are compatible with Web3.js2 right out of the gate!).

We also define BASE_TRANSACTION_FEE which will help us check if our post transaction SOL balance is as expected.

Helpers

Environment Verification

Add the following function to ensure our environment variables are set and available:

function getEnvVars(): {
walletSecret: string;
endpoint: string;
wsEndpoint: string;
} {
const walletSecret = process.env.WALLET_SECRET;
const endpoint = process.env.SOLANA_ENDPOINT;
const wsEndpoint = process.env.SOLANA_WS_ENDPOINT;
if (!walletSecret) {
throw new Error("WALLET_SECRET is required");
}
if (!endpoint) {
throw new Error("RPC_ENDPOINT is required");
}
if (!wsEndpoint) {
throw new Error("WS_ENDPOINT is required");
}
return { walletSecret, endpoint, wsEndpoint };
}

RPC Connection

Let's create a helper function to establish a connection to the network. Add the following to your script:

function createRpcConnection(
endpoint: string,
wsEndpoint: string,
): {
rpc: Rpc<SolanaRpcApi>;
rpcSubscriptions: RpcSubscriptions<SolanaRpcSubscriptionsApi>;
} {
try {
const rpc = createSolanaRpc(endpoint);
const rpcSubscriptions = createSolanaRpcSubscriptions(wsEndpoint);
return { rpc, rpcSubscriptions };
} catch (error) {
throw new Error("Failed to create Solana RPC connection");
}
}

Create a Transaction

Generate Transaction Message

Since we are going to run several transactions under various scenarios, lets create a helper function to create the transaction message based on our desired conditions. Add the following to your code:

interface GenerateMessageParams {
authority: Address;
attack: boolean;
blockhash: Readonly<{
blockhash: Blockhash;
lastValidBlockHeight: bigint;
}>;
intendedInstruction: IInstruction;
attackInstruction: IInstruction;
includeGuard: boolean;
initialLamports: Lamports;
signer: KeyPairSigner;
}

function generateTransactionMessage({
authority,
blockhash,
attack,
attackInstruction,
intendedInstruction,
includeGuard,
initialLamports,
signer,
}: GenerateMessageParams) {
const targetInstruction = attack ? attackInstruction : intendedInstruction;
return pipe(
createTransactionMessage({ version: 0 }),
(msg) => setTransactionMessageFeePayerSigner(signer, msg),
(msg) => setTransactionMessageLifetimeUsingBlockhash(blockhash, msg),
(msg) => appendTransactionMessageInstruction(targetInstruction, msg),
(msg) =>
includeGuard
? appendTransactionMessageInstruction(
getAssertAccountInfoInstruction({
targetAccount: authority,
assertion: accountInfoAssertion("Lamports", {
value: initialLamports - BASE_TRANSACTION_FEE,
operator: IntegerOperator.GreaterThanOrEqual,
}),
}),
msg,
)
: msg,
);
}

This function uses the standard pipe assembly to create our message with a couple of toggles in our parameters. We include an attack boolean to determine whether to use the "attack" instruction or the "intended" instruction, and we include an includeGuard boolean to determine whether or not to append a Lighthouse instruction guard to the message.

Here, we can see how easy it is to add a Lighthouse assertion instruction:

Assertion Instruction
    getAssertAccountInfoInstruction({
targetAccount: authority,
assertion: accountInfoAssertion("Lamports", {
value: initialLamports - BASE_TRANSACTION_FEE,
operator: IntegerOperator.GreaterThanOrEqual,
})

We are defining the target account we which to protect, in this case our payer, authority. We specific that we want to run an AccountInfo assertion, looking explicitly at the lamports field. We specify that we want the value of our lamports to be greater than or equal to our initial lamports (less the transaction fee). It's that easy! For more assertions and operators, check out the Lighthouse Documentation.

Transaction Execution

Now, let's pull all of these pieces together. Add the following execute function to your code:

async function execute(attack: boolean, includeGuard: boolean) {
// 1. Set up environment and signer
const { walletSecret, endpoint, wsEndpoint } = getEnvVars();
const keypairBytes = new Uint8Array(JSON.parse(walletSecret));
const signer = await createKeyPairSignerFromBytes(keypairBytes);
const attacker = await generateKeyPairSigner();

// 2. Establish RPC connection
const { rpc, rpcSubscriptions } = createRpcConnection(endpoint, wsEndpoint);
const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({
rpc,
rpcSubscriptions,
});

// 3. Create Scenario Instructions
const { value: initialLamports } = await rpc
.getBalance(signer.address)
.send();

const attackInstruction = getTransferSolInstruction({
source: signer,
destination: attacker.address,
amount: initialLamports - BASE_TRANSACTION_FEE,
});
const intendedInstruction = getAddMemoInstruction({
memo: "Just a safe instruction.",
});

// 4. Get latest blockhash
const { value: blockhash } = await rpc.getLatestBlockhash().send();

// 5. Generate transaction message
const transactionMessage = generateTransactionMessage({
authority: signer.address,
blockhash,
attackInstruction,
intendedInstruction,
attack,
includeGuard,
initialLamports,
signer,
});

// 6. Sign the transaction
const signedTransaction =
await signTransactionMessageWithSigners(transactionMessage);
const signature = getSignatureFromTransaction(signedTransaction);

// 7. Send and confirm transaction, handling success or failure
console.log("-------------------------------------------------");
console.log(
`Simulation Details: ${attack ? "Attack" : "Intended"} instruction, ${
includeGuard ? "with" : "without"
} guard.`,
);
try {
await sendAndConfirmTransaction(signedTransaction, {
commitment: "confirmed",
});
if (!attack) {
console.log("✅ - Transaction succeeded (expected): ");
console.log(" - Signature: ", signature);
} else if (attack && !includeGuard) {
console.log("😭 - Attacker Succeeded without Guard (expected).");
} else {
console.log("❌ - Attacker Succeeded with Guard (unexpected).");
}
} catch (error) {
if (
isSolanaError(error) &&
isLighthouseError(error.cause, transactionMessage) &&
LIGHTHOUSE_ERROR__ASSERTION_FAILED === error.cause.context.code
) {
console.log("🛡️ - Attacker instruction blocked! (expected)");
} else {
console.log("❌ - Unexpected error:", error);
}
} finally {
console.log("-------------------------------------------------\n");
}
}

There's a bit here, so let's break it down:

  1. First, we are simply setting up the environment, by validating/fetching our environment variables, and defining our wallets (we will use the local wallet as our payer/authority and generate a random keypair as our attacker)
  2. Next, we set up our RPC connection and define our sendAndConfirmTransaction function utilizing the Solana factory function.
  3. We then create our scenario instructions (attack and intended memo instruction).
  4. Fetch the latest blockhash
  5. Create our transaction message using the generateTransactionMessage function we defined in the previous step
  6. Sign the transaction
  7. Send the transaction to the network and log the results. Note that we've included a few different logs depending on the input params to help us make sure the results are as expected.

Run Your Code

Finally, add the following code to the bottom of your script to run each of the four scenarios we discussed earlier:

async function main() {
console.log("Running scenarios...");
await execute(false, true); // Attack: false, Guard: true
await execute(false, false); // Attack: false, Guard: false
await execute(true, true); // Attack: true, Guard: true
await execute(true, false); // Attack: true, Guard: false
console.log("Scenarios complete.");
}
main().catch(console.error);

That's it! You should now be able to run your code. In your terminal, run:

npm run start

You should see an output like this:

Running scenarios...
Simulation Details: Intended instruction, with guard.
✅ - Transaction succeeded (expected):
- Signature: 33EE...AUHj
-------------------------------------------------

Simulation Details: Intended instruction, without guard.
✅ - Transaction succeeded (expected):
- Signature: 2u5J...CGGz
-------------------------------------------------

Simulation Details: Attack instruction, with guard.
🛡️ - Attacker instruction blocked! (expected)
-------------------------------------------------

Simulation Details: Attack instruction, without guard.
😭 - Attacker Succeeded without Guard (expected).
-------------------------------------------------

Scenarios complete.

Amazing! Nice work here. Based on our output, the assertion has done exactly what we expected to. The first 2 transactions were able to execute as expected. Our 3rd transaction (an attack) was blocked because the assertion ensured that the final lamports did not go down--since the attacker instruction attempts to drain the wallet, the assertion is true, and the instruction fails. Finally, our 4th transaction confirms that if we do not have that safeguard in place, our wallet is, infact, drained 😭.

Wrap up

Transaction guards offer an elegant way to protect end users from malicious transactions on Solana. By appending Lighthouse assertion instructions to your transactions, you give your users an extra layer of security and confidence.

Whether you're building a DeFi platform, a wallet, or any other Solana-based app, Lighthouse helps ensure that what you sign is truly what you get. We're excited to see how developers incorporate these guards to safeguard the Solana ecosystem.

If you run into trouble, or just want to share what you are working on, drop us a line on Discord or Twitter.

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Resources

Share this guide