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
7 changes: 7 additions & 0 deletions .changeset/fast-pumas-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@solana/transaction-messages': patch
'@solana/transactions': patch
'@solana/errors': patch
---

Do not allow decoding transactions with an unsupported version
2 changes: 2 additions & 0 deletions packages/errors/src/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export const SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH = 5663017;
export const SOLANA_ERROR__TRANSACTION__FAILED_TO_ESTIMATE_COMPUTE_LIMIT = 5663018;
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;

// 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_MUST_NOT_BE_WRITABLE
| typeof SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH
| typeof SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING
| typeof SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED
| typeof SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE
| typeof SOLANA_ERROR__TRANSACTION_ERROR__ACCOUNT_BORROW_OUTSTANDING
| typeof SOLANA_ERROR__TRANSACTION_ERROR__ACCOUNT_IN_USE
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 @@ -162,6 +162,7 @@ import {
SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_MUST_NOT_BE_WRITABLE,
SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH,
SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE,
SOLANA_ERROR__TRANSACTION_ERROR__DUPLICATE_INSTRUCTION,
SOLANA_ERROR__TRANSACTION_ERROR__INSUFFICIENT_FUNDS_FOR_RENT,
Expand Down Expand Up @@ -638,6 +639,9 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined<
[SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING]: {
addresses: string[];
};
[SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED]: {
unsupportedVersion: number;
};
[SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE]: {
actualVersion: number;
};
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 @@ -205,6 +205,7 @@ import {
SOLANA_ERROR__TRANSACTION__INVOKED_PROGRAMS_MUST_NOT_BE_WRITABLE,
SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH,
SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE,
SOLANA_ERROR__TRANSACTION_ERROR__ACCOUNT_BORROW_OUTSTANDING,
SOLANA_ERROR__TRANSACTION_ERROR__ACCOUNT_IN_USE,
Expand Down Expand Up @@ -648,4 +649,6 @@ export const SolanaErrorMessages: Readonly<{
[SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING]: 'Transaction is missing signatures for addresses: $addresses.',
[SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE]:
'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.',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this better than what I wrote in the offchain messages PR. I'm going to use this instead.

};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Decoder, Encoder } from '@solana/codecs-core';
import { SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, SolanaError } from '@solana/errors';

import { TransactionVersion } from '../../transaction-message';
import {
Expand All @@ -10,6 +11,7 @@ import {
const VERSION_FLAG_MASK = 0x80;
const VERSION_TEST_CASES = // Versions 0–127
[...Array(128).keys()].map(version => [version | VERSION_FLAG_MASK, version as TransactionVersion] as const);
const UNSUPPORTED_VERSION_TEST_CASES = VERSION_TEST_CASES.slice(1); // versions 1-127

describe.each([getTransactionVersionCodec, getTransactionVersionEncoder])(
'Transaction version encoder',
Expand All @@ -21,8 +23,15 @@ describe.each([getTransactionVersionCodec, getTransactionVersionEncoder])(
it('serializes no data when the version is `legacy`', () => {
expect(transactionVersion.encode('legacy')).toEqual(new Uint8Array());
});
it.each(VERSION_TEST_CASES)('serializes to `%s` when the version is `%s`', (expected, version) => {
expect(transactionVersion.encode(version)).toEqual(new Uint8Array([expected]));
it('serializes to `0x80` when the version is `0`', () => {
expect(transactionVersion.encode(0)).toEqual(new Uint8Array([0x80]));
});
it.each(UNSUPPORTED_VERSION_TEST_CASES)('fatals for unsupported version `%s`', (_byte, version) => {
expect(() => transactionVersion.encode(version)).toThrow(
new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, {
unsupportedVersion: version,
}),
);
});
it.each([-1 as TransactionVersion, 128 as TransactionVersion])(
'throws when passed the out-of-range version `%s`',
Expand All @@ -40,9 +49,6 @@ describe.each([getTransactionVersionCodec, getTransactionVersionDecoder])(
beforeEach(() => {
transactionVersion = serializerFactory();
});
it.each(VERSION_TEST_CASES)('deserializes `%s` to the version `%s`', (byte, expected) => {
expect(transactionVersion.decode(new Uint8Array([byte]))).toEqual(expected);
});
it('deserializes to `legacy` when missing the version flag', () => {
expect(
transactionVersion.decode(
Expand All @@ -51,5 +57,19 @@ describe.each([getTransactionVersionCodec, getTransactionVersionDecoder])(
),
).toBe('legacy');
});
it('deserializes to 0 for a version 0 transaction', () => {
expect(
transactionVersion.decode(
new Uint8Array([0 | VERSION_FLAG_MASK]), // version 0 with the version flag
),
).toBe(0);
});
it.each(UNSUPPORTED_VERSION_TEST_CASES)('fatals for unsupported version `%s`', (byte, version) => {
expect(() => transactionVersion.decode(new Uint8Array([byte]))).toThrow(
new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, {
unsupportedVersion: version,
}),
);
});
},
);
23 changes: 19 additions & 4 deletions packages/transaction-messages/src/codecs/transaction-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import {
VariableSizeDecoder,
VariableSizeEncoder,
} from '@solana/codecs-core';
import { SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE, SolanaError } from '@solana/errors';
import {
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_OUT_OF_RANGE,
SolanaError,
} from '@solana/errors';

import { TransactionVersion } from '../transaction-message';
import { MAX_SUPPORTED_TRANSACTION_VERSION, TransactionVersion } from '../transaction-message';

const VERSION_FLAG_MASK = 0x80;

Expand All @@ -31,6 +35,12 @@ export function getTransactionVersionEncoder(): VariableSizeEncoder<TransactionV
actualVersion: value,
});
}

if (value > MAX_SUPPORTED_TRANSACTION_VERSION) {
throw new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, {
unsupportedVersion: value,
});
}
Comment on lines +39 to +43
Copy link
Copy Markdown
Member Author

@mcintyre94 mcintyre94 Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've also disallowed encoding a TransactionVersion > 0, but I haven't added other checks for encoding because I don't think there's risk of accidentally encoding the wrong versions from Kit APIs, and we have a Typescript type for valid versions.

I mostly did this because I don't think it makes sense to have different supported/unsupported versions in the encode and decode tests.

bytes.set([value | VERSION_FLAG_MASK], offset);
return offset + 1;
},
Expand All @@ -53,8 +63,13 @@ export function getTransactionVersionDecoder(): VariableSizeDecoder<TransactionV
// No version flag set; it's a legacy (unversioned) transaction.
return ['legacy', offset];
} else {
const version = (firstByte ^ VERSION_FLAG_MASK) as TransactionVersion;
return [version, offset + 1];
const version = firstByte ^ VERSION_FLAG_MASK;
if (version > MAX_SUPPORTED_TRANSACTION_VERSION) {
throw new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, {
unsupportedVersion: version,
});
}
return [version as TransactionVersion, offset + 1];
}
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/transaction-messages/src/compile/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ type VersionedCompiledTransactionMessage = BaseCompiledTransactionMessage &
Readonly<{
/** A list of address tables and the accounts that this transaction loads from them */
addressTableLookups?: ReturnType<typeof getCompiledAddressTableLookups>;
version: number;
version: 0;
}>;

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/transaction-messages/src/transaction-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type BaseTransactionMessage<
version: TVersion;
}>;

export const MAX_SUPPORTED_TRANSACTION_VERSION = 0;

type LegacyInstruction<TProgramAddress extends string = string> = Instruction<TProgramAddress, readonly AccountMeta[]>;
type LegacyTransactionMessage = BaseTransactionMessage<'legacy', LegacyInstruction>;
type V0TransactionMessage = BaseTransactionMessage<0, Instruction>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import '@solana/test-matchers/toBeFrozenObject';

import { Address } from '@solana/addresses';
import { ReadonlyUint8Array, VariableSizeDecoder, VariableSizeEncoder } from '@solana/codecs-core';
import { SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH, SolanaError } from '@solana/errors';
import {
SOLANA_ERROR__TRANSACTION__MESSAGE_SIGNATURES_MISMATCH,
SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED,
SolanaError,
} from '@solana/errors';
import { SignatureBytes } from '@solana/keys';

import { Transaction, TransactionMessageBytes } from '../../transaction';
Expand Down Expand Up @@ -167,6 +171,40 @@ describe.each([getTransactionDecoder, getTransactionCodec])('Transaction decoder
const decoded = decoder.decode(encodedTransaction);
expect(decoded.signatures).toBeFrozenObject();
});

it('should fatal for unsupported transaction version 1', () => {
const signature = new Uint8Array(64).fill(0) as ReadonlyUint8Array as SignatureBytes;
const messageBytes = new Uint8Array([
/** VERSION HEADER */
129, // 1 + version mask

/** MESSAGE HEADER */
1, // numSignerAccounts
0, // numReadonlySignerAccount
1, // numReadonlyNonSignerAccounts

/** STATIC ADDRESSES */
2, // Number of static accounts
...addressBytes,
...new Uint8Array(64).fill(12),

/** REST OF TRANSACTION MESSAGE (arbitrary) */
...new Uint8Array(100).fill(1),
]) as ReadonlyUint8Array as TransactionMessageBytes;

const encodedTransaction = new Uint8Array([
/** SIGNATURES */
1, // num signatures
...signature,

/** MESSAGE */
...messageBytes,
]);

expect(() => decoder.decode(encodedTransaction)).toThrow(
new SolanaError(SOLANA_ERROR__TRANSACTION__VERSION_NUMBER_NOT_SUPPORTED, { unsupportedVersion: 1 }),
);
});
});

describe('for a transaction with multiple signatures', () => {
Expand Down