diff --git a/packages/rpc-api/src/getEpochSchedule.ts b/packages/rpc-api/src/getEpochSchedule.ts index d95f6ff24..8c4a5537f 100644 --- a/packages/rpc-api/src/getEpochSchedule.ts +++ b/packages/rpc-api/src/getEpochSchedule.ts @@ -1,7 +1,12 @@ type GetEpochScheduleApiResponse = Readonly<{ - /** First normal-length epoch, log2(slotsPerEpoch) - log2(MINIMUM_SLOTS_PER_EPOCH) */ + /** + * First normal-length epoch after the warmup period, + * log2(slotsPerEpoch) - log2(MINIMUM_SLOTS_PER_EPOCH) + */ firstNormalEpoch: bigint; - /** MINIMUM_SLOTS_PER_EPOCH * (2^(firstNormalEpoch) - 1) */ + /** + * The first slot after the warmup period, MINIMUM_SLOTS_PER_EPOCH * (2^(firstNormalEpoch) - 1) + */ firstNormalSlot: bigint; /** * The number of slots before beginning of an epoch to calculate a leader schedule for that diff --git a/packages/rpc-api/src/getTransaction.ts b/packages/rpc-api/src/getTransaction.ts index 41b8dc560..df7eae30b 100644 --- a/packages/rpc-api/src/getTransaction.ts +++ b/packages/rpc-api/src/getTransaction.ts @@ -24,25 +24,37 @@ type ReturnData = { }; type TransactionMetaBase = Readonly<{ - /** The number of compute units consumed by the transaction */ + /** Number of compute units consumed by the transaction */ computeUnitsConsumed?: bigint; - /** If the transaction failed, this property will contain the error */ + /** Error if transaction failed, `null` if transaction succeeded. */ err: TransactionError | null; /** The fee this transaction was charged, in {@link Lamports} */ fee: Lamports; - /** An array of string log messages or `null` if log message recording was disabled when this transaction was processed */ + /** + * String log messages or `null` if log message recording was not enabled during this + * transaction + */ logMessages: readonly string[] | null; - /** An array of account balances, in {@link Lamports}, after the transaction was processed */ + /** Account balances after the transaction was processed */ postBalances: readonly Lamports[]; - /** List of token balances from after the transaction was processed or omitted if token balance recording was disabled when this transaction was processed */ + /** + * List of token balances from after the transaction was processed or omitted if token balance + * recording was not yet enabled during this transaction + */ postTokenBalances?: readonly TokenBalance[]; - /** An array of account balances, in {@link Lamports}, before the transaction was processed */ + /** Account balances from before the transaction was processed */ preBalances: readonly Lamports[]; - /** List of token balances from before the transaction was processed or omitted if token balance recording was disabled when this transaction was processed */ + /** + * List of token balances from before the transaction was processed or omitted if token balance + * recording was not yet enabled during this transaction + */ preTokenBalances?: readonly TokenBalance[]; /** The most-recent return data generated by an instruction in the transaction */ returnData?: ReturnData; - /** Transaction-level rewards; currently only `"Rent"`, but other types may be added in the future */ + /** + * Transaction-level rewards; currently only `"Rent"`, but other types may be added in the + * future + */ rewards: readonly Reward[] | null; /** @deprecated */ status: TransactionStatus; @@ -51,32 +63,94 @@ type TransactionMetaBase = Readonly<{ type AddressTableLookup = Readonly<{ /** The address of the address lookup table account. */ accountKey: Address; - /** The list of indices used to load addresses of readonly accounts from the lookup table. */ + /** Indices of accounts in a lookup table to load as read-only. */ readableIndexes: readonly number[]; - /** The list of indices used to load addresses of writable accounts from the lookup table. */ + /** Indices of accounts in a lookup table to load as writable. */ writableIndexes: readonly number[]; }>; type TransactionBase = Readonly<{ message: { + /** + * For transactions whose lifetime is specified by a recent blockhash, this is that + * blockhash, and for transactions whose lifetime is specified by a durable nonce, this is + * the nonce value. + */ recentBlockhash: Blockhash; }; + /** + * An ordered list of signatures belonging to the accounts required to sign this transaction. + * + * Each signature is an Ed25519 signature of the transaction message using the private key + * associated with the account required to sign the transaction. + */ signatures: readonly Base58EncodedBytes[]; }>; -type TransactionInstruction = Readonly<{ - accounts: readonly number[]; +type InstructionWithStackHeight = Readonly<{ + /** + * A number indicating the height at which this instruction was called with respect to the + * bottom of the call stack denoted by `1` or `null`. + * + * For instance, an instruction explicitly declared in the transaction message will have a `1` + * or `null` height, the first instruction that it calls using a cross-program invocation (CPI) + * will have a height of 2, an instruction called by that instruction using a CPI will have a + * depth of 3, and so on. + */ + stackHeight: number; // FIXME(https://github.com/anza-xyz/agave/issues/5732) Should be `1` instead of `null` at base of stack +}>; + +type InstructionWithData = Readonly<{ + /** The input to the invoked program */ data: Base58EncodedBytes; - programIdIndex: number; - stackHeight?: number; }>; +type TransactionInstruction = InstructionWithData & + Partial & + Readonly<{ + /** + * An ordered list of indices that indicate which accounts in the transaction message's + * accounts list are loaded by this instruction. + */ + accounts: readonly number[]; + /** + * The index of the address in the transaction message's accounts list associated with the + * program to invoke. + */ + programIdIndex: number; + }>; + type TransactionJson = Readonly<{ message: { + /** An ordered list of addresses belonging to the accounts loaded by this transaction */ accountKeys: readonly Address[]; header: { + /** + * The number of read-only accounts in the static accounts list that must sign this + * transaction. + * + * Subtracting this number from `numRequiredSignatures` yields the index of the first + * read-only signer account in the static accounts list. + */ numReadonlySignedAccounts: number; + /** + * The number of accounts in the static accounts list that are neither writable nor + * signers. + * + * Adding this number to `numRequiredSignatures` yields the index of the first read-only + * non-signer account in the static accounts list. + */ numReadonlyUnsignedAccounts: number; + /** + * The number of accounts in the static accounts list that must sign this transaction. + * + * Subtracting `numReadonlySignedAccounts` from this number yields the number of + * writable signer accounts in the static accounts list. Writable signer accounts always + * begin at index zero in the static accounts list. + * + * This number itself is the index of the first non-signer account in the static + * accounts list. + */ numRequiredSignatures: number; }; instructions: readonly TransactionInstruction[]; @@ -84,32 +158,52 @@ type TransactionJson = Readonly<{ }> & TransactionBase; -type PartiallyDecodedTransactionInstruction = Readonly<{ - accounts: readonly Address[]; - data: Base58EncodedBytes; - programId: Address; - stackHeight?: number; -}>; +type PartiallyDecodedTransactionInstruction = InstructionWithData & + Partial & + Readonly<{ + /** An ordered list of addresses belonging to the accounts loaded by this instruction */ + accounts: readonly Address[]; + /** The address of the program to invoke */ + programId: Address; + }>; -type ParsedTransactionInstruction = Readonly<{ - parsed: { - info?: object; - type: string; - }; - program: string; - programId: Address; - stackHeight?: number; -}>; +type ParsedTransactionInstruction = Partial & + Readonly<{ + /** The output of the program's instruction parser */ + parsed: { + /** The instruction, as interpreted the program's instruction parser. */ + info?: object; + /** + * A label that indicates the type of the instruction, as determined by the program's + * instruction parser. + */ + type: string; + }; + /** The name of the program. */ + program: string; + /** The address of the program */ + programId: Address; + }>; type ParsedAccount = Readonly<{ + /** The address of the account */ pubkey: Address; + /** Whether this account is required to sign the transaction that it's a part of */ signer: boolean; + /** + * Indicates whether the account was statically declared in the transaction message or loaded + * from an address lookup table. + */ source: 'lookupTable' | 'transaction'; + /** Whether this account must be loaded with a write-lock */ writable: boolean; }>; type TransactionJsonParsed = Readonly<{ message: { + /** + * An ordered list of parsed accounts belonging to the accounts loaded by this transaction + */ accountKeys: readonly ParsedAccount[]; instructions: readonly (ParsedTransactionInstruction | PartiallyDecodedTransactionInstruction)[]; }; @@ -166,25 +260,29 @@ type GetTransactionApiResponseBase = Readonly<{ }>; type TransactionMetaLoadedAddresses = Readonly<{ - /** Transaction account addresses loaded from address lookup tables */ + /** Addresses loaded from lookup tables */ loadedAddresses: { - /** Ordered list of base-58 encoded addresses for writable accounts */ - readonly: readonly Address[]; /** Ordered list of base-58 encoded addresses for read-only accounts */ + readonly: readonly Address[]; + /** Ordered list of base-58 encoded addresses for writable accounts */ writable: readonly Address[]; }; }>; type InnerInstructions = Readonly<{ + /** The index of the instruction in the transaction */ index: number; + /** The instructions */ instructions: readonly TInstructionType[]; }>; type TransactionMetaInnerInstructionsNotParsed = Readonly<{ + /** A list of instructions called by programs via cross-program invocation (CPI) */ innerInstructions?: readonly InnerInstructions[] | null; }>; type TransactionMetaInnerInstructionsParsed = Readonly<{ + /** A list of instructions called by programs via cross-program invocation (CPI) */ innerInstructions?: | readonly InnerInstructions[] | null; @@ -192,6 +290,7 @@ type TransactionMetaInnerInstructionsParsed = Readonly<{ type TransactionAddressTableLookups = Readonly<{ message: Readonly<{ + /** A list of address tables and the accounts that this transaction loads from them */ addressTableLookups: readonly AddressTableLookup[]; }>; }>; diff --git a/packages/rpc-graphql/src/resolvers/block.ts b/packages/rpc-graphql/src/resolvers/block.ts index 3c7594568..2e2a93403 100644 --- a/packages/rpc-graphql/src/resolvers/block.ts +++ b/packages/rpc-graphql/src/resolvers/block.ts @@ -83,10 +83,15 @@ export const resolveBlock = (fieldName?: string) => { }) ] = data[0]; } else if (typeof data === 'object') { - const jsonParsedData = data; - jsonParsedData.message.instructions = mapJsonParsedInstructions( - jsonParsedData.message.instructions, - ) as unknown as (typeof jsonParsedData)['message']['instructions']; + const jsonParsedData: typeof data = { + ...data, + message: { + ...data.message, + instructions: mapJsonParsedInstructions( + data.message.instructions, + ) as unknown as (typeof jsonParsedData)['message']['instructions'], + }, + }; const loadedInnerInstructions = loadedTransaction.meta?.innerInstructions; if (loadedInnerInstructions) { diff --git a/packages/rpc-types/src/account-info.ts b/packages/rpc-types/src/account-info.ts index 4328c84f3..e7910c6ba 100644 --- a/packages/rpc-types/src/account-info.ts +++ b/packages/rpc-types/src/account-info.ts @@ -9,15 +9,15 @@ import type { import type { Lamports } from './lamports'; export type AccountInfoBase = Readonly<{ - /** indicates if the account contains a program (and is strictly read-only) */ + /** Indicates if the account contains a program (and is strictly read-only) */ executable: boolean; - /** number of lamports assigned to this account */ + /** Number of {@link Lamports} assigned to this account */ lamports: Lamports; - /** pubkey of the program this account has been assigned to */ + /** Address of the program this account has been assigned to */ owner: Address; - /** the epoch at which this account will next owe rent */ + /** The epoch at which this account will next owe rent */ rentEpoch: bigint; - /** the size of the account data in bytes (excluding the 128 bytes of header) */ + /** The size of the account data in bytes (excluding the 128 bytes of header) */ space: bigint; }>; diff --git a/packages/rpc-types/src/blockhash.ts b/packages/rpc-types/src/blockhash.ts index ffcc24bfc..8fb797eb8 100644 --- a/packages/rpc-types/src/blockhash.ts +++ b/packages/rpc-types/src/blockhash.ts @@ -31,6 +31,24 @@ function getMemoizedBase58Decoder(): Decoder { return memoizedBase58Decoder; } +/** + * A type guard that returns `true` if the input string conforms to the {@link Blockhash} type, and + * refines its type for use in your program. + * + * @example + * ```ts + * import { isBlockhash } from '@solana/rpc-types'; + * + * if (isBlockhash(blockhash)) { + * // At this point, `blockhash` has been refined to a + * // `Blockhash` that can be used with the RPC. + * const { value: isValid } = await rpc.isBlockhashValid(blockhash).send(); + * setBlockhashIsFresh(isValid); + * } else { + * setError(`${blockhash} is not a blockhash`); + * } + * ``` + */ export function isBlockhash(putativeBlockhash: string): putativeBlockhash is Blockhash { // Fast-path; see if the input string is of an acceptable length. if ( @@ -51,6 +69,31 @@ export function isBlockhash(putativeBlockhash: string): putativeBlockhash is Blo return true; } +/** + * From time to time you might acquire a string, that you expect to validate as a blockhash, from an + * untrusted network API or user input. Use this function to assert that such an arbitrary string is + * a base58-encoded blockhash. + * + * @example + * ```ts + * import { assertIsBlockhash } from '@solana/rpc-types'; + * + * // Imagine a function that determines whether a blockhash is fresh when a user submits a form. + * function handleSubmit() { + * // We know only that what the user typed conforms to the `string` type. + * const blockhash: string = blockhashInput.value; + * try { + * // If this type assertion function doesn't throw, then + * // Typescript will upcast `blockhash` to `Blockhash`. + * assertIsBlockhash(blockhash); + * // At this point, `blockhash` is a `Blockhash` that can be used with the RPC. + * const { value: isValid } = await rpc.isBlockhashValid(blockhash).send(); + * } catch (e) { + * // `blockhash` turned out not to be a base58-encoded blockhash + * } + * } + * ``` + */ export function assertIsBlockhash(putativeBlockhash: string): asserts putativeBlockhash is Blockhash { // Fast-path; see if the input string is of an acceptable length. if ( @@ -74,21 +117,85 @@ export function assertIsBlockhash(putativeBlockhash: string): asserts putativeBl } } +/** + * Combines _asserting_ that a string is a blockhash with _coercing_ it to the {@link Blockhash} + * type. It's most useful with untrusted input. + * + * @example + * ```ts + * import { blockhash } from '@solana/rpc-types'; + * + * const { value: isValid } = await rpc.isBlockhashValid(blockhash(blockhashFromUserInput)).send(); + * ``` + * + * > [!TIP] + * > When starting from a known-good blockhash as a string, it's more efficient to typecast it + * rather than to use the {@link blockhash} helper, because the helper unconditionally performs + * validation on its input. + * > + * > ```ts + * > import { Blockhash } from '@solana/rpc-types'; + * > + * > const blockhash = 'ABmPH5KDXX99u6woqFS5vfBGSNyKG42SzpvBMWWqAy48' as Blockhash; + * > ``` + */ export function blockhash(putativeBlockhash: string): Blockhash { assertIsBlockhash(putativeBlockhash); return putativeBlockhash; } +/** + * Returns an encoder that you can use to encode a base58-encoded blockhash to a byte array. + * + * @example + * ```ts + * import { getBlockhashEncoder } from '@solana/rpc-types'; + * + * const blockhash = 'ABmPH5KDXX99u6woqFS5vfBGSNyKG42SzpvBMWWqAy48' as Blockhash; + * const blockhashEncoder = getBlockhashEncoder(); + * const blockhashBytes = blockhashEncoder.encode(blockhash); + * // Uint8Array(32) [ + * // 136, 123, 44, 249, 43, 19, 60, 14, + * // 144, 16, 168, 241, 121, 111, 70, 232, + * // 186, 26, 140, 202, 213, 64, 231, 82, + * // 179, 66, 103, 237, 52, 117, 217, 93 + * // ] + * ``` + */ export function getBlockhashEncoder(): FixedSizeEncoder { return transformEncoder(fixEncoderSize(getMemoizedBase58Encoder(), 32), putativeBlockhash => blockhash(putativeBlockhash), ); } +/** + * Returns a decoder that you can use to convert an array of 32 bytes representing a blockhash to + * the base58-encoded representation of that blockhash. + * + * @example + * ```ts + * import { getBlockhashDecoder } from '@solana/rpc-types'; + * + * const blockhashBytes = new Uint8Array([ + * 136, 123, 44, 249, 43, 19, 60, 14, + * 144, 16, 168, 241, 121, 111, 70, 232, + * 186, 26, 140, 202, 213, 64, 231, 82, + * 179, 66, 103, 237, 52, 117, 217, 93 + * ]); + * const blockhashDecoder = getBlockhashDecoder(); + * const blockhash = blockhashDecoder.decode(blockhashBytes); // ABmPH5KDXX99u6woqFS5vfBGSNyKG42SzpvBMWWqAy48 + * ``` + */ export function getBlockhashDecoder(): FixedSizeDecoder { return fixDecoderSize(getMemoizedBase58Decoder(), 32) as FixedSizeDecoder; } +/** + * Returns a codec that you can use to encode from or decode to a base-58 encoded blockhash. + * + * @see {@link getBlockhashDecoder} + * @see {@link getBlockhashEncoder} + */ export function getBlockhashCodec(): FixedSizeCodec { return combineCodec(getBlockhashEncoder(), getBlockhashDecoder()); } diff --git a/packages/rpc-types/src/cluster-url.ts b/packages/rpc-types/src/cluster-url.ts index 5c8ed6bf0..f173f1cc1 100644 --- a/packages/rpc-types/src/cluster-url.ts +++ b/packages/rpc-types/src/cluster-url.ts @@ -3,12 +3,15 @@ export type DevnetUrl = string & { '~cluster': 'devnet' }; export type TestnetUrl = string & { '~cluster': 'testnet' }; export type ClusterUrl = DevnetUrl | MainnetUrl | TestnetUrl | string; +/** Given a URL casts it to a type that is only accepted where mainnet URLs are expected. */ export function mainnet(putativeString: string): MainnetUrl { return putativeString as MainnetUrl; } +/** Given a URL casts it to a type that is only accepted where devnet URLs are expected. */ export function devnet(putativeString: string): DevnetUrl { return putativeString as DevnetUrl; } +/** Given a URL casts it to a type that is only accepted where testnet URLs are expected. */ export function testnet(putativeString: string): TestnetUrl { return putativeString as TestnetUrl; } diff --git a/packages/rpc-types/src/commitment.ts b/packages/rpc-types/src/commitment.ts index 29023c754..a05708cc3 100644 --- a/packages/rpc-types/src/commitment.ts +++ b/packages/rpc-types/src/commitment.ts @@ -1,5 +1,11 @@ import { SOLANA_ERROR__INVARIANT_VIOLATION__SWITCH_MUST_BE_EXHAUSTIVE, SolanaError } from '@solana/errors'; +/** + * A union of all possible commitment statuses -- each a measure of the network confirmation and + * stake levels on a particular block. + * + * Read more about the statuses themselves, [here](https://docs.solana.com/cluster/commitments). + */ export type Commitment = 'confirmed' | 'finalized' | 'processed'; function getCommitmentScore(commitment: Commitment): number { diff --git a/packages/rpc-types/src/index.ts b/packages/rpc-types/src/index.ts index a915f7a8b..2c4f8314d 100644 --- a/packages/rpc-types/src/index.ts +++ b/packages/rpc-types/src/index.ts @@ -1,3 +1,11 @@ +/** + * This package defines types for values used in the + * [Solana JSON-RPC](https://docs.solana.com/api/http) and a series of helpers for working with + * them. It can be used standalone, but it is also exported as part of Kit + * [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). + * + * @packageDocumentation + */ export * from './account-filters'; export * from './account-info'; export * from './blockhash'; diff --git a/packages/rpc-types/src/lamports.ts b/packages/rpc-types/src/lamports.ts index db78fc252..9e76ce895 100644 --- a/packages/rpc-types/src/lamports.ts +++ b/packages/rpc-types/src/lamports.ts @@ -11,6 +11,11 @@ import { import { getU64Decoder, getU64Encoder, NumberCodec, NumberDecoder, NumberEncoder } from '@solana/codecs-numbers'; import { SOLANA_ERROR__LAMPORTS_OUT_OF_RANGE, SolanaError } from '@solana/errors'; +/** + * Represents an integer value denominated in Lamports (ie. $1 \times 10^{-9}$ ◎). + * + * It is represented as a `bigint` in client code and an `u64` in server code. + */ export type Lamports = bigint & { readonly __brand: unique symbol }; // Largest possible value to be represented by a u64 @@ -29,16 +34,72 @@ function getMemoizedU64Decoder(): FixedSizeDecoder { return memoizedU64Decoder; } +/** + * This is a type guard that accepts a `bigint` as input. It will both return `true` if the integer + * conforms to the {@link Lamports} type and will refine the type for use in your program. + * + * @example + * ```ts + * import { isLamports } from '@solana/rpc-types'; + * + * if (isLamports(lamports)) { + * // At this point, `lamports` has been refined to a + * // `Lamports` that can be used anywhere Lamports are expected. + * await transfer(fromAddress, toAddress, lamports); + * } else { + * setError(`${lamports} is not a quantity of Lamports`); + * } + * ``` + */ export function isLamports(putativeLamports: bigint): putativeLamports is Lamports { return putativeLamports >= 0 && putativeLamports <= maxU64Value; } +/** + * Lamport values returned from the RPC API conform to the type {@link Lamports}. You can use a + * value of that type wherever a quantity of Lamports is expected. + * + * @example + * From time to time you might acquire a number that you expect to be a quantity of Lamports, from + * an untrusted network API or user input. To assert that such an arbitrary number is usable as a + * quantity of Lamports, use this function. + * + * ```ts + * import { assertIsLamports } from '@solana/rpc-types'; + * + * // Imagine a function that creates a transfer instruction when a user submits a form. + * function handleSubmit() { + * // We know only that what the user typed conforms to the `number` type. + * const lamports: number = parseInt(quantityInput.value, 10); + * try { + * // If this type assertion function doesn't throw, then + * // Typescript will upcast `lamports` to `Lamports`. + * assertIsLamports(lamports); + * // At this point, `lamports` is a `Lamports` that can be used anywhere Lamports are expected. + * await transfer(fromAddress, toAddress, lamports); + * } catch (e) { + * // `lamports` turned out not to validate as a quantity of Lamports. + * } + * } + * ``` + */ export function assertIsLamports(putativeLamports: bigint): asserts putativeLamports is Lamports { if (putativeLamports < 0 || putativeLamports > maxU64Value) { throw new SolanaError(SOLANA_ERROR__LAMPORTS_OUT_OF_RANGE); } } +/** + * This helper combines _asserting_ that a number is a possible number of {@link Lamports} with + * _coercing_ it to the {@link Lamports} type. It's best used with untrusted input. + * + * @example + * ```ts + * import { lamports } from '@solana/rpc-types'; + * + * await transfer(address(fromAddress), address(toAddress), lamports(100000n)); + * ``` + */ export function lamports(putativeLamports: bigint): Lamports { assertIsLamports(putativeLamports); return putativeLamports; @@ -46,20 +107,61 @@ export function lamports(putativeLamports: bigint): Lamports { type ExtractAdditionalProps = Omit; +/** + * Returns an encoder that you can use to encode a 64-bit {@link Lamports} value to 8 bytes in + * little endian order. + */ export function getDefaultLamportsEncoder(): FixedSizeEncoder { return getLamportsEncoder(getMemoizedU64Encoder()); } +/** + * Returns an encoder that you can use to encode a {@link Lamports} value to a byte array. + * + * You must supply a number decoder that will determine how encode the numeric value. + * + * @example + * ```ts + * import { getLamportsEncoder } from '@solana/rpc-types'; + * import { getU16Encoder } from '@solana/codecs-numbers'; + * + * const lamports = lamports(256n); + * const lamportsEncoder = getLamportsEncoder(getU16Encoder()); + * const lamportsBytes = lamportsEncoder.encode(lamports); + * // Uint8Array(2) [ 0, 1 ] + * ``` + */ export function getLamportsEncoder( innerEncoder: TEncoder, ): Encoder & ExtractAdditionalProps { return innerEncoder; } +/** + * Returns a decoder that you can use to decode a byte array representing a 64-bit little endian + * number to a {@link Lamports} value. + */ export function getDefaultLamportsDecoder(): FixedSizeDecoder { return getLamportsDecoder(getMemoizedU64Decoder()); } +/** + * Returns a decoder that you can use to convert an array of bytes representing a number to a + * {@link Lamports} value. + * + * You must supply a number decoder that will determine how many bits to use to decode the numeric + * value. + * + * @example + * ```ts + * import { getLamportsDecoder } from '@solana/rpc-types'; + * import { getU16Decoder } from '@solana/codecs-numbers'; + * + * const lamportsBytes = new Uint8Array([ 0, 1 ]); + * const lamportsDecoder = getLamportsDecoder(getU16Decoder()); + * const lamports = lamportsDecoder.decode(lamportsBytes); // lamports(256n) + * ``` + */ export function getLamportsDecoder( innerDecoder: TDecoder, ): Decoder & ExtractAdditionalProps { @@ -68,10 +170,22 @@ export function getLamportsDecoder( ) as Decoder & ExtractAdditionalProps; } +/** + * Returns a codec that you can use to encode from or decode to a 64-bit {@link Lamports} value. + * + * @see {@link getDefaultLamportsDecoder} + * @see {@link getDefaultLamportsEncoder} + */ export function getDefaultLamportsCodec(): FixedSizeCodec { return combineCodec(getDefaultLamportsEncoder(), getDefaultLamportsDecoder()); } +/** + * Returns a codec that you can use to encode from or decode to {@link Lamports} value. + * + * @see {@link getLamportsDecoder} + * @see {@link getLamportsEncoder} + */ export function getLamportsCodec( innerCodec: TCodec, ): Codec & ExtractAdditionalProps { diff --git a/packages/rpc-types/src/stringified-bigint.ts b/packages/rpc-types/src/stringified-bigint.ts index bea0f3448..6ec056b2d 100644 --- a/packages/rpc-types/src/stringified-bigint.ts +++ b/packages/rpc-types/src/stringified-bigint.ts @@ -1,7 +1,27 @@ import { SOLANA_ERROR__MALFORMED_BIGINT_STRING, SolanaError } from '@solana/errors'; +/** + * This type represents a `bigint` which has been encoded as a string for transit over a transport + * that does not support `bigint` values natively. The JSON-RPC is such a transport. + */ export type StringifiedBigInt = string & { readonly __brand: unique symbol }; +/** + * A type guard that returns `true` if the input string parses as a `BigInt`, and refines its type + * for use in your program. + * + * @example + * ```ts + * import { isStringifiedBigInt } from '@solana/rpc-types'; + * + * if (isStringifiedBigInt(bigintString)) { + * // At this point, `bigintString` has been refined to a `StringifiedBigInt` + * bigintString satisfies StringifiedBigInt; // OK + * } else { + * setError(`${bigintString} does not represent a BigInt`); + * } + * ``` + */ export function isStringifiedBigInt(putativeBigInt: string): putativeBigInt is StringifiedBigInt { try { BigInt(putativeBigInt); @@ -11,6 +31,28 @@ export function isStringifiedBigInt(putativeBigInt: string): putativeBigInt is S } } +/** + * From time to time you might acquire a string, that you expect to parse as a `BigInt`, from an + * untrusted network API or user input. Use this function to assert that such an arbitrary string + * will in fact parse as a `BigInt`. + * + * @example + * ```ts + * import { assertIsStringifiedBigInt } from '@solana/rpc-types'; + * + * // Imagine having received a value that you presume represents the supply of some token. + * // At this point we know only that it conforms to the `string` type. + * try { + * // If this type assertion function doesn't throw, then + * // Typescript will upcast `supplyString` to `StringifiedBigInt`. + * assertIsStringifiedBigInt(supplyString); + * // At this point, `supplyString` is a `StringifiedBigInt`. + * supplyString satisfies StringifiedBigInt; + * } catch (e) { + * // `supplyString` turned out not to parse as a `BigInt` + * } + * ``` + */ export function assertIsStringifiedBigInt(putativeBigInt: string): asserts putativeBigInt is StringifiedBigInt { try { BigInt(putativeBigInt); @@ -21,6 +63,17 @@ export function assertIsStringifiedBigInt(putativeBigInt: string): asserts putat } } +/** + * This helper combines _asserting_ that a string will parse as a `BigInt` with _coercing_ it to the + * {@link StringifiedBigInt} type. It's best used with untrusted input. + * + * @example + * ```ts + * import { stringifiedBigInt } from '@solana/rpc-types'; + * + * const supplyString = stringifiedBigInt('1000000000'); + * ``` + */ export function stringifiedBigInt(putativeBigInt: string): StringifiedBigInt { assertIsStringifiedBigInt(putativeBigInt); return putativeBigInt; diff --git a/packages/rpc-types/src/stringified-number.ts b/packages/rpc-types/src/stringified-number.ts index 6a581ff79..b62d3f823 100644 --- a/packages/rpc-types/src/stringified-number.ts +++ b/packages/rpc-types/src/stringified-number.ts @@ -1,11 +1,54 @@ import { SOLANA_ERROR__MALFORMED_NUMBER_STRING, SolanaError } from '@solana/errors'; +/** + * This type represents a number which has been encoded as a string for transit over a transport + * where loss of precision when using the native number type is a concern. The JSON-RPC is such a + * transport. + */ export type StringifiedNumber = string & { readonly __brand: unique symbol }; +/** + * A type guard that returns `true` if the input string parses as a `Number`, and refines its type + * for use in your program. + * + * @example + * ```ts + * import { isStringifiedNumber } from '@solana/rpc-types'; + * + * if (isStringifiedNumber(numericString)) { + * // At this point, `numericString` has been refined to a `StringifiedNumber` + * numericString satisfies StringifiedNumber; // OK + * } else { + * setError(`${numericString} does not represent a number`); + * } + * ``` + */ export function isStringifiedNumber(putativeNumber: string): putativeNumber is StringifiedNumber { return !Number.isNaN(Number(putativeNumber)); } +/** + * From time to time you might acquire a string, that you expect to parse as a `Number`, from an + * untrusted network API or user input. Use this function to assert that such an arbitrary string + * will in fact parse as a `Number`. + * + * @example + * ```ts + * import { assertIsStringifiedNumber } from '@solana/rpc-types'; + * + * // Imagine having received a value that you presume represents some decimal number. + * // At this point we know only that it conforms to the `string` type. + * try { + * // If this type assertion function doesn't throw, then + * // Typescript will upcast `decimalNumberString` to `StringifiedNumber`. + * assertIsStringifiedNumber(decimalNumberString); + * // At this point, `decimalNumberString` is a `StringifiedNumber`. + * decimalNumberString satisfies StringifiedNumber; + * } catch (e) { + * // `decimalNumberString` turned out not to parse as a number. + * } + * ``` + */ export function assertIsStringifiedNumber(putativeNumber: string): asserts putativeNumber is StringifiedNumber { if (Number.isNaN(Number(putativeNumber))) { throw new SolanaError(SOLANA_ERROR__MALFORMED_NUMBER_STRING, { @@ -14,6 +57,17 @@ export function assertIsStringifiedNumber(putativeNumber: string): asserts putat } } +/** + * This helper combines _asserting_ that a string will parse as a `Number` with _coercing_ it to the + * {@link StringifiedNumber} type. It's best used with untrusted input. + * + * @example + * ```ts + * import { stringifiedNumber } from '@solana/rpc-types'; + * + * const decimalNumberString = stringifiedNumber('-42.1'); + * ``` + */ export function stringifiedNumber(putativeNumber: string): StringifiedNumber { assertIsStringifiedNumber(putativeNumber); return putativeNumber; diff --git a/packages/rpc-types/src/token-balance.ts b/packages/rpc-types/src/token-balance.ts index 9af32f1f7..cdcee990b 100644 --- a/packages/rpc-types/src/token-balance.ts +++ b/packages/rpc-types/src/token-balance.ts @@ -5,11 +5,11 @@ import type { TokenAmount } from './token-amount'; export type TokenBalance = Readonly<{ /** Index of the account in which the token balance is provided for. */ accountIndex: number; - /** Pubkey of the token's mint. */ + /** Address of the token's mint. */ mint: Address; - /** Pubkey of token balance's owner. */ + /** Address of token balance's owner. */ owner?: Address; - /** Pubkey of the Token program that owns the account. */ + /** Address of the Token program that owns the account. */ programId?: Address; uiTokenAmount: TokenAmount; }>; diff --git a/packages/rpc-types/src/transaction.ts b/packages/rpc-types/src/transaction.ts index ce0297a42..50575f5cc 100644 --- a/packages/rpc-types/src/transaction.ts +++ b/packages/rpc-types/src/transaction.ts @@ -10,81 +10,178 @@ import type { SignedLamports } from './typed-numbers'; type TransactionVersion = 'legacy' | 0; type AddressTableLookup = Readonly<{ - /** public key for an address lookup table account. */ + /** Address of the address lookup table account. */ accountKey: Address; - /** List of indices used to load addresses of readonly accounts from a lookup table. */ + /** Indices of accounts in a lookup table to load as read-only. */ readableIndexes: readonly number[]; - /** List of indices used to load addresses of writable accounts from a lookup table. */ + /** Indices of accounts in a lookup table to load as writable. */ writableIndexes: readonly number[]; }>; -type ParsedTransactionInstruction = Readonly<{ - parsed: { - info?: object; - type: string; - }; - program: string; - programId: Address; - stackHeight?: number; +type InstructionWithStackHeight = Readonly<{ + /** + * A number indicating the height at which this instruction was called with respect to the + * bottom of the call stack denoted by `1` or `null`. + * + * For instance, an instruction explicitly declared in the transaction message will have a `1` + * or `null` height, the first instruction that it calls using a cross-program invocation (CPI) + * will have a height of 2, an instruction called by that instruction using a CPI will have a + * depth of 3, and so on. + */ + stackHeight: number; // FIXME(https://github.com/anza-xyz/agave/issues/5732) Should be `1` instead of `null` at base of stack }>; -type PartiallyDecodedTransactionInstruction = Readonly<{ - accounts: readonly Address[]; +type InstructionWithData = Readonly<{ + /** The input to the invoked program */ data: Base58EncodedBytes; - programId: Address; - stackHeight?: number; }>; +type ParsedTransactionInstruction = Partial & + Readonly<{ + /** The output of the program's instruction parser */ + parsed: { + /** The instruction, as interpreted the program's instruction parser. */ + info?: object; + /** + * A label that indicates the type of the instruction, as determined by the program's + * instruction parser. + */ + type: string; + }; + /** The name of the program. */ + program: string; + /** The address of the program */ + programId: Address; + }>; + +type PartiallyDecodedTransactionInstruction = InstructionWithData & + Partial & + Readonly<{ + /** An ordered list of addresses belonging to the accounts loaded by this instruction */ + accounts: readonly Address[]; + /** The address of the program to invoke */ + programId: Address; + }>; + type ReturnData = { - /** the return data itself */ + /** A tuple whose first element is the bytes of the return data as a base64-encoded string. */ data: Base64EncodedDataResponse; - /** the program that generated the return data */ + /** The address of the program that generated the return data */ programId: Address; }; -type TransactionInstruction = Readonly<{ - accounts: readonly number[]; - data: Base58EncodedBytes; - programIdIndex: number; - stackHeight?: number; +type TransactionInstruction = InstructionWithData & + Partial & + Readonly<{ + /** + * An ordered list of indices that indicate which accounts in the transaction message's + * accounts list are loaded by this instruction. + */ + accounts: readonly number[]; + /** + * The index of the address in the transaction message's accounts list associated with the + * program to invoke. + */ + programIdIndex: number; + }>; + +type TransactionMessageBase = Readonly<{ + header: { + /** + * The number of read-only accounts in the static accounts list that must sign this + * transaction. + * + * Subtracting this number from `numRequiredSignatures` yields the index of the first + * read-only signer account in the static accounts list. + */ + numReadonlySignedAccounts: number; + /** + * The number of accounts in the static accounts list that are neither writable nor signers. + * + * Adding this number to `numRequiredSignatures` yields the index of the first read-only + * non-signer account in the static accounts list. + */ + numReadonlyUnsignedAccounts: number; + /** + * The number of accounts in the static accounts list that must sign this transaction. + * + * Subtracting `numReadonlySignedAccounts` from this number yields the number of writable + * signer accounts in the static accounts list. Writable signer accounts always begin at + * index zero in the static accounts list. + * + * This number itself is the index of the first non-signer account in the static accounts + * list. + */ + numRequiredSignatures: number; + }; + /** + * For transactions whose lifetime is specified by a recent blockhash, this is that blockhash, + * and for transactions whose lifetime is specified by a durable nonce, this is the nonce value. + */ + recentBlockhash: Blockhash; }>; -type TransactionParsedAccountLegacy = Readonly<{ +type TransactionParsedAccountBase = Readonly<{ + /** The address of the account */ pubkey: Address; + /** Whether this account is required to sign the transaction that it's a part of */ signer: boolean; - source: 'transaction'; + /** Whether this account must be loaded with a write-lock */ writable: boolean; }>; + +type TransactionParsedAccountLegacy = Readonly<{ + /** Indicates that the account was statically declared in the transaction message */ + source: 'transaction'; +}> & + TransactionParsedAccountBase; + type TransactionParsedAccountVersioned = Readonly<{ - pubkey: Address; - signer: boolean; + /** + * Indicates whether the account was statically declared in the transaction message or loaded + * from an address lookup table. + */ source: 'lookupTable' | 'transaction'; - writable: boolean; -}>; +}> & + TransactionParsedAccountBase; // Types for `accounts` transactionDetails -// Only a partial version of the `TransactionMetaBase` type for when `transactionDetails: accounts` is provided. +// Only a partial version of the `TransactionMetaBase` type for when +// `transactionDetails: 'accounts'` is provided. type TransactionForAccountsMetaBase = Readonly<{ - /** Error if transaction failed, null if transaction succeeded. */ + /** Error if transaction failed, `null` if transaction succeeded. */ err: TransactionError | null; - /** fee this transaction was charged */ + /** The fee this transaction was charged, in {@link Lamports} */ fee: Lamports; - /** array of account balances after the transaction was processed */ + /** Account balances after the transaction was processed */ postBalances: readonly Lamports[]; - /** List of token balances from after the transaction was processed or omitted if token balance recording was not yet enabled during this transaction */ + /** + * List of token balances from after the transaction was processed or omitted if token balance + * recording was not yet enabled during this transaction + */ postTokenBalances?: readonly TokenBalance[]; - /** array of account balances from before the transaction was processed */ + /** Account balances from before the transaction was processed */ preBalances: readonly Lamports[]; - /** List of token balances from before the transaction was processed or omitted if token balance recording was not yet enabled during this transaction */ - preTokenBalances?: readonly TokenBalance[]; /** - * Transaction status - * @deprecated + * List of token balances from before the transaction was processed or omitted if token balance + * recording was not yet enabled during this transaction */ + preTokenBalances?: readonly TokenBalance[]; + /** @deprecated */ status: TransactionStatus; }>; +type TransactionWithSignatures = Readonly<{ + /** + * An ordered list of signatures belonging to the accounts required to sign this transaction. + * + * Each signature is an Ed25519 signature of the transaction message using the private key + * associated with the account required to sign the transaction. + */ + signatures: readonly Base58EncodedBytes[]; +}>; + // Accounts export type TransactionForAccounts = @@ -96,9 +193,8 @@ export type TransactionForAccounts; + }> & + TransactionWithSignatures; }> : Readonly<{ /** Transaction partial meta */ @@ -107,9 +203,8 @@ export type TransactionForAccounts; + }> & + TransactionWithSignatures; /** The transaction version */ version: TransactionVersion; }>; @@ -117,47 +212,33 @@ export type TransactionForAccounts; +}> & + TransactionForAccountsMetaBase; export type TransactionForFullMetaInnerInstructionsUnparsed = Readonly<{ + /** A list of instructions called by programs via cross-program invocation (CPI) */ innerInstructions: readonly Readonly<{ /** The index of the instruction in the transaction */ index: number; - /** The instruction */ + /** The instructions */ instructions: readonly TransactionInstruction[]; }>[]; }>; export type TransactionForFullMetaInnerInstructionsParsed = Readonly<{ + /** A list of instructions called by programs via cross-program invocation (CPI) */ innerInstructions: readonly Readonly<{ /** The index of the instruction in the transaction */ index: number; - /** The instruction */ + /** The instructions */ instructions: readonly (ParsedTransactionInstruction | PartiallyDecodedTransactionInstruction)[]; }>[]; }>; @@ -168,13 +249,16 @@ export type TransactionForFullMetaInnerInstructionsParsed = Readonly<{ type TransactionForFullMetaLoadedAddresses = Readonly<{ /** Addresses loaded from lookup tables */ loadedAddresses: { + /** Ordered list of base-58 encoded addresses for read-only accounts */ readonly: readonly Address[]; + /** Ordered list of base-58 encoded addresses for writable accounts */ writable: readonly Address[]; }; }>; type TransactionForFullTransactionAddressTableLookups = Readonly<{ message: { + /** A list of address tables and the accounts that this transaction loads from them */ addressTableLookups?: readonly AddressTableLookup[] | null; }; }>; @@ -228,17 +312,12 @@ export type TransactionForFullBase64; + }> & + TransactionMessageBase; +}> & + TransactionWithSignatures; export type TransactionForFullJsonParsed = TMaxSupportedTransactionVersion extends void @@ -246,6 +325,7 @@ export type TransactionForFullJsonParsed; }; @@ -258,6 +338,7 @@ export type TransactionForFullJsonParsed; }; @@ -267,18 +348,14 @@ export type TransactionForFullJsonParsed; + }> & + TransactionMessageBase; +}> & + TransactionWithSignatures; export type TransactionForFullJson = TMaxSupportedTransactionVersion extends void diff --git a/packages/rpc-types/src/unix-timestamp.ts b/packages/rpc-types/src/unix-timestamp.ts index b49ebb411..ca0e5b80b 100644 --- a/packages/rpc-types/src/unix-timestamp.ts +++ b/packages/rpc-types/src/unix-timestamp.ts @@ -1,15 +1,63 @@ import { SOLANA_ERROR__TIMESTAMP_OUT_OF_RANGE, SolanaError } from '@solana/errors'; +/** + * This type represents a Unix timestamp in _seconds_. + * + * It is represented as a `bigint` in client code and an `i64` in server code. + */ export type UnixTimestamp = bigint & { readonly __brand: unique symbol }; // Largest possible value to be represented by an i64 const maxI64Value = 9223372036854775807n; // 2n ** 63n - 1n const minI64Value = -9223372036854775808n; // -(2n ** 63n) +/** + * This is a type guard that accepts a `bigint` as input. It will both return `true` if the integer + * conforms to the {@link UnixTimestamp} type and will refine the type for use in your program. + * + * @example + * ```ts + * import { isUnixTimestamp } from '@solana/rpc-types'; + * + * if (isUnixTimestamp(timestamp)) { + * // At this point, `timestamp` has been refined to a + * // `UnixTimestamp` that can be used anywhere timestamps are expected. + * timestamp satisfies UnixTimestamp; + * } else { + * setError(`${timestamp} is not a Unix timestamp`); + * } + * ``` + */ + export function isUnixTimestamp(putativeTimestamp: bigint): putativeTimestamp is UnixTimestamp { return putativeTimestamp >= minI64Value && putativeTimestamp <= maxI64Value; } +/** + * Timestamp values returned from the RPC API conform to the type {@link UnixTimestamp}. You can use + * a value of that type wherever a timestamp is expected. + * + * @example + * From time to time you might acquire a number that you expect to be a timestamp, from an untrusted + * network API or user input. To assert that such an arbitrary number is usable as a Unix timestamp, + * use this function. + * + * ```ts + * import { assertIsUnixTimestamp } from '@solana/rpc-types'; + * + * // Imagine having received a value that you presume represents a timestamp. + * // At this point we know only that it conforms to the `bigint` type. + * try { + * // If this type assertion function doesn't throw, then + * // Typescript will upcast `timestamp` to `UnixTimestamp`. + * assertIsUnixTimestamp(timestamp); + * // At this point, `timestamp` is a `UnixTimestamp`. + * timestamp satisfies UnixTimestamp; + * } catch (e) { + * // `timestamp` turned out not to be a valid Unix timestamp + * } + * ``` + */ export function assertIsUnixTimestamp(putativeTimestamp: bigint): asserts putativeTimestamp is UnixTimestamp { if (putativeTimestamp < minI64Value || putativeTimestamp > maxI64Value) { throw new SolanaError(SOLANA_ERROR__TIMESTAMP_OUT_OF_RANGE, { @@ -18,6 +66,17 @@ export function assertIsUnixTimestamp(putativeTimestamp: bigint): asserts putati } } +/** + * This helper combines _asserting_ that a `bigint` represents a Unix timestamp with _coercing_ it + * to the {@link UnixTimestamp} type. It's best used with untrusted input. + * + * @example + * ```ts + * import { unixTimestamp } from '@solana/rpc-types'; + * + * const timestamp = unixTimestamp(-42n); // Wednesday, December 31, 1969 3:59:18 PM GMT-08:00 + * ``` + */ export function unixTimestamp(putativeTimestamp: bigint): UnixTimestamp { assertIsUnixTimestamp(putativeTimestamp); return putativeTimestamp; diff --git a/packages/subscribable/src/async-iterable.ts b/packages/subscribable/src/async-iterable.ts index 92d3a9512..7d6e6c212 100644 --- a/packages/subscribable/src/async-iterable.ts +++ b/packages/subscribable/src/async-iterable.ts @@ -8,13 +8,29 @@ import { AbortController } from '@solana/event-target-impl'; import { DataPublisher } from './data-publisher'; type Config = Readonly<{ + /** + * Triggering this abort signal will cause all iterators spawned from this iterator to return + * once they have published all queued messages. + */ abortSignal: AbortSignal; + /** + * Messages from this channel of `dataPublisher` will be the ones yielded through the iterators. + * + * Messages only begin to be queued after the first time an iterator begins to poll. Channel + * messages published before that time will be dropped. + */ dataChannelName: string; // FIXME: It would be nice to be able to constrain the type of `dataPublisher` to one that // definitely supports the `dataChannelName` and `errorChannelName` channels, and // furthermore publishes `TData` on the `dataChannelName` channel. This is more difficult // than it should be: https://tsplay.dev/NlZelW dataPublisher: DataPublisher; + /** + * Messages from this channel of `dataPublisher` will be the ones thrown through the iterators. + * + * Any new iterators created after the first error is encountered will reject with that error + * when polled. + */ errorChannelName: string; }>; @@ -58,6 +74,48 @@ function createExplicitAbortToken() { const UNINITIALIZED = Symbol(); +/** + * Returns an `AsyncIterable` given a data publisher. + * + * The iterable will produce iterators that vend messages published to `dataChannelName` and will + * throw the first time a message is published to `errorChannelName`. Triggering the abort signal + * will cause all iterators spawned from this iterator to return once they have published all queued + * messages. + * + * Things to note: + * + * - If a message is published over a channel before the `AsyncIterator` attached to it has polled + * for the next result, the message will be queued in memory. + * - Messages only begin to be queued after the first time an iterator begins to poll. Channel + * messages published before that time will be dropped. + * - If there are messages in the queue and an error occurs, all queued messages will be vended to + * the iterator before the error is thrown. + * - If there are messages in the queue and the abort signal fires, all queued messages will be + * vended to the iterator after which it will return. + * - Any new iterators created after the first error is encountered will reject with that error when + * polled. + * + * @param config + * + * @example + * ```ts + * const iterable = createAsyncIterableFromDataPublisher({ + * abortSignal: AbortSignal.timeout(10_000), + * dataChannelName: 'message', + * dataPublisher, + * errorChannelName: 'error', + * }); + * try { + * for await (const message of iterable) { + * console.log('Got message', message); + * } + * } catch (e) { + * console.error('An error was published to the error channel', e); + * } finally { + * console.log("It's been 10 seconds; that's enough for now."); + * } + * ``` + */ export function createAsyncIterableFromDataPublisher({ abortSignal, dataChannelName, diff --git a/packages/subscribable/src/data-publisher.ts b/packages/subscribable/src/data-publisher.ts index 59367bf2f..9fd804658 100644 --- a/packages/subscribable/src/data-publisher.ts +++ b/packages/subscribable/src/data-publisher.ts @@ -2,7 +2,29 @@ import { TypedEventEmitter, TypedEventTarget } from './event-emitter'; type UnsubscribeFn = () => void; +/** + * Represents an object with an `on` function that you can call to subscribe to certain data over a + * named channel. + * + * @example + * ```ts + * let dataPublisher: DataPublisher<{ error: SolanaError }>; + * dataPublisher.on('data', handleData); // ERROR. `data` is not a known channel name. + * dataPublisher.on('error', e => { + * console.error(e); + * }); // OK. + * ``` + */ export interface DataPublisher = Record> { + /** + * Call this to subscribe to data over a named channel. + * + * @param channelName The name of the channel on which to subscribe for messages + * @param subscriber The function to call when a message becomes available + * @param options.signal An abort signal you can fire to unsubscribe + * + * @returns A function that you can call to unsubscribe + */ on( channelName: TChannelName, subscriber: (data: TDataByChannelName[TChannelName]) => void, @@ -10,6 +32,23 @@ export interface DataPublisher { + * if (JSON.parse(message.data).id === 42) { + * console.log('Got response 42'); + * unsubscribe(); + * } + * }); + * ``` + */ export function getDataPublisherFromEventEmitter>( eventEmitter: TypedEventEmitter | TypedEventTarget, ): DataPublisher<{ diff --git a/packages/subscribable/src/demultiplex.ts b/packages/subscribable/src/demultiplex.ts index 0b7e21497..8a0cfb7ae 100644 --- a/packages/subscribable/src/demultiplex.ts +++ b/packages/subscribable/src/demultiplex.ts @@ -2,6 +2,37 @@ import { EventTarget } from '@solana/event-target-impl'; import { DataPublisher, getDataPublisherFromEventEmitter } from './data-publisher'; +/** + * Given a channel that carries messages for multiple subscribers on a single channel name, this + * function returns a new {@link DataPublisher} that splits them into multiple channel names. + * + * @param messageTransformer A function that receives the message as the first argument, and returns + * a tuple of the derived channel name and the message. + * + * @example + * Imagine a channel that carries multiple notifications whose destination is contained within the + * message itself. + * + * ```ts + * const demuxedDataPublisher = demultiplexDataPublisher(channel, 'message', message => { + * const destinationChannelName = `notification-for:${message.subscriberId}`; + * return [destinationChannelName, message]; + * }); + * ``` + * + * Now you can subscribe to _only_ the messages you are interested in, without having to subscribe + * to the entire `'message'` channel and filter out the messages that are not for you. + * + * ```ts + * demuxedDataPublisher.on( + * 'notification-for:123', + * message => { + * console.log('Got a message for subscriber 123', message); + * }, + * { signal: AbortSignal.timeout(5_000) }, + * ); + * ``` + */ export function demultiplexDataPublisher< TDataPublisher extends DataPublisher, const TChannelName extends Parameters[0], diff --git a/packages/subscribable/src/event-emitter.ts b/packages/subscribable/src/event-emitter.ts index 77839a5ea..fdc94b37c 100644 --- a/packages/subscribable/src/event-emitter.ts +++ b/packages/subscribable/src/event-emitter.ts @@ -1,6 +1,19 @@ type EventMap = Record; type Listener = ((evt: TEvent) => void) | { handleEvent(object: TEvent): void }; +/** + * This type allows you to type `addEventListener` and `removeEventListener` so that the call + * signature of the listener matches the event type given. + * + * @example + * ```ts + * const emitter: TypedEventEmitter<{ message: MessageEvent }> = new WebSocket('wss://api.devnet.solana.com'); + * emitter.addEventListener('data', handleData); // ERROR. `data` is not a known event type. + * emitter.addEventListener('message', message => { + * console.log(message.origin); // OK. `message` is a `MessageEvent` so it has an `origin` property. + * }); + * ``` + */ export interface TypedEventEmitter { addEventListener( type: TEventType, @@ -14,9 +27,18 @@ export interface TypedEventEmitter { ): void; } +// Why not just extend the interface above, rather than to copy/paste it? +// See https://github.com/microsoft/TypeScript/issues/60008 /** - * Why not just extend the interface above, rather than to copy/paste it? - * See https://github.com/microsoft/TypeScript/issues/60008 + * This type is a superset of `TypedEventEmitter` that allows you to constrain calls to + * `dispatchEvent`. + * + * @example + * ```ts + * const target: TypedEventTarget<{ candyVended: CustomEvent<{ flavour: string }> }> = new EventTarget(); + * target.dispatchEvent(new CustomEvent('candyVended', { detail: { flavour: 'raspberry' } })); // OK. + * target.dispatchEvent(new CustomEvent('candyVended', { detail: { flavor: 'raspberry' } })); // ERROR. Misspelling in detail. + * ``` */ export interface TypedEventTarget { addEventListener( diff --git a/packages/subscribable/src/index.ts b/packages/subscribable/src/index.ts index 43b987822..7d8f0ea7f 100644 --- a/packages/subscribable/src/index.ts +++ b/packages/subscribable/src/index.ts @@ -1,3 +1,11 @@ +/** + * This package contains utilities for creating subscription-based event targets. These differ from + * the `EventTarget` interface in that the method you use to add a listener returns an unsubscribe + * function. It is primarily intended for internal use -- particularly for those building + * {@link RpcSubscriptionChannel | RpcSubscriptionChannels} and associated infrastructure. + * + * @packageDocumentation + */ export * from './async-iterable'; export * from './data-publisher'; export * from './demultiplex'; diff --git a/packages/sysvars/src/clock.ts b/packages/sysvars/src/clock.ts index 5c48f465a..c5056ea9e 100644 --- a/packages/sysvars/src/clock.ts +++ b/packages/sysvars/src/clock.ts @@ -20,18 +20,30 @@ import { fetchEncodedSysvarAccount, SYSVAR_CLOCK_ADDRESS } from './sysvar'; type SysvarClockSize = 40; /** - * The `Clock` sysvar. - * - * Information about the network’s clock, ticks, slots, etc. + * Contains data on cluster time, including the current slot, epoch, and estimated wall-clock Unix + * timestamp. It is updated every slot. */ export type SysvarClock = Readonly<{ + /** The current epoch */ epoch: Epoch; + /** + * The Unix timestamp of the first slot in this epoch. + * + * In the first slot of an epoch, this timestamp is identical to the `unixTimestamp`. + */ epochStartTimestamp: UnixTimestamp; + /** The most recent epoch for which the leader schedule has already been generated */ leaderScheduleEpoch: Epoch; + /** The current slot */ slot: Slot; + /** The Unix timestamp of this slot */ unixTimestamp: UnixTimestamp; }>; +/** + * Returns an encoder that you can use to encode a {@link SysvarClock} to a byte array representing + * the `Clock` sysvar's account data. + */ export function getSysvarClockEncoder(): FixedSizeEncoder { return getStructEncoder([ ['slot', getU64Encoder()], @@ -42,6 +54,10 @@ export function getSysvarClockEncoder(): FixedSizeEncoder; } +/** + * Returns a decoder that you can use to decode a byte array representing the `Clock` sysvar's + * account data to a {@link SysvarClock}. + */ export function getSysvarClockDecoder(): FixedSizeDecoder { return getStructDecoder([ ['slot', getU64Decoder()], @@ -52,14 +68,18 @@ export function getSysvarClockDecoder(): FixedSizeDecoder; } +/** + * Returns a codec that you can use to encode from or decode to {@link SysvarClock} + * + * @see {@link getSysvarClockDecoder} + * @see {@link getSysvarClockEncoder} + */ export function getSysvarClockCodec(): FixedSizeCodec { return combineCodec(getSysvarClockEncoder(), getSysvarClockDecoder()); } /** - * Fetch the `Clock` sysvar. - * - * Information about the network’s clock, ticks, slots, etc. + * Fetches the `Clock` sysvar account using any RPC that supports the {@link GetAccountInfoApi}. */ export async function fetchSysvarClock(rpc: Rpc, config?: FetchAccountConfig): Promise { const account = await fetchEncodedSysvarAccount(rpc, SYSVAR_CLOCK_ADDRESS, config); diff --git a/packages/sysvars/src/epoch-rewards.ts b/packages/sysvars/src/epoch-rewards.ts index d5743b5d2..4a78594e1 100644 --- a/packages/sysvars/src/epoch-rewards.ts +++ b/packages/sysvars/src/epoch-rewards.ts @@ -29,28 +29,43 @@ import { fetchEncodedSysvarAccount, SYSVAR_EPOCH_REWARDS_ADDRESS } from './sysva type SysvarEpochRewardsSize = 81; /** - * The `EpochRewards` sysvar. + * Tracks whether the rewards period (including calculation and distribution) is in progress, as + * well as the details needed to resume distribution when starting from a snapshot during the + * rewards period. * - * Tracks the progress of epoch rewards distribution. It includes: - * - Total rewards for the current epoch, in lamports. - * - Rewards for the current epoch distributed so far, in lamports. - * - Distribution completed block height, i.e. distribution of all staking rewards for the current - * epoch will be completed at this block height. - * - * Note that `EpochRewards` only lasts for a handful of blocks at the start of - * an epoch. When all rewards have been distributed, the sysvar is deleted. - * See https://github.com/anza-xyz/agave/blob/e0203f22dc83cb792fa97f91dbe6e924cbd08af1/docs/src/runtime/sysvars.md?plain=1#L155-L168 + * The sysvar is repopulated at the start of the first block of each epoch. Therefore, the sysvar + * contains data about the current epoch until a new epoch begins. */ export type SysvarEpochRewards = Readonly<{ + /** Whether the rewards period (including calculation and distribution) is active */ active: boolean; + /** The rewards currently distributed for the current epoch, in {@link Lamports} */ distributedRewards: Lamports; + /** The starting block height of the rewards distribution in the current epoch */ distributionStartingBlockHeight: bigint; + /** + * Number of partitions in the rewards distribution in the current epoch, used to generate an + * `EpochRewardsHasher` + */ numPartitions: bigint; + /** + * The {@link Blockhash} of the parent block of the first block in the epoch, used to seed an + * `EpochRewardsHasher` + */ parentBlockhash: Blockhash; + /** + * The total rewards points calculated for the current epoch, where points equals the sum of + * (delegated stake * credits observed) for all delegations + */ totalPoints: bigint; + /** The total rewards for the current epoch, in {@link Lamports} */ totalRewards: Lamports; }>; +/** + * Returns an encoder that you can use to encode a {@link SysvarEpochRewards} to a byte array + * representing the `EpochRewards` sysvar's account data. + */ export function getSysvarEpochRewardsEncoder(): FixedSizeEncoder { return getStructEncoder([ ['distributionStartingBlockHeight', getU64Encoder()], @@ -63,6 +78,10 @@ export function getSysvarEpochRewardsEncoder(): FixedSizeEncoder; } +/** + * Returns a decoder that you can use to decode a byte array representing the `EpochRewards` + * sysvar's account data to a {@link SysvarEpochRewards}. + */ export function getSysvarEpochRewardsDecoder(): FixedSizeDecoder { return getStructDecoder([ ['distributionStartingBlockHeight', getU64Decoder()], @@ -75,6 +94,12 @@ export function getSysvarEpochRewardsDecoder(): FixedSizeDecoder; } +/** + * Returns a codec that you can use to encode from or decode to {@link SysvarEpochRewards} + * + * @see {@link getSysvarEpochRewardsDecoder} + * @see {@link getSysvarEpochRewardsEncoder} + */ export function getSysvarEpochRewardsCodec(): FixedSizeCodec< SysvarEpochRewards, SysvarEpochRewards, @@ -84,17 +109,8 @@ export function getSysvarEpochRewardsCodec(): FixedSizeCodec< } /** - * Fetch the `EpochRewards` sysvar. - * - * Tracks the progress of epoch rewards distribution. It includes: - * - Total rewards for the current epoch, in lamports. - * - Rewards for the current epoch distributed so far, in lamports. - * - Distribution completed block height, i.e. distribution of all staking rewards for the current - * epoch will be completed at this block height. - * - * Note that `EpochRewards` only lasts for a handful of blocks at the start of - * an epoch. When all rewards have been distributed, the sysvar is deleted. - * See https://github.com/anza-xyz/agave/blob/e0203f22dc83cb792fa97f91dbe6e924cbd08af1/docs/src/runtime/sysvars.md?plain=1#L155-L168 + * Fetch the `EpochRewards` sysvar account using any RPC that supports the + * {@link GetAccountInfoApi}. */ export async function fetchSysvarEpochRewards( rpc: Rpc, diff --git a/packages/sysvars/src/epoch-schedule.ts b/packages/sysvars/src/epoch-schedule.ts index d8bd0d75a..c13deb62a 100644 --- a/packages/sysvars/src/epoch-schedule.ts +++ b/packages/sysvars/src/epoch-schedule.ts @@ -20,18 +20,34 @@ import { fetchEncodedSysvarAccount, SYSVAR_EPOCH_SCHEDULE_ADDRESS } from './sysv type SysvarEpochScheduleSize = 33; /** - * The `EpochSchedule` sysvar. - * - * Information about epoch duration. + * Includes the number of slots per epoch, timing of leader schedule selection, and information + * about epoch warm-up time. */ export type SysvarEpochSchedule = Readonly<{ + /** + * First normal-length epoch after the warmup period, + * log2(slotsPerEpoch) - log2(MINIMUM_SLOTS_PER_EPOCH) + */ firstNormalEpoch: Epoch; + /** + * The first slot after the warmup period, MINIMUM_SLOTS_PER_EPOCH * (2^(firstNormalEpoch) - 1) + */ firstNormalSlot: Slot; + /** + * A number of slots before beginning of an epoch to calculate a leader schedule for that + * epoch. + */ leaderScheduleSlotOffset: bigint; + /** The maximum number of slots in each epoch */ slotsPerEpoch: bigint; + /** Whether epochs start short and grow */ warmup: boolean; }>; +/** + * Returns an encoder that you can use to encode a {@link SysvarEpochSchedule} to a byte array + * representing the `EpochSchedule` sysvar's account data. + */ export function getSysvarEpochScheduleEncoder(): FixedSizeEncoder { return getStructEncoder([ ['slotsPerEpoch', getU64Encoder()], @@ -42,6 +58,10 @@ export function getSysvarEpochScheduleEncoder(): FixedSizeEncoder; } +/** + * Returns a decoder that you can use to decode a byte array representing the `EpochSchedule` + * sysvar's account data to a {@link SysvarEpochSchedule}. + */ export function getSysvarEpochScheduleDecoder(): FixedSizeDecoder { return getStructDecoder([ ['slotsPerEpoch', getU64Decoder()], @@ -52,6 +72,12 @@ export function getSysvarEpochScheduleDecoder(): FixedSizeDecoder; } +/** + * Returns a codec that you can use to encode from or decode to {@link SysvarEpochSchedule} + * + * @see {@link getSysvarEpochScheduleDecoder} + * @see {@link getSysvarEpochScheduleEncoder} + */ export function getSysvarEpochScheduleCodec(): FixedSizeCodec< SysvarEpochSchedule, SysvarEpochSchedule, @@ -61,9 +87,8 @@ export function getSysvarEpochScheduleCodec(): FixedSizeCodec< } /** - * Fetch the `EpochSchedule` sysvar. - * - * Information about epoch duration. + * Fetches the `EpochSchedule` sysvar account using any RPC that supports the + * {@link GetAccountInfoApi}. */ export async function fetchSysvarEpochSchedule( rpc: Rpc, diff --git a/packages/sysvars/src/index.ts b/packages/sysvars/src/index.ts index 02b22110d..58f313e4e 100644 --- a/packages/sysvars/src/index.ts +++ b/packages/sysvars/src/index.ts @@ -1,3 +1,57 @@ +/** + * This package contains types and helpers for fetching and decoding Solana sysvars. + * It can be used standalone, but it is also exported as part of Kit + * [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). + * + * More information about the available sysvars on Solana can be found in the docs at + * https://docs.solanalabs.com/runtime/sysvars. + * + * All currently available sysvars can be retrieved and/or decoded using this library. + * + * - `Clock` + * - `EpochRewards` + * - `EpochSchedule` + * - `Fees` + * - `LastRestartSlot` + * - `RecentBlockhashes` + * - `Rent` + * - `SlotHashes` + * - `SlotHistory` + * - `StakeHistory` + * + * The `Instructions` sysvar is also supported but does not exist on-chain, therefore has no + * corresponding module or codec. + * + * @example Fetch and decode a sysvar account. + * ```ts + * const clock: SysvarClock = await fetchSysvarClock(rpc); + * ``` + * + * @example Fetch and decode a sysvar account manually. + * ```ts + * // Fetch. + * const clock = await fetchEncodedSysvarAccount(rpc, SYSVAR_CLOCK_ADDRESS); + * clock satisfies MaybeEncodedAccount<'SysvarC1ock11111111111111111111111111111111'>; + * + * // Assert. + * assertAccountExists(clock); + * clock satisfies EncodedAccount<'SysvarC1ock11111111111111111111111111111111'>; + * + * // Decode. + * const decodedClock = decodeAccount(clock, getSysvarClockDecoder()); + * decodedClock satisfies Account; + * ``` + * + * @example Fetch a JSON-parsed sysvar account. + * ```ts + * const maybeJsonParsedClock = await fetchJsonParsedSysvarAccount(rpc, SYSVAR_CLOCK_ADDRESS); + * maybeJsonParsedClock satisfies + * | MaybeAccount + * | MaybeEncodedAccount<'SysvarC1ock11111111111111111111111111111111'>; + * ``` + * + * @packageDocumentation + */ export * from './clock'; export * from './epoch-rewards'; export * from './epoch-schedule'; diff --git a/packages/sysvars/src/last-restart-slot.ts b/packages/sysvars/src/last-restart-slot.ts index 77e988f6f..d7876c2f6 100644 --- a/packages/sysvars/src/last-restart-slot.ts +++ b/packages/sysvars/src/last-restart-slot.ts @@ -18,18 +18,21 @@ import { fetchEncodedSysvarAccount, SYSVAR_LAST_RESTART_SLOT_ADDRESS } from './s type SysvarLastRestartSlotSize = 8; /** - * The `LastRestartSlot` sysvar. - * * Information about the last restart slot (hard fork). * - * The `LastRestartSlot` sysvar provides access to the last restart slot kept in the - * bank fork for the slot on the fork that executes the current transaction. - * In case there was no fork it returns `0`. + * The `LastRestartSlot` sysvar provides access to the last restart slot kept in the bank fork for + * the slot on the fork that executes the current transaction. In case there was no fork it returns + * `0`. */ export type SysvarLastRestartSlot = Readonly<{ + /** The last restart {@link Slot} */ lastRestartSlot: Slot; }>; +/** + * Returns an encoder that you can use to encode a {@link SysvarLastRestartSlot} to a byte array + * representing the `LastRestartSlot` sysvar's account data. + */ export function getSysvarLastRestartSlotEncoder(): FixedSizeEncoder { return getStructEncoder([['lastRestartSlot', getU64Encoder()]]) as FixedSizeEncoder< SysvarLastRestartSlot, @@ -37,6 +40,10 @@ export function getSysvarLastRestartSlotEncoder(): FixedSizeEncoder; } +/** + * Returns a decoder that you can use to decode a byte array representing the `LastRestartSlot` + * sysvar's account data to a {@link SysvarLastRestartSlot}. + */ export function getSysvarLastRestartSlotDecoder(): FixedSizeDecoder { return getStructDecoder([['lastRestartSlot', getU64Decoder()]]) as FixedSizeDecoder< SysvarLastRestartSlot, @@ -44,6 +51,12 @@ export function getSysvarLastRestartSlotDecoder(): FixedSizeDecoder; } +/** + * Returns a codec that you can use to encode from or decode to {@link SysvarLastRestartSlot} + * + * @see {@link getSysvarLastRestartSlotDecoder} + * @see {@link getSysvarLastRestartSlotEncoder} + */ export function getSysvarLastRestartSlotCodec(): FixedSizeCodec< SysvarLastRestartSlot, SysvarLastRestartSlot, @@ -53,13 +66,8 @@ export function getSysvarLastRestartSlotCodec(): FixedSizeCodec< } /** - * Fetch the `LastRestartSlot` sysvar. - * - * Information about the last restart slot (hard fork). - * - * The `LastRestartSlot` sysvar provides access to the last restart slot kept in the - * bank fork for the slot on the fork that executes the current transaction. - * In case there was no fork it returns `0`. + * Fetches the `LastRestartSlot` sysvar account using any RPC that supports the + * {@link GetAccountInfoApi}. */ export async function fetchSysvarLastRestartSlot( rpc: Rpc, diff --git a/packages/sysvars/src/recent-blockhashes.ts b/packages/sysvars/src/recent-blockhashes.ts index a9b3c03b0..6d28fb43e 100644 --- a/packages/sysvars/src/recent-blockhashes.ts +++ b/packages/sysvars/src/recent-blockhashes.ts @@ -23,6 +23,11 @@ import { import { fetchEncodedSysvarAccount, SYSVAR_RECENT_BLOCKHASHES_ADDRESS } from './sysvar'; type FeeCalculator = Readonly<{ + /** + * The current cost of a signature. + * + * This amount may increase/decrease over time based on cluster processing load + */ lamportsPerSignature: Lamports; }>; type Entry = Readonly<{ @@ -31,12 +36,22 @@ type Entry = Readonly<{ }>; /** - * The `RecentBlockhashes` sysvar. - * * Information about recent blocks and their fee calculators. + * + * @deprecated Transaction fees should be determined with the + * {@link GetFeeForMessageApi.getFeeForMessage} RPC method. For additional context see the + * [Comprehensive Compute Fees proposal](https://docs.anza.xyz/proposals/comprehensive-compute-fees/). */ export type SysvarRecentBlockhashes = Entry[]; +/** + * Returns an encoder that you can use to encode a {@link SysvarRecentBlockhashes} to a byte array + * representing the `RecentBlockhashes` sysvar's account data. + * + * @deprecated Transaction fees should be determined with the + * {@link GetFeeForMessageApi.getFeeForMessage} RPC method. For additional context see the + * [Comprehensive Compute Fees proposal](https://docs.anza.xyz/proposals/comprehensive-compute-fees/). + */ export function getSysvarRecentBlockhashesEncoder(): VariableSizeEncoder { return getArrayEncoder( getStructEncoder([ @@ -46,6 +61,14 @@ export function getSysvarRecentBlockhashesEncoder(): VariableSizeEncoder { return getArrayDecoder( getStructDecoder([ @@ -55,14 +78,27 @@ export function getSysvarRecentBlockhashesDecoder(): VariableSizeDecoder { return combineCodec(getSysvarRecentBlockhashesEncoder(), getSysvarRecentBlockhashesDecoder()); } /** - * Fetch the `RecentBlockhashes` sysvar. + * Fetches the `RecentBlockhashes` sysvar account using any RPC that supports the + * {@link GetAccountInfoApi}. * - * Information about recent blocks and their fee calculators. + * @deprecated Transaction fees should be determined with the + * {@link GetFeeForMessageApi.getFeeForMessage} RPC method. For additional context see the + * [Comprehensive Compute Fees proposal](https://docs.anza.xyz/proposals/comprehensive-compute-fees/). */ export async function fetchSysvarRecentBlockhashes( rpc: Rpc, diff --git a/packages/sysvars/src/rent.ts b/packages/sysvars/src/rent.ts index 9ad0e26fc..0b48dede8 100644 --- a/packages/sysvars/src/rent.ts +++ b/packages/sysvars/src/rent.ts @@ -13,23 +13,38 @@ import { } from '@solana/codecs'; import type { GetAccountInfoApi } from '@solana/rpc-api'; import type { Rpc } from '@solana/rpc-spec'; -import { getDefaultLamportsDecoder, getDefaultLamportsEncoder, type Lamports } from '@solana/rpc-types'; +import { + F64UnsafeSeeDocumentation, + getDefaultLamportsDecoder, + getDefaultLamportsEncoder, + type Lamports, +} from '@solana/rpc-types'; import { fetchEncodedSysvarAccount, SYSVAR_RENT_ADDRESS } from './sysvar'; type SysvarRentSize = 17; /** - * The `Rent` sysvar. - * * Configuration for network rent. */ export type SysvarRent = Readonly<{ + /** + * The percentage of collected rent that is burned. + * + * Valid values are in the range [0, 100]. The remaining percentage is distributed to + * validators. + */ burnPercent: number; - exemptionThreshold: number; + /** Amount of time (in years) a balance must include rent for the account to be rent exempt */ + exemptionThreshold: F64UnsafeSeeDocumentation; + /** Rental rate in {@link Lamports}/byte-year. */ lamportsPerByteYear: Lamports; }>; +/** + * Returns an encoder that you can use to encode a {@link SysvarRent} to a byte array representing + * the `Rent` sysvar's account data. + */ export function getSysvarRentEncoder(): FixedSizeEncoder { return getStructEncoder([ ['lamportsPerByteYear', getDefaultLamportsEncoder()], @@ -38,6 +53,10 @@ export function getSysvarRentEncoder(): FixedSizeEncoder; } +/** + * Returns a decoder that you can use to decode a byte array representing the `Rent` sysvar's + * account data to a {@link SysvarRent}. + */ export function getSysvarRentDecoder(): FixedSizeDecoder { return getStructDecoder([ ['lamportsPerByteYear', getDefaultLamportsDecoder()], @@ -46,14 +65,18 @@ export function getSysvarRentDecoder(): FixedSizeDecoder; } +/** + * Returns a codec that you can use to encode from or decode to {@link SysvarRent} + * + * @see {@link getSysvarRentDecoder} + * @see {@link getSysvarRentEncoder} + */ export function getSysvarRentCodec(): FixedSizeCodec { return combineCodec(getSysvarRentEncoder(), getSysvarRentDecoder()); } /** - * Fetch the `Rent` sysvar. - * - * Configuration for network rent. + * Fetches the `Rent` sysvar account using any RPC that supports the {@link GetAccountInfoApi}. */ export async function fetchSysvarRent(rpc: Rpc, config?: FetchAccountConfig): Promise { const account = await fetchEncodedSysvarAccount(rpc, SYSVAR_RENT_ADDRESS, config); diff --git a/packages/sysvars/src/slot-hashes.ts b/packages/sysvars/src/slot-hashes.ts index c952c5ef9..b4a664822 100644 --- a/packages/sysvars/src/slot-hashes.ts +++ b/packages/sysvars/src/slot-hashes.ts @@ -22,13 +22,13 @@ type Entry = Readonly<{ slot: Slot; }>; -/** - * The `SlotHashes` sysvar. - * - * The most recent hashes of a slot's parent banks. - */ +/** The most recent hashes of a slot's parent banks. */ export type SysvarSlotHashes = Entry[]; +/** + * Returns an encoder that you can use to encode a {@link SysvarSlotHashes} to a byte array + * representing the `SlotHashes` sysvar's account data. + */ export function getSysvarSlotHashesEncoder(): VariableSizeEncoder { return getArrayEncoder( getStructEncoder([ @@ -38,6 +38,10 @@ export function getSysvarSlotHashesEncoder(): VariableSizeEncoder { return getArrayDecoder( getStructDecoder([ @@ -47,14 +51,18 @@ export function getSysvarSlotHashesDecoder(): VariableSizeDecoder { return combineCodec(getSysvarSlotHashesEncoder(), getSysvarSlotHashesDecoder()); } /** - * Fetch the `SlotHashes` sysvar. - * - * The most recent hashes of a slot's parent banks. + * Fetches the `SlotHashes` sysvar account using any RPC that supports the {@link GetAccountInfoApi}. */ export async function fetchSysvarSlotHashes( rpc: Rpc, diff --git a/packages/sysvars/src/slot-history.ts b/packages/sysvars/src/slot-history.ts index 51b7b45d2..fa0b96398 100644 --- a/packages/sysvars/src/slot-history.ts +++ b/packages/sysvars/src/slot-history.ts @@ -66,16 +66,25 @@ function getMemoizedU64ArrayDecoder(): FixedSizeDecoder { return createEncoder({ fixedSize: SLOT_HISTORY_ACCOUNT_DATA_STATIC_SIZE, @@ -101,6 +110,10 @@ export function getSysvarSlotHistoryEncoder(): FixedSizeEncoder { return createDecoder({ fixedSize: SLOT_HISTORY_ACCOUNT_DATA_STATIC_SIZE, @@ -158,6 +171,12 @@ export function getSysvarSlotHistoryDecoder(): FixedSizeDecoder, diff --git a/packages/sysvars/src/stake-history.ts b/packages/sysvars/src/stake-history.ts index 14885d2f1..b65ce818c 100644 --- a/packages/sysvars/src/stake-history.ts +++ b/packages/sysvars/src/stake-history.ts @@ -18,21 +18,31 @@ import { Epoch, getDefaultLamportsDecoder, getDefaultLamportsEncoder, type Lampo import { fetchEncodedSysvarAccount, SYSVAR_STAKE_HISTORY_ADDRESS } from './sysvar'; type Entry = Readonly<{ + /** The epoch to which this stake history entry pertains */ epoch: Epoch; stakeHistory: Readonly<{ + /** + * Sum of portion of stakes requested to be warmed up, but not fully activated yet, in + * {@link Lamports} + */ activating: Lamports; + /** + * Sum of portion of stakes requested to be cooled down, but not fully deactivated yet, in + * {@link Lamports} + */ deactivating: Lamports; + /** Effective stake at this epoch, in {@link Lamports} */ effective: Lamports; }>; }>; -/** - * The `StakeHistory` sysvar. - * - * History of stake activations and de-activations. - */ +/** History of stake activations and de-activations. */ export type SysvarStakeHistory = Entry[]; +/** + * Returns an encoder that you can use to encode a {@link SysvarStakeHistory} to a byte array + * representing the `StakeHistory` sysvar's account data. + */ export function getSysvarStakeHistoryEncoder(): VariableSizeEncoder { return getArrayEncoder( getStructEncoder([ @@ -50,6 +60,10 @@ export function getSysvarStakeHistoryEncoder(): VariableSizeEncoder { return getArrayDecoder( getStructDecoder([ @@ -67,14 +81,19 @@ export function getSysvarStakeHistoryDecoder(): VariableSizeDecoder { return combineCodec(getSysvarStakeHistoryEncoder(), getSysvarStakeHistoryDecoder()); } /** - * Fetch the `StakeHistory` sysvar. - * - * History of stake activations and de-activations. + * Fetches the `StakeHistory` sysvar account using any RPC that supports the + * {@link GetAccountInfoApi}. */ export async function fetchSysvarStakeHistory( rpc: Rpc, diff --git a/packages/sysvars/src/sysvar.ts b/packages/sysvars/src/sysvar.ts index 2fce9118c..74ebc0aed 100644 --- a/packages/sysvars/src/sysvar.ts +++ b/packages/sysvars/src/sysvar.ts @@ -46,8 +46,8 @@ type SysvarAddress = /** * Fetch an encoded sysvar account. * - * Sysvars are special accounts that contain dynamically-updated data about the - * network cluster, the blockchain history, and the executing transaction. + * Sysvars are special accounts that contain dynamically-updated data about the network cluster, the + * blockchain history, and the executing transaction. */ export async function fetchEncodedSysvarAccount( rpc: Rpc, @@ -60,8 +60,8 @@ export async function fetchEncodedSysvarAccount( /** * Fetch a JSON-parsed sysvar account. * - * Sysvars are special accounts that contain dynamically-updated data about the - * network cluster, the blockchain history, and the executing transaction. + * Sysvars are special accounts that contain dynamically-updated data about the network cluster, the + * blockchain history, and the executing transaction. */ export async function fetchJsonParsedSysvarAccount( rpc: Rpc, diff --git a/packages/transaction-confirmation/README.md b/packages/transaction-confirmation/README.md index de230f75e..cc0fd5286 100644 --- a/packages/transaction-confirmation/README.md +++ b/packages/transaction-confirmation/README.md @@ -135,7 +135,7 @@ try { ### `waitForRecentTransactionConfirmation()` -Supply your own confirmation implementations to this function to create a custom nonce transaction confirmation strategy. +Supply your own confirmation implementations to this function to create a custom confirmation strategy for recently-landed transactions. ```ts import { waitForRecentTransactionConfirmation } from '@solana/transaction-confirmation'; diff --git a/packages/transaction-confirmation/src/confirmation-strategy-blockheight.ts b/packages/transaction-confirmation/src/confirmation-strategy-blockheight.ts index 45e895c3b..15d58f048 100644 --- a/packages/transaction-confirmation/src/confirmation-strategy-blockheight.ts +++ b/packages/transaction-confirmation/src/confirmation-strategy-blockheight.ts @@ -6,7 +6,16 @@ import type { Commitment } from '@solana/rpc-types'; type GetBlockHeightExceedencePromiseFn = (config: { abortSignal: AbortSignal; + /** + * Fetch the block height as of the highest slot that has reached this level of commitment. + * + * @defaultValue Whichever default is applied by the underlying {@link RpcApi} in use. For + * example, when using an API created by a `createSolanaRpc*()` helper, the default commitment + * is `"confirmed"` unless configured otherwise. Unmitigated by an API layer on the client, the + * default commitment applied by the server is `"finalized"`. + */ commitment?: Commitment; + /** The block height after which to reject the promise */ lastValidBlockHeight: bigint; }) => Promise; @@ -15,6 +24,40 @@ type CreateBlockHeightExceedencePromiseFactoryConfig = { rpcSubscriptions: RpcSubscriptions & { '~cluster'?: TCluster }; }; +/** + * Creates a promise that throws when the network progresses past the block height after which the + * supplied blockhash is considered expired for use as a transaction lifetime specifier. + * + * When a transaction's lifetime is tied to a blockhash, that transaction can be landed on the + * network until that blockhash expires. All blockhashes have a block height after which they are + * considered to have expired. + * + * @param config + * + * @example + * ```ts + * import { isSolanaError, SolanaError } from '@solana/errors'; + * import { createBlockHeightExceedencePromiseFactory } from '@solana/transaction-confirmation'; + * + * const getBlockHeightExceedencePromise = createBlockHeightExceedencePromiseFactory({ + * rpc, + * rpcSubscriptions, + * }); + * try { + * await getBlockHeightExceedencePromise({ lastValidBlockHeight }); + * } catch (e) { + * if (isSolanaError(e, SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED)) { + * console.error( + * `The block height of the network has exceeded ${e.context.lastValidBlockHeight}. ` + + * `It is now ${e.context.currentBlockHeight}`, + * ); + * // Re-sign and retry the transaction. + * return; + * } + * throw e; + * } + * ``` + */ export function createBlockHeightExceedencePromiseFactory({ rpc, rpcSubscriptions, diff --git a/packages/transaction-confirmation/src/confirmation-strategy-nonce.ts b/packages/transaction-confirmation/src/confirmation-strategy-nonce.ts index 0e0dd7e5c..368312fdc 100644 --- a/packages/transaction-confirmation/src/confirmation-strategy-nonce.ts +++ b/packages/transaction-confirmation/src/confirmation-strategy-nonce.ts @@ -10,8 +10,17 @@ import { Nonce } from '@solana/transaction-messages'; type GetNonceInvalidationPromiseFn = (config: { abortSignal: AbortSignal; + /** + * Fetch the nonce account details as of the highest slot that has reached this level of + * commitment. + */ commitment: Commitment; + /** + * The value of the nonce that we would expect to see in the nonce account in order for any + * transaction with that nonce-based lifetime to be considered valid. + */ currentNonceValue: Nonce; + /** The address of the account in which the currently-valid nonce value is stored */ nonceAccountAddress: Address; }) => Promise; @@ -26,6 +35,40 @@ const NONCE_VALUE_OFFSET = 32; // nonce authority(pubkey) // Then comes the nonce value. +/** + * Creates a promise that throws when the value stored in a nonce account is not the expected one. + * + * When a transaction's lifetime is tied to the value stored in a nonce account, that transaction + * can be landed on the network until the nonce is advanced to a new value. + * + * @param config + * + * @example + * ```ts + * import { isSolanaError, SolanaError } from '@solana/errors'; + * import { createNonceInvalidationPromiseFactory } from '@solana/transaction-confirmation'; + * + * const getNonceInvalidationPromise = createNonceInvalidationPromiseFactory({ + * rpc, + * rpcSubscriptions, + * }); + * try { + * await getNonceInvalidationPromise({ + * currentNonceValue, + * nonceAccountAddress, + * }); + * } catch (e) { + * if (isSolanaError(e, SOLANA_ERROR__NONCE_INVALID)) { + * console.error(`The nonce has advanced to ${e.context.actualNonceValue}`); + * // Re-sign and retry the transaction. + * return; + * } else if (isSolanaError(e, SOLANA_ERROR__NONCE_ACCOUNT_NOT_FOUND)) { + * console.error(`No nonce account was found at ${nonceAccountAddress}`); + * } + * throw e; + * } + * ``` + */ export function createNonceInvalidationPromiseFactory({ rpc, rpcSubscriptions, diff --git a/packages/transaction-confirmation/src/confirmation-strategy-recent-signature.ts b/packages/transaction-confirmation/src/confirmation-strategy-recent-signature.ts index 3639fafc9..1ab8c8e6c 100644 --- a/packages/transaction-confirmation/src/confirmation-strategy-recent-signature.ts +++ b/packages/transaction-confirmation/src/confirmation-strategy-recent-signature.ts @@ -8,7 +8,15 @@ import { type Commitment, commitmentComparator } from '@solana/rpc-types'; type GetRecentSignatureConfirmationPromiseFn = (config: { abortSignal: AbortSignal; + /** + * The level of commitment the transaction must have achieved in order for the promise to + * resolve. + */ commitment: Commitment; + /** + * A 64 byte Ed25519 signature, encoded as a base-58 string, that uniquely identifies a + * transaction by virtue of being the first or only signature in its list of signatures. + */ signature: Signature; }) => Promise; @@ -17,6 +25,38 @@ type CreateRecentSignatureConfirmationPromiseFactoryConfig = { rpcSubscriptions: RpcSubscriptions & { '~cluster'?: TCluster }; }; +/** + * Creates a promise that resolves when a recently-landed transaction achieves the target + * confirmation commitment, and throws when the transaction fails with an error. + * + * The status of recently-landed transactions is available in the network's status cache. This + * confirmation strategy will only yield a result if the signature is still in the status cache. To + * fetch the status of transactions older than those available in the status cache, use the + * {@link GetSignatureStatusesApi.getSignatureStatuses} method setting the + * `searchTransactionHistory` configuration param to `true`. + * + * @param config + * + * @example + * ```ts + * import { createRecentSignatureConfirmationPromiseFactory } from '@solana/transaction-confirmation'; + * + * const getRecentSignatureConfirmationPromise = createRecentSignatureConfirmationPromiseFactory({ + * rpc, + * rpcSubscriptions, + * }); + * try { + * await getRecentSignatureConfirmationPromise({ + * commitment, + * signature, + * }); + * console.log(`The transaction with signature \`${signature}\` has achieved a commitment level of \`${commitment}\``); + * } catch (e) { + * console.error(`The transaction with signature \`${signature}\` failed`, e.cause); + * throw e; + * } + * ``` + */ export function createRecentSignatureConfirmationPromiseFactory({ rpc, rpcSubscriptions, diff --git a/packages/transaction-confirmation/src/confirmation-strategy-timeout.ts b/packages/transaction-confirmation/src/confirmation-strategy-timeout.ts index 69bd02711..a0c934735 100644 --- a/packages/transaction-confirmation/src/confirmation-strategy-timeout.ts +++ b/packages/transaction-confirmation/src/confirmation-strategy-timeout.ts @@ -2,9 +2,35 @@ import type { Commitment } from '@solana/rpc-types'; type Config = Readonly<{ abortSignal: AbortSignal; + /** + * The timeout promise will throw after 30 seconds when the commitment is `processed`, and 60 + * seconds otherwise. + */ commitment: Commitment; }>; +/** + * When no other heuristic exists to infer that a transaction has expired, you can use this promise + * factory with a commitment level. It throws after 30 seconds when the commitment is `processed`, + * and 60 seconds otherwise. You would typically race this with another confirmation strategy. + * + * @param config + * + * @example + * ```ts + * import { safeRace } from '@solana/promises'; + * import { getTimeoutPromise } from '@solana/transaction-confirmation'; + * + * try { + * await safeRace([getCustomTransactionConfirmationPromise(/* ... *\/), getTimeoutPromise({ commitment })]); + * } catch (e) { + * if (e instanceof DOMException && e.name === 'TimeoutError') { + * console.log('Could not confirm transaction after a timeout'); + * } + * throw e; + * } + * ``` + */ export async function getTimeoutPromise({ abortSignal: callerAbortSignal, commitment }: Config) { return await new Promise((_, reject) => { const handleAbort = (e: AbortSignalEventMap['abort']) => { diff --git a/packages/transaction-confirmation/src/index.ts b/packages/transaction-confirmation/src/index.ts index dcf394d4f..76fef2e13 100644 --- a/packages/transaction-confirmation/src/index.ts +++ b/packages/transaction-confirmation/src/index.ts @@ -1,3 +1,9 @@ +/** + * This package contains utilities for confirming transactions and for building your own transaction + * confirmation strategies. + * + * @packageDocumentation + */ export * from './confirmation-strategy-blockheight'; export * from './confirmation-strategy-nonce'; export * from './confirmation-strategy-recent-signature'; diff --git a/packages/transaction-confirmation/src/waiters.ts b/packages/transaction-confirmation/src/waiters.ts index 4c3791e34..e2d30c763 100644 --- a/packages/transaction-confirmation/src/waiters.ts +++ b/packages/transaction-confirmation/src/waiters.ts @@ -29,9 +29,35 @@ interface WaitForRecentTransactionWithBlockhashLifetimeConfirmationConfig interface WaitForRecentTransactionWithTimeBasedLifetimeConfirmationConfig extends BaseTransactionConfirmationStrategyConfig { getTimeoutPromise: typeof getTimeoutPromise; + /** + * A 64 byte Ed25519 signature, encoded as a base-58 string, that uniquely identifies a + * transaction by virtue of being the first or only signature in its list of signatures. + */ signature: Signature; } +/** + * Supply your own confirmation implementations to this function to create a custom nonce + * transaction confirmation strategy. + * + * @example + * ```ts + * import { waitForDurableNonceTransactionConfirmation } from '@solana/transaction-confirmation'; + * + * try { + * await waitForDurableNonceTransactionConfirmation({ + * getNonceInvalidationPromise({ abortSignal, commitment, currentNonceValue, nonceAccountAddress }) { + * // Return a promise that rejects when a nonce becomes invalid. + * }, + * getRecentSignatureConfirmationPromise({ abortSignal, commitment, signature }) { + * // Return a promise that resolves when a transaction achieves confirmation + * }, + * }); + * } catch (e) { + * // Handle errors. + * } + * ``` + */ export async function waitForDurableNonceTransactionConfirmation( config: WaitForDurableNonceTransactionConfirmationConfig, ): Promise { @@ -51,6 +77,28 @@ export async function waitForDurableNonceTransactionConfirmation( ); } +/** + * Supply your own confirmation implementations to this function to create a custom confirmation + * strategy for recently-landed transactions. + * + * @example + * ```ts + * import { waitForRecentTransactionConfirmation } from '@solana/transaction-confirmation'; + * + * try { + * await waitForRecentTransactionConfirmation({ + * getBlockHeightExceedencePromise({ abortSignal, commitment, lastValidBlockHeight }) { + * // Return a promise that rejects when the blockhash's block height has been exceeded + * }, + * getRecentSignatureConfirmationPromise({ abortSignal, commitment, signature }) { + * // Return a promise that resolves when a transaction achieves confirmation + * }, + * }); + * } catch (e) { + * // Handle errors. + * } + * ``` + */ export async function waitForRecentTransactionConfirmation( config: WaitForRecentTransactionWithBlockhashLifetimeConfirmationConfig, ): Promise { diff --git a/packages/transaction-messages/README.md b/packages/transaction-messages/README.md index a538dc7ef..ef1ebb0df 100644 --- a/packages/transaction-messages/README.md +++ b/packages/transaction-messages/README.md @@ -24,11 +24,11 @@ import { setTransactionMessageLifetimeUsingBlockhash, } from '@solana/transaction-messages'; -const transferTransaction = pipe( +const transferTransactionMessage = pipe( createTransactionMessage({ version: 0 }), - tx => setTransactionMessageFeePayer(myAddress, tx), - tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), - tx => appendTransactionMessageInstruction(getTransferSolInstruction({ source, destination, amount }), tx), + m => setTransactionMessageFeePayer(myAddress, m), + m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), + m => appendTransactionMessageInstruction(getTransferSolInstruction({ source, destination, amount }), m), ); ``` @@ -49,7 +49,7 @@ Given a `TransactionVersion` this method will return an empty transaction having ```ts import { createTransactionMessage } from '@solana/transaction-messages'; -const tx = createTransactionMessage({ version: 0 }); +const message = createTransactionMessage({ version: 0 }); ``` ## Setting the fee payer @@ -111,7 +111,7 @@ Given a blockhash and the last block height at which that blockhash is considere import { setTransactionMessageLifetimeUsingBlockhash } from '@solana/transaction-messages'; const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); -const txWithBlockhashLifetime = setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx); +const txMessageWithBlockhashLifetime = setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, txMessage); ``` #### `setTransactionMessageLifetimeUsingDurableNonce()` @@ -164,7 +164,7 @@ function handleSubmit() { // Typescript will upcast `blockhash` to `Blockhash`. assertIsBlockhash(blockhash); // At this point, `blockhash` is a `Blockhash` that can be used with the RPC. - const blockhashIsValid = await rpc.isBlockhashValid(blockhash).send(); + const { value: blockhashIsValid } = await rpc.isBlockhashValid(blockhash).send(); } catch (e) { // `blockhash` turned out not to be a base58-encoded blockhash } diff --git a/packages/transaction-messages/src/__typetests__/transaction-message-typetests.ts b/packages/transaction-messages/src/__typetests__/transaction-message-typetests.ts index 6aec398a7..0d7683844 100644 --- a/packages/transaction-messages/src/__typetests__/transaction-message-typetests.ts +++ b/packages/transaction-messages/src/__typetests__/transaction-message-typetests.ts @@ -65,29 +65,29 @@ setTransactionMessageLifetimeUsingBlockhash( { // assertIsTransactionMessageWithBlockhashLifetime - const transaction = null as unknown as BaseTransactionMessage; + const transactionMessage = null as unknown as BaseTransactionMessage; // @ts-expect-error Should not be blockhash lifetime - transaction satisfies TransactionMessageWithBlockhashLifetime; + transactionMessage satisfies TransactionMessageWithBlockhashLifetime; // @ts-expect-error Should not satisfy has blockhash - transaction satisfies { + transactionMessage satisfies { lifetimeConstraint: { blockhash: Blockhash; }; }; // @ts-expect-error Should not satisfy has lastValidBlockHeight - transaction satisfies { + transactionMessage satisfies { lifetimeConstraint: { lastValidBlockHeight: bigint; }; }; - assertIsTransactionMessageWithBlockhashLifetime(transaction); - transaction satisfies TransactionMessageWithBlockhashLifetime; - transaction satisfies { + assertIsTransactionMessageWithBlockhashLifetime(transactionMessage); + transactionMessage satisfies TransactionMessageWithBlockhashLifetime; + transactionMessage satisfies { lifetimeConstraint: { blockhash: Blockhash; }; }; - transaction satisfies { + transactionMessage satisfies { lifetimeConstraint: { lastValidBlockHeight: bigint; }; @@ -233,9 +233,9 @@ null as unknown as BaseTransactionMessage & // @ts-expect-error missing fee payer TransactionMessageWithBlockhashLifetime satisfies CompilableTransactionMessage; { - const transaction = null as unknown as BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime; + const transactionMessage = null as unknown as BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime; // @ts-expect-error missing fee payer - transaction satisfies CompilableTransactionMessage; + transactionMessage satisfies CompilableTransactionMessage; } null as unknown as BaseTransactionMessage & ITransactionMessageWithFeePayer & diff --git a/packages/transaction-messages/src/addresses-by-lookup-table-address.ts b/packages/transaction-messages/src/addresses-by-lookup-table-address.ts index b44b339a1..ee81bb66c 100644 --- a/packages/transaction-messages/src/addresses-by-lookup-table-address.ts +++ b/packages/transaction-messages/src/addresses-by-lookup-table-address.ts @@ -1,3 +1,7 @@ import { Address } from '@solana/addresses'; +/** + * Represents a mapping of lookup table addresses to the addresses of the accounts that are stored + * in them. + */ export type AddressesByLookupTableAddress = { [lookupTableAddress: Address]: Address[] }; diff --git a/packages/transaction-messages/src/blockhash.ts b/packages/transaction-messages/src/blockhash.ts index 5a4bdaf25..7d82f3b96 100644 --- a/packages/transaction-messages/src/blockhash.ts +++ b/packages/transaction-messages/src/blockhash.ts @@ -4,66 +4,150 @@ import { assertIsBlockhash, type Blockhash } from '@solana/rpc-types'; import { TransactionMessageWithDurableNonceLifetime } from './durable-nonce'; import { BaseTransactionMessage } from './transaction-message'; +/** + * A constraint which, when applied to a transaction message, makes that transaction message + * eligible to land on the network. The transaction message will continue to be eligible to land + * until the network considers the `blockhash` to be expired. + * + * This can happen when the network proceeds past the `lastValidBlockHeight` for which the blockhash + * is considered valid, or when the network switches to a fork where that blockhash is not present. + */ type BlockhashLifetimeConstraint = Readonly<{ + /** + * A recent blockhash observed by the transaction proposer. + * + * The transaction message will be considered eligible to land until the network determines this + * blockhash to be too old, or has switched to a fork where it is not present. + */ blockhash: Blockhash; + /** + * This is the block height beyond which the network will consider the blockhash to be too old + * to make a transaction message eligible to land. + */ lastValidBlockHeight: bigint; }>; +/** + * Represents a transaction message whose lifetime is defined by the age of the blockhash it + * includes. + * + * Such a transaction can only be landed on the network if the current block height of the network + * is less than or equal to the value of + * `TransactionMessageWithBlockhashLifetime['lifetimeConstraint']['lastValidBlockHeight']`. + */ export interface TransactionMessageWithBlockhashLifetime { readonly lifetimeConstraint: BlockhashLifetimeConstraint; } +/** + * A type guard that returns `true` if the transaction message conforms to the + * {@link TransactionMessageWithBlockhashLifetime} type, and refines its type for use in your + * program. + * + * @example + * ```ts + * import { isTransactionMessageWithBlockhashLifetime } from '@solana/transaction-messages'; + * + * if (isTransactionMessageWithBlockhashLifetime(message)) { + * // At this point, `message` has been refined to a `TransactionMessageWithBlockhashLifetime`. + * const { blockhash } = message.lifetimeConstraint; + * const { value: blockhashIsValid } = await rpc.isBlockhashValid(blockhash).send(); + * setBlockhashIsValid(blockhashIsValid); + * } else { + * setError( + * `${getSignatureFromTransaction(transaction)} does not have a blockhash-based lifetime`, + * ); + * } + * ``` + */ export function isTransactionMessageWithBlockhashLifetime( - transaction: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), -): transaction is BaseTransactionMessage & TransactionMessageWithBlockhashLifetime { + transactionMessage: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), +): transactionMessage is BaseTransactionMessage & TransactionMessageWithBlockhashLifetime { const lifetimeConstraintShapeMatches = - 'lifetimeConstraint' in transaction && - typeof transaction.lifetimeConstraint.blockhash === 'string' && - typeof transaction.lifetimeConstraint.lastValidBlockHeight === 'bigint'; + 'lifetimeConstraint' in transactionMessage && + typeof transactionMessage.lifetimeConstraint.blockhash === 'string' && + typeof transactionMessage.lifetimeConstraint.lastValidBlockHeight === 'bigint'; if (!lifetimeConstraintShapeMatches) return false; try { - assertIsBlockhash(transaction.lifetimeConstraint.blockhash); + assertIsBlockhash(transactionMessage.lifetimeConstraint.blockhash); return true; } catch { return false; } } +/** + * From time to time you might acquire a transaction message, that you expect to have a + * blockhash-based lifetime, from an untrusted network API or user input. Use this function to + * assert that such a transaction message actually has a blockhash-based lifetime. + * + * @example + * ```ts + * import { assertIsTransactionMessageWithBlockhashLifetime } from '@solana/transaction-messages'; + * + * try { + * // If this type assertion function doesn't throw, then + * // Typescript will upcast `message` to `TransactionMessageWithBlockhashLifetime`. + * assertIsTransactionMessageWithBlockhashLifetime(message); + * // At this point, `message` is a `TransactionMessageWithBlockhashLifetime` that can be used + * // with the RPC. + * const { blockhash } = message.lifetimeConstraint; + * const { value: blockhashIsValid } = await rpc.isBlockhashValid(blockhash).send(); + * } catch (e) { + * // `message` turned out not to have a blockhash-based lifetime + * } + * ``` + */ export function assertIsTransactionMessageWithBlockhashLifetime( - transaction: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), -): asserts transaction is BaseTransactionMessage & TransactionMessageWithBlockhashLifetime { - if (!isTransactionMessageWithBlockhashLifetime(transaction)) { + transactionMessage: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), +): asserts transactionMessage is BaseTransactionMessage & TransactionMessageWithBlockhashLifetime { + if (!isTransactionMessageWithBlockhashLifetime(transactionMessage)) { throw new SolanaError(SOLANA_ERROR__TRANSACTION__EXPECTED_BLOCKHASH_LIFETIME); } } +/** + * Given a blockhash and the last block height at which that blockhash is considered usable to land + * transactions, this method will return a new transaction message having the same type as the one + * supplied plus the `TransactionMessageWithBlockhashLifetime` type. + * + * @example + * ```ts + * import { setTransactionMessageLifetimeUsingBlockhash } from '@solana/transaction-messages'; + * + * const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); + * const txMessageWithBlockhashLifetime = setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, txMessage); + * ``` + */ export function setTransactionMessageLifetimeUsingBlockhash< - TTransaction extends BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime, + TTransactionMessage extends BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime, >( blockhashLifetimeConstraint: BlockhashLifetimeConstraint, - transaction: TTransaction, -): Omit & TransactionMessageWithBlockhashLifetime; + transactionMessage: TTransactionMessage, +): Omit & TransactionMessageWithBlockhashLifetime; export function setTransactionMessageLifetimeUsingBlockhash< - TTransaction extends BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), + TTransactionMessage extends + | BaseTransactionMessage + | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), >( blockhashLifetimeConstraint: BlockhashLifetimeConstraint, - transaction: TTransaction, -): TransactionMessageWithBlockhashLifetime & TTransaction; + transactionMessage: TTransactionMessage, +): TransactionMessageWithBlockhashLifetime & TTransactionMessage; export function setTransactionMessageLifetimeUsingBlockhash( blockhashLifetimeConstraint: BlockhashLifetimeConstraint, - transaction: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), + transactionMessage: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithBlockhashLifetime), ) { if ( - 'lifetimeConstraint' in transaction && - transaction.lifetimeConstraint.blockhash === blockhashLifetimeConstraint.blockhash && - transaction.lifetimeConstraint.lastValidBlockHeight === blockhashLifetimeConstraint.lastValidBlockHeight + 'lifetimeConstraint' in transactionMessage && + transactionMessage.lifetimeConstraint.blockhash === blockhashLifetimeConstraint.blockhash && + transactionMessage.lifetimeConstraint.lastValidBlockHeight === blockhashLifetimeConstraint.lastValidBlockHeight ) { - return transaction; + return transactionMessage; } const out = { - ...transaction, + ...transactionMessage, lifetimeConstraint: Object.freeze(blockhashLifetimeConstraint), }; Object.freeze(out); diff --git a/packages/transaction-messages/src/codecs/message.ts b/packages/transaction-messages/src/codecs/message.ts index 2dc6aac76..a7b7ae8ae 100644 --- a/packages/transaction-messages/src/codecs/message.ts +++ b/packages/transaction-messages/src/codecs/message.ts @@ -73,6 +73,13 @@ function getAddressTableLookupArrayDecoder() { return getArrayDecoder(getAddressTableLookupDecoder(), { size: getShortU16Decoder() }); } +/** + * Returns an encoder that you can use to encode a {@link CompiledTransactionMessage} to a byte + * array. + * + * The wire format of a Solana transaction consists of signatures followed by a compiled transaction + * message. The byte array produced by this encoder is the message part. + */ export function getCompiledTransactionMessageEncoder(): VariableSizeEncoder { return createEncoder({ getSizeFromValue: (compiledMessage: CompiledTransactionMessage) => { @@ -92,6 +99,13 @@ export function getCompiledTransactionMessageEncoder(): VariableSizeEncoder { return transformDecoder( getStructDecoder(getPreludeStructDecoderTuple()) as VariableSizeDecoder< @@ -109,6 +123,12 @@ export function getCompiledTransactionMessageDecoder(): VariableSizeDecoder { return combineCodec(getCompiledTransactionMessageEncoder(), getCompiledTransactionMessageDecoder()); } diff --git a/packages/transaction-messages/src/codecs/transaction-version.ts b/packages/transaction-messages/src/codecs/transaction-version.ts index 5c75e54c2..7b190970e 100644 --- a/packages/transaction-messages/src/codecs/transaction-version.ts +++ b/packages/transaction-messages/src/codecs/transaction-version.ts @@ -12,6 +12,12 @@ import { TransactionVersion } from '../transaction-message'; const VERSION_FLAG_MASK = 0x80; +/** + * Returns an encoder that you can use to encode a {@link TransactionVersion} to a byte array. + * + * Legacy messages will produce an empty array and will not advance the offset. Versioned messages + * will produce an array with a single byte. + */ export function getTransactionVersionEncoder(): VariableSizeEncoder { return createEncoder({ getSizeFromValue: value => (value === 'legacy' ? 0 : 1), @@ -31,6 +37,13 @@ export function getTransactionVersionEncoder(): VariableSizeEncoder { return createDecoder({ maxSize: 1, @@ -47,6 +60,12 @@ export function getTransactionVersionDecoder(): VariableSizeDecoder { return combineCodec(getTransactionVersionEncoder(), getTransactionVersionDecoder()); } diff --git a/packages/transaction-messages/src/compilable-transaction-message.ts b/packages/transaction-messages/src/compilable-transaction-message.ts index 8b87204cc..132bf94a2 100644 --- a/packages/transaction-messages/src/compilable-transaction-message.ts +++ b/packages/transaction-messages/src/compilable-transaction-message.ts @@ -5,6 +5,11 @@ import { TransactionMessageWithDurableNonceLifetime } from './durable-nonce'; import { ITransactionMessageWithFeePayer } from './fee-payer'; import { BaseTransactionMessage, TransactionVersion } from './transaction-message'; +/** + * A transaction message having sufficient detail to be compiled for execution on the network. + * + * In essence, this means that it has at minimum a version, a fee payer, and a lifetime constraint. + */ export type CompilableTransactionMessage< TVersion extends TransactionVersion = TransactionVersion, TInstruction extends IInstruction = IInstruction, diff --git a/packages/transaction-messages/src/compile/address-table-lookups.ts b/packages/transaction-messages/src/compile/address-table-lookups.ts index 7701658ee..3c2848457 100644 --- a/packages/transaction-messages/src/compile/address-table-lookups.ts +++ b/packages/transaction-messages/src/compile/address-table-lookups.ts @@ -4,8 +4,11 @@ import { AccountRole } from '@solana/instructions'; import { OrderedAccounts } from '../compile/accounts'; type AddressTableLookup = Readonly<{ + /** The address of the address lookup table account. */ lookupTableAddress: Address; + /** Indices of accounts in a lookup table to load as read-only. */ readableIndices: readonly number[]; + /** Indices of accounts in a lookup table to load as writable. */ writableIndices: readonly number[]; }>; diff --git a/packages/transaction-messages/src/compile/header.ts b/packages/transaction-messages/src/compile/header.ts index 8fcc45744..684f82667 100644 --- a/packages/transaction-messages/src/compile/header.ts +++ b/packages/transaction-messages/src/compile/header.ts @@ -3,8 +3,32 @@ import { isSignerRole, isWritableRole } from '@solana/instructions'; import { OrderedAccounts } from '../compile/accounts'; type MessageHeader = Readonly<{ + /** + * The number of accounts in the static accounts list that are neither writable nor + * signers. + * + * Adding this number to `numSignerAccounts` yields the index of the first read-only non-signer + * account in the static accounts list. + */ numReadonlyNonSignerAccounts: number; + /** + * The number of read-only accounts in the static accounts list that must sign this + * transaction. + * + * Subtracting this number from `numSignerAccounts` yields the index of the first read-only + * signer account in the static accounts list. + */ numReadonlySignerAccounts: number; + /** + * The number of accounts in the static accounts list that must sign this transaction. + * + * Subtracting `numReadonlySignerAccounts` from this number yields the number of + * writable signer accounts in the static accounts list. Writable signer accounts always + * begin at index zero in the static accounts list. + * + * This number itself is the index of the first non-signer account in the static + * accounts list. + */ numSignerAccounts: number; }>; diff --git a/packages/transaction-messages/src/compile/instructions.ts b/packages/transaction-messages/src/compile/instructions.ts index 69cd67f2a..ac207eadb 100644 --- a/packages/transaction-messages/src/compile/instructions.ts +++ b/packages/transaction-messages/src/compile/instructions.ts @@ -5,8 +5,17 @@ import { IInstruction } from '@solana/instructions'; import { OrderedAccounts } from './accounts'; type CompiledInstruction = Readonly<{ + /** + * An ordered list of indices that indicate which accounts in the transaction message's + * accounts list are loaded by this instruction. + */ accountIndices?: number[]; + /** The input to the invoked program */ data?: ReadonlyUint8Array; + /** + * The index of the address in the transaction message's accounts list associated with the + * program to invoke. + */ programAddressIndex: number; }>; diff --git a/packages/transaction-messages/src/compile/message.ts b/packages/transaction-messages/src/compile/message.ts index bd674d2d7..ecee18798 100644 --- a/packages/transaction-messages/src/compile/message.ts +++ b/packages/transaction-messages/src/compile/message.ts @@ -7,12 +7,31 @@ import { getCompiledLifetimeToken } from './lifetime-token'; import { getCompiledStaticAccounts } from './static-accounts'; type BaseCompiledTransactionMessage = Readonly<{ + /** + * Information about the version of the transaction message and the role of the accounts it + * loads. + */ header: ReturnType; instructions: ReturnType; + /** + * 32 bytes of data observed by the transaction proposed that makes a transaction eligible to + * land on the network. + * + * In the case of a transaction message with a nonce lifetime constraint, this will be the value + * of the nonce itself. In all other cases this will be a recent blockhash. + */ lifetimeToken: ReturnType; + /** A list of addresses indicating which accounts to load */ staticAccounts: ReturnType; }>; +/** + * A transaction message in a form suitable for encoding for execution on the network. + * + * You can not fully reconstruct a source message from a compiled message without extra information. + * In particular, supporting details about the lifetime constraint and the concrete addresses of + * accounts sourced from account lookup tables are lost to compilation. + */ export type CompiledTransactionMessage = LegacyCompiledTransactionMessage | VersionedCompiledTransactionMessage; type LegacyCompiledTransactionMessage = BaseCompiledTransactionMessage & @@ -22,27 +41,44 @@ type LegacyCompiledTransactionMessage = BaseCompiledTransactionMessage & type VersionedCompiledTransactionMessage = BaseCompiledTransactionMessage & Readonly<{ + /** A list of address tables and the accounts that this transaction loads from them */ addressTableLookups?: ReturnType; version: number; }>; +/** + * Converts the type of transaction message data structure that you create in your application to + * the type of transaction message data structure that can be encoded for execution on the network. + * + * This is a lossy process; you can not fully reconstruct a source message from a compiled message + * without extra information. In particular, supporting details about the lifetime constraint and + * the concrete addresses of accounts sourced from account lookup tables will be lost to + * compilation. + * + * @see {@link decompileTransactionMessage} + */ export function compileTransactionMessage( - transaction: CompilableTransactionMessage & Readonly<{ version: 'legacy' }>, + transactionMessage: CompilableTransactionMessage & Readonly<{ version: 'legacy' }>, ): LegacyCompiledTransactionMessage; export function compileTransactionMessage( - transaction: CompilableTransactionMessage, + transactionMessage: CompilableTransactionMessage, ): VersionedCompiledTransactionMessage; -export function compileTransactionMessage(transaction: CompilableTransactionMessage): CompiledTransactionMessage { - const addressMap = getAddressMapFromInstructions(transaction.feePayer.address, transaction.instructions); +export function compileTransactionMessage( + transactionMessage: CompilableTransactionMessage, +): CompiledTransactionMessage { + const addressMap = getAddressMapFromInstructions( + transactionMessage.feePayer.address, + transactionMessage.instructions, + ); const orderedAccounts = getOrderedAccountsFromAddressMap(addressMap); return { - ...(transaction.version !== 'legacy' + ...(transactionMessage.version !== 'legacy' ? { addressTableLookups: getCompiledAddressTableLookups(orderedAccounts) } : null), header: getCompiledMessageHeader(orderedAccounts), - instructions: getCompiledInstructions(transaction.instructions, orderedAccounts), - lifetimeToken: getCompiledLifetimeToken(transaction.lifetimeConstraint), + instructions: getCompiledInstructions(transactionMessage.instructions, orderedAccounts), + lifetimeToken: getCompiledLifetimeToken(transactionMessage.lifetimeConstraint), staticAccounts: getCompiledStaticAccounts(orderedAccounts), - version: transaction.version, + version: transactionMessage.version, }; } diff --git a/packages/transaction-messages/src/compress-transaction-message.ts b/packages/transaction-messages/src/compress-transaction-message.ts index 16b59673b..7f5afa29b 100644 --- a/packages/transaction-messages/src/compress-transaction-message.ts +++ b/packages/transaction-messages/src/compress-transaction-message.ts @@ -54,6 +54,34 @@ type WidenTransactionMessageInstructions : TTransactionMessage; +/** + * Given a transaction message and a mapping of lookup tables to the addresses stored in them, this + * function will return a new transaction message with the same instructions but with all non-signer + * accounts that are found in the given lookup tables represented by an {@link IAccountLookupMeta} + * instead of an {@link IAccountMeta}. + * + * This means that these accounts will take up less space in the compiled transaction message. This + * size reduction is most significant when the transaction includes many accounts from the same + * lookup table. + * + * @example + * ```ts + * import { address } from '@solana/addresses'; + * import { compressTransactionMessageUsingAddressLookupTables } from '@solana/transaction-messages'; + * + * const lookupTableAddress = address('4QwSwNriKPrz8DLW4ju5uxC2TN5cksJx6tPUPj7DGLAW'); + * const accountAddress = address('5n2ADjHPsqB4EVUNEX48xRqtnmuLu5XSHDwkJRR98qpM'); + * const lookupTableAddresses: AddressesByLookupTableAddress = { + * [lookupTableAddress]: [accountAddress], + * }; + * + * const compressedTransactionMessage = compressTransactionMessageUsingAddressLookupTables( + * transactionMessage, + * lookupTableAddresses, + * ); + * ``` + * + */ export function compressTransactionMessageUsingAddressLookupTables< TTransactionMessage extends TransactionMessageNotLegacy = TransactionMessageNotLegacy, >( diff --git a/packages/transaction-messages/src/create-transaction-message.ts b/packages/transaction-messages/src/create-transaction-message.ts index 5a242e5b3..43dc8b65a 100644 --- a/packages/transaction-messages/src/create-transaction-message.ts +++ b/packages/transaction-messages/src/create-transaction-message.ts @@ -4,6 +4,17 @@ type TransactionConfig = Readonly<{ version: TVersion; }>; +/** + * Given a {@link TransactionVersion} this method will return an empty transaction having the + * capabilities of that version. + * + * @example + * ```ts + * import { createTransactionMessage } from '@solana/transaction-messages'; + * + * const message = createTransactionMessage({ version: 0 }); + * ``` + */ export function createTransactionMessage( config: TransactionConfig, ): Extract; diff --git a/packages/transaction-messages/src/decompile-message.ts b/packages/transaction-messages/src/decompile-message.ts index 50d02f013..a53d1126f 100644 --- a/packages/transaction-messages/src/decompile-message.ts +++ b/packages/transaction-messages/src/decompile-message.ts @@ -181,10 +181,33 @@ function getLifetimeConstraint( } export type DecompileTransactionMessageConfig = { + /** + * If the compiled message loads addresses from one or more address lookup tables, you will have + * to supply a map of those tables to an array of the addresses they contained at the time that + * the transaction message was constructed. + * + * @see {@link decompileTransactionMessageFetchingLookupTables} if you do not already have this. + */ addressesByLookupTableAddress?: AddressesByLookupTableAddress; + /** + * If the compiled message has a blockhash-based lifetime constraint, you will have to supply + * the block height after which that blockhash is no longer valid for use as a lifetime + * constraint. + */ lastValidBlockHeight?: bigint; }; +/** + * Converts the type of transaction message data structure appropriate for execution on the network + * to the type of transaction message data structure designed for use in your application. + * + * Because compilation is a lossy process, you can not fully reconstruct a source message from a + * compiled message without extra information. In order to faithfully reconstruct the original + * source message you will need to supply supporting details about the lifetime constraint and the + * concrete addresses of any accounts sourced from account lookup tables. + * + * @see {@link compileTransactionMessage} + */ export function decompileTransactionMessage( compiledTransactionMessage: CompiledTransactionMessage, config?: DecompileTransactionMessageConfig, @@ -219,14 +242,14 @@ export function decompileTransactionMessage( return pipe( createTransactionMessage({ version: compiledTransactionMessage.version as TransactionVersion }), - tx => setTransactionMessageFeePayer(feePayer, tx), - tx => + m => setTransactionMessageFeePayer(feePayer, m), + m => instructions.reduce((acc, instruction) => { return appendTransactionMessageInstruction(instruction, acc); - }, tx), - tx => + }, m), + m => 'blockhash' in lifetimeConstraint - ? setTransactionMessageLifetimeUsingBlockhash(lifetimeConstraint, tx) - : setTransactionMessageLifetimeUsingDurableNonce(lifetimeConstraint, tx), + ? setTransactionMessageLifetimeUsingBlockhash(lifetimeConstraint, m) + : setTransactionMessageLifetimeUsingDurableNonce(lifetimeConstraint, m), ); } diff --git a/packages/transaction-messages/src/durable-nonce.ts b/packages/transaction-messages/src/durable-nonce.ts index ecc465783..286e33415 100644 --- a/packages/transaction-messages/src/durable-nonce.ts +++ b/packages/transaction-messages/src/durable-nonce.ts @@ -39,8 +39,23 @@ type DurableNonceConfig< readonly nonceAccountAddress: Address; readonly nonceAuthorityAddress: Address; }>; +/** Represents a string that is particularly known to be the base58-encoded value of a nonce. */ export type Nonce = TNonceValue & { readonly __brand: unique symbol }; +/** + * A constraint which, when applied to a transaction message, makes that transaction message + * eligible to land on the network. + * + * The transaction message will continue to be eligible to land until the network considers the + * `nonce` to have advanced. This can happen when the nonce account in which this nonce is found is + * destroyed, or the nonce value within changes. + */ type NonceLifetimeConstraint = Readonly<{ + /** + * A value contained in the related nonce account at the time the transaction was prepared. + * + * The transaction will be considered eligible to land until the nonce account ceases to exist + * or contain this value. + */ nonce: Nonce; }>; @@ -48,6 +63,12 @@ const RECENT_BLOCKHASHES_SYSVAR_ADDRESS = 'SysvarRecentB1ockHashes11111111111111111111' as Address<'SysvarRecentB1ockHashes11111111111111111111'>; const SYSTEM_PROGRAM_ADDRESS = '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; +/** + * Represents a transaction message whose lifetime is defined by the value of a nonce it includes. + * + * Such a transaction can only be landed on the network if the nonce is known to the network and has + * not already been used to land a different transaction. + */ export interface TransactionMessageWithDurableNonceLifetime< TNonceAccountAddress extends string = string, TNonceAuthorityAddress extends string = string, @@ -61,14 +82,49 @@ export interface TransactionMessageWithDurableNonceLifetime< readonly lifetimeConstraint: NonceLifetimeConstraint; } +/** + * From time to time you might acquire a transaction message, that you expect to have a + * nonce-based lifetime, from an untrusted network API or user input. Use this function to assert + * that such a transaction message actually has a nonce-based lifetime. + * + * @example + * ```ts + * import { assertIsDurableNonceTransactionMessage } from '@solana/transaction-messages'; + * + * try { + * // If this type assertion function doesn't throw, then + * // Typescript will upcast `message` to `TransactionMessageWithDurableNonceLifetime`. + * assertIsDurableNonceTransactionMessage(message); + * // At this point, `message` is a `TransactionMessageWithDurableNonceLifetime` that can be used + * // with the RPC. + * const { nonce, nonceAccountAddress } = message.lifetimeConstraint; + * const { data: { blockhash: actualNonce } } = await fetchNonce(nonceAccountAddress); + * } catch (e) { + * // `message` turned out not to have a nonce-based lifetime + * } + * ``` + */ export function assertIsDurableNonceTransactionMessage( - transaction: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime), -): asserts transaction is BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime { - if (!isDurableNonceTransaction(transaction)) { + transactionMessage: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime), +): asserts transactionMessage is BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime { + if (!isDurableNonceTransaction(transactionMessage)) { throw new SolanaError(SOLANA_ERROR__TRANSACTION__EXPECTED_NONCE_LIFETIME); } } +/** + * Creates an instruction for the System program to advance a nonce. + * + * This instruction is a prerequisite for a transaction with a nonce-based lifetime to be landed on + * the network. In order to be considered valid, the transaction must meet all of these criteria. + * + * 1. Its lifetime constraint must be a {@link NonceLifetimeConstraint}. + * 2. The value contained in the on-chain account at the address `nonceAccountAddress` must be equal + * to {@link NonceLifetimeConstraint.nonce} at the time the transaction is landed. + * 3. The first instruction in that transaction message must be the one returned by this function. + * + * You could also use the `getAdvanceNonceAccountInstruction` method of `@solana-program/system`. + */ function createAdvanceNonceAccountInstruction< TNonceAccountAddress extends string = string, TNonceAuthorityAddress extends string = string, @@ -90,6 +146,23 @@ function createAdvanceNonceAccountInstruction< }; } +/** + * A type guard that returns `true` if the instruction conforms to the + * {@link AdvanceNonceAccountInstruction} type, and refines its type for use in your program. + * + * @example + * ```ts + * import { isAdvanceNonceAccountInstruction } from '@solana/transaction-messages'; + * + * if (isAdvanceNonceAccountInstruction(message.instructions[0])) { + * // At this point, the first instruction in the message has been refined to a + * // `AdvanceNonceAccountInstruction`. + * setNonceAccountAddress(message.instructions[0].accounts[0].address); + * } else { + * setError('The first instruction is not an `AdvanceNonce` instruction'); + * } + * ``` + */ export function isAdvanceNonceAccountInstruction( instruction: IInstruction, ): instruction is AdvanceNonceAccountInstruction { @@ -117,14 +190,37 @@ function isAdvanceNonceAccountInstructionData(data: ReadonlyUint8Array): data is return data.byteLength === 4 && data[0] === 4 && data[1] === 0 && data[2] === 0 && data[3] === 0; } +/** + * A type guard that returns `true` if the transaction message conforms to the + * {@link TransactionMessageWithDurableNonceLifetime} type, and refines its type for use in your + * program. + * + * @example + * ```ts + * import { isTransactionMessageWithDurableNonceLifetime } from '@solana/transaction-messages'; + * import { fetchNonce } from "@solana-program/system"; + * + * if (isTransactionMessageWithDurableNonceLifetime(message)) { + * // At this point, `message` has been refined to a + * // `TransactionMessageWithDurableNonceLifetime`. + * const { nonce, nonceAccountAddress } = message.lifetimeConstraint; + * const { data: { blockhash: actualNonce } } = await fetchNonce(nonceAccountAddress); + * setNonceIsValid(nonce === actualNonce); + * } else { + * setError( + * `${getSignatureFromTransaction(transaction)} does not have a nonce-based lifetime`, + * ); + * } + * ``` + */ export function isDurableNonceTransaction( - transaction: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime), -): transaction is BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime { + transactionMessage: BaseTransactionMessage | (BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime), +): transactionMessage is BaseTransactionMessage & TransactionMessageWithDurableNonceLifetime { return ( - 'lifetimeConstraint' in transaction && - typeof transaction.lifetimeConstraint.nonce === 'string' && - transaction.instructions[0] != null && - isAdvanceNonceAccountInstruction(transaction.instructions[0]) + 'lifetimeConstraint' in transactionMessage && + typeof transactionMessage.lifetimeConstraint.nonce === 'string' && + transactionMessage.instructions[0] != null && + isAdvanceNonceAccountInstruction(transactionMessage.instructions[0]) ); } @@ -142,8 +238,47 @@ function isAdvanceNonceAccountInstructionForNonce< ); } +/** + * Given a nonce, the account where the value of the nonce is stored, and the address of the account + * authorized to consume that nonce, this method will return a new transaction having the same type + * as the one supplied plus the {@link TransactionMessageWithDurableNonceLifetime} type. + * + * In particular, this method _prepends_ an instruction to the transaction message designed to + * consume (or 'advance') the nonce in the same transaction whose lifetime is defined by it. + * + * @param config + * + * @example + * ```ts + * import { setTransactionMessageLifetimeUsingDurableNonce } from '@solana/transactions'; + * + * const NONCE_VALUE_OFFSET = + * 4 + // version(u32) + * 4 + // state(u32) + * 32; // nonce authority(pubkey) + * // Then comes the nonce value. + * + * const nonceAccountAddress = address('EGtMh4yvXswwHhwVhyPxGrVV2TkLTgUqGodbATEPvojZ'); + * const nonceAuthorityAddress = address('4KD1Rdrd89NG7XbzW3xsX9Aqnx2EExJvExiNme6g9iAT'); + * const { value: nonceAccount } = await rpc + * .getAccountInfo(nonceAccountAddress, { + * dataSlice: { length: 32, offset: NONCE_VALUE_OFFSET }, + * encoding: 'base58', + * }) + * .send(); + * const nonce = + * // This works because we asked for the exact slice of data representing the nonce + * // value, and furthermore asked for it in `base58` encoding. + * nonceAccount!.data[0] as unknown as Nonce; + * + * const durableNonceTransactionMessage = setTransactionMessageLifetimeUsingDurableNonce( + * { nonce, nonceAccountAddress, nonceAuthorityAddress }, + * tx, + * ); + * ``` + */ export function setTransactionMessageLifetimeUsingDurableNonce< - TTransaction extends BaseTransactionMessage, + TTransactionMessage extends BaseTransactionMessage, TNonceAccountAddress extends string = string, TNonceAuthorityAddress extends string = string, TNonceValue extends string = string, @@ -153,49 +288,52 @@ export function setTransactionMessageLifetimeUsingDurableNonce< nonceAccountAddress, nonceAuthorityAddress, }: DurableNonceConfig, - transaction: TTransaction | (TransactionMessageWithDurableNonceLifetime & TTransaction), + transactionMessage: TTransactionMessage | (TransactionMessageWithDurableNonceLifetime & TTransactionMessage), ): TransactionMessageWithDurableNonceLifetime & - TTransaction { + TTransactionMessage { let newInstructions: [ AdvanceNonceAccountInstruction, ...IInstruction[], ]; - const firstInstruction = transaction.instructions[0]; + const firstInstruction = transactionMessage.instructions[0]; if (firstInstruction && isAdvanceNonceAccountInstruction(firstInstruction)) { if (isAdvanceNonceAccountInstructionForNonce(firstInstruction, nonceAccountAddress, nonceAuthorityAddress)) { - if (isDurableNonceTransaction(transaction) && transaction.lifetimeConstraint.nonce === nonce) { - return transaction as TransactionMessageWithDurableNonceLifetime< + if ( + isDurableNonceTransaction(transactionMessage) && + transactionMessage.lifetimeConstraint.nonce === nonce + ) { + return transactionMessage as TransactionMessageWithDurableNonceLifetime< TNonceAccountAddress, TNonceAuthorityAddress, TNonceValue > & - TTransaction; + TTransactionMessage; } else { // we already have the right first instruction, leave it as-is - newInstructions = [firstInstruction, ...transaction.instructions.slice(1)]; + newInstructions = [firstInstruction, ...transactionMessage.instructions.slice(1)]; } } else { // we have a different advance nonce instruction as the first instruction, replace it newInstructions = [ Object.freeze(createAdvanceNonceAccountInstruction(nonceAccountAddress, nonceAuthorityAddress)), - ...transaction.instructions.slice(1), + ...transactionMessage.instructions.slice(1), ]; } } else { // we don't have an existing advance nonce instruction as the first instruction, prepend one newInstructions = [ Object.freeze(createAdvanceNonceAccountInstruction(nonceAccountAddress, nonceAuthorityAddress)), - ...transaction.instructions, + ...transactionMessage.instructions, ]; } return Object.freeze({ - ...transaction, + ...transactionMessage, instructions: Object.freeze(newInstructions), lifetimeConstraint: Object.freeze({ nonce, }), }) as TransactionMessageWithDurableNonceLifetime & - TTransaction; + TTransactionMessage; } diff --git a/packages/transaction-messages/src/fee-payer.ts b/packages/transaction-messages/src/fee-payer.ts index dc47f637d..b4a2dc740 100644 --- a/packages/transaction-messages/src/fee-payer.ts +++ b/packages/transaction-messages/src/fee-payer.ts @@ -2,10 +2,28 @@ import { Address } from '@solana/addresses'; import { BaseTransactionMessage } from './transaction-message'; +/** + * Represents a transaction message for which a fee payer has been declared. A transaction must + * conform to this type to be compiled and landed on the network. + */ export interface ITransactionMessageWithFeePayer { readonly feePayer: Readonly<{ address: Address }>; } +/** + * Given a base58-encoded address of a system account, this method will return a new transaction + * message having the same type as the one supplied plus the {@link ITransactionMessageWithFeePayer} + * type. + * + * @example + * ```ts + * import { address } from '@solana/addresses'; + * import { setTransactionMessageFeePayer } from '@solana/transaction-messages'; + * + * const myAddress = address('mpngsFd4tmbUfzDYJayjKZwZcaR7aWb2793J6grLsGu'); + * const txPaidByMe = setTransactionMessageFeePayer(myAddress, tx); + * ``` + */ export function setTransactionMessageFeePayer< TFeePayerAddress extends string, TTransactionMessage extends BaseTransactionMessage & Partial, diff --git a/packages/transaction-messages/src/index.ts b/packages/transaction-messages/src/index.ts index fd07d506d..fa11c451e 100644 --- a/packages/transaction-messages/src/index.ts +++ b/packages/transaction-messages/src/index.ts @@ -1,3 +1,32 @@ +/** + * This package contains types and functions for creating transaction messages. + * It can be used standalone, but it is also exported as part of Kit + * [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). + * + * @example + * Transaction messages are built one step at a time using the transform functions offered by this + * package. To make it more ergonomic to apply consecutive transforms to your transaction messages, + * consider using a pipelining helper like the one in `@solana/functional`. + * + * ```ts + * import { pipe } from '@solana/functional'; + * import { + * appendTransactionMessageInstruction, + * createTransactionMessage, + * setTransactionMessageFeePayer, + * setTransactionMessageLifetimeUsingBlockhash, + * } from '@solana/transaction-messages'; + * + * const transferTransactionMessage = pipe( + * createTransactionMessage({ version: 0 }), + * m => setTransactionMessageFeePayer(myAddress, m), + * m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), + * m => appendTransactionMessageInstruction(getTransferSolInstruction({ source, destination, amount }), m), + * ); + * ``` + * + * @packageDocumentation + */ export * from './addresses-by-lookup-table-address'; export * from './blockhash'; export * from './codecs'; diff --git a/packages/transaction-messages/src/instructions.ts b/packages/transaction-messages/src/instructions.ts index f9048abe8..eaa3fc43a 100644 --- a/packages/transaction-messages/src/instructions.ts +++ b/packages/transaction-messages/src/instructions.ts @@ -1,20 +1,68 @@ import { TransactionMessageWithDurableNonceLifetime } from './durable-nonce'; import { BaseTransactionMessage } from './transaction-message'; -export function appendTransactionMessageInstruction( - instruction: TTransaction['instructions'][number], - transaction: TTransaction, -): TTransaction { - return appendTransactionMessageInstructions([instruction], transaction); +/** + * Given an instruction, this method will return a new transaction message with that instruction + * having been added to the end of the list of existing instructions. + * + * @see {@link appendTransactionInstructions} if you need to append multiple instructions to a + * transaction message. + * + * @example + * ```ts + * import { address } from '@solana/addresses'; + * import { appendTransactionMessageInstruction } from '@solana/transaction-messages'; + * + * const memoTransaction = appendTransactionMessageInstruction( + * { + * data: new TextEncoder().encode('Hello world!'), + * programAddress: address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + * }, + * tx, + * ); + * ``` + */ +export function appendTransactionMessageInstruction( + instruction: TTransactionMessage['instructions'][number], + transactionMessage: TTransactionMessage, +): TTransactionMessage { + return appendTransactionMessageInstructions([instruction], transactionMessage); } -export function appendTransactionMessageInstructions( - instructions: ReadonlyArray, - transaction: TTransaction, -): TTransaction { +/** + * Given an array of instructions, this method will return a new transaction message with those + * instructions having been added to the end of the list of existing instructions. + * + * @see {@link appendTransactionInstruction} if you only need to append one instruction to a + * transaction message. + * + * @example + * ```ts + * import { address } from '@solana/addresses'; + * import { appendTransactionMessageInstructions } from '@solana/transaction-messages'; + * + * const memoTransaction = appendTransactionMessageInstructions( + * [ + * { + * data: new TextEncoder().encode('Hello world!'), + * programAddress: address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + * }, + * { + * data: new TextEncoder().encode('How are you?'), + * programAddress: address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + * }, + * ], + * tx, + * ); + * ``` + */ +export function appendTransactionMessageInstructions( + instructions: ReadonlyArray, + transactionMessage: TTransactionMessage, +): TTransactionMessage { return Object.freeze({ - ...transaction, - instructions: Object.freeze([...transaction.instructions, ...instructions]), + ...transactionMessage, + instructions: Object.freeze([...transactionMessage.instructions, ...instructions]), }); } @@ -24,19 +72,67 @@ type ExcludeDurableNonce = T extends TransactionMessageWithDurableNonceLifeti ? BaseTransactionMessage & Omit : T; -export function prependTransactionMessageInstruction( - instruction: TTransaction['instructions'][number], - transaction: TTransaction, -): ExcludeDurableNonce { - return prependTransactionMessageInstructions([instruction], transaction); +/** + * Given an instruction, this method will return a new transaction message with that instruction + * having been added to the beginning of the list of existing instructions. + * + * @see {@link prependTransactionInstructions} if you need to prepend multiple instructions to a + * transaction message. + * + * @example + * ```ts + * import { address } from '@solana/addresses'; + * import { prependTransactionMessageInstruction } from '@solana/transaction-messages'; + * + * const memoTransaction = prependTransactionMessageInstruction( + * { + * data: new TextEncoder().encode('Hello world!'), + * programAddress: address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + * }, + * tx, + * ); + * ``` + */ +export function prependTransactionMessageInstruction( + instruction: TTransactionMessage['instructions'][number], + transactionMessage: TTransactionMessage, +): ExcludeDurableNonce { + return prependTransactionMessageInstructions([instruction], transactionMessage); } -export function prependTransactionMessageInstructions( - instructions: ReadonlyArray, - transaction: TTransaction, -): ExcludeDurableNonce { +/** + * Given an array of instructions, this method will return a new transaction message with those + * instructions having been added to the beginning of the list of existing instructions. + * + * @see {@link prependTransactionInstruction} if you only need to prepend one instruction to a + * transaction message. + * + * @example + * ```ts + * import { address } from '@solana/addresses'; + * import { prependTransactionMessageInstructions } from '@solana/transaction-messages'; + * + * const memoTransaction = prependTransactionMessageInstructions( + * [ + * { + * data: new TextEncoder().encode('Hello world!'), + * programAddress: address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + * }, + * { + * data: new TextEncoder().encode('How are you?'), + * programAddress: address('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'), + * }, + * ], + * tx, + * ); + * ``` + */ +export function prependTransactionMessageInstructions( + instructions: ReadonlyArray, + transactionMessage: TTransactionMessage, +): ExcludeDurableNonce { return Object.freeze({ - ...transaction, - instructions: Object.freeze([...instructions, ...transaction.instructions]), - }) as ExcludeDurableNonce; + ...transactionMessage, + instructions: Object.freeze([...instructions, ...transactionMessage.instructions]), + }) as ExcludeDurableNonce; } diff --git a/packages/transactions/README.md b/packages/transactions/README.md index 6e6b50bd4..353928ccd 100644 --- a/packages/transactions/README.md +++ b/packages/transactions/README.md @@ -54,7 +54,7 @@ console.debug(`Inspect this transaction at https://explorer.solana.com/tx/${sign ### `signTransaction()` -Given an array of `CryptoKey` objects which are private keys pertaining to addresses that are required to sign a transaction, this method will return a new signed transaction of type `FullySignedTransaction`. The transaction must have a signature for all required signers after being signed by the input `CryptoKey` objects. +Given an array of `CryptoKey` objects which are private keys pertaining to addresses that are required to sign a transaction, this method will return a new signed transaction of type `FullySignedTransaction`. This function will throw unless the resulting transaction is fully signed. ```ts import { generateKeyPair } from '@solana/keys'; diff --git a/packages/transactions/src/codecs/transaction-codec.ts b/packages/transactions/src/codecs/transaction-codec.ts index f9d865293..359635a41 100644 --- a/packages/transactions/src/codecs/transaction-codec.ts +++ b/packages/transactions/src/codecs/transaction-codec.ts @@ -25,6 +25,10 @@ import { getTransactionVersionDecoder } from '@solana/transaction-messages'; import { SignaturesMap, Transaction, TransactionMessageBytes } from '../transaction'; import { getSignaturesEncoder } from './signatures-encoder'; +/** + * Returns an encoder that you can use to encode a {@link Transaction} to a byte array in a wire + * format appropriate for sending to the Solana network for execution. + */ export function getTransactionEncoder(): VariableSizeEncoder { return getStructEncoder([ ['signatures', getSignaturesEncoder()], @@ -32,6 +36,22 @@ export function getTransactionEncoder(): VariableSizeEncoder { ]); } +/** + * Returns a decoder that you can use to convert a byte array in the Solana transaction wire format + * to a {@link Transaction} object. + * + * @example + * ```ts + * import { getTransactionDecoder } from '@solana/transactions'; + * + * const transactionDecoder = getTransactionDecoder(); + * const transaction = transactionDecoder.decode(wireTransactionBytes); + * for (const [address, signature] in Object.entries(transaction.signatures)) { + * console.log(`Signature by ${address}`, signature); + * } + * ``` + */ + export function getTransactionDecoder(): VariableSizeDecoder { return transformDecoder( getStructDecoder([ @@ -42,6 +62,12 @@ export function getTransactionDecoder(): VariableSizeDecoder { ); } +/** + * Returns a codec that you can use to encode from or decode to a {@link Transaction} + * + * @see {@link getTransactionDecoder} + * @see {@link getTransactionEncoder} + */ export function getTransactionCodec(): VariableSizeCodec { return combineCodec(getTransactionEncoder(), getTransactionDecoder()); } diff --git a/packages/transactions/src/compile-transaction.ts b/packages/transactions/src/compile-transaction.ts index 7068da19b..19c0c0552 100644 --- a/packages/transactions/src/compile-transaction.ts +++ b/packages/transactions/src/compile-transaction.ts @@ -14,6 +14,22 @@ import { } from './lifetime'; import { SignaturesMap, Transaction, TransactionMessageBytes } from './transaction'; +/** + * Returns a {@link Transaction} object for a given {@link TransactionMessage}. + * + * This includes the compiled bytes of the transaction message, and a map of signatures. This map + * will have a key for each address that is required to sign the transaction. The transaction will + * not yet have signatures for any of these addresses. + * + * Whether a transaction message is ready to be compiled or not is enforced for you at the type + * level. In order to be signable, a transaction message must: + * + * - have a version and a list of zero or more instructions (ie. conform to + * {@link BaseTransactionMessage}) + * - have a fee payer set (ie. conform to {@link ITransactionMessageWithFeePayer}) + * - have a lifetime specified (ie. conform to {@link TransactionMessageWithBlockhashLifetime} or + * {@link TransactionMessageWithDurableNonceLifetime}) + */ export function compileTransaction( transactionMessage: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime, ): Readonly; diff --git a/packages/transactions/src/index.ts b/packages/transactions/src/index.ts index 8f921b1c8..5c10e1792 100644 --- a/packages/transactions/src/index.ts +++ b/packages/transactions/src/index.ts @@ -1,3 +1,13 @@ +/** + * This package contains types and functions for compiling, signing and sending transactions. + * It can be used standalone, but it is also exported as part of Kit + * [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit). + * + * Transactions are created by compiling a transaction message. They must then be signed before + * being submitted to the network. + * + * @packageDocumentation + */ export * from './codecs'; export * from './lifetime'; export * from './compile-transaction'; diff --git a/packages/transactions/src/lifetime.ts b/packages/transactions/src/lifetime.ts index 4582b56c6..0a3e90a65 100644 --- a/packages/transactions/src/lifetime.ts +++ b/packages/transactions/src/lifetime.ts @@ -2,24 +2,79 @@ import { Address } from '@solana/addresses'; import { Blockhash, Slot } from '@solana/rpc-types'; import { Nonce } from '@solana/transaction-messages'; +/** + * A constraint which, when applied to a transaction, makes that transaction eligible to land on the + * network. The transaction will continue to be eligible to land until the network considers the + * `blockhash` to be expired. + * + * This can happen when the network proceeds past the `lastValidBlockHeight` for which the blockhash + * is considered valid, or when the network switches to a fork where that blockhash is not present. + */ export type TransactionBlockhashLifetime = { + /** + * A recent blockhash observed by the transaction proposer. + * + * The transaction will be considered eligible to land until the network determines this + * blockhash to be too old, or has switched to a fork where it is not present. + */ blockhash: Blockhash; + /** + * This is the block height beyond which the network will consider the blockhash to be too old + * to make a transaction eligible to land. + */ lastValidBlockHeight: Slot; }; +/** + * A constraint which, when applied to a transaction, makes that transaction eligible to land on the + * network. + * + * The transaction will continue to be eligible to land until the network considers the `nonce` to + * have advanced. This can happen when the nonce account in which this nonce is found is destroyed, + * or the nonce value within changes. + */ export type TransactionDurableNonceLifetime = { + /** + * A value contained in the account with address `nonceAccountAddress` at the time the + * transaction was prepared. + * + * The transaction will be considered eligible to land until the nonce account ceases to exist + * or contain this value. + */ nonce: Nonce; + /** The account that contains the `nonce` value */ nonceAccountAddress: Address; }; +/** + * A transaction whose ability to land on the network is determined by some evanescent criteria. + * + * This describes a window of time after which a transaction is constructed and before which it will + * no longer be accepted by the network. + * + * No transaction can land on Solana without having a `lifetimeConstraint` set. + */ export type TransactionWithLifetime = { readonly lifetimeConstraint: TransactionBlockhashLifetime | TransactionDurableNonceLifetime; }; +/** + * A transaction whose lifetime is determined by the age of a blockhash observed on the network. + * + * The transaction will continue to be eligible to land until the network considers the `blockhash` + * to be expired. + */ export type TransactionWithBlockhashLifetime = { readonly lifetimeConstraint: TransactionBlockhashLifetime; }; +/** + * A transaction whose lifetime is determined by a nonce. + * + * The transaction will continue to be eligible to land until the network considers the `nonce` to + * have advanced. This can happen when the nonce account in which this nonce is found is destroyed, + * or the nonce value within changes. + */ export type TransactionWithDurableNonceLifetime = { readonly lifetimeConstraint: TransactionDurableNonceLifetime; }; diff --git a/packages/transactions/src/signatures.ts b/packages/transactions/src/signatures.ts index aea05f8cb..b06d1b8a8 100644 --- a/packages/transactions/src/signatures.ts +++ b/packages/transactions/src/signatures.ts @@ -11,12 +11,29 @@ import { Signature, SignatureBytes, signBytes } from '@solana/keys'; import { Transaction } from './transaction'; +/** + * Represents a transaction that is signed by all of its required signers. Being fully signed is a + * prerequisite of functions designed to land transactions on the network. + */ export interface FullySignedTransaction extends Transaction { readonly __brand: unique symbol; } let base58Decoder: Decoder | undefined; +/** + * Given a transaction signed by its fee payer, this method will return the {@link Signature} that + * uniquely identifies it. This string can be used to look up transactions at a later date, for + * example on a Solana block explorer. + * + * @example + * ```ts + * import { getSignatureFromTransaction } from '@solana/transactions'; + * + * const signature = getSignatureFromTransaction(tx); + * console.debug(`Inspect this transaction at https://explorer.solana.com/tx/${signature}`); + * ``` + */ export function getSignatureFromTransaction(transaction: Transaction): Signature { if (!base58Decoder) base58Decoder = getBase58Decoder(); @@ -34,6 +51,26 @@ function uint8ArraysEqual(arr1: Uint8Array, arr2: Uint8Array) { return arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index]); } +/** + * Given an array of `CryptoKey` objects which are private keys pertaining to addresses that are + * required to sign a transaction, this method will return a new signed transaction of type + * {@link Transaction}. + * + * Though the resulting transaction might have every signature it needs to land on the network, this + * function will not assert that it does. A partially signed transaction cannot be landed on the + * network, but can be serialized and deserialized. + * + * @example + * ```ts + * import { generateKeyPair } from '@solana/keys'; + * import { partiallySignTransaction } from '@solana/transactions'; + * + * const partiallySignedTransaction = await partiallySignTransaction([myPrivateKey], tx); + * ``` + * + * @see {@link signTransaction} if you want to assert that the transaction has all of its required + * signatures after signing. + */ export async function partiallySignTransaction( keyPairs: CryptoKeyPair[], transaction: T, @@ -92,6 +129,24 @@ export async function partiallySignTransaction( }); } +/** + * Given an array of `CryptoKey` objects which are private keys pertaining to addresses that are + * required to sign a transaction, this method will return a new signed transaction of type + * {@link FullySignedTransaction}. + * + * This function will throw unless the resulting transaction is fully signed. + * + * @example + * ```ts + * import { generateKeyPair } from '@solana/keys'; + * import { signTransaction } from '@solana/transactions'; + * + * const signedTransaction = await signTransaction([myPrivateKey], tx); + * ``` + * + * @see {@link partiallySignTransaction} if you want to sign the transaction without asserting that + * the resulting transaction is fully signed. + */ export async function signTransaction( keyPairs: CryptoKeyPair[], transaction: T, @@ -102,6 +157,29 @@ export async function signTransaction( return out; } +/** + * From time to time you might acquire a {@link Transaction}, that you expect to be fully signed, + * from an untrusted network API or user input. Use this function to assert that such a transaction + * is fully signed. + * + * @example + * ```ts + * import { assertTransactionIsFullySigned } from '@solana/transactions'; + * + * const transaction = getTransactionDecoder().decode(transactionBytes); + * try { + * // If this type assertion function doesn't throw, then Typescript will upcast `transaction` + * // to `FullySignedTransaction`. + * assertTransactionIsFullySigned(transaction); + * // At this point we know that the transaction is signed and can be sent to the network. + * await sendAndConfirmTransaction(transaction, { commitment: 'confirmed' }); + * } catch(e) { + * if (isSolanaError(e, SOLANA_ERROR__TRANSACTION__SIGNATURES_MISSING)) { + * setError(`Missing signatures for ${e.context.addresses.join(', ')}`); + * } + * throw; + * } + */ export function assertTransactionIsFullySigned( transaction: Transaction, ): asserts transaction is FullySignedTransaction { diff --git a/packages/transactions/src/transaction.ts b/packages/transactions/src/transaction.ts index 9fa505e1d..fa56a2123 100644 --- a/packages/transactions/src/transaction.ts +++ b/packages/transactions/src/transaction.ts @@ -9,6 +9,11 @@ type OrderedMap = Record; export type SignaturesMap = OrderedMap; export type Transaction = Readonly<{ + /** The bytes of a compiled transaction message, encoded in wire format */ messageBytes: TransactionMessageBytes; + /** + * A map between the addresses of a transaction message's signers, and the 64-byte Ed25519 + * signature of the transaction's `messageBytes` by the private key associated with each. + */ signatures: SignaturesMap; }>; diff --git a/packages/transactions/src/wire-transaction.ts b/packages/transactions/src/wire-transaction.ts index e8df8b52c..94fbeaa2a 100644 --- a/packages/transactions/src/wire-transaction.ts +++ b/packages/transactions/src/wire-transaction.ts @@ -3,10 +3,23 @@ import { getBase64Decoder } from '@solana/codecs-strings'; import { getTransactionEncoder } from './codecs'; import { Transaction } from './transaction'; +/** Represents the wire format of a transaction as a base64-encoded string. */ export type Base64EncodedWireTransaction = string & { readonly __brand: unique symbol; }; +/** + * Given a signed transaction, this method returns the transaction as a string that conforms to the + * {@link Base64EncodedWireTransaction} type. + * + * @example + * ```ts + * import { getBase64EncodedWireTransaction, signTransaction } from '@solana/transactions'; + * + * const serializedTransaction = getBase64EncodedWireTransaction(signedTransaction); + * const signature = await rpc.sendTransaction(serializedTransaction, { encoding: 'base64' }).send(); + * ``` + */ export function getBase64EncodedWireTransaction(transaction: Transaction): Base64EncodedWireTransaction { const wireTransactionBytes = getTransactionEncoder().encode(transaction); return getBase64Decoder().decode(wireTransactionBytes) as Base64EncodedWireTransaction; diff --git a/packages/webcrypto-ed25519-polyfill/src/index.ts b/packages/webcrypto-ed25519-polyfill/src/index.ts index b3910c7d8..818b382bb 100644 --- a/packages/webcrypto-ed25519-polyfill/src/index.ts +++ b/packages/webcrypto-ed25519-polyfill/src/index.ts @@ -1 +1,43 @@ +/** + * This package contains a polyfill that enables Ed25519 key manipulation in environments where it + * is not yet implemented. It does so by proxying calls to `SubtleCrypto` instance methods to an + * Ed25519 implementation in userspace. + * + * > [!WARNING] + * > Because this package's implementation of Ed25519 key generation exists in userspace, it can't + * > guarantee that the keys you generate with it are non-exportable. Untrusted code running in your + * > JavaScript context may still be able to gain access to and/or exfiltrate secret key material. + * + * > [!NOTE] + * > Native `CryptoKeys` can be stored in IndexedDB but the keys created by this polyfill can not. + * > This is because, unlike native `CryptoKeys`, our polyfilled key objects can not implement the + * > [structured clone algorithm](https://www.w3.org/TR/WebCryptoAPI/#cryptokey-interface-clone). + * + * ## Usage + * + * Environments that support Ed25519 (see https://github.com/WICG/webcrypto-secure-curves/issues/20) + * do not require this polyfill. + * + * For all others, simply import this polyfill before use. + * + * ```ts + * import { install } from '@solana/webcrypto-ed25519-polyfill'; + * + * // Calling this will shim methods on `SubtleCrypto`, adding Ed25519 support. + * install(); + * + * // Now you can do this, in environments that do not otherwise support Ed25519. + * const keyPair = await crypto.subtle.generateKey('Ed25519', false, ['sign']); + * const publicKeyBytes = await crypto.subtle.exportKey('raw', keyPair.publicKey); + * const data = new Uint8Array([1, 2, 3]); + * const signature = await crypto.subtle.sign('Ed25519', keyPair.privateKey, data); + * if (await crypto.subtle.verify('Ed25519', keyPair.publicKey, signature, data)) { + * console.log('Data was signed using the private key associated with this public key'); + * } else { + * throw new Error('Signature verification error'); + * } + * ``` + * + * @packageDocumentation + */ export * from './install'; diff --git a/packages/webcrypto-ed25519-polyfill/src/install.ts b/packages/webcrypto-ed25519-polyfill/src/install.ts index 0605787ab..eb5a6bf96 100644 --- a/packages/webcrypto-ed25519-polyfill/src/install.ts +++ b/packages/webcrypto-ed25519-polyfill/src/install.ts @@ -15,6 +15,28 @@ function isAlgorithmEd25519(putativeEd25519Algorithm: AlgorithmIdentifier): bool return name.localeCompare('Ed25519', 'en-US', { sensitivity: 'base' }) === 0; } +/** + * Polyfills methods on `globalThis.SubtleCrypto` to add support for the Ed25519 algorithm. + * + * @example + * ```ts + * import { install } from '@solana/webcrypto-ed25519-polyfill'; + * + * // Calling this will shim methods on `SubtleCrypto`, adding Ed25519 support. + * install(); + * + * // Now you can do this, in environments that do not otherwise support Ed25519. + * const keyPair = await crypto.subtle.generateKey('Ed25519', false, ['sign']); + * const publicKeyBytes = await crypto.subtle.exportKey('raw', keyPair.publicKey); + * const data = new Uint8Array([1, 2, 3]); + * const signature = await crypto.subtle.sign('Ed25519', keyPair.privateKey, data); + * if (await crypto.subtle.verify('Ed25519', keyPair.publicKey, signature, data)) { + * console.log('Data was signed using the private key associated with this public key'); + * } else { + * throw new Error('Signature verification error'); + * } + * ``` + */ export function install() { if (__NODEJS__) { /**