From 4aefd38d50a1fc38ad5ec028f948aae8ead398bd Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 16 Sep 2026 15:13:13 +0100 Subject: [PATCH] Add memo extraction helpers to the JS client The Memo program has been deployed under several addresses over time (v1, v3 and the current v4), so consumers that filter by a single program address silently miss memos emitted under older versions. This adds getMemosFromInstructions, which matches instructions against every historical address and returns each memo's decoded text, raw bytes, source program address and instruction index. The supported addresses are exposed as a SUPPORTED_MEMO_PROGRAM_ADDRESSES constant, with the legacy addresses available individually as LEGACY_MEMO_PROGRAM_ADDRESS_V1 and LEGACY_MEMO_PROGRAM_ADDRESS_V3. The current address remains the generated MEMO_PROGRAM_ADDRESS, which should still be used when building new memo instructions. New hand-written modules (src/constants.ts, src/memos.ts) live alongside the generated code and are re-exported from src/index.ts. Unit tests cover extraction across all program versions, ordering and index preservation, multi-byte UTF-8, and the empty-data case. --- clients/js/src/constants.ts | 43 +++++++++ clients/js/src/index.ts | 2 + clients/js/src/memos.ts | 59 ++++++++++++ .../js/test/getMemosFromInstructions.test.ts | 93 +++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 clients/js/src/constants.ts create mode 100644 clients/js/src/memos.ts create mode 100644 clients/js/test/getMemosFromInstructions.test.ts diff --git a/clients/js/src/constants.ts b/clients/js/src/constants.ts new file mode 100644 index 00000000..1d6cf533 --- /dev/null +++ b/clients/js/src/constants.ts @@ -0,0 +1,43 @@ +import { type Address } from '@solana/kit'; + +import { MEMO_PROGRAM_ADDRESS } from './generated'; + +/** + * The address of the version 1 Memo program. + * + * This is a legacy address, kept for reading memos emitted by older transactions. + * Use {@link MEMO_PROGRAM_ADDRESS} when building new memo instructions. + */ +export const LEGACY_MEMO_PROGRAM_ADDRESS_V1 = + 'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo' as Address<'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo'>; + +/** + * The address of the version 3 Memo program. + * + * This is a legacy address, kept for reading memos emitted by older transactions. + * Use {@link MEMO_PROGRAM_ADDRESS} when building new memo instructions. + */ +export const LEGACY_MEMO_PROGRAM_ADDRESS_V3 = + 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' as Address<'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'>; + +/** + * Every Memo program address ever deployed, ordered from oldest (v1) to newest (v4). + * + * Consumers that need to detect memos should match against every address in this + * list rather than a single program address, since a transaction may carry memos + * emitted under any historical version of the program. The newest address (v4) is + * exported separately as `MEMO_PROGRAM_ADDRESS` and should be used to build new + * memo instructions. + * + * @example + * ```ts + * import { SUPPORTED_MEMO_PROGRAM_ADDRESSES } from '@solana-program/memo'; + * + * const isMemoInstruction = SUPPORTED_MEMO_PROGRAM_ADDRESSES.includes(instruction.programAddress); + * ``` + */ +export const SUPPORTED_MEMO_PROGRAM_ADDRESSES = Object.freeze([ + LEGACY_MEMO_PROGRAM_ADDRESS_V1, + LEGACY_MEMO_PROGRAM_ADDRESS_V3, + MEMO_PROGRAM_ADDRESS, +] as const) satisfies readonly Address[]; diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 69e4e4eb..aaa175f8 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1 +1,3 @@ +export * from './constants'; export * from './generated'; +export * from './memos'; diff --git a/clients/js/src/memos.ts b/clients/js/src/memos.ts new file mode 100644 index 00000000..38c2baa8 --- /dev/null +++ b/clients/js/src/memos.ts @@ -0,0 +1,59 @@ +import { getUtf8Decoder, type Address, type Instruction, type ReadonlyUint8Array } from '@solana/kit'; + +import { SUPPORTED_MEMO_PROGRAM_ADDRESSES } from './constants'; + +/** + * A memo extracted from an instruction by {@link getMemosFromInstructions}. + */ +export type ExtractedMemo = { + /** The UTF-8 decoded memo text. Instructions with no data yield an empty string. */ + memo: string; + /** The raw, undecoded instruction data. Instructions with no data yield an empty array. */ + bytes: ReadonlyUint8Array; + /** The address of the Memo program that emitted this memo. */ + programAddress: Address; + /** The index of the source instruction within the array passed to the helper. */ + index: number; +}; + +/** + * Extracts every memo from an array of instructions, regardless of which version of + * the Memo program emitted it. + * + * The Memo program has been deployed under several addresses over time (see + * {@link SUPPORTED_MEMO_PROGRAM_ADDRESSES}). This helper matches instructions against + * all of them, so consumers do not need to know about the individual program + * addresses. Matching instructions are decoded as UTF-8, matching the program's own + * contract; the original ordering is preserved and each result carries the source + * program address and instruction index. + * + * @example + * ```ts + * import { getMemosFromInstructions } from '@solana-program/memo'; + * + * const memos = getMemosFromInstructions(transactionMessage.instructions); + * // => [{ memo: 'Hello world!', programAddress: 'Memo4c2p…', index: 1 }] + * ``` + */ +export function getMemosFromInstructions(instructions: readonly Instruction[]): ExtractedMemo[] { + const decoder = getUtf8Decoder(); + const memos: ExtractedMemo[] = []; + + const supportedAddresses: readonly Address[] = SUPPORTED_MEMO_PROGRAM_ADDRESSES; + + instructions.forEach((instruction, index) => { + if (!supportedAddresses.includes(instruction.programAddress)) { + return; + } + + const bytes = instruction.data ?? new Uint8Array(); + memos.push({ + memo: decoder.decode(bytes), + bytes, + programAddress: instruction.programAddress, + index, + }); + }); + + return memos; +} diff --git a/clients/js/test/getMemosFromInstructions.test.ts b/clients/js/test/getMemosFromInstructions.test.ts new file mode 100644 index 00000000..8547265c --- /dev/null +++ b/clients/js/test/getMemosFromInstructions.test.ts @@ -0,0 +1,93 @@ +import { getUtf8Encoder, type Address, type Instruction } from '@solana/kit'; +import { describe, expect, it } from 'vitest'; + +import { + getMemosFromInstructions, + LEGACY_MEMO_PROGRAM_ADDRESS_V1, + LEGACY_MEMO_PROGRAM_ADDRESS_V3, + MEMO_PROGRAM_ADDRESS, +} from '../src'; + +const NON_MEMO_PROGRAM_ADDRESS = '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; + +function memoInstruction(programAddress: Address, memo: string): Instruction { + return { programAddress, data: getUtf8Encoder().encode(memo) }; +} + +describe('getMemosFromInstructions', () => { + it('extracts memos emitted by every supported program version', () => { + const instructions = [ + memoInstruction(LEGACY_MEMO_PROGRAM_ADDRESS_V1, 'from v1'), + memoInstruction(LEGACY_MEMO_PROGRAM_ADDRESS_V3, 'from v3'), + memoInstruction(MEMO_PROGRAM_ADDRESS, 'from v4'), + ]; + + expect(getMemosFromInstructions(instructions)).toEqual([ + { + memo: 'from v1', + bytes: getUtf8Encoder().encode('from v1'), + programAddress: LEGACY_MEMO_PROGRAM_ADDRESS_V1, + index: 0, + }, + { + memo: 'from v3', + bytes: getUtf8Encoder().encode('from v3'), + programAddress: LEGACY_MEMO_PROGRAM_ADDRESS_V3, + index: 1, + }, + { + memo: 'from v4', + bytes: getUtf8Encoder().encode('from v4'), + programAddress: MEMO_PROGRAM_ADDRESS, + index: 2, + }, + ]); + }); + + it('ignores non-memo instructions and preserves the original index', () => { + const instructions = [ + { programAddress: NON_MEMO_PROGRAM_ADDRESS } satisfies Instruction, + memoInstruction(MEMO_PROGRAM_ADDRESS, 'Hello world!'), + { programAddress: NON_MEMO_PROGRAM_ADDRESS } satisfies Instruction, + ]; + + expect(getMemosFromInstructions(instructions)).toEqual([ + { + memo: 'Hello world!', + bytes: getUtf8Encoder().encode('Hello world!'), + programAddress: MEMO_PROGRAM_ADDRESS, + index: 1, + }, + ]); + }); + + it('returns multiple memos in order', () => { + const instructions = [ + memoInstruction(MEMO_PROGRAM_ADDRESS, 'first'), + { programAddress: NON_MEMO_PROGRAM_ADDRESS } satisfies Instruction, + memoInstruction(MEMO_PROGRAM_ADDRESS, 'second'), + ]; + + expect(getMemosFromInstructions(instructions).map(m => m.memo)).toEqual(['first', 'second']); + }); + + it('decodes multi-byte UTF-8 memos', () => { + const instructions = [memoInstruction(MEMO_PROGRAM_ADDRESS, 'gm 🌞 café')]; + + expect(getMemosFromInstructions(instructions)[0]?.memo).toBe('gm 🌞 café'); + }); + + it('yields an empty string and empty bytes for a memo instruction with no data', () => { + const instructions = [{ programAddress: MEMO_PROGRAM_ADDRESS } satisfies Instruction]; + + expect(getMemosFromInstructions(instructions)).toEqual([ + { memo: '', bytes: new Uint8Array(), programAddress: MEMO_PROGRAM_ADDRESS, index: 0 }, + ]); + }); + + it('returns an empty array when there are no memo instructions', () => { + const instructions = [{ programAddress: NON_MEMO_PROGRAM_ADDRESS } satisfies Instruction]; + + expect(getMemosFromInstructions(instructions)).toEqual([]); + }); +});