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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions clients/js/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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[];
2 changes: 2 additions & 0 deletions clients/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './constants';
export * from './generated';
export * from './memos';
59 changes: 59 additions & 0 deletions clients/js/src/memos.ts
Original file line number Diff line number Diff line change
@@ -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;
}
93 changes: 93 additions & 0 deletions clients/js/test/getMemosFromInstructions.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});