diff --git a/.changeset/tidy-melons-search.md b/.changeset/tidy-melons-search.md new file mode 100644 index 000000000..4329f6528 --- /dev/null +++ b/.changeset/tidy-melons-search.md @@ -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. diff --git a/packages/dynamic-parsers/README.md b/packages/dynamic-parsers/README.md index 321566271..c998ccd65 100644 --- a/packages/dynamic-parsers/README.md +++ b/packages/dynamic-parsers/README.md @@ -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` object if the identification was successful, or `undefined` otherwise. It is used by the `parseAccountData` function under the hood. diff --git a/packages/dynamic-parsers/src/identify.ts b/packages/dynamic-parsers/src/identify.ts index bf7de008f..1074cc73a 100644 --- a/packages/dynamic-parsers/src/identify.ts +++ b/packages/dynamic-parsers/src/identify.ts @@ -2,6 +2,7 @@ import { CodecAndValueVisitors, getCodecAndValueVisitors, ReadonlyUint8Array } f import { AccountNode, EventNode, + getAllPrograms, GetNodeFromKind, InstructionNode, isNodeFilter, @@ -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 | undefined { - return identifyData(root, bytes, 'accountNode'); + return identifyData(root, bytes, 'accountNode', options); } export function identifyEventData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, + options: IdentifyDataOptions = {}, ): NodePath | undefined { - return identifyData(root, bytes, 'eventNode'); + return identifyData(root, bytes, 'eventNode', options); } export function identifyInstructionData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, + options: IdentifyDataOptions = {}, ): NodePath | undefined { - return identifyData(root, bytes, 'instructionNode'); + return identifyData(root, bytes, 'instructionNode', options); } export function identifyData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, kind?: TKind | TKind[], + options: IdentifyDataOptions = {}, ): NodePath> | undefined { const kinds = kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[]); @@ -58,25 +71,32 @@ export function identifyData( 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>; + 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>; + } + return undefined; } export function getByteIdentificationVisitor( 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( { @@ -109,7 +129,10 @@ export function getByteIdentificationVisitor } }, 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> | undefined, @@ -119,6 +142,13 @@ export function getByteIdentificationVisitor ); } +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[], diff --git a/packages/dynamic-parsers/src/parsers.ts b/packages/dynamic-parsers/src/parsers.ts index 3664e1181..30d2a3302 100644 --- a/packages/dynamic-parsers/src/parsers.ts +++ b/packages/dynamic-parsers/src/parsers.ts @@ -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']; @@ -22,30 +22,39 @@ export type ParsedData = { export function parseAccountData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, + options: IdentifyDataOptions = {}, ): ParsedData | undefined { - return parseData(root, bytes, 'accountNode'); + return parseData(root, bytes, 'accountNode', options); } export function parseEventData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, + options: IdentifyDataOptions = {}, ): ParsedData | undefined { - return parseData(root, bytes, 'eventNode'); + return parseData(root, bytes, 'eventNode', options); } export function parseInstructionData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, + options: IdentifyDataOptions = {}, ): ParsedData | undefined { - return parseData(root, bytes, 'instructionNode'); + return parseData(root, bytes, 'instructionNode', options); } export function parseData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, kind?: TKind | TKind[], + options: IdentifyDataOptions = {}, ): ParsedData> | undefined { - const path = identifyData(root, bytes, kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[])); + const path = identifyData( + root, + bytes, + kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[]), + options, + ); if (!path) return undefined; const codec = getNodeCodec(path as NodePath); const data = codec.decode(bytes); @@ -68,7 +77,7 @@ export function parseInstruction( InstructionWithAccounts & InstructionWithData, ): 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) => { diff --git a/packages/dynamic-parsers/test/identify.test.ts b/packages/dynamic-parsers/test/identify.test.ts index 3acb3b0e9..1bfb8e716 100644 --- a/packages/dynamic-parsers/test/identify.test.ts +++ b/packages/dynamic-parsers/test/identify.test.ts @@ -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' }), [ @@ -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' }), [ diff --git a/packages/dynamic-parsers/test/parsers.test.ts b/packages/dynamic-parsers/test/parsers.test.ts index 760b2bbe4..e5278cf80 100644 --- a/packages/dynamic-parsers/test/parsers.test.ts +++ b/packages/dynamic-parsers/test/parsers.test.ts @@ -361,6 +361,71 @@ describe('parseInstruction', () => { path: [root, root.program, root.program.instructions[0]], }); }); + + test('it parses an instruction from an additional program using the program address', () => { + // Given a token-shaped main program and an ATA-shaped additional program whose + // instructions share the same one-byte field discriminator. + const discriminator = (defaultValue: number) => + instructionArgumentNode({ + defaultValue: numberValueNode(defaultValue), + name: 'discriminator', + type: numberTypeNode('u8'), + }); + const root = rootNode( + programNode({ + instructions: [ + instructionNode({ + accounts: [instructionAccountNode({ isSigner: false, isWritable: true, name: 'account' })], + arguments: [discriminator(1)], + discriminators: [fieldDiscriminatorNode('discriminator')], + name: 'initializeAccount', + }), + ], + name: 'token', + publicKey: '1111', + }), + [ + programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ isSigner: true, isWritable: true, name: 'payer' }), + instructionAccountNode({ isSigner: false, isWritable: true, name: 'ata' }), + ], + arguments: [discriminator(1)], + discriminators: [fieldDiscriminatorNode('discriminator')], + name: 'createAssociatedTokenIdempotent', + }), + ], + name: 'associatedToken', + publicKey: '2222', + }), + ], + ); + + // And a concrete instruction targeting the additional program's address. + const instruction = { + accounts: [ + { address: 'payer111', role: AccountRole.WRITABLE_SIGNER }, + { address: 'ata11111', role: AccountRole.WRITABLE }, + ], + data: hex('01'), + programAddress: '2222', + } as unknown as Parameters[1]; + + // When we parse the instruction. + const result = parseInstruction(root, instruction); + + // Then we expect the additional program's instruction, not the main program's. + expect(result).toStrictEqual({ + accounts: [ + { address: 'payer111', name: 'payer', role: AccountRole.WRITABLE_SIGNER }, + { address: 'ata11111', name: 'ata', role: AccountRole.WRITABLE }, + ], + data: { discriminator: 1 }, + path: [root, root.additionalPrograms[0], root.additionalPrograms[0].instructions[0]], + }); + }); }); describe('parseData', () => {