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
2 changes: 2 additions & 0 deletions packages/common-solana/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Util function to get discriminator from name (#25)

## [1.1.1] - 2025-09-10
### Fixed
Expand Down
7 changes: 7 additions & 0 deletions packages/common-solana/src/codegen/idl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors
// SPDX-License-Identifier: GPL-3.0

import {createHash} from 'node:crypto';
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import is correctly using the 'node:' prefix for the built-in crypto module, which is good practice for Node.js 16+.

Copilot uses AI. Check for mistakes.
import fs from 'node:fs';
import {AnchorIdl, IdlV01, rootNodeFromAnchor} from '@codama/nodes-from-anchor';
import {getBase16Encoder, getBase58Encoder, getBase64Encoder, getUtf8Encoder} from '@solana/codecs-strings';
Expand Down Expand Up @@ -87,3 +88,9 @@ export function findInstructionDiscriminatorByName(rootNode: RootNode, name: str
return undefined;
}
}

// Discriminator are the first 8 bytes of the sha256 over the event's name
export function getDiscriminator(name: string): Uint8Array {
const hash = createHash('sha256').update(`event:${name}`).digest();
return new Uint8Array(hash.subarray(0, 8));
}
2 changes: 2 additions & 0 deletions packages/node/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- Support for decoding codama program logs (#25)

## [6.1.2] - 2025-09-11
### Fixed
Expand Down
46 changes: 45 additions & 1 deletion packages/node/src/solana/decoder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import { SolanaDecoder } from './decoder';
import { getProgramId, filterInstructionsProcessor } from './utils.solana';

const HTTP_ENDPOINT =
process.env.HTTP_ENDPOINT ?? 'https://solana.api.onfinality.io/public';
process.env.HTTP_ENDPOINT ??
'https://api.mainnet-beta.solana.com' ??
'https://solana.api.onfinality.io/public';

const IDL_codama_0_1_0: IdlV01 = require('../../test/8t2R21V3vjS1ucZzmX2memtGptjYZi2yGY3cYVa8dak7.idl.json');
const IDL_Jupiter: IdlV01 = require('../../test/JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4.idl.json');
const IDL_swap: IdlV01 = require('../../test/swapFpHZwjELNnjvThjajtiVmkz3yPQEHjLtka2fwHW.idl.json');
const IDL_token: RootNode = require('../../test/TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA.idl.json');
Expand Down Expand Up @@ -47,6 +50,9 @@ describe('SolanaDecoder', () => {
decoder.idls['swapFpHZwjELNnjvThjajtiVmkz3yPQEHjLtka2fwHW'] = IDL_swap;
// eslint-disable-next-line @typescript-eslint/dot-notation
decoder.idls['TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'] = IDL_token;
// eslint-disable-next-line @typescript-eslint/dot-notation
decoder.idls['8t2R21V3vjS1ucZzmX2memtGptjYZi2yGY3cYVa8dak7'] =
IDL_codama_0_1_0;
};

describe('caching IDLs', () => {
Expand Down Expand Up @@ -265,5 +271,43 @@ describe('SolanaDecoder', () => {
expect(decoded!.name).toBe('poolBalanceUpdatedEvent');
expect(decoded!.data).toEqual(logData);
});

it('can decode codama 0.1.0 spec idl events', async () => {
const solanaApi = await SolanaApi.create(
'https://api.devnet.solana.com',
new EventEmitter2(),
decoder,
);

const block = await solanaApi.fetchBlock(405402294);

const tx = findTx(
block.block,
'4xHLJtomvuF2DAwtDoHgGqABv47kmqMCvd691bRe9XhiAP99grjvpAdvNQGnA4PETZSvhXdfHvvHUNWu2SNM3aqE',
);

expect(tx).toBeDefined();

const programLogs = tx!.meta!.logs?.filter((l) =>
l.message.startsWith('Program data:'),
);
expect(programLogs).toBeDefined();
const decoded = await programLogs![0].decodedMessage;
expect(decoded).not.toBeNull();
expect(decoded!.name).toEqual('createCampaign');
expect(decoded!.data).toMatchObject({
aggregateAmount: 10000n,
campaign: 'BjYpCVaiksvD8Dw4LixUJVNaCptefKk86nDtw419b7Y5',
campaignName: 'HODL or Nothing',
campaignStartTime: 1754142441n,
creator: 'HTtnrJ5iq9HVVypJZxFKMCcR6JDiUqT6yaE7c6BvfeTp',
expirationTime: 1757776469n,
ipfsCid: 'bafkreiecpwdhvkmw4y6iihfndk7jhwjas3m5htm7nczovt6m37mucwgsrq',
merkleRoot: ['base64', '1SVJywcqH80FJBL8gPZ47/6Sru7czRyuYyxcbh3ok3k='],
recipientCount: 100,
tokenDecimals: 6,
tokenMint: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
});
});
});
});
42 changes: 37 additions & 5 deletions packages/node/src/solana/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
isAnchorIdlV01,
getInstructionDiscriminatorBytes,
findInstructionDiscriminatorByName,
getDiscriminator,
isRootNode,
isAnchorIdl,
} from '@subql/common-solana';
import { getLogger } from '@subql/node-core';
import {
Expand All @@ -21,7 +24,14 @@ import {
} from '@subql/types-solana';
import { isHex } from '@subql/utils';
import bs58 from 'bs58';
import { camelCase, DefinedTypeNode, InstructionNode, RootNode } from 'codama';
import {
camelCase,
DefinedTypeNode,
InstructionNode,
pascalCase,
RootNode,
titleCase,
} from 'codama';
import { Memoize } from '../utils/decorators';
import { allAccounts, getProgramId } from './utils.solana';

Expand Down Expand Up @@ -99,14 +109,36 @@ export function decodeInstruction(

// Codama doesn't support Logs so extra work is required to decode logs
export function decodeLog(idl: Idl, message: string): DecodedData | null {
const msgData = message.replace('Program data: ', '');
const msgBuffer = basedToBuffer(msgData);

// Codama IDL, doesn't include events in the IDL but it should decoded to a definedType in the IDL
if (!isAnchorIdl(idl)) {
if (msgBuffer.length < 8) {
// Not enough data for a discriminator
return null;
}

// Split the discriminator and data
const logDisc = msgBuffer.subarray(0, 8);
const data = msgBuffer.subarray(8);

// Attempt to find the matching type by discriminator and decode the data
return decodeData(idl, data.toString('base64'), (root) => {
return root.program.definedTypes.find(
(t) =>
// Warning this is fragile as there can be various casings of the type names, but because it requires hashing data we can only go one way
logDisc.indexOf(getDiscriminator(t.name)) === 0 ||
logDisc.indexOf(getDiscriminator(pascalCase(t.name))) === 0,
);
Comment on lines +128 to +133
Copy link

Copilot AI Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The comment indicates this is fragile due to casing variations. Consider using a more robust approach by trying additional common naming conventions (camelCase, titleCase) or storing discriminators in a map to avoid repeated hash computations.

Suggested change
return root.program.definedTypes.find(
(t) =>
// Warning this is fragile as there can be various casings of the type names, but because it requires hashing data we can only go one way
logDisc.indexOf(getDiscriminator(t.name)) === 0 ||
logDisc.indexOf(getDiscriminator(pascalCase(t.name))) === 0,
);
// Build a map from all possible discriminators to their type nodes, covering common casing conventions
const discriminatorToType = new Map<string, typeof root.program.definedTypes[0]>();
for (const t of root.program.definedTypes) {
const nameVariants = [
t.name,
pascalCase(t.name),
camelCase(t.name),
titleCase(t.name),
];
for (const variant of nameVariants) {
const disc = getDiscriminator(variant);
discriminatorToType.set(disc.toString('hex'), t);
}
}
const logDiscHex = logDisc.toString('hex');
return discriminatorToType.get(logDiscHex);

Copilot uses AI. Check for mistakes.
});
}

// Older versions don't support events
if (!isAnchorIdlV01(idl)) {
throw new Error('Only Anchor IDL v0.1.0 is supported for decoding logs');
}

const msgData = message.replace('Program data: ', '') as any;
const msgBuffer = basedToBuffer(msgData);

// Codama doesn't include events so we have to find it manually
const event = idl.events?.find(
(e) => msgBuffer.indexOf(Buffer.from(e.discriminator)) === 0,
Expand All @@ -121,7 +153,7 @@ export function decodeLog(idl: Idl, message: string): DecodedData | null {
.subarray(event.discriminator.length)
.toString('base64');

return decodeData(idl, input as any, (root, data) => {
return decodeData(idl, input, (root) => {
return root.program.definedTypes.find(
(t) => t.name === event.name || t.name === camelCase(event.name),
);
Expand Down
Loading
Loading