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:
- Create a durable nonce account
- Create and serialize a transaction using the durable nonce
- Sign the transaction in a simulated offline environment
- Send the signed transaction to the Solana network
- Attempt several scenarios to test how nonces work
What You Will Need
- Basic knowledge of Solana Fundamentals
- Experience with Basic Solana Transactions
- Solana CLI latest version installed
Dependency | Version |
---|---|
node.js | 18.12.1 |
tsc | 5.0.2 |
ts-node | 10.9.1 |
solana-cli | 1.14.16 |
@solana/web3.js | 1.74.0 |
bs58 | 5.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 transactionnonceAuthKeypair
- the keypair for the nonce authority accountnonceKeypair
- the keypair for the nonce accountsenderKeypair
- the keypair for the sender accountconnection
- 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 theKeypair.generate()
method. - We are creating a
transferIx
instruction using theSystemProgram.transfer
method. This instruction will transferTRANSFER_AMOUNT
from thesenderKeypair
to thedestination
account. - We are creating an
advanceIx
instruction using theSystemProgram.nonceAdvance
method. This instruction will advance the nonce account using thenonceAuthKeypair
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 theadvanceIx
instruction to the transaction before thetransferIx
instruction. We are also setting therecentBlockhash
to the nonce account's nonce. - If
useNonce
is false, we simply put thetransferIx
instruction to the transaction and set therecentBlockhash
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:
Simulation | useNonce | waitTime | Expected Results |
---|---|---|---|
1. Simulate a typical transaction using recent blockhash | false | 0 | Success |
2. Simulate an offline (delayed) transaction using a blockhash | false | 120000 | Fail |
3. Simulate an offline (delayed) transaction using a nonce account | true | 120000 | Success |
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.