Skip to main content

How to Send Offline Transactions on Solana using Durable Nonce

Created on
Updated on
Aug 21, 2024

18 min read

Overview

Durable nonces are a handy tool that can be used to avoid transaction expirations. In this guide, we will show you how to use durable nonces to sign and send offline transactions on Solana without concern for your transaction expiring.

What You Will Do

In this guide, you will:

  1. Create a durable nonce account
  2. Create and serialize a transaction using the durable nonce
  3. Sign the transaction in a simulated offline environment
  4. Send the signed transaction to the Solana network
  5. Attempt several scenarios to test how nonces work

What You Will Need

DependencyVersion
node.js18.12.1
tsc5.0.2
ts-node10.9.1
solana-cli1.14.16
@solana/web3.js1.74.0
bs585.0.0

What is a Nonce?

A nonce is a number that is used only once. In the context of Solana, a nonce is a number used to prevent replay attacks. A replay attack is when a transaction is intercepted and resent to the network.

Typical Solana transactions include a recent blockhash in the transaction data so that the runtime can verify that the transaction is unique. To limit the amount of history that the runtime needs to double-check, Solana only looks at the last 150 blocks. This means the second transaction will fail if two identical transactions are sent within 150 blocks of each other. It also means stale transactions (older than 150 blocks) will fail.

Unfortunately, if you send transactions offline (or have other particularly time-consuming constraints), you may have issues with expiring transactions. This is where durable nonces come in. Solana allows you to create a special type of account, a nonce account. You can think of this account like your own private blockhash queue. You can generate new unique IDs, move forward to the next ID, or even transfer control of the queue to someone else. This account holds a unique value or nonce. You can include the nonce instead of the recent blockhash when creating a transaction. To prevent replay attacks, the nonce is changed each time by calling advanceNonceAccount in the first instruction of the transaction. Transactions that attempt to use a nonce account without the nonce advancing will fail. Here's an example:

    // The nonceAdvance method is on the SystemProgram class and returns a TransactionInstruction (like SystemProgram.transfer)
const advanceIx = SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthKeypair.publicKey,
noncePubkey: nonceKeypair.publicKey
})
const transferIx = SystemProgram.transfer({
fromPubkey: senderKeypair.publicKey,
toPubkey: destination.publicKey,
lamports: TRANSFER_AMOUNT,
});
const sampleTx = new Transaction();
// Add the nonce advance instruction to the transaction first
sampleTx.add(advanceIx, transferIx);

This is what Solana's Durable Nonces offer: a way to prepare a transaction in advance with a unique ID that will not get rejected for being too old. It is a way to set up transactions for later execution while preventing fraud and maintaining order in the transaction queue.

Create Durable Nonce Script

Set Up Your Environment

Let's create a new Node.js project and install the Solana-Web3.js library. In your terminal, enter the following commands in order:

mkdir offline-tx && cd offline-tx && echo > app.ts
npm init -y # or yarn init -y
npm install @solana/web3.js bs58 # or yarn add @solana/web3.js bs58

Open the app.ts file in your favorite editor and add the following imports:

import { Connection, Keypair, LAMPORTS_PER_SOL, NonceAccount, NONCE_ACCOUNT_LENGTH, SystemProgram, Transaction, TransactionSignature, TransactionConfirmationStatus, SignatureStatus } from "@solana/web3.js";
import { encode, decode } from 'bs58';
import fs from 'fs';

We import necessary dependencies from @solana/web3.js, bs58 (a JS package for doing base-58 encoding and decoding), and fs (to allow us to read and write files to our project directory).

Let's declare a few constants that we will use throughout the guide. Add the following code to your app.ts file below your imports:

const RPC_URL = 'http://127.0.0.1:8899';
const TRANSFER_AMOUNT = LAMPORTS_PER_SOL * 0.01;

const nonceAuthKeypair = Keypair.generate();
const nonceKeypair = Keypair.generate();
const senderKeypair = Keypair.generate();
const connection = new Connection(RPC_URL);

Let's break down what each of these is:

  • RPC_URL - the URL of the default local Solana cluster (if you prefer to use devnet or mainnet, simply change the Connection URL to your QuickNode RPC endpoint)
  • TRANSFER_AMOUNT - the number of SOL we will transfer in our sample transaction
  • nonceAuthKeypair - the keypair for the nonce authority account
  • nonceKeypair - the keypair for the nonce account
  • senderKeypair - the keypair for the sender account
  • connection - the connection to a local Solana cluster

Finally, create an async function called main and add the following code:

async function main() {  
const { useNonce, waitTime } = parseCommandLineArgs();
console.log(`Attempting to send a transaction using a ${useNonce ? "nonce" : "recent blockhash"}. Waiting ${waitTime}ms before signing to simulate an offline transaction.`)

try {
// Step 1 - Fund the nonce authority account
await fundAccounts([nonceAuthKeypair, senderKeypair]);
// Step 2 - Create the nonce account
await createNonce();
// Step 3 - Create a transaction
await createTx(useNonce);
// Step 4 - Sign the transaction offline
await signOffline(waitTime, useNonce);
// Step 5 - Execute the transaction
await executeTx();
} catch (error) {
console.error(error);
}
}

We are outlining the steps to create a nonce, generate a transaction, sign it offline, and execute it. We will fill in the details of each step as we go. We will also be using command line arguments to enable some scenario testing later in the guide. We will use a boolean, useNonce, and a waitTime in ms to help us test our offline signing.

Create Helper Functions

Let's create a couple of functions that will help with repetitive tasks.

Fetch Nonce Info

To fetch the nonce info, we will need to get the account info for the nonce account. To do this, we will use the getAccountInfo method from the Connection class. We must also decode the account data using the fromAccountData method from the NonceAccount class. Add the following code to your app.ts file:

async function fetchNonceInfo() {
const accountInfo = await connection.getAccountInfo(nonceKeypair.publicKey);
if (!accountInfo) throw new Error("No account info found");
const nonceAccount = NonceAccount.fromAccountData(accountInfo.data);
console.log(" Auth:", nonceAccount.authorizedPubkey.toBase58());
console.log(" Nonce:", nonceAccount.nonce);
return nonceAccount;
}

Parse Command Line Arguments

We will be using command line arguments to enable some scenario testing later in the guide. We will use a boolean, useNonce, and a waitTime in ms to help us test our offline signing. Add the following code to your app.ts file:

function parseCommandLineArgs() {
let useNonce = false;
let waitTime = 120000;

for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === '-useNonce') {
useNonce = true;
} else if (process.argv[i] === '-waitTime') {
if (i + 1 < process.argv.length) {
waitTime = parseInt(process.argv[i + 1]);
i++;
} else {
console.error('Error: The -waitTime flag requires an argument');
process.exit(1);
}
} else {
console.error(`Error: Unknown argument '${process.argv[i]}'`);
process.exit(1);
}
}

return { useNonce, waitTime };
}

This function will parse the command line arguments ('-useNonce' and '-waitTime') using a for loop that iterates each command line argument using process.argv. The function will return the useNonce and waitTime values.

Encode and Write Transaction

Let's create a function that encodes and writes our serialized transaction to a file. Add the following code to your app.ts file:

async function encodeAndWriteTransaction(tx: Transaction, filename: string, requireAllSignatures = true) {
const serialisedTx = encode(tx.serialize({ requireAllSignatures }));
fs.writeFileSync(filename, serialisedTx);
console.log(` Tx written to ${filename}`);
return serialisedTx;
}

We use the encode method from the bs58 library we imported earlier to encode the serialized transaction. We then use the writeFileSync method from the fs library to write the encoded transaction to a file. We have an optional parameter, requireAllSignatures that defaults to true. This will require all signatures to be present in the transaction. Our partial, unsigned transaction will not have all signatures, so we will set this to false when we call this function.

Read and Decode Transaction

Let's create a counter function to read and decode our serialized transaction from a file. Add the following code to your app.ts file:

async function readAndDecodeTransaction(filename: string): Promise<Transaction> {
const transactionData = fs.readFileSync(filename, 'utf-8');
const decodedData = decode(transactionData);
const transaction = Transaction.from(decodedData);
return transaction;
}

We use the fs library to read the transaction data from the file. We then use the decode method from the bs58 library. Finally, we use the from method from the Transaction class to create a transaction object from the decoded data.

Confirm Transaction

First, let's define a helper function for confirming that transactions have been processed by the cluster. Add the following code to your app.ts file:

async function confirmTransaction(
connection: Connection,
signature: TransactionSignature,
desiredConfirmationStatus: TransactionConfirmationStatus = 'confirmed',
timeout: number = 30000,
pollInterval: number = 1000,
searchTransactionHistory: boolean = false
): Promise<SignatureStatus> {
const start = Date.now();

while (Date.now() - start < timeout) {
const { value: statuses } = await connection.getSignatureStatuses([signature], { searchTransactionHistory });

if (!statuses || statuses.length === 0) {
throw new Error('Failed to get signature status');
}

const status = statuses[0];

if (status === null) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
continue;
}

if (status.err) {
throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
}

if (status.confirmationStatus && status.confirmationStatus === desiredConfirmationStatus) {
return status;
}

if (status.confirmationStatus === 'finalized') {
return status;
}

await new Promise(resolve => setTimeout(resolve, pollInterval));
}

throw new Error(`Transaction confirmation timeout after ${timeout}ms`);
}

This function will poll the Solana network for the transaction status until it is either confirmed or the timeout is reached. We have included some default values for the timeout and poll interval, but you can adjust them as needed.

Great job! Now let's build out each of those steps we outlined in our main function.

Step 1 - Fund the Payer Accounts

We will need some test SOL for implementing our transactions. Let's create a function called fundAccounts and add the following code:


async function fundAccounts(accountsToFund: Keypair[]) {
console.log("---Step 1---Funding accounts");
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
const airdropPromises = accountsToFund.map(account => {
return connection.requestAirdrop(account.publicKey, LAMPORTS_PER_SOL);
});
const airDropSignatures = await Promise.all(airdropPromises).catch(error => {
console.error("Failed to request airdrops: ", error);
throw error;
});
const airdropConfirmations = airDropSignatures.map(signature => {
return confirmTransaction(connection, signature, 'finalized');
});
await Promise.all(airdropConfirmations).catch(error => {
console.error("Failed to confirm airdrops: ", error);
throw error;
});
}

We simply pass an array of Keypairs and request an airdrop of 1 SOL for each using the requestAirdrop method. We are waiting for the airdrops to be confirmed (finalized) on the network to ensure our funds are available for the subsequent steps.

Step 2 - Create the Nonce Account

We need to create a nonce account before creating a transaction using a nonce. Let's create a function called createNonce and add the following code:


async function createNonce() {
console.log("---Step 2---Creating nonce account");
const newNonceTx = new Transaction();
const rent = await connection.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH);
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
newNonceTx.feePayer = nonceAuthKeypair.publicKey;
newNonceTx.recentBlockhash = blockhash;
newNonceTx.lastValidBlockHeight = lastValidBlockHeight;
newNonceTx.add(
SystemProgram.createAccount({
fromPubkey: nonceAuthKeypair.publicKey,
newAccountPubkey: nonceKeypair.publicKey,
lamports: rent,
space: NONCE_ACCOUNT_LENGTH,
programId: SystemProgram.programId,
}),
SystemProgram.nonceInitialize({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: nonceAuthKeypair.publicKey,
})
);

newNonceTx.sign(nonceKeypair, nonceAuthKeypair);
try {
const signature = await connection.sendRawTransaction(newNonceTx.serialize());
await confirmTransaction(connection, signature, 'finalized');
console.log(" Nonce Acct Created: ", signature);
} catch (error) {
console.error("Failed to create nonce account: ", error);
throw error;
}

}

We are creating a new transaction, newNonceTx, and adding two instructions. The first instruction is to create a new account using the SystemProgram.createAccount method. We are using the nonceAuthKeypair to pay to fund the new account, nonceKeypair. We also use the SystemProgram.nonceInitialize method to initialize this account as a nonce. We are using the nonceAuthKeypair as the authorizedPubkey and the nonceKeypair as the noncePubkey. We then sign the transaction with the nonceKeypair and the nonceAuthKeypair and send it to the network.

If the transaction succeeds, we will now have a nonce account which can be utilized in subsequent transactions with our nonceAuthKeypair as the authority.

Step 3 - Create a Transaction

We are now ready to create a transaction that we will use for our offline signing. Let's create a function called createTx and add the following code:

async function createTx(useNonce = false) {
console.log("---Step 3---Creating transaction");
const destination = Keypair.generate();
const transferIx = SystemProgram.transfer({
fromPubkey: senderKeypair.publicKey,
toPubkey: destination.publicKey,
lamports: TRANSFER_AMOUNT,
});
const advanceIx = SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthKeypair.publicKey,
noncePubkey: nonceKeypair.publicKey
})
const sampleTx = new Transaction();

if (useNonce) {
sampleTx.add(advanceIx, transferIx);
const nonceAccount = await fetchNonceInfo();
sampleTx.recentBlockhash = nonceAccount.nonce;
}
else {
sampleTx.add(transferIx);
sampleTx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
}

sampleTx.feePayer = senderKeypair.publicKey;
const serialisedTx = encodeAndWriteTransaction(sampleTx, './unsigned.json', false);
return serialisedTx;
}

Our function accepts a parameter, useNonce, which we will use to determine if we want to use a nonce in our transaction (or a recent blockhash if not). The transaction will transfer TRANSFER_AMOUNT from the senderKeypair to a new, randomly generated account, destination. The function will return the serialized transaction so that we can use it in our offline signing. Let's break down the function a bit:

  • We are creating a new destination account using the Keypair.generate() method.
  • We are creating a transferIx instruction using the SystemProgram.transfer method. This instruction will transfer TRANSFER_AMOUNT from the senderKeypair to the destination account.
  • We are creating an advanceIx instruction using the SystemProgram.nonceAdvance method. This instruction will advance the nonce account using the nonceAuthKeypair as the authority.
  • We are creating a new sampleTx transaction.
  • If you recall, when using Nonce accounts, we must advance the nonce when we use it. If useNonce is true, we add the advanceIx instruction to the transaction before the transferIx instruction. We are also setting the recentBlockhash to the nonce account's nonce.
  • If useNonce is false, we simply put the transferIx instruction to the transaction and set the recentBlockhash to the latest blockhash on the network.
  • We are setting the feePayer to be the same account transferring SOL (senderKeypair)

Step 4 - Sign the Transaction Offline

Instead of disconnecting our internet or going to a cold storage device, we will simulate an offline transaction by adding some processing time (waitTime) to our signing process. Let's create a function called signOffline and add the following code:

async function signOffline(waitTime = 120000, useNonce = false): Promise<string> {
console.log("---Step 4---Signing transaction offline");
await new Promise((resolve) => setTimeout(resolve, waitTime));
const unsignedTx = await readAndDecodeTransaction('./unsigned.json');
if (useNonce) unsignedTx.sign(nonceAuthKeypair, senderKeypair);
else unsignedTx.sign(senderKeypair);
const serialisedTx = encodeAndWriteTransaction(unsignedTx, './signed.json');
return serialisedTx;
}

As you can see, we pass an optional parameter, waitTime, that is called via a setTimeout function. This will simulate the time it takes to transfer the transaction to a cold storage device, sign it, and transfer it back. We have included this as a parameter so you can adjust the time to see how a nonce account can be used to sign a transaction offline.

We are then reading the unsigned transaction from the file we created in the previous step, signing it with the nonceAuthKeypair (if useNonce is true) and the senderKeypair, and writing the signed transaction to a file called signed.json. We are returning the serialized transaction so that we can use it in the next step.

Step 5 - Send the Signed Transaction

We should now have a signed transaction saved to a file called signed.json. Let's create a function called sendSignedTx that decodes the transaction and sends it to the network:

async function executeTx() {
console.log("---Step 5---Executing transaction");
const signedTx = await readAndDecodeTransaction('./signed.json');
const sig = await connection.sendRawTransaction(signedTx.serialize());
console.log(" Tx sent: ", sig);
}

Our helper function makes it easy! We simply read the signed transaction from the file, serialize it, and send it to the network.

Great job! Let's test it out. Add the following code to the bottom of your file:

main();

This will call our main function when we run our program.

Run the Program

Great job to this point. Now all we have left to do is run our program. If you are using a local network like we did above, you will need to open two terminals. In the first terminal, run the following command:

solana-test-validator

This should initiate your local Solana cluster.

If you recall, our program expects two parameters, useNonce and waitTime. Let's run three simulations to see how the nonce account works:

SimulationuseNoncewaitTimeExpected Results
1. Simulate a typical transaction using recent blockhashfalse0Success
2. Simulate an offline (delayed) transaction using a blockhashfalse120000Fail
3. Simulate an offline (delayed) transaction using a nonce accounttrue120000Success

Simulation 1 - Typical Transaction

In the second terminal, run your first simulation, a typical "on-line" transaction that uses a recent blockhash (we achieve this by setting waitTime to 0 and not using the useNonce parameter):

ts-node app -waitTime 0 # useNonce is false by default

This is a typical transaction, and we should see a successful result.

Simulation 2 - Offline Transaction using a Blockhash

In the second terminal, run your second simulation to simulate an offline transaction using a recent blockhash. Because the blockhash will expire before the transaction is processed, we expect to see an error. Run the following command:

ts-node app -waitTime 120000 # useNonce is false by default

Did you see an error (e.g., Blockhash Not Found)? If so, great! This is what we expected. If not, try increasing the waitTime and running the command again.

Simulation 3 - Offline Transaction using a Nonce Account

In the second terminal, run your third simulation to simulate an offline transaction using a nonce account. Because the nonce account will not expire, we expect to see a successful result. Run the following command:

ts-node app -useNonce -waitTime 120000

Because we used a nonce account, we should not have any issues with the blockhash expiring. We should see a successful result like this:

Attempting to send a transaction using a nonce. Waiting 12000ms before signing to simulate an offline transaction.
---Step 1---Funding accounts
---Step 2---Creating nonce account
Nonce Acct Created: 3XzR...USMq
---Step 3---Creating transaction
Auth: EVxuoBFLQ8KpTLChkgW4RBU5pdxvCLUndntUsNW1cSyQ
Nonce: H4rwV9cdhcwNk4jSUxRTgrnSnkLYCTU7c8Vc3ZS6RNRQ
Tx written to ./unsigned.json
---Step 4---Signing transaction offline
Tx written to ./signed.json
---Step 5---Executing transaction
Tx sent: 5Ep3sPV1r1hr73kKQG8Q2xD4B9erPEzW7Hitxwe8iwBooXFq8iz4WC6YzrRE6VBUL8arZHqmYKBF52QrPKbRgmRK

Great Job 🎉! You have successfully created a nonce account and used it to execute an offline transaction.

"Advance Nonce"

You have successfully created a nonce account and used it to execute an offline transaction. You're ready to advance to the next stage of your Solana journey.

How are you planning to use nonces and offline transactions in your workflow? We'd love to talk about what you're cooking! Drop us a line in Discord or give us a follow on Twitter to stay up to date on all the latest information!

We ❤️ Feedback!

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

Share this guide