diff --git a/.changeset/four-spoons-fix.md b/.changeset/four-spoons-fix.md new file mode 100644 index 000000000..76888cafd --- /dev/null +++ b/.changeset/four-spoons-fix.md @@ -0,0 +1,6 @@ +--- +"@codama/dynamic-codecs": minor +"@codama/dynamic-parsers": minor +--- + +Add event support to dynamic codecs and parsers. diff --git a/packages/dynamic-codecs/src/codecs.ts b/packages/dynamic-codecs/src/codecs.ts index 07d83ddde..9da5402a1 100644 --- a/packages/dynamic-codecs/src/codecs.ts +++ b/packages/dynamic-codecs/src/codecs.ts @@ -10,6 +10,7 @@ import { CountNode, DefinedTypeLinkNode, DefinedTypeNode, + EventNode, InstructionArgumentLinkNode, InstructionArgumentNode, InstructionLinkNode, @@ -85,6 +86,7 @@ export type EncodableNodes = | AccountNode | DefinedTypeLinkNode | DefinedTypeNode + | EventNode | InstructionArgumentLinkNode | InstructionArgumentNode | InstructionLinkNode @@ -199,6 +201,9 @@ export function getNodeCodecVisitor( const variants = node.variants.map(variant => [pascalCase(variant.name), visit(variant, this)] as const); return getDiscriminatedUnionCodec(variants, { size }) as unknown as Codec; }, + visitEvent(node) { + return visit(node.data, this); + }, visitFixedSizeType(node) { const type = visit(node.type, this); return fixCodecSize(type, node.size); diff --git a/packages/dynamic-codecs/test/codecs/EventNode.test.ts b/packages/dynamic-codecs/test/codecs/EventNode.test.ts new file mode 100644 index 000000000..65b600a28 --- /dev/null +++ b/packages/dynamic-codecs/test/codecs/EventNode.test.ts @@ -0,0 +1,21 @@ +import { eventNode, numberTypeNode, structFieldTypeNode, structTypeNode } from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { getNodeCodec } from '../../src'; +import { hex } from '../_setup'; + +test('it delegates to the underlying event data node', () => { + const codec = getNodeCodec([ + eventNode({ + data: structTypeNode([ + structFieldTypeNode({ + name: 'foo', + type: numberTypeNode('u32'), + }), + ]), + name: 'myEvent', + }), + ]); + expect(codec.encode({ foo: 42 })).toStrictEqual(hex('2a000000')); + expect(codec.decode(hex('2a000000'))).toStrictEqual({ foo: 42 }); +}); diff --git a/packages/dynamic-parsers/README.md b/packages/dynamic-parsers/README.md index 70ba166ef..321566271 100644 --- a/packages/dynamic-parsers/README.md +++ b/packages/dynamic-parsers/README.md @@ -7,7 +7,7 @@ [npm-image]: https://img.shields.io/npm/v/@codama/dynamic-parsers.svg?style=flat&label=%40codama%2Fdynamic-parsers [npm-url]: https://www.npmjs.com/package/@codama/dynamic-parsers -This package provides a set of helpers that, given any Codama IDL, dynamically identifies and parses any byte array into deserialized accounts and instructions. +This package provides a set of helpers that, given any Codama IDL, dynamically identifies and parses any byte array into deserialized accounts, events, and instructions. ## Installation @@ -25,7 +25,7 @@ pnpm install @codama/dynamic-parsers This type represents the result of identifying and parsing a byte array from a given root node. It provides us with the full `NodePath` of the identified node, as well as the data deserialized from the provided bytes. ```ts -type ParsedData = { +type ParsedData = { data: unknown; path: NodePath; }; @@ -47,6 +47,20 @@ if (parsedData) { } ``` +### `parseEventData(rootNode, bytes)` + +Similarly to `parseAccountData`, this function will match the provided bytes to an event node and deserialize them accordingly. It returns a `ParsedData` object if the parsing was successful, or `undefined` otherwise. + +```ts +const parsedData = parseEventData(rootNode, bytes); +// ^ ParsedData | undefined + +if (parsedData) { + const eventNode: EventNode = getLastNodeFromPath(parsedData.path); + const decodedData: unknown = parsedData.data; +} +``` + ### `parseInstructionData(rootNode, bytes)` Similarly to `parseAccountData`, this function will match the provided bytes to an instruction node and deserialize them accordingly. It returns a `ParsedData` object if the parsing was successful, or `undefined` otherwise. @@ -99,3 +113,16 @@ if (path) { const instructionNode: InstructionNode = getLastNodeFromPath(path); } ``` + +### `identifyEventData` + +This function tries to match the provided bytes to an event node, returning a `NodePath` object if the identification was successful, or `undefined` otherwise. It is used by the `parseEventData` function under the hood. + +```ts +const path = identifyEventData(root, bytes); +// ^ NodePath | undefined + +if (path) { + const eventNode: EventNode = getLastNodeFromPath(path); +} +``` diff --git a/packages/dynamic-parsers/src/discriminators.ts b/packages/dynamic-parsers/src/discriminators.ts index 1038ff0f3..13bf8299e 100644 --- a/packages/dynamic-parsers/src/discriminators.ts +++ b/packages/dynamic-parsers/src/discriminators.ts @@ -14,32 +14,33 @@ import { isNode, SizeDiscriminatorNode, StructTypeNode, + TypeNode, } from '@codama/nodes'; import { visit } from '@codama/visitors-core'; export function matchDiscriminators( bytes: ReadonlyUint8Array, discriminators: DiscriminatorNode[], - struct: StructTypeNode, + typeNode: TypeNode, visitors: CodecAndValueVisitors, ): boolean { return ( discriminators.length > 0 && - discriminators.every(discriminator => matchDiscriminator(bytes, discriminator, struct, visitors)) + discriminators.every(discriminator => matchDiscriminator(bytes, discriminator, typeNode, visitors)) ); } function matchDiscriminator( bytes: ReadonlyUint8Array, discriminator: DiscriminatorNode, - struct: StructTypeNode, + typeNode: TypeNode, visitors: CodecAndValueVisitors, ): boolean { if (isNode(discriminator, 'constantDiscriminatorNode')) { return matchConstantDiscriminator(bytes, discriminator, visitors); } if (isNode(discriminator, 'fieldDiscriminatorNode')) { - return matchFieldDiscriminator(bytes, discriminator, struct, visitors); + return matchFieldDiscriminator(bytes, discriminator, typeNode, visitors); } assertIsNode(discriminator, 'sizeDiscriminatorNode'); return matchSizeDiscriminator(bytes, discriminator); @@ -59,9 +60,15 @@ function matchConstantDiscriminator( function matchFieldDiscriminator( bytes: ReadonlyUint8Array, discriminator: FieldDiscriminatorNode, - struct: StructTypeNode, + typeNode: TypeNode, visitors: CodecAndValueVisitors, ): boolean { + if (!isNode(typeNode, 'structTypeNode')) { + throw new CodamaError(CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND, { + field: discriminator.name, + }); + } + const struct = typeNode as StructTypeNode; const field = struct.fields.find(field => field.name === discriminator.name); if (!field) { throw new CodamaError(CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND, { diff --git a/packages/dynamic-parsers/src/identify.ts b/packages/dynamic-parsers/src/identify.ts index 14757a121..4dc0906e0 100644 --- a/packages/dynamic-parsers/src/identify.ts +++ b/packages/dynamic-parsers/src/identify.ts @@ -1,6 +1,7 @@ import { CodecAndValueVisitors, getCodecAndValueVisitors, ReadonlyUint8Array } from '@codama/dynamic-codecs'; import { AccountNode, + EventNode, GetNodeFromKind, InstructionNode, isNodeFilter, @@ -28,6 +29,13 @@ export function identifyAccountData( return identifyData(root, bytes, 'accountNode'); } +export function identifyEventData( + root: RootNode, + bytes: ReadonlyUint8Array | Uint8Array, +): NodePath | undefined { + return identifyData(root, bytes, 'eventNode'); +} + export function identifyInstructionData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, @@ -35,7 +43,7 @@ export function identifyInstructionData( return identifyData(root, bytes, 'instructionNode'); } -export function identifyData( +export function identifyData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, kind?: TKind | TKind[], @@ -46,7 +54,7 @@ export function identifyData( const codecAndValueVisitors = getCodecAndValueVisitors(linkables, { stack }); const visitor = getByteIdentificationVisitor( - kind ?? (['accountNode', 'instructionNode'] as TKind[]), + kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[]), bytes, codecAndValueVisitors, { stack }, @@ -55,7 +63,7 @@ export function identifyData( return visit(root, visitor); } -export function getByteIdentificationVisitor( +export function getByteIdentificationVisitor( kind: TKind | TKind[], bytes: ReadonlyUint8Array | Uint8Array, codecAndValueVisitors: CodecAndValueVisitors, @@ -71,6 +79,16 @@ export function getByteIdentificationVisitor> | undefined, - 'accountNode' | 'instructionNode' | 'programNode' | 'rootNode' + 'accountNode' | 'eventNode' | 'instructionNode' | 'programNode' | 'rootNode' >, v => recordNodeStackVisitor(v, stack), ); diff --git a/packages/dynamic-parsers/src/parsers.ts b/packages/dynamic-parsers/src/parsers.ts index 13c0f77b3..b50a63728 100644 --- a/packages/dynamic-parsers/src/parsers.ts +++ b/packages/dynamic-parsers/src/parsers.ts @@ -1,5 +1,5 @@ import { getNodeCodec, ReadonlyUint8Array } from '@codama/dynamic-codecs'; -import { AccountNode, CamelCaseString, GetNodeFromKind, InstructionNode, RootNode } from '@codama/nodes'; +import { AccountNode, CamelCaseString, EventNode, GetNodeFromKind, InstructionNode, RootNode } from '@codama/nodes'; import { getLastNodeFromPath, NodePath } from '@codama/visitors-core'; import type { AccountLookupMeta, @@ -11,7 +11,10 @@ import type { import { identifyData } from './identify'; -export type ParsedData = { +type ParsableNode = AccountNode | EventNode | InstructionNode; +type ParsableNodeKind = ParsableNode['kind']; + +export type ParsedData = { data: unknown; path: NodePath; }; @@ -23,6 +26,13 @@ export function parseAccountData( return parseData(root, bytes, 'accountNode'); } +export function parseEventData( + root: RootNode, + bytes: ReadonlyUint8Array | Uint8Array, +): ParsedData | undefined { + return parseData(root, bytes, 'eventNode'); +} + export function parseInstructionData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, @@ -30,14 +40,14 @@ export function parseInstructionData( return parseData(root, bytes, 'instructionNode'); } -export function parseData( +export function parseData( root: RootNode, bytes: ReadonlyUint8Array | Uint8Array, kind?: TKind | TKind[], ): ParsedData> | undefined { - const path = identifyData(root, bytes, kind ?? (['accountNode', 'instructionNode'] as TKind[])); + const path = identifyData(root, bytes, kind ?? (['accountNode', 'instructionNode', 'eventNode'] as TKind[])); if (!path) return undefined; - const codec = getNodeCodec(path as NodePath); + const codec = getNodeCodec(path as NodePath); const data = codec.decode(bytes); return { data, path }; } diff --git a/packages/dynamic-parsers/test/identify.test.ts b/packages/dynamic-parsers/test/identify.test.ts index 8c0b3bca0..290154f04 100644 --- a/packages/dynamic-parsers/test/identify.test.ts +++ b/packages/dynamic-parsers/test/identify.test.ts @@ -1,15 +1,23 @@ import { accountNode, + bytesTypeNode, constantDiscriminatorNode, + constantValueNode, constantValueNodeFromBytes, + eventNode, + fixedSizeTypeNode, + hiddenPrefixTypeNode, instructionNode, + numberTypeNode, programNode, rootNode, sizeDiscriminatorNode, + structTypeNode, + tupleTypeNode, } from '@codama/nodes'; import { describe, expect, test } from 'vitest'; -import { identifyAccountData, identifyInstructionData } from '../src'; +import { identifyAccountData, identifyEventData, identifyInstructionData } from '../src'; import { hex } from './_setup'; describe('identifyAccountData', () => { @@ -165,3 +173,91 @@ describe('identifyInstructionData', () => { expect(result).toBeUndefined(); }); }); + +describe('identifyEventData', () => { + test('it identifies an event using its discriminator nodes', () => { + const root = rootNode( + programNode({ + events: [ + eventNode({ + data: structTypeNode([]), + discriminators: [sizeDiscriminatorNode(4)], + name: 'myEvent', + }), + ], + name: 'myProgram', + publicKey: '1111', + }), + ); + const result = identifyEventData(root, hex('01020304')); + expect(result).toStrictEqual([root, root.program, root.program.events[0]]); + }); + test('it fails to identify events whose discriminator nodes do not match the given data', () => { + const root = rootNode( + programNode({ + events: [ + eventNode({ + data: structTypeNode([]), + discriminators: [sizeDiscriminatorNode(999)], + name: 'myEvent', + }), + ], + name: 'myProgram', + publicKey: '1111', + }), + ); + const result = identifyEventData(root, hex('01020304')); + expect(result).toBeUndefined(); + }); + test('it fails to identify events with no discriminator nodes', () => { + const root = rootNode( + programNode({ + events: [eventNode({ data: structTypeNode([]), name: 'myEvent' })], + name: 'myProgram', + publicKey: '1111', + }), + ); + const result = identifyEventData(root, hex('01020304')); + expect(result).toBeUndefined(); + }); + test('it does not identify events using instruction discriminators', () => { + const root = rootNode( + programNode({ + instructions: [instructionNode({ discriminators: [sizeDiscriminatorNode(4)], name: 'myInstruction' })], + name: 'myProgram', + publicKey: '1111', + }), + ); + const result = identifyEventData(root, hex('01020304')); + expect(result).toBeUndefined(); + }); + test('it identifies tuple events using constant discriminators', () => { + const root = rootNode( + programNode({ + events: [ + eventNode({ + data: hiddenPrefixTypeNode(tupleTypeNode([numberTypeNode('u32')]), [ + constantValueNode( + fixedSizeTypeNode(bytesTypeNode(), 2), + constantValueNodeFromBytes('base16', '0102'), + ), + ]), + discriminators: [ + constantDiscriminatorNode( + constantValueNode( + fixedSizeTypeNode(bytesTypeNode(), 2), + constantValueNodeFromBytes('base16', '0102'), + ), + ), + ], + name: 'tupleEvent', + }), + ], + name: 'myProgram', + publicKey: '1111', + }), + ); + const result = identifyEventData(root, hex('01022a000000')); + expect(result).toStrictEqual([root, root.program, root.program.events[0]]); + }); +}); diff --git a/packages/dynamic-parsers/test/parsers.test.ts b/packages/dynamic-parsers/test/parsers.test.ts index 266bcf06a..496e16cdb 100644 --- a/packages/dynamic-parsers/test/parsers.test.ts +++ b/packages/dynamic-parsers/test/parsers.test.ts @@ -1,6 +1,13 @@ import { accountNode, + bytesTypeNode, + constantDiscriminatorNode, + constantValueNode, + constantValueNodeFromBytes, + eventNode, fieldDiscriminatorNode, + fixedSizeTypeNode, + hiddenPrefixTypeNode, instructionArgumentNode, instructionNode, numberTypeNode, @@ -11,10 +18,11 @@ import { stringTypeNode, structFieldTypeNode, structTypeNode, + tupleTypeNode, } from '@codama/nodes'; import { describe, expect, test } from 'vitest'; -import { parseAccountData, parseInstructionData } from '../src'; +import { parseAccountData, parseEventData, parseInstructionData } from '../src'; import { hex } from './_setup'; describe('parseAccountData', () => { @@ -90,3 +98,72 @@ describe('parseInstructionData', () => { }); }); }); + +describe('parseEventData', () => { + test('it parses some event data from a root node', () => { + const root = rootNode( + programNode({ + events: [ + eventNode({ + data: structTypeNode([ + structFieldTypeNode({ + defaultValue: numberValueNode(9), + name: 'discriminator', + type: numberTypeNode('u8'), + }), + structFieldTypeNode({ + name: 'firstname', + type: sizePrefixTypeNode(stringTypeNode('utf8'), numberTypeNode('u16')), + }), + structFieldTypeNode({ + name: 'age', + type: numberTypeNode('u8'), + }), + ]), + discriminators: [fieldDiscriminatorNode('discriminator')], + name: 'myEvent', + }), + ], + name: 'myProgram', + publicKey: '1111', + }), + ); + const result = parseEventData(root, hex('090500416c6963652a')); + expect(result).toStrictEqual({ + data: { age: 42, discriminator: 9, firstname: 'Alice' }, + path: [root, root.program, root.program.events[0]], + }); + }); + test('it parses tuple event data from a root node', () => { + const root = rootNode( + programNode({ + events: [ + eventNode({ + data: hiddenPrefixTypeNode(tupleTypeNode([numberTypeNode('u32')]), [ + constantValueNode( + fixedSizeTypeNode(bytesTypeNode(), 2), + constantValueNodeFromBytes('base16', '0102'), + ), + ]), + discriminators: [ + constantDiscriminatorNode( + constantValueNode( + fixedSizeTypeNode(bytesTypeNode(), 2), + constantValueNodeFromBytes('base16', '0102'), + ), + ), + ], + name: 'tupleEvent', + }), + ], + name: 'myProgram', + publicKey: '1111', + }), + ); + const result = parseEventData(root, hex('01022a000000')); + expect(result).toStrictEqual({ + data: [42], + path: [root, root.program, root.program.events[0]], + }); + }); +});