Summary #
- Durable transactions have no expiration date unlike regular transactions that have an expiration date of 150 blocks (~80-90 seconds).
- After signing a durable transaction you can store it in a database or a file or send it to another device to submit it later.
- A durable transaction is created using a nonce account. A nonce account holds the authority and the nonce value which replaces the recent blockhash to make a durable transaction
- Durable transactions must start with an
advanceNonce
instruction, and the nonce authority must be a signer of the transaction. - If the transaction fails for any reason other than the
advanceNonce
instruction, the nonce will still be advanced, even though all other instructions will be reverted.
Overview #
Durable Nonces are a way to bypass the expiration date of regular transactions. To understand this, we'll start by looking at the concepts behind regular transactions.
In Solana, transactions have three main parts:
-
Instructions: Instructions are the operations you want to perform on the blockchain, like transferring tokens, creating accounts, or calling a program. These are executed in order.
-
Signatures: Signatures are proof that the transaction was made by someone with the signer's private key - which should usually be the signer themselves. For instance, if you are transferring SOL from your wallet to another, you'll need to sign the transaction so the network can verify that the transaction is valid.
-
Recent Blockhash: The recent blockhash is a unique identifier for each transaction. It is used to prevent replay attacks, where an attacker records a transaction and then tries to submit it again. The recent blockhash ensures that each transaction is unique and can only be submitted once. A recent blockhash is only valid for 150 blocks.
In durable transactions, the first two concepts remain the same. Durable transactions are possible by playing with recent blockhashes.
Let's dive deep into the recent blockhash, to understand the blockhash better let's look at the problem that it tries to solve, the double-spend problem.
Imagine you're buying an NFT on MagicEden or Tensor. You must sign a transaction that allows the marketplace's program to extract some SOL from your wallet. After signing the transaction the marketplace will submit it to the network. If the marketplace submits it again, without checks, you could be charged twice.
This is known as the double-spend problem and is one of the core issues that blockchains, like Solana, solve. A naive solution could be to crosscheck all transactions made in the past and see if we find a duplicate transaction signature. This is not practically possible, as the size of the Solana ledger is >80 TB. So to solve this, Solana uses recent blockhashs.
A recent blockhash is a 32-byte SHA-256 hash of a valid block's last entry id within the last 150 blocks. Since this recent blockhash was part of the transaction before it was signed, we can guarantee the signer has signed it within the last 150 blocks. Checking 150 blocks is much more reasonable than the entire ledger.
When the transaction is submitted, the Solana validators will do the following:
- Check if the signature of the transaction has been submitted within the last 150 blocks - if there is a duplicate signature it'll fail the duplicate transaction.
- If the transaction signature has not been found, it will check the recent blockhash to see if it exists within the last 150 blocks - if it does not, it will return a "Blockhash not found" error. If it does, the transaction goes through its execution checks.
While this solution is great for most use cases, it has some limitations. Mainly, the transaction needs to get signed and submitted to the network within 150 blocks or around ~80-90 seconds. But there are some use cases where we need more than 90 seconds to submit a transaction.
From the Durable Nonce guide:
- Scheduled Transactions: One of the most apparent applications of Durable Nonces is the ability to schedule transactions. Users can pre-sign a transaction and then submit it at a later date, allowing for planned transfers, contract interactions, or even executing pre-determined investment strategies.
- Multisig Wallets: Durable Nonces are very useful for multi-signature wallets where one party signs a transaction, and others may confirm it at a later time. This feature enables the proposal, review, and later execution of a transaction within a trustless system.
- Programs Requiring Future Interaction: If a program on Solana requires interaction at a future point (such as a vesting contract or a timed release of funds), a transaction can be pre-signed using a Durable Nonce. This ensures the contract interaction happens at the correct time without necessitating the presence of the transaction creator.
- Cross-chain Interactions: When you need to interact with another blockchain and it requires waiting for confirmations, you can sign the transaction with a Durable Nonce and execute it once the required confirmations are received.
- Decentralized Derivatives Platforms: In a decentralized derivatives platform, complex transactions might need to be executed based on specific triggers. With Durable Nonces, these transactions can be pre-signed and executed when the trigger condition is met.
Considerations #
Considerations #
Durable transactions should be treated with care, which is why users should always trust the transactions they sign.
As developers, it's important to inform users that their durable nonce transactions may be flagged by wallets. Durable nonces are often used for malicious transactions, and understanding the risks can help users make informed decisions.
For example, imagine a user blindly signed a malicious durable transaction. This transaction could sign away 500 SOL to an attacker and change the nonce authority to the attacker as well. Even if the user doesn't have this amount yet, the attacker could wait to cash this check as soon as the user's balance exceeds 500 SOL. The user would have no recollection of what they clicked on, and the transaction could remain dormant for days, weeks, or years.
To mitigate these risks, developers should educate users on the following points:
- Trust the Source: Users should only sign transactions from trusted sources. Encourage users to verify the origin of the transaction before signing.
- Use Hot Wallets Cautiously: Users should only keep in hot wallets what they're willing to lose. Hot wallets are more susceptible to attacks, so it's wise to limit the amount of funds stored in them.
- Protect Cold Wallets: Users should avoid signing transactions with their cold wallets unless absolutely necessary. Cold wallets are more secure and should be used to store larger amounts of funds.
- Monitor Transactions: Encourage users to regularly monitor their transaction history and account balances. Promptly reporting any suspicious activity can help mitigate potential losses.
By providing this information, developers can help users understand the potential dangers of durable nonce transactions and take appropriate precautions. This is not meant to provoke hysteria but serves to show what's possible and emphasize the importance of security in handling durable transactions.
Using Durable Nonces to Overcome the Short Lifespan of Regular Transactions #
Durable nonces are a way to sign transactions offchain and keep them in storage until they are ready to be submitted to the network. This allows us to create durable transactions.
Durable nonces, which are 32 bytes in length (usually represented as base58 encoded strings), are used in place of recent blockhashes to make each transaction unique (to avoid double-spending) while removing the expiration of the unexecuted transactions.
If nonces are used in place of recent blockhashes, the first instruction of the
transaction needs to be a nonceAdvance
instruction, which changes or advances
the nonce. This ensures that every transaction which is signed using the nonce
as the recent blockhash will be unique.
It is important to note that durable nonces require unique mechanisms within Solana to function, thus they have some special rules that don't apply normally. We'll see this as we deep dive into the technicals.
Durable Nonces In-Depth #
Durable transactions differ from regular transactions in the following ways:
- Durable Nonces replace the recent blockhash with a nonce. This nonce is stored in a nonce account and will be used only once in one transaction. The nonce is a unique blockhash.
- Each durable transaction must start with the
nonceAdvance
instruction, which will change the nonce in the nonce account. This ensures that the nonce is unique and cannot be reused in another transaction.
The nonce account is an account that holds the following values:
- nonce value: the nonce value that will be used in the transaction.
- authority: the public key that can change the nonce value.
- fee calculator: the fee calculator for the transaction.
Again, every durable transaction must start with the nonceAdvance
instruction,
and the authority
must be a signer.
Lastly, there is a special rule - if a durable transaction fails because of any
instruction other than the nonceAdvance
instruction, the nonce will still
advance, while the rest of the transaction is rolled back. This behavior is
unique only to durable nonces.
Durable Nonce Operations #
Durable nonces have a few helpers and constants in the @solana/web3.js
package:
SystemProgram.nonceInitialize
: This instruction creates a new nonce account.SystemProgram.nonceAdvance
: This instruction changes the Nonce in the nonce account.SystemProgram.nonceWithdraw
: This instruction withdraws funds from the nonce account. To delete the nonce account, withdraw all the funds from it.SystemProgram.nonceAuthorize
: This instruction changes the authority of the nonce account.NONCE_ACCOUNT_LENGTH
: A constant that represents the length of the nonce account data.NonceAccount
: A class that represents the nonce account. It contains a static functionfromAccountData
that can take the nonce account data and return a nonce account object.
Let's look into each one of the helper functions in detail.
nonceInitialize
#
The nonceInitialize
instruction is used to create a new nonce account. It
takes two parameters:
noncePubkey
: the public key of the nonce account.authorizedPubkey
: the public key of the authority of the nonce account.
Here is a code example for it:
// 1. Generate/get a keypair for the nonce account, and the authority.
const [nonceKeypair, nonceAuthority] = makeKeypairs(2); // from '@solana-developers/helpers'
// Calculate the minimum balance required for rent exemption
const rentExemptBalance =
await connection.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH);
const tx = new Transaction().add(
// 2. Allocate the account and transfer funds to it (the rent-exempt balance)
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: nonceKeypair.publicKey,
lamports: rentExemptBalance,
space: NONCE_ACCOUNT_LENGTH,
programId: SystemProgram.programId,
}),
// 3. Initialize the nonce account using the `SystemProgram.nonceInitialize` instruction.
SystemProgram.nonceInitialize({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
}),
);
// Send the transaction
await sendAndConfirmTransaction(connection, tx, [payer, nonceKeypair]);
The system program will take care of setting the nonce value for us inside the nonce account.
nonceAdvance
#
This instruction is used to change the nonce value in the nonce account, it takes two parameters:
noncePubkey
: the public key of the nonce account.authorizedPubkey
: the public key of the authority of the nonce account.
Here is a code example for it:
const instruction = SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthority.publicKey,
noncePubkey: nonceKeypair.publicKey,
});
You will see this instruction as the first instruction in any durable transaction. But that doesn't mean that you only have to use it as the first instruction of a durable transaction. You can always call this function, and it will automatically invalidate any durable transaction tied to its previous nonce value.
nonceWithdraw
#
This instruction is used to withdraw the funds from the nonce account, it takes four parameters:
noncePubkey
: the public key of the nonce account.toPubkey
: the public key of the account that will receive the funds.lamports
: the amount of lamports that will be withdrawn.authorizedPubkey
: the public key of the authority of the nonce account.
Here is a code example for it:
const instruction = SystemProgram.nonceWithdraw({
noncePubkey: nonceKeypair.publicKey,
toPubkey: payer.publicKey,
lamports: amount,
authorizedPubkey: nonceAuthority.publicKey,
});
You can also use this instruction to close the nonce account by withdrawing all the funds in it.
nonceAuthorize
#
This instruction is used to change the authority of the nonce account, it takes three parameters:
noncePubkey
: the public key of the nonce account.authorizedPubkey
: the public key of the current authority of the nonce account.newAuthorizedPubkey
: the public key of the new authority of the nonce account.
Here is a code example for it:
const instruction = SystemProgram.nonceAuthorize({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
newAuthorizedPubkey: newAuthority.publicKey,
});
How to use the durable nonces #
Now that we learned about the nonce account and its different operations, let's talk about how to use it.
We'll discuss:
- Fetching the nonce account
- Using the nonce in the transaction to make a durable transaction.
- Submitting a durable transaction.
Fetching the nonce account #
We can fetch the nonce account to get the nonce value by fetching the account and serializing it:
const nonceAccount = await connection.getAccountInfo(nonceKeypair.publicKey);
const nonce = NonceAccount.fromAccountData(nonceAccount.data);
Using the nonce in the transaction to make a durable transaction #
To build a fully functioning durable transaction, we need the following:
- Use the nonce value in replacement of the recent blockhash.
- Add the nonceAdvance instruction as the first instruction in the transaction.
- Sign the transaction with the authority of the nonce account.
After building and signing the transaction we can serialize it and encode it into a base58 string, and we can save this string in some store to submit it later.
// Assemble the durable transaction
const durableTx = new Transaction();
durableTx.feePayer = payer.publicKey;
// use the nonceAccount's stored nonce as the recentBlockhash
durableTx.recentBlockhash = nonceAccount.nonce;
// make a nonce advance instruction
durableTx.add(
SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthority.publicKey,
noncePubkey: nonceKeypair.publicKey,
}),
);
// Add any instructions you want to the transaction in this case we are just doing a transfer
durableTx.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: 0.1 * LAMPORTS_PER_SOL,
}),
);
// sign the tx with the nonce authority's keypair
durableTx.sign(payer, nonceAuthority);
// once you have the signed tx, you can serialize it and store it in a database, or send it to another device.
// You can submit it at a later point.
const serializedTx = base58.encode(
durableTx.serialize({ requireAllSignatures: false }),
);
submitting a durable transaction: #
Now that we have a base58 encoded transaction, we can decode it and submit it:
const tx = base58.decode(serializedTx);
const sig = await sendAndConfirmRawTransaction(connection, tx as Buffer);
Some important edge cases #
There are a few things that you need to consider when dealing with durable transactions:
- If the transaction fails due to an instruction other than the nonce advanced instruction.
- If the transaction fails due to the nonce advanced instruction.
If the transaction fails due to an instruction other than the nonce advanced instruction #
In the normal case of failing transactions, the known behavior is that all the instructions in the transaction will get reverted to the original state. But in the case of a durable transaction, if any instruction fails that is not the advance nonce instruction, the nonce will still get advanced and all other instructions will get reverted. This feature is designed for security purposes, ensuring that once a user signs a transaction, if it fails, it cannot be used again.
Presigned, never expiring, durable transactions are like signed paychecks. They can be dangerous in the right scenarios. This extra safety feature effectively "voids" the paycheck if handled incorrectly.
If the transaction fails due to the nonce advanced instruction #
If a transaction fails because of the advance instruction, the entire transaction is reverted, meaning the nonce does not advance.
Lab #
In this lab, we'll learn how to create a durable transaction. We'll focus on what you can and can't do with it. Additionally, we'll discuss some edge cases and how to handle them.
0. Getting started #
Let's go ahead and clone our starter code
git clone https://github.com/Unboxed-Software/solana-lab-durable-nonces
cd Solana-lab-durable-nonces
git checkout starter
npm install
In the starter code, you will find a file inside test/index.ts
, with a testing
skeleton, we'll write all of our code here.
We're going to use the local validator for this lab. However, feel free to use devnet if you'd like. ( If you have issues airdropping on devnet, check out Solana's Faucet )
To run the local validator, you'll need to have it installed, if you don't you
can refer to installing the Solana CLI, once you
install the CLI you'll have access to the solana-test-validator
.
In a separate terminal run:
solana-test-validator
In test/index.ts
you'll see five tests, these will help us understand durable
nonces better.
We'll discuss each test case in depth.
1. Create the nonce account #
Before we write any tests, let's create a helper function above the describe
block called createNonceAccount
.
It will take the following parameters:
Connection
: Connection to usepayer
: The payernonceKeypair
: The nonce keypairauthority
: Authority over the nonce
It will:
- Assemble and submit a transaction that will:
- Allocate the account that will be the nonce account.
- Initialize the nonce account using the
SystemProgram.nonceInitialize
instruction.
- Fetch the nonce account.
- Serialize the nonce account data and return it.
Paste the following somewhere above the describe
block.
async function createNonceAccount(
connection: Connection,
payer: Keypair,
nonceKeypair: Keypair,
authority: PublicKey,
) {
const rentExemptBalance =
await connection.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH);
// 2. Assemble and submit a transaction that will:
const tx = new Transaction().add(
// 2.1. Allocate the account that will be the nonce account.
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: nonceKeypair.publicKey,
lamports: rentExemptBalance,
space: NONCE_ACCOUNT_LENGTH,
programId: SystemProgram.programId,
}),
// 2.2. Initialize the nonce account using the `SystemProgram.nonceInitialize` instruction.
SystemProgram.nonceInitialize({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: authority,
}),
);
const sig = await sendAndConfirmTransaction(connection, tx, [
payer,
nonceKeypair,
]);
console.log("Creating Nonce TX:", getExplorerLink("tx", sig, "localnet"));
// 3. Fetch the nonce account.
const accountInfo = await connection.getAccountInfo(nonceKeypair.publicKey);
// 4. Serialize the nonce account data and return it.
return NonceAccount.fromAccountData(accountInfo!.data);
}
2. Test: Create and submit a durable transaction #
To create and submit a durable transaction we must follow these steps:
- Create a Durable Transaction.
- Create the nonce account.
- Create a new transaction.
- Set the
recentBlockhash
to be the nonce value. - Add the
nonceAdvance
instruction as the first instruction in the transaction. - Add the transfer instruction (you can add any instruction you want here).
- Sign the transaction with the keypairs that need to sign it, and make sure to add the nonce authority as a signer as well.
- Serialize the transaction and encode it.
- At this point you have a durable transaction, you can store it in a database or a file or send it somewhere else, etc.
- Submit the durable transaction.
- Decode the serialized transaction.
- Submit it using the
sendAndConfirmRawTransaction
function.
We can put all of this together in our first test:
it("Creates a durable transaction and submits it", async () => {
// Step 1: Initialize the payer
const payer = await initializeKeypair(connection, {
airdropAmount: AIRDROP_AMOUNT,
minimumBalance: MINIMUM_BALANCE,
});
// Step 1.1: Create keypairs for nonce account and recipient
const [nonceKeypair, recipient] = makeKeypairs(2);
// Step 1.2: Create the nonce account
const nonceAccount = await createNonceAccount(
connection,
payer,
nonceKeypair,
payer.publicKey,
);
// Step 1.3: Create a new transaction
const durableTx = new Transaction();
durableTx.feePayer = payer.publicKey;
// Step 1.4: Set the recentBlockhash to the nonce value from the nonce account
durableTx.recentBlockhash = nonceAccount.nonce;
// Step 1.5: Add the `nonceAdvance` instruction as the first instruction
durableTx.add(
SystemProgram.nonceAdvance({
authorizedPubkey: payer.publicKey,
noncePubkey: nonceKeypair.publicKey,
}),
);
// Step 1.6: Add the transfer instruction
durableTx.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: TRANSFER_AMOUNT,
}),
);
// Step 1.7: Sign the transaction with the payer's keypair
await durableTx.partialSign(payer);
// Step 1.8: Serialize the transaction (base64 encoding for easier handling)
const serializedTx = durableTx
.serialize({ requireAllSignatures: false })
.toString("base64");
// Step 1.9: At this point, you can store the durable transaction for future use.
// ------------------------------------------------------------------
// Step 2: Submit the durable transaction
// Step 2.1: Decode the serialized transaction
const tx = Buffer.from(serializedTx, "base64");
// Step 2.2: Submit the transaction using `sendAndConfirmRawTransaction`
const sig = await sendAndConfirmRawTransaction(connection, tx, {
skipPreflight: true,
});
// Step 2.3: Generate and log the explorer link using `getExplorerLink`
console.log("Transaction Signature:", getExplorerLink("tx", sig, "localnet"));
});
3. Test: Transaction fails if the nonce has advanced #
Because we are using the nonce in place of the recent blockhash, the system will
check to ensure that the nonce we provided matches the nonce in the
nonce_account
. Additionally with each transaction, we need to add the
nonceAdvance
instruction is the first instruction. This ensures that if the
transaction goes through, the nonce will change, and no one will be able to
submit it twice.
Here is what we'll test:
- Create a durable transaction just like in the previous step.
- Advance the nonce.
- Try to submit the transaction, and it should fail.
it("Fails if the nonce has advanced", async () => {
try {
const payer = await initializeKeypair(connection, {
airdropAmount: AIRDROP_AMOUNT,
minimumBalance: MINIMUM_BALANCE,
});
const [nonceKeypair, nonceAuthority, recipient] = makeKeypairs(3);
// Step 1: Create a Durable Transaction
const nonceAccount = await createNonceAccount(
connection,
payer,
nonceKeypair,
nonceAuthority.publicKey,
);
const durableTransaction = new Transaction();
durableTransaction.feePayer = payer.publicKey;
durableTransaction.recentBlockhash = nonceAccount.nonce;
// Add a nonce advance instruction
durableTransaction.add(
SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthority.publicKey,
noncePubkey: nonceKeypair.publicKey,
}),
);
// Add a transfer instruction
durableTransaction.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: TRANSFER_AMOUNT,
}),
);
// Sign the transaction with both the payer and nonce authority's keypairs
await durableTransaction.partialSign(payer, nonceAuthority);
// Serialize the transaction (in base64 format for simplicity)
const serializedTransaction = durableTransaction
.serialize({ requireAllSignatures: false })
.toString("base64");
// Step 2: Advance the nonce
const nonceAdvanceSignature = await sendAndConfirmTransaction(
connection,
new Transaction().add(
SystemProgram.nonceAdvance({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
}),
),
[payer, nonceAuthority],
);
// Using getExplorerLink from solana-helpers
console.log(
"Nonce Advance Signature:",
getExplorerLink("tx", nonceAdvanceSignature, "localnet"),
);
// Deserialize the transaction
const deserializedTransaction = Buffer.from(
serializedTransaction,
"base64",
);
// Step 3: Try to submit the transaction, expecting it to fail due to nonce advancement
await assert.rejects(
sendAndConfirmRawTransaction(connection, deserializedTransaction),
);
} catch (error) {
console.error("Test failed:", error);
throw error;
}
});
4. Test: Nonce account advances even if the transaction fails #
An important edge case to be aware of is that even if a transaction fails for any reason other than the nonce advance instruction, the nonce will still advance. This feature is designed for security purposes, ensuring that once a user signs a transaction and it fails, that durable transaction cannot be used again.
The following code demonstrates this use case. We'll attempt to create a durable transaction to transfer 50 SOL from the payer to the recipient. However, the payer doesn't have enough SOL for the transfer, so the transaction will fail, but the nonce will still advance.
it("Advances the nonce account even if the transaction fails", async () => {
const TRANSFER_AMOUNT = 50;
const payer = await initializeKeypair(connection, {
airdropAmount: 3 * LAMPORTS_PER_SOL,
minimumBalance: 1 * LAMPORTS_PER_SOL,
});
// Generate keypairs for nonce account, nonce authority, and recipient
const [nonceKeypair, nonceAuthority, recipient] = makeKeypairs(3);
// Step 1: Create the nonce account
const nonceAccount = await createNonceAccount(
connection,
payer,
nonceKeypair,
nonceAuthority.publicKey,
);
const nonceBeforeAdvancing = nonceAccount.nonce;
console.log("Nonce Before Advancing:", nonceBeforeAdvancing);
// Step 2: Check payer's balance to ensure it doesn't have enough to transfer
const balance = await connection.getBalance(payer.publicKey);
// Ensure the balance is less than the transfer amount (50 SOL)
assert(
balance < TRANSFER_AMOUNT * LAMPORTS_PER_SOL,
`Balance too high! Adjust 'TRANSFER_AMOUNT' to be higher than the current balance of ${balance / LAMPORTS_PER_SOL} SOL.`,
);
// Step 3: Create a durable transaction that will fail
const durableTx = new Transaction();
durableTx.feePayer = payer.publicKey;
// Set the recent blockhash to the nonce value from the nonce account
durableTx.recentBlockhash = nonceAccount.nonce;
// Step 4: Add the nonce advance instruction as the first instruction
durableTx.add(
SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthority.publicKey,
noncePubkey: nonceKeypair.publicKey,
}),
);
// Step 5: Add a transfer instruction that will fail (since the payer has insufficient funds)
durableTx.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: TRANSFER_AMOUNT * LAMPORTS_PER_SOL,
}),
);
// Step 6: Sign the transaction with both the payer and nonce authority
durableTx.sign(payer, nonceAuthority);
// Serialize the transaction and store or send it (if needed)
const serializedTx = base58.encode(
durableTx.serialize({ requireAllSignatures: false }),
);
const tx = base58.decode(serializedTx);
// Step 7: Send the transaction and expect it to fail (due to insufficient funds)
await assert.rejects(
sendAndConfirmRawTransaction(connection, tx as Buffer, {
skipPreflight: true, // Ensure the transaction reaches the network despite the expected failure
}),
);
// Step 8: Fetch the nonce account again after the failed transaction
const nonceAccountAfterAdvancing = await connection.getAccountInfo(
nonceKeypair.publicKey,
);
const nonceAfterAdvancing = NonceAccount.fromAccountData(
nonceAccountAfterAdvancing!.data,
).nonce;
// Step 9: Verify that the nonce has advanced even though the transaction failed
assert.notEqual(nonceBeforeAdvancing, nonceAfterAdvancing);
});
Notice that we are setting skipPreflight: true
in the
sendAndConfirmRawTransaction
function. This step is crucial because, without
it, the transaction would never reach the network. Instead, the library would
reject it and throw an error, leading to a failure where the nonce does not
advance.
However, this is not the whole story. In the upcoming test case, we'll discover a scenario where even if the transaction fails, the nonce will not advance.
5. Test: Nonce account will not advance if the transaction fails because of the nonce advance instruction #
For the nonce to advance, the advanceNonce
instruction must succeed. Thus, if
the transaction fails for any reason related to this instruction, the nonce will
not advance.
A well-formatted nonceAdvance
instruction will only ever fail if the nonce
authority did not sign the transaction.
Let's see this in action.
it("The nonce account will not advance if the transaction fails because the nonce authority did not sign the transaction", async () => {
// Step 1: Initialize payer with SOL airdrop
const payer = await initializeKeypair(connection, {
airdropAmount: 3 * LAMPORTS_PER_SOL,
minimumBalance: 1 * LAMPORTS_PER_SOL,
});
// Step 2: Generate keypairs for nonce account, nonce authority, and recipient
const [nonceKeypair, nonceAuthority, recipient] = makeKeypairs(3);
// Step 3: Create the nonce account
const nonceAccount = await createNonceAccount(
connection,
payer,
nonceKeypair,
nonceAuthority.publicKey,
);
const nonceBeforeAdvancing = nonceAccount.nonce;
console.log("Nonce before submitting:", nonceBeforeAdvancing);
// Step 4: Create a durable transaction that will fail (due to missing nonce authority signature)
const durableTx = new Transaction();
durableTx.feePayer = payer.publicKey;
// Use the nonce account's stored nonce as the recent blockhash
durableTx.recentBlockhash = nonceAccount.nonce;
// Add nonce advance instruction
durableTx.add(
SystemProgram.nonceAdvance({
authorizedPubkey: nonceAuthority.publicKey,
noncePubkey: nonceKeypair.publicKey,
}),
);
// Add transfer instruction
durableTx.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: 0.1 * LAMPORTS_PER_SOL,
}),
);
// Sign the transaction only with the payer, omitting nonce authority signature (this will cause the failure)
durableTx.sign(payer);
// Step 5: Serialize the transaction
const serializedTx = base58.encode(
durableTx.serialize({ requireAllSignatures: false }),
);
// Decode the serialized transaction
const tx = base58.decode(serializedTx);
// Step 6: Send the transaction and expect it to fail (due to missing nonce authority signature)
await assert.rejects(
sendAndConfirmRawTransaction(connection, tx as Buffer, {
skipPreflight: true, // Ensure the transaction reaches the network despite the expected failure
}),
);
// Step 7: Fetch the nonce account again after the failed transaction
const nonceAccountAfterAdvancing = await connection.getAccountInfo(
nonceKeypair.publicKey,
);
const nonceAfterAdvancing = NonceAccount.fromAccountData(
nonceAccountAfterAdvancing!.data,
).nonce;
// Step 8: Verify that the nonce has not advanced, as the failure was due to the nonce advance instruction
assert.equal(nonceBeforeAdvancing, nonceAfterAdvancing);
});
6. Test sign transaction and then change nonce authority #
The last test case we'll go over is creating a durable transaction. Try to send it with the wrong nonce authority (it will fail). Change the nonce authority and send it with the correct one this time and it will succeed.
it("Submits after changing the nonce authority to an already signed address", async () => {
try {
// Step 1: Initialize payer with an airdrop
const payer = await initializeKeypair(connection, {
airdropAmount: AIRDROP_AMOUNT,
minimumBalance: MINIMUM_BALANCE,
});
// Step 2: Generate keypairs for nonce account, nonce authority, and recipient
const [nonceKeypair, nonceAuthority, recipient] = makeKeypairs(3);
// Step 3: Create the nonce account
const nonceAccount = await createNonceAccount(
connection,
payer,
nonceKeypair,
nonceAuthority.publicKey,
);
const nonceBeforeAdvancing = nonceAccount.nonce;
console.log("Nonce before submitting:", nonceBeforeAdvancing);
// Step 4: Create a durable transaction that will initially fail
const durableTransaction = new Transaction();
durableTransaction.feePayer = payer.publicKey;
// Use the nonceAccount's stored nonce as the recent blockhash
durableTransaction.recentBlockhash = nonceAccount.nonce;
// Add nonce advance instruction
durableTransaction.add(
SystemProgram.nonceAdvance({
authorizedPubkey: payer.publicKey, // should be nonce authority, will fail
noncePubkey: nonceKeypair.publicKey,
}),
);
// Add a transfer instruction
durableTransaction.add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: TRANSACTION_LAMPORTS,
}),
);
// Sign the transaction with the payer
durableTransaction.sign(payer);
// Step 5: Serialize and store the transaction
const serializedTransaction = base58.encode(
durableTransaction.serialize({ requireAllSignatures: false }),
);
const deserializedTx = base58.decode(serializedTransaction);
// Step 6: Attempt to send the transaction, expect it to fail (due to incorrect authority)
await assert.rejects(
sendAndConfirmRawTransaction(connection, deserializedTx as Buffer, {
skipPreflight: true, // Ensures the transaction hits the network despite failure
}),
);
// Step 7: Verify that the nonce did not advance after the failed transaction
const nonceAccountAfterAdvancing = await connection.getAccountInfo(
nonceKeypair.publicKey,
);
const nonceAfterAdvancing = NonceAccount.fromAccountData(
nonceAccountAfterAdvancing!.data,
).nonce;
assert.equal(nonceBeforeAdvancing, nonceAfterAdvancing);
// Step 8: Change the nonce authority to the payer
const nonceAuthSignature = await sendAndConfirmTransaction(
connection,
new Transaction().add(
SystemProgram.nonceAuthorize({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: nonceAuthority.publicKey,
newAuthorizedPubkey: payer.publicKey, // changing authority to payer
}),
),
[payer, nonceAuthority],
);
console.log(
"Nonce Auth Signature:",
getExplorerLink("tx", nonceAuthSignature, "localnet"),
);
// Step 9: Submit the transaction again, which should now succeed
const transactionSignature = await sendAndConfirmRawTransaction(
connection,
deserializedTx as Buffer,
{
skipPreflight: true, // Ensures submission without preflight checks
},
);
console.log(
"Transaction Signature:",
getExplorerLink("tx", transactionSignature, "localnet"),
);
} catch (error) {
console.error("Test failed:", error);
throw error;
}
});
8. Run the tests #
Finally, let's run the tests:
npm start
Ensure that all tests pass successfully.
For your reference, here is a screenshot showing the successful execution of the tests:
image
If you see this result, it means your durable nonce implementation is correct!
Congratulations! You now know how durable nonces work!
Challenge #
Write a program that creates a durable transaction and saves it to a file, then create a separate program that reads the durable transaction file and sends it to the network.