Skip to content

Commit 55ae1d3

Browse files
committed
Swap out anchor for codama and support decoding SPL programs
1 parent 3944188 commit 55ae1d3

11 files changed

+3380
-456
lines changed

packages/node/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"subql-node-solana": "./bin/run"
2020
},
2121
"dependencies": {
22-
"@coral-xyz/anchor": "^0.31.0",
22+
"@codama/dynamic-codecs": "^1.1.12",
23+
"@codama/nodes-from-anchor": "^1.1.11",
2324
"@nestjs/common": "^11.0.8",
2425
"@nestjs/core": "^11.0.8",
2526
"@nestjs/event-emitter": "^2.0.0",
@@ -32,6 +33,7 @@
3233
"@subql/testing": "^2.2.1",
3334
"@subql/types-solana": "workspace:*",
3435
"cacheable-lookup": "6",
36+
"codama": "^1.2.11",
3537
"ethers": "^5.7.0",
3638
"eventemitter2": "^6.4.5",
3739
"json5": "^2.2.3",

packages/node/src/solana/api.solana.spec.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors
22
// SPDX-License-Identifier: GPL-3.0
33

4-
import { translateAddress } from '@coral-xyz/anchor';
54
import { EventEmitter2 } from '@nestjs/event-emitter';
6-
import { Connection } from '@solana/web3.js';
75
import { TransactionFilter } from '@subql/common-solana';
86
import {
97
SolanaBlock,
@@ -259,16 +257,4 @@ describe('Api.solana', () => {
259257
throw new Error('Test not implemented');
260258
});
261259
});
262-
263-
it('has the same behaviour for getAccountInfo as legacy @solana/web3.js', async () => {
264-
const connection2 = new Connection(HTTP_ENDPOINT);
265-
266-
const res1 = await solanaApi.getAccountInfo(
267-
'SAGE2HAwep459SNq61LHvjxPk4pLPEJLoMETef7f7EE',
268-
);
269-
const res2 = await connection2.getAccountInfo(
270-
translateAddress('SAGE2HAwep459SNq61LHvjxPk4pLPEJLoMETef7f7EE'),
271-
);
272-
expect(res1?.data).toEqual(res2?.data);
273-
});
274260
});

packages/node/src/solana/api.solana.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class SolanaApi {
7979
): Promise<SolanaApi> {
8080
try {
8181
// TODO keep alive, user agent and other headers
82+
console.log('CREATING API', endpoint);
8283
const client = createSolanaRpc(endpoint);
8384
const genesisBlockHash = await client.getGenesisHash().send();
8485

@@ -239,6 +240,7 @@ export class SolanaApi {
239240
handleError(e: Error): Error {
240241
if ((e as any)?.context?.statusCode === 429) {
241242
const { hostname } = new URL(this.endpoint);
243+
console.log('ERROR', e);
242244
return new Error(`Rate Limited at endpoint: ${hostname}`);
243245
}
244246

packages/node/src/solana/block.solana.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ type RawSolanaBlock = Readonly<{
2929
/** The blockhash of this block's parent */
3030
previousBlockhash: Blockhash;
3131

32-
transactions: readonly TransactionForFullJson<void>[];
32+
transactions: readonly TransactionForFullJson<0>[];
3333
}>;
3434

3535
function wrapInstruction(
3636
instruction: Omit<SolanaInstruction, 'transaction' | 'decodedData'>,
37-
transaction: TransactionForFullJson<void>,
37+
transaction: TransactionForFullJson<0>,
3838
decoder: SolanaDecoder,
3939
): SolanaInstruction {
4040
let pendingDecode: Promise<DecodedData | null>;

packages/node/src/solana/decoder.spec.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors
22
// SPDX-License-Identifier: GPL-3.0
33

4-
const HTTP_ENDPOINT =
5-
process.env.HTTP_ENDPOINT ?? 'https://solana.api.onfinality.io/public';
6-
7-
import { Idl } from '@coral-xyz/anchor';
84
import { EventEmitter2 } from '@nestjs/event-emitter';
95
import { SolanaBlock } from '@subql/types-solana';
106
import { BN } from 'bn.js';
117
import { SolanaApi } from './api.solana';
12-
import { SolanaDecoder } from './decoder';
8+
import { Idl, SolanaDecoder } from './decoder';
9+
import { getProgramId } from './utils.solana';
10+
11+
const HTTP_ENDPOINT =
12+
process.env.HTTP_ENDPOINT ?? 'https://solana.api.onfinality.io/public';
1313

14+
console.log('HTTPE HTTP_ENDPOINT', HTTP_ENDPOINT);
1415
const IDL_Jupiter: Idl = require('../../test/JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4.idl.json');
1516
const IDL_swap: Idl = require('../../test/swapFpHZwjELNnjvThjajtiVmkz3yPQEHjLtka2fwHW.idl.json');
17+
const IDL_token: Idl = require('../../test/TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA.idl.json');
1618

1719
function stringify(value: any): string {
1820
return JSON.stringify(value, (_, v) => (BN.isBN(v) ? v.toString() : v));
@@ -60,31 +62,33 @@ describe('SolanaDecoder', () => {
6062

6163
describe('decode instrutions', () => {
6264
beforeAll(async () => {
63-
//https://solscan.io/block/330469167
65+
// https://solscan.io/block/330469167
6466
const { block } = await solanaApi.fetchBlock(330_469_167);
6567
blockData = block;
6668

6769
// eslint-disable-next-line @typescript-eslint/dot-notation
6870
decoder.idls['JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'] = IDL_Jupiter;
6971
// eslint-disable-next-line @typescript-eslint/dot-notation
7072
decoder.idls['swapFpHZwjELNnjvThjajtiVmkz3yPQEHjLtka2fwHW'] = IDL_swap;
73+
// eslint-disable-next-line @typescript-eslint/dot-notation
74+
decoder.idls['TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'] = IDL_token;
7175
}, 30_000);
7276

7377
const instructionData = stringify({
74-
route_plan: [
78+
routePlan: [
7579
{
7680
swap: {
77-
MeteoraDlmm: {},
81+
__kind: 'MeteoraDlmm',
7882
},
7983
percent: 100,
80-
input_index: 0,
81-
output_index: 1,
84+
inputIndex: 0,
85+
outputIndex: 1,
8286
},
8387
],
84-
in_amount: new BN(16000),
85-
quoted_out_amount: new BN(126754),
86-
slippage_bps: 200,
87-
platform_fee_bps: 98,
88+
inAmount: BigInt(16000),
89+
quotedOutAmount: BigInt(126754),
90+
slippageBps: 200,
91+
platformFeeBps: 98,
8892
});
8993

9094
it('can decode an instruction with an IDL file', async () => {
@@ -105,7 +109,8 @@ describe('SolanaDecoder', () => {
105109
expect(stringify(decoded!.data)).toEqual(instructionData);
106110
});
107111

108-
it('can decode an instruction with an IDL found on chain', async () => {
112+
// Since removing anchor we don't have a way of fetching IDLS
113+
it.skip('can decode an instruction with an IDL found on chain', async () => {
109114
// https://solscan.io/tx/3rf2sSMeJC1dd4t4TDvYPfvQjpL6DG6qdDcMnruDtATPbwjqt3xDnNvddtiTBESL8AWiFt2zENghqAh1h252bKQi
110115
const tx = blockData.transactions.find((tx) =>
111116
tx.transaction.signatures.find(
@@ -121,6 +126,34 @@ describe('SolanaDecoder', () => {
121126
expect(decoded!.name).toEqual('route');
122127
expect(stringify(decoded!.data)).toEqual(instructionData);
123128
});
129+
130+
it('can decode SPL token program instructions', async () => {
131+
const tx = blockData.transactions.find((tx) =>
132+
tx.transaction.signatures.find(
133+
(s) =>
134+
s ===
135+
'61vjnjBfvU3e2BqmatPd3uYi37woXS44oqcQ3gD1XoS4demqXSmT32vGpdYdXTHW5niePACTKQaDxipn6jhbTWDL',
136+
),
137+
);
138+
139+
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
140+
const instruction = tx!.meta?.innerInstructions.find(
141+
(inner) => inner.index === 0,
142+
)?.instructions[1]!;
143+
144+
const program = getProgramId(instruction);
145+
expect(program).toBe('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
146+
147+
const decoded = await decoder.decodeInstruction(instruction);
148+
149+
expect(decoded).toEqual({
150+
name: 'transferChecked',
151+
data: {
152+
amount: BigInt('5904646875'),
153+
decimals: 6,
154+
},
155+
});
156+
});
124157
});
125158

126159
describe('decode log', () => {

packages/node/src/solana/decoder.ts

Lines changed: 131 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,144 @@
22
// SPDX-License-Identifier: GPL-3.0
33

44
import fs from 'node:fs';
5+
import { getNodeCodec } from '@codama/dynamic-codecs';
6+
import { AnchorIdl, rootNodeFromAnchor } from '@codama/nodes-from-anchor';
57
import {
6-
Program,
7-
BorshInstructionCoder,
8-
BorshEventCoder,
9-
Idl,
10-
} from '@coral-xyz/anchor';
8+
getBase16Encoder,
9+
getBase58Encoder,
10+
getBase64Encoder,
11+
getUtf8Encoder,
12+
} from '@solana/codecs-strings';
13+
import { Base58EncodedBytes } from '@solana/kit';
1114
import { getLogger } from '@subql/node-core';
1215
import {
1316
DecodedData,
1417
SolanaInstruction,
1518
SolanaLogMessage,
1619
SubqlRuntimeDatasource,
1720
} from '@subql/types-solana';
21+
import bs58 from 'bs58';
22+
import {
23+
BytesValueNode,
24+
createFromRoot,
25+
InstructionNode,
26+
RootNode,
27+
} from 'codama';
1828
import { SolanaApi } from './api.solana';
1929
import { getProgramId } from './utils.solana';
2030

2131
const logger = getLogger('SolanaDecoder');
2232

33+
export function getBytesFromBytesValueNode(node: BytesValueNode): Uint8Array {
34+
switch (node.encoding) {
35+
case 'utf8':
36+
return getUtf8Encoder().encode(node.data) as Uint8Array;
37+
case 'base16':
38+
return getBase16Encoder().encode(node.data) as Uint8Array;
39+
case 'base58':
40+
return getBase58Encoder().encode(node.data) as Uint8Array;
41+
case 'base64':
42+
default:
43+
return getBase64Encoder().encode(node.data) as Uint8Array;
44+
}
45+
}
46+
47+
// TODO fill with appropriate type, this could be a codama or anchor IDL
48+
export type Idl = AnchorIdl | RootNode;
49+
50+
function findInstructionNode(
51+
rootNode: RootNode,
52+
data: Buffer,
53+
): InstructionNode | undefined {
54+
return rootNode.program.instructions.find((inst) => {
55+
const discArg = inst.arguments.find((arg) => arg.name === 'discriminator');
56+
if (!discArg) return false;
57+
58+
// TODO what about other types of discriminators or ones that are larger than 1 byte?
59+
switch (discArg.defaultValue?.kind) {
60+
case 'numberValueNode':
61+
return data[0] === discArg.defaultValue.number;
62+
case 'bytesValueNode': {
63+
const defaultBytes = getBytesFromBytesValueNode(discArg.defaultValue);
64+
return data.indexOf(defaultBytes) === 0;
65+
}
66+
case undefined:
67+
break;
68+
default:
69+
throw new Error(
70+
`Unable to handle unknown discriminator type ${discArg.defaultValue?.kind}`,
71+
);
72+
}
73+
74+
return false;
75+
});
76+
}
77+
78+
function findEventNode(
79+
rootNode: RootNode,
80+
data: Buffer,
81+
): InstructionNode | undefined {
82+
throw new Error('Not implemented');
83+
}
84+
85+
function decodeData(
86+
idl: Idl,
87+
data: Base58EncodedBytes,
88+
getEncodableNode: (
89+
rootNode: RootNode,
90+
data: Buffer,
91+
) => InstructionNode | undefined,
92+
): DecodedData | null {
93+
let codama = createFromRoot(rootNodeFromAnchor(idl as AnchorIdl));
94+
// Check if the idl was an anchor idl
95+
if (codama.getRoot().program.publicKey === '') {
96+
codama = createFromRoot(idl as RootNode);
97+
}
98+
99+
const root = codama.getRoot();
100+
const buffer = bs58.decode(data);
101+
102+
const node = getEncodableNode(root, buffer);
103+
if (!node) {
104+
logger.warn(
105+
`Failed to find instruction with discriminator in ${root.program.name}`,
106+
);
107+
return null;
108+
}
109+
110+
try {
111+
// Path is required to find other defined structs
112+
const codec = getNodeCodec([root, root.program, node]);
113+
114+
// Strip the discriminator
115+
const { discriminator, ...data } = codec.decode(buffer) as any;
116+
117+
return {
118+
data: data,
119+
name: node.name,
120+
};
121+
} catch (e) {
122+
logger.warn(`Failed to decode data name: ${node.name}, error: ${e}`);
123+
return null;
124+
}
125+
}
126+
127+
export function decodeInstruction(
128+
idl: Idl,
129+
data: Base58EncodedBytes,
130+
): DecodedData | null {
131+
return decodeData(idl, data, findInstructionNode);
132+
}
133+
134+
export function decodeLog(idl: Idl, message: string): DecodedData | null {
135+
const data = message.replace('Program data:', '') as Base58EncodedBytes;
136+
return decodeData(idl, data, findEventNode);
137+
}
138+
23139
export class SolanaDecoder {
24140
idls: Record<string, Idl | null> = {};
25-
instructionDecoders: Record<string, BorshInstructionCoder> = {};
26-
eventDecoders: Record<string, BorshEventCoder> = {};
141+
// instructionDecoders: Record<string, BorshInstructionCoder> = {};
142+
// eventDecoders: Record<string, BorshEventCoder> = {};
27143

28144
constructor(public api: SolanaApi) {}
29145

@@ -45,12 +161,14 @@ export class SolanaDecoder {
45161
}
46162
}
47163

164+
// eslint-disable-next-line @typescript-eslint/require-await
48165
async getIdlFromChain(programId: string): Promise<Idl | null> {
49-
this.idls[programId] ??= await Program.fetchIdl(programId, {
50-
connection: this.api as any,
51-
});
166+
throw new Error('Not implemented');
167+
// this.idls[programId] ??= await Program.fetchIdl(programId, {
168+
// connection: this.api as any,
169+
// });
52170

53-
return this.idls[programId];
171+
// return this.idls[programId];
54172
}
55173

56174
async decodeInstruction(
@@ -66,11 +184,7 @@ export class SolanaDecoder {
66184
return null;
67185
}
68186

69-
const coder =
70-
this.instructionDecoders[programId] ?? new BorshInstructionCoder(idl);
71-
const decoded = coder.decode(instruction.data, 'base58');
72-
73-
return decoded;
187+
return decodeInstruction(idl, instruction.data);
74188
} catch (e) {
75189
logger.debug(`Failed to decode instruction: ${e}`);
76190
}
@@ -89,10 +203,7 @@ export class SolanaDecoder {
89203
return null;
90204
}
91205

92-
const coder = this.eventDecoders[programId] ?? new BorshEventCoder(idl);
93-
const decoded = coder.decode(log.message.replace('Program data:', ''));
94-
95-
return decoded;
206+
return decodeLog(idl, log.message);
96207
} catch (e) {
97208
logger.debug(`Failed to decode log: ${e}`);
98209
}

packages/node/src/solana/utils.solana.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Copyright 2020-2025 SubQuery Pte Ltd authors & contributors
22
// SPDX-License-Identifier: GPL-3.0
33

4-
import { Idl } from '@coral-xyz/anchor';
4+
import { IdlV01 } from '@codama/nodes-from-anchor';
55
import { getAnchorDiscriminator } from './utils.solana';
66

7-
const IDL_Jupiter: Idl = require('../../test/JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4.idl.json');
7+
const IDL_Jupiter: IdlV01 = require('../../test/JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4.idl.json');
88

99
describe('SolanaUtils', () => {
1010
describe('Calculating discriminators', () => {

0 commit comments

Comments
 (0)