Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/all-buses-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@solana/transactions': patch
'@solana/errors': patch
---

Add a function to extract the lifetime from a CompiledTransactionMessage
2 changes: 2 additions & 0 deletions packages/errors/src/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export const SOLANA_ERROR__TRANSACTION__FAILED_TO_ESTIMATE_COMPUTE_LIMIT = 56630
export const SOLANA_ERROR__TRANSACTION__FAILED_WHEN_SIMULATING_TO_ESTIMATE_COMPUTE_LIMIT = 5663019;
export const SOLANA_ERROR__TRANSACTION__EXCEEDS_SIZE_LIMIT = 5663020;
export const SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED = 5663021;
export const SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE = 5663022;

// Transaction errors.
// Reserve error codes starting with [7050000-7050999] for the Rust enum `TransactionError`.
Expand Down Expand Up @@ -531,6 +532,7 @@ export type SolanaErrorCode =
| typeof SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_CANNOT_PAY_FEES
| typeof SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_MUST_NOT_BE_WRITABLE
| typeof SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH
| typeof SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE
| typeof SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING
| typeof SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED
| typeof SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE
Expand Down
4 changes: 4 additions & 0 deletions packages/errors/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ import {
SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_CANNOT_PAY_FEES,
SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_MUST_NOT_BE_WRITABLE,
SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH,
SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE,
SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE,
Expand Down Expand Up @@ -636,6 +637,9 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined<
signaturesLength: number;
signerAddresses: string[];
};
[SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE]: {
nonce: string;
};
[SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING]: {
addresses: string[];
};
Expand Down
3 changes: 3 additions & 0 deletions packages/errors/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ import {
SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_CANNOT_PAY_FEES,
SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_MUST_NOT_BE_WRITABLE,
SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH,
SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE,
SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE,
Expand Down Expand Up @@ -651,4 +652,6 @@ export const SolanaErrorMessages: Readonly<{
'Transaction version must be in the range [0, 127]. `$actualVersion` given',
[SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED]:
'This version of Kit does not support decoding transactions with version $unsupportedVersion. The current max supported version is 0.',
[SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE]:
'The transaction has a durable nonce lifetime (with nonce `$nonce`), but the nonce account address is in a lookup table. The lifetime constraint cannot be constructed without fetching the lookup tables for the transaction.',
};
151 changes: 151 additions & 0 deletions packages/transactions/src/__tests__/lifetime-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Address } from '@solana/addresses';
import { SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, SolanaError } from '@solana/errors';
import { Blockhash } from '@solana/rpc-types';
import {
CompiledTransactionMessage,
CompiledTransactionMessageWithLifetime,
Nonce,
} from '@solana/transaction-messages';

import { getTransactionLifetimeConstraintFromCompiledTransactionMessage } from '../lifetime';

const SYSTEM_PROGRAM_ADDRESS = '11111111111111111111111111111111' as Address;
const U64_MAX = 2n ** 64n - 1n;
Comment thread
lorisleiva marked this conversation as resolved.

describe('getTransactionLifetimeConstraintFromCompiledTransactionMessage', () => {
it('returns a blockhash transaction lifetime when there are no instructions', async () => {
expect.assertions(1);
const compiledTransactionMessage = {
instructions: [],
lifetimeToken: 'abc',
} as unknown as CompiledTransactionMessage & CompiledTransactionMessageWithLifetime;

await expect(
getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage),
).resolves.toEqual({ blockhash: 'abc' as Blockhash, lastValidBlockHeight: U64_MAX });
});

describe('returns a blockhash transaction lifetime when the first instruction is not an AdvanceNonceAccount instruction', () => {
Comment thread
mcintyre94 marked this conversation as resolved.
it('because the program is not the System Program', async () => {
expect.assertions(1);
const compiledTransactionMessage = {
instructions: [
{
accountIndices: [1, 2, 3],
data: new Uint8Array([4, 0, 0, 0]),
programAddressIndex: 0,
},
],
lifetimeToken: 'abc',
staticAccounts: ['otherProgramAddress'],
} as unknown as CompiledTransactionMessage & CompiledTransactionMessageWithLifetime;

await expect(
getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage),
).resolves.toEqual({ blockhash: 'abc' as Blockhash, lastValidBlockHeight: U64_MAX });
});

it('because the instruction data is not for AdvanceNonceAccount', async () => {
expect.assertions(1);
const compiledTransactionMessage = {
instructions: [
{
accountIndices: [1, 2, 3],
data: new Uint8Array([1, 0, 0, 0]),
programAddressIndex: 0,
},
],
lifetimeToken: 'abc',
staticAccounts: [SYSTEM_PROGRAM_ADDRESS],
} as unknown as CompiledTransactionMessage & CompiledTransactionMessageWithLifetime;

await expect(
getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage),
).resolves.toEqual({ blockhash: 'abc' as Blockhash, lastValidBlockHeight: U64_MAX });
});

it('because the instruction does not have exactly 3 accounts', async () => {
expect.assertions(1);
const compiledTransactionMessage = {
instructions: [
{
accountIndices: [1, 2],
data: new Uint8Array([4, 0, 0, 0]),
programAddressIndex: 0,
},
],
lifetimeToken: 'abc',
staticAccounts: [SYSTEM_PROGRAM_ADDRESS],
} as unknown as CompiledTransactionMessage & CompiledTransactionMessageWithLifetime;

await expect(
getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage),
).resolves.toEqual({ blockhash: 'abc' as Blockhash, lastValidBlockHeight: U64_MAX });
});

it('because it has no account indices', async () => {
expect.assertions(1);
const compiledTransactionMessage = {
instructions: [
{
data: new Uint8Array([4, 0, 0, 0]),
programAddressIndex: 0,
},
],
lifetimeToken: 'abc',
staticAccounts: [SYSTEM_PROGRAM_ADDRESS],
} as unknown as CompiledTransactionMessage & CompiledTransactionMessageWithLifetime;

await expect(
getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage),
).resolves.toEqual({ blockhash: 'abc' as Blockhash, lastValidBlockHeight: U64_MAX });
});
});

it('returns a durable nonce transaction lifetime when the first instruction is an AdvanceNonceAccount instruction', async () => {
expect.assertions(1);
const compiledTransactionMessage = {
instructions: [
{
accountIndices: [1, 2, 3],
data: new Uint8Array([4, 0, 0, 0]),
programAddressIndex: 0,
},
],
lifetimeToken: 'abc',
staticAccounts: [
'11111111111111111111111111111111',
'nonceAccountAddress',
'recentBlockhashesSysvarAddress',
'nonceAuthorityAddress',
],
} as unknown as CompiledTransactionMessage & CompiledTransactionMessageWithLifetime;

await expect(
getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage),
).resolves.toEqual({ nonce: 'abc' as Nonce, nonceAccountAddress: 'nonceAccountAddress' });
});

it('fatals if the nonce account address is not in static accounts', async () => {
expect.assertions(1);
const compiledTransactionMessage = {
instructions: [
{
accountIndices: [1, 2, 3],
data: new Uint8Array([4, 0, 0, 0]),
programAddressIndex: 0,
},
],
lifetimeToken: 'abc',
staticAccounts: ['11111111111111111111111111111111'],
} as unknown as CompiledTransactionMessage & CompiledTransactionMessageWithLifetime;

await expect(() =>
getTransactionLifetimeConstraintFromCompiledTransactionMessage(compiledTransactionMessage),
).rejects.toThrow(
new SolanaError(SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, {
nonce: 'abc',
}),
);
});
});
64 changes: 64 additions & 0 deletions packages/transactions/src/lifetime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Address } from '@solana/addresses';
import { ReadonlyUint8Array } from '@solana/codecs-core';
import { SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, SolanaError } from '@solana/errors';
import type { Blockhash, Slot } from '@solana/rpc-types';
import type {
CompiledTransactionMessage,
CompiledTransactionMessageWithLifetime,
Nonce,
TransactionMessage,
TransactionMessageWithBlockhashLifetime,
Expand Down Expand Up @@ -102,3 +106,63 @@ export type SetTransactionLifetimeFromTransactionMessage<
? TransactionWithDurableNonceLifetime & TTransaction
: TransactionWithLifetime & TTransaction
: TTransaction;

const SYSTEM_PROGRAM_ADDRESS = '11111111111111111111111111111111' as Address;

function compiledInstructionIsAdvanceNonceInstruction(
instruction: CompiledTransactionMessage['instructions'][number],
staticAddresses: Address[],
): instruction is typeof instruction & { accountIndices: [number, number, number] } {
return (
staticAddresses[instruction.programAddressIndex] === SYSTEM_PROGRAM_ADDRESS &&
// Test for `AdvanceNonceAccount` instruction data
instruction.data != null &&
isAdvanceNonceAccountInstructionData(instruction.data) &&
// Test for exactly 3 accounts
instruction.accountIndices?.length === 3
);
}

function isAdvanceNonceAccountInstructionData(data: ReadonlyUint8Array): boolean {
// AdvanceNonceAccount is the fifth instruction in the System Program (index 4)
return data.byteLength === 4 && data[0] === 4 && data[1] === 0 && data[2] === 0 && data[3] === 0;
}

/**
* Get the lifetime constraint for a transaction from a compiled transaction message that includes a lifetime token.
* @param compiledTransactionMessage A compiled transaction message that includes a lifetime token
* @returns A lifetime constraint for the transaction
* Note that this is less precise than checking a decompiled instruction, as we can't inspect
* the address or role of input accounts (which may be in lookup tables). However, this is
* sufficient for all valid advance durable nonce instructions.
* Note that the program address must not be in a lookup table, see [this answer on StackExchange](https://solana.stackexchange.com/a/16224/289)
* @see {@link isAdvanceNonceAccountInstruction}
* Note that this function is async to allow for future implementations that may fetch `lastValidBlockHeight` using an RPC
*/
// eslint-disable-next-line @typescript-eslint/require-await
export async function getTransactionLifetimeConstraintFromCompiledTransactionMessage(
Comment thread
lorisleiva marked this conversation as resolved.
compiledTransactionMessage: CompiledTransactionMessage & CompiledTransactionMessageWithLifetime,
): Promise<TransactionBlockhashLifetime | TransactionDurableNonceLifetime> {
const firstInstruction = compiledTransactionMessage.instructions[0];
const { staticAccounts } = compiledTransactionMessage;

// We need to check if the first instruction is an AdvanceNonceAccount instruction
if (firstInstruction && compiledInstructionIsAdvanceNonceInstruction(firstInstruction, staticAccounts)) {
const nonceAccountAddress = staticAccounts[firstInstruction.accountIndices[0]];
if (!nonceAccountAddress) {
throw new SolanaError(SOLANA_ERROR__TRANSACTION__NONCE_ACCOUNT_CANNOT_BE_IN_LOOKUP_TABLE, {
nonce: compiledTransactionMessage.lifetimeToken,
});
}
return {
nonce: compiledTransactionMessage.lifetimeToken as Nonce,
nonceAccountAddress,
};
} else {
return {
blockhash: compiledTransactionMessage.lifetimeToken as Blockhash,
// This is not known from the compiled message, so we set it to the maximum possible value
lastValidBlockHeight: 0xffffffffffffffffn,
};
}
}