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([]); + }); +});