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
- A working knowledge of Solana development and Solana Web3.js 2.0 (Lighthouse assertions work in any Solana client, but this guide uses Web3.js v2.0 for examples)
- A Solana development environment set up (e.g., Node.js, TypeScript, your favorite IDE)
Dependency | Version |
---|---|
node | 23.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:
- Token Account Balances - “Ensure this user's token account still has at least 90 tokens after the swap.”
- Oracle Prices - “Verify the oracle price is within my expected range before proceeding.”
- 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:
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 Type | Description |
---|---|
Account Info | Check lamports, executable, owner, rent epoch, writable, signer status |
Account Delta | Check Account Data/Info by comparing state of two different accounts |
Account Data | Check that arbitrary account data matches a specific condition |
Token Mint | Check information about a token mint (e.g., authority, supply, freeze status, etc.) |
Token Account | Check information about a token account (e.g., balance, delegate, owner, mint, etc.) |
Stake Account | Check information about a stake account (e.g., stake authorities, lockup, stake, etc.) |
Upgradeable Loader Account | Check information about an upgradeable loader account (e.g., program data, upgrade authority, etc.) |
Merkle Tree | A 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 Case | Example |
---|---|
Wallet Draining Scam | Ensure final SOL or token balances match expectations. |
Oracle Price Integrity | A DeFi protocol requires that the price feed not deviate beyond a certain threshold. |
Validator Blacklist | Utilize 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:
- The memo transaction is sent as-is with no assertions
- The memo transaction is replaced with a SOL drainer instruction with an assertion that the user's SOL balance has not decreased
- The memo transaction is sent with an assertion that the user's SOL balance has not decreased
- 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:
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:
- 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)
- Next, we set up our RPC connection and define our
sendAndConfirmTransaction
function utilizing the Solana factory function. - We then create our scenario instructions (attack and intended memo instruction).
- Fetch the latest blockhash
- Create our transaction message using the
generateTransactionMessage
function we defined in the previous step - Sign the transaction
- 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.