Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tidy-melons-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@codama/dynamic-parsers': minor
---

Search additional programs when identifying and parsing data. `parseInstruction` now uses the instruction's `programAddress` to restrict the search to the matching program, falling back to all programs when none matches. All `identify*` and `parse*` functions accept an optional `programAddress` option.
10 changes: 10 additions & 0 deletions packages/dynamic-parsers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ if (parsedData) {
}
```

Note that it uses the instruction's `programAddress` to restrict the search to the matching program — including any of the root node's `additionalPrograms` — falling back to all programs when none matches.

## Program selection

All functions above search the root node's main program as well as its `additionalPrograms`, in that order. Additionally, they all accept an optional `programAddress` option that restricts the search to the programs matching that address, if any.

```ts
const parsedData = parseInstructionData(rootNode, bytes, { programAddress: address });
```

### `identifyAccountData`

This function tries to match the provided bytes to an account node, returning a `NodePath<AccountNode>` object if the identification was successful, or `undefined` otherwise. It is used by the `parseAccountData` function under the hood.
Expand Down
48 changes: 39 additions & 9 deletions packages/dynamic-parsers/src/identify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CodecAndValueVisitors, getCodecAndValueVisitors, ReadonlyUint8Array } f
import {
AccountNode,
EventNode,
getAllPrograms,
GetNodeFromKind,
InstructionNode,
isNodeFilter,
Expand All @@ -25,31 +26,43 @@ import { matchDiscriminators } from './discriminators';

type IdentifiableNodeKind = 'accountNode' | 'eventNode' | 'instructionNode';

export type IdentifyDataOptions = {
/**
* When provided, restricts the search to the programs matching this address,
* if any. When no program matches the address, all programs are searched.
*/
programAddress?: string;
};

export function identifyAccountData(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
options: IdentifyDataOptions = {},
): NodePath<AccountNode> | undefined {
return identifyData(root, bytes, 'accountNode');
return identifyData(root, bytes, 'accountNode', options);
}

export function identifyEventData(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
options: IdentifyDataOptions = {},
): NodePath<EventNode> | undefined {
return identifyData(root, bytes, 'eventNode');
return identifyData(root, bytes, 'eventNode', options);
}

export function identifyInstructionData(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
options: IdentifyDataOptions = {},
): NodePath<InstructionNode> | undefined {
return identifyData(root, bytes, 'instructionNode');
return identifyData(root, bytes, 'instructionNode', options);
}

export function identifyData<TKind extends IdentifiableNodeKind>(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
kind?: TKind | TKind[],
options: IdentifyDataOptions = {},
): NodePath<GetNodeFromKind<TKind>> | undefined {
const kinds = kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[]);

Expand All @@ -58,25 +71,32 @@ export function identifyData<TKind extends IdentifiableNodeKind>(
visit(root, getRecordLinkablesVisitor(linkables));

const codecAndValueVisitors = getCodecAndValueVisitors(linkables, { stack });
const visitor = getByteIdentificationVisitor(kinds, bytes, codecAndValueVisitors, { stack });
const visitor = getByteIdentificationVisitor(kinds, bytes, codecAndValueVisitors, {
programAddress: options.programAddress,
stack,
});

const identified = visit(root, visitor);
if (identified) return identified;

// Fallback: When Node of given kind doesn't have a discriminator and is single then we can identify it.
// Example: `Memo4c2pN8afCj432Lb7RMVKi9PbQnnW7ewFFaV3oAH` program with single instruction omits a discriminator.
const candidates = getNodeCandidates(root.program, kinds);
if (candidates.length !== 1 || candidates[0].discriminators?.length) return undefined;
return [root, root.program, candidates[0]] as unknown as NodePath<GetNodeFromKind<TKind>>;
for (const program of getCandidatePrograms(root, options.programAddress)) {
const candidates = getNodeCandidates(program, kinds);
if (candidates.length !== 1 || candidates[0].discriminators?.length) continue;
return [root, program, candidates[0]] as unknown as NodePath<GetNodeFromKind<TKind>>;
}
return undefined;
}

export function getByteIdentificationVisitor<TKind extends IdentifiableNodeKind>(
kind: TKind | TKind[],
bytes: ReadonlyUint8Array | Uint8Array,
codecAndValueVisitors: CodecAndValueVisitors,
options: { stack?: NodeStack } = {},
options: IdentifyDataOptions & { stack?: NodeStack } = {},
) {
const stack = options.stack ?? new NodeStack();
const programAddress = options.programAddress;

return pipe(
{
Expand Down Expand Up @@ -109,7 +129,10 @@ export function getByteIdentificationVisitor<TKind extends IdentifiableNodeKind>
}
},
visitRoot(node) {
return visit(node.program, this);
for (const program of getCandidatePrograms(node, programAddress)) {
const result = visit(program, this);
if (result) return result;
}
},
} as Visitor<
NodePath<GetNodeFromKind<TKind>> | undefined,
Expand All @@ -119,6 +142,13 @@ export function getByteIdentificationVisitor<TKind extends IdentifiableNodeKind>
);
}

function getCandidatePrograms(root: RootNode, programAddress?: string): ProgramNode[] {
const programs = getAllPrograms(root);
if (programAddress === undefined) return programs;
const matches = programs.filter(program => program.publicKey === programAddress);
return matches.length > 0 ? matches : programs;
}

function getNodeCandidates(
program: ProgramNode,
kind: IdentifiableNodeKind | IdentifiableNodeKind[],
Expand Down
21 changes: 15 additions & 6 deletions packages/dynamic-parsers/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
InstructionWithData,
} from '@solana/instructions';

import { identifyData } from './identify';
import { identifyData, IdentifyDataOptions } from './identify';

type ParsableNode = AccountNode | EventNode | InstructionNode;
type ParsableNodeKind = ParsableNode['kind'];
Expand All @@ -22,30 +22,39 @@ export type ParsedData<TNode extends ParsableNode> = {
export function parseAccountData(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
options: IdentifyDataOptions = {},
): ParsedData<AccountNode> | undefined {
return parseData(root, bytes, 'accountNode');
return parseData(root, bytes, 'accountNode', options);
}

export function parseEventData(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
options: IdentifyDataOptions = {},
): ParsedData<EventNode> | undefined {
return parseData(root, bytes, 'eventNode');
return parseData(root, bytes, 'eventNode', options);
}

export function parseInstructionData(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
options: IdentifyDataOptions = {},
): ParsedData<InstructionNode> | undefined {
return parseData(root, bytes, 'instructionNode');
return parseData(root, bytes, 'instructionNode', options);
}

export function parseData<TKind extends ParsableNodeKind>(
root: RootNode,
bytes: ReadonlyUint8Array | Uint8Array,
kind?: TKind | TKind[],
options: IdentifyDataOptions = {},
): ParsedData<GetNodeFromKind<TKind>> | undefined {
const path = identifyData<TKind>(root, bytes, kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[]));
const path = identifyData<TKind>(
root,
bytes,
kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[]),
options,
);
if (!path) return undefined;
const codec = getNodeCodec(path as NodePath<ParsableNode>);
const data = codec.decode(bytes);
Expand All @@ -68,7 +77,7 @@ export function parseInstruction(
InstructionWithAccounts<readonly (AccountLookupMeta | AccountMeta)[]> &
InstructionWithData<ReadonlyUint8Array>,
): ParsedInstruction | undefined {
const parsedData = parseInstructionData(root, instruction.data);
const parsedData = parseInstructionData(root, instruction.data, { programAddress: instruction.programAddress });
if (!parsedData) return undefined;
const instructionNode = getLastNodeFromPath(parsedData.path);
const accounts: ParsedInstructionAccounts = instructionNode.accounts.flatMap((account, index) => {
Expand Down
98 changes: 91 additions & 7 deletions packages/dynamic-parsers/test/identify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,16 @@ describe('identifyAccountData', () => {
const result = identifyAccountData(root, hex('ff010203'));
expect(result).toStrictEqual([root, root.program, root.program.accounts[0]]);
});
test('it does not identify accounts in additional programs', () => {
test('it identifies accounts in additional programs', () => {
const root = rootNode(programNode({ name: 'myProgram', publicKey: '1111' }), [
programNode({
accounts: [accountNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'myAccount' })],
name: 'myProgram',
publicKey: '1111',
name: 'myAdditionalProgram',
publicKey: '2222',
}),
]);
const result = identifyAccountData(root, hex('01020304'));
expect(result).toBeUndefined();
expect(result).toStrictEqual([root, root.additionalPrograms[0], root.additionalPrograms[0].accounts[0]]);
});
test('it does not identify accounts using instruction discriminators', () => {
const root = rootNode(programNode({ name: 'myProgram', publicKey: '1111' }), [
Expand Down Expand Up @@ -156,16 +156,100 @@ describe('identifyInstructionData', () => {
const result = identifyInstructionData(root, hex('ff010203'));
expect(result).toStrictEqual([root, root.program, root.program.instructions[0]]);
});
test('it does not identify instructions in additional programs', () => {
test('it identifies instructions in additional programs', () => {
const root = rootNode(programNode({ name: 'myProgram', publicKey: '1111' }), [
programNode({
instructions: [instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'myInstruction' })],
name: 'myAdditionalProgram',
publicKey: '2222',
}),
]);
const result = identifyInstructionData(root, hex('01020304'));
expect(result).toStrictEqual([root, root.additionalPrograms[0], root.additionalPrograms[0].instructions[0]]);
});
test('it identifies instructions in the main program before additional programs', () => {
// Given a main program and an additional program whose instructions both match the data.
const root = rootNode(
programNode({
instructions: [
instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'mainInstruction' }),
],
name: 'myProgram',
publicKey: '1111',
}),
]);
[
programNode({
instructions: [
instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'additionalInstruction' }),
],
name: 'myAdditionalProgram',
publicKey: '2222',
}),
],
);
// When we identify the data without a program address.
const result = identifyInstructionData(root, hex('01020304'));
expect(result).toBeUndefined();
// Then we expect the main program's instruction to win.
expect(result).toStrictEqual([root, root.program, root.program.instructions[0]]);
});
test('it restricts the search to programs matching the provided program address', () => {
// Given a main program and an additional program whose instructions both match the data.
const root = rootNode(
programNode({
instructions: [
instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'mainInstruction' }),
],
name: 'myProgram',
publicKey: '1111',
}),
[
programNode({
instructions: [
instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'additionalInstruction' }),
],
name: 'myAdditionalProgram',
publicKey: '2222',
}),
],
);
// When we identify the data using the additional program's address.
const result = identifyInstructionData(root, hex('01020304'), { programAddress: '2222' });
// Then we expect the additional program's instruction, not the main program's.
expect(result).toStrictEqual([root, root.additionalPrograms[0], root.additionalPrograms[0].instructions[0]]);
});
test('it searches all programs when no program matches the provided program address', () => {
const root = rootNode(
programNode({
instructions: [instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'myInstruction' })],
name: 'myProgram',
publicKey: '1111',
}),
);
const result = identifyInstructionData(root, hex('01020304'), { programAddress: '9999' });
expect(result).toStrictEqual([root, root.program, root.program.instructions[0]]);
});
test('it identifies a single non-discriminated instruction in an additional program as a fallback', () => {
// Given an additional program with exactly one non-discriminated instruction.
const root = rootNode(
programNode({
instructions: [
instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'mainInstruction' }),
],
name: 'myProgram',
publicKey: '1111',
}),
[
programNode({
instructions: [instructionNode({ name: 'additionalInstruction' })],
name: 'myAdditionalProgram',
publicKey: '2222',
}),
],
);
// When we identify non-matching data using the additional program's address.
const result = identifyInstructionData(root, hex('0102030405'), { programAddress: '2222' });
// Then we expect the additional program's instruction to be identified as the fallback.
expect(result).toStrictEqual([root, root.additionalPrograms[0], root.additionalPrograms[0].instructions[0]]);
});
test('it does not identify instructions using account discriminators', () => {
const root = rootNode(programNode({ name: 'myProgram', publicKey: '1111' }), [
Expand Down
Loading
Loading