From e5770fcc4480b176ad992e2900321cb160aae991 Mon Sep 17 00:00:00 2001 From: chray-zhang Date: Mon, 1 Dec 2025 17:40:38 -0500 Subject: [PATCH 1/4] Added TokenAccount Support --- .../src/shared/buffer-layout-accounts.ts | 39 +++++++++++---- .../token-account-data-2025-12-01.json | 21 ++++++++ .../test/unit/buffer-layout-accounts.test.ts | 50 ++++++++++++++++++- 3 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 packages/sources/solana-functions/test/fixtures/token-account-data-2025-12-01.json diff --git a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts index 8ac408bf36..9a6c3fa7ff 100644 --- a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts +++ b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts @@ -2,7 +2,7 @@ import { AdapterInputError } from '@chainlink/external-adapter-framework/validat import { type Address } from '@solana/addresses' import * as BufferLayout from '@solana/buffer-layout' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' -import { MintLayout } from '@solana/spl-token' +import { AccountLayout, MintLayout } from '@solana/spl-token' interface SanctumPoolState { total_sol_value: bigint @@ -37,8 +37,11 @@ const SanctumPoolStateLayout = BufferLayout.struct([ const solanaTokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' const sanctumControllerProgramAddress = '5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx' +// Token Program account sizes +const MINT_SIZE = 82 +const TOKEN_ACCOUNT_SIZE = 165 + const programToBufferLayoutMap: Record> = { - [solanaTokenProgramAddress]: MintLayout, [sanctumControllerProgramAddress]: SanctumPoolStateLayout, } @@ -61,16 +64,32 @@ export const fetchFieldFromBufferLayoutStateAccount = async ({ }) } - const layout = programToBufferLayoutMap[programAddress.toString()] + const data = Buffer.from(resp.value.data[0] as string, encoding) - if (!layout) { - throw new AdapterInputError({ - message: `No layout known for program address '${programAddress}'`, - statusCode: 500, - }) - } + // Dynamically select layout for Token Program accounts based on size + let layout: BufferLayout.Layout - const data = Buffer.from(resp.value.data[0] as string, encoding) + if (programAddress.toString() === solanaTokenProgramAddress) { + if (data.length === MINT_SIZE) { + layout = MintLayout + } else if (data.length === TOKEN_ACCOUNT_SIZE) { + layout = AccountLayout + } else { + throw new AdapterInputError({ + message: `Unsupported Token Program account size: ${data.length} bytes. Expected ${MINT_SIZE} (Mint) or ${TOKEN_ACCOUNT_SIZE} (Token Account)`, + statusCode: 500, + }) + } + } else { + layout = programToBufferLayoutMap[programAddress.toString()] + + if (!layout) { + throw new AdapterInputError({ + message: `No layout known for program address '${programAddress}'`, + statusCode: 500, + }) + } + } const dataDecoded = layout.decode(data) as Record const resultValue = dataDecoded[field] diff --git a/packages/sources/solana-functions/test/fixtures/token-account-data-2025-12-01.json b/packages/sources/solana-functions/test/fixtures/token-account-data-2025-12-01.json new file mode 100644 index 0000000000..1e1b25293c --- /dev/null +++ b/packages/sources/solana-functions/test/fixtures/token-account-data-2025-12-01.json @@ -0,0 +1,21 @@ +{ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "3.0.6", + "slot": 383821139 + }, + "value": { + "data": [ + "cfVpF1Wn0nAiY7UIkQQyjFTIGoa9eq2F/hJLLMDuLiq4/T4aEZY7Jno3BrKIQgFrsPIpazIt+8X12FbVNJ4bKjDWLvgHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "base64" + ], + "executable": false, + "lamports": 2039280, + "owner": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "rentEpoch": 18446744073709551615, + "space": 165 + } + }, + "id": 1 +} diff --git a/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts b/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts index 67c59dbd35..0137d88ed6 100644 --- a/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts +++ b/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts @@ -3,6 +3,7 @@ import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { fetchFieldFromBufferLayoutStateAccount } from '../../src/shared/buffer-layout-accounts' import * as sanctumInfinityPoolAccountData from '../fixtures/sanctum-infinity-pool-account-data-2025-10-07.json' import * as sanctumInfinityTokenAccountData from '../fixtures/sanctum-infinity-token-account-data-2025-10-07.json' +import * as tokenAccountData from '../fixtures/token-account-data-2025-12-01.json' describe('buffer-layout-accounts', () => { const sendMock = jest.fn() @@ -17,7 +18,7 @@ describe('buffer-layout-accounts', () => { }) describe('fetchFieldFromBufferLayoutStateAccount', () => { - it('should fetch and decode field from token state account', async () => { + it('should fetch and decode field from mint account', async () => { const response = makeStub('response', sanctumInfinityTokenAccountData.result) sendMock.mockResolvedValue(response) @@ -35,6 +36,24 @@ describe('buffer-layout-accounts', () => { expect(getAccountInfoMock).toHaveBeenCalledTimes(1) }) + it('should fetch and decode field from token account', async () => { + const response = makeStub('response', tokenAccountData.result) + + sendMock.mockResolvedValue(response) + + const stateAccountAddress = 'FvkbfMm98jefJWrqkvXvsSZ9RFaRBae8k6c1jaYA5vY3' + + const amount = await fetchFieldFromBufferLayoutStateAccount({ + stateAccountAddress, + field: 'amount', + rpc, + }) + expect(amount).toBe('34228590128') + + expect(getAccountInfoMock).toHaveBeenCalledWith(stateAccountAddress, { encoding: 'base64' }) + expect(getAccountInfoMock).toHaveBeenCalledTimes(1) + }) + it('should fetch and decode field from sanctum state account', async () => { const response = makeStub('response', sanctumInfinityPoolAccountData.result) sendMock.mockResolvedValue(response) @@ -73,6 +92,35 @@ describe('buffer-layout-accounts', () => { expect(getAccountInfoMock).toHaveBeenCalledTimes(1) }) + it('should throw for unsupported Token Program account size', async () => { + const response = makeStub('response', { + value: { + data: [ + 'dGVzdA==', // Just some test data + 'base64', + ], + owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + }) + + sendMock.mockResolvedValue(response) + + const stateAccountAddress = '5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm' + + await expect(() => + fetchFieldFromBufferLayoutStateAccount({ + stateAccountAddress, + field: 'amount', + rpc, + }), + ).rejects.toThrow( + 'Unsupported Token Program account size: 4 bytes. Expected 82 (Mint) or 165 (Token Account)', + ) + + expect(getAccountInfoMock).toHaveBeenCalledWith(stateAccountAddress, { encoding: 'base64' }) + expect(getAccountInfoMock).toHaveBeenCalledTimes(1) + }) + it('should throw for unknown program', async () => { const response = makeStub('response', { value: { From 87c5dfb7774f05489e32384ab07f96b0ffc49d30 Mon Sep 17 00:00:00 2001 From: chray-zhang Date: Mon, 1 Dec 2025 17:49:24 -0500 Subject: [PATCH 2/4] comment --- .../solana-functions/src/shared/buffer-layout-accounts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts index 9a6c3fa7ff..9565d5a6d2 100644 --- a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts +++ b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts @@ -37,8 +37,9 @@ const SanctumPoolStateLayout = BufferLayout.struct([ const solanaTokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' const sanctumControllerProgramAddress = '5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx' -// Token Program account sizes +// https://github.com/solana-labs/solana-program-library/blob/token-v4.0.0/token/program/src/state.rs#L37 const MINT_SIZE = 82 +// https://github.com/solana-labs/solana-program-library/blob/token-v4.0.0/token/program/src/state.rs#L129 const TOKEN_ACCOUNT_SIZE = 165 const programToBufferLayoutMap: Record> = { From e9a5a9f20cc8f7adbf8fc2baf6038d686683b747 Mon Sep 17 00:00:00 2001 From: chray-zhang Date: Mon, 1 Dec 2025 17:57:43 -0500 Subject: [PATCH 3/4] changeset --- .changeset/fresh-fans-sit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-fans-sit.md diff --git a/.changeset/fresh-fans-sit.md b/.changeset/fresh-fans-sit.md new file mode 100644 index 0000000000..f85c63b6c3 --- /dev/null +++ b/.changeset/fresh-fans-sit.md @@ -0,0 +1,5 @@ +--- +'@chainlink/solana-functions-adapter': minor +--- + +Added support for Token Accounts From 592b4ddb7ed58a9fe5c01f414accb1bd30c181a6 Mon Sep 17 00:00:00 2001 From: David de Kloet Date: Mon, 8 Dec 2025 18:20:37 +0100 Subject: [PATCH 4/4] Don't hard code layout length --- .../src/shared/buffer-layout-accounts.ts | 55 ++++++++----------- .../test/unit/buffer-layout-accounts.test.ts | 2 +- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts index 9565d5a6d2..2cdd47b85c 100644 --- a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts +++ b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts @@ -37,13 +37,30 @@ const SanctumPoolStateLayout = BufferLayout.struct([ const solanaTokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' const sanctumControllerProgramAddress = '5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx' -// https://github.com/solana-labs/solana-program-library/blob/token-v4.0.0/token/program/src/state.rs#L37 -const MINT_SIZE = 82 -// https://github.com/solana-labs/solana-program-library/blob/token-v4.0.0/token/program/src/state.rs#L129 -const TOKEN_ACCOUNT_SIZE = 165 +const programToBufferLayoutMap: Record[]> = { + [solanaTokenProgramAddress]: [AccountLayout, MintLayout], + [sanctumControllerProgramAddress]: [SanctumPoolStateLayout], +} -const programToBufferLayoutMap: Record> = { - [sanctumControllerProgramAddress]: SanctumPoolStateLayout, +const getLayout = (programAddress: string, dataLength: number): BufferLayout.Layout => { + const layoutCandidates = programToBufferLayoutMap[programAddress] + if (!layoutCandidates) { + throw new AdapterInputError({ + message: `No layout known for program address '${programAddress}'`, + statusCode: 500, + }) + } + for (const layout of layoutCandidates) { + if (layout.span === dataLength) { + return layout + } + } + throw new AdapterInputError({ + message: `No layout with matching data length (${dataLength}) for program address '${programAddress}'. Available layouts have lengths: [${layoutCandidates + .map((l) => l.span) + .join(', ')}]`, + statusCode: 500, + }) } export const fetchFieldFromBufferLayoutStateAccount = async ({ @@ -66,31 +83,7 @@ export const fetchFieldFromBufferLayoutStateAccount = async ({ } const data = Buffer.from(resp.value.data[0] as string, encoding) - - // Dynamically select layout for Token Program accounts based on size - let layout: BufferLayout.Layout - - if (programAddress.toString() === solanaTokenProgramAddress) { - if (data.length === MINT_SIZE) { - layout = MintLayout - } else if (data.length === TOKEN_ACCOUNT_SIZE) { - layout = AccountLayout - } else { - throw new AdapterInputError({ - message: `Unsupported Token Program account size: ${data.length} bytes. Expected ${MINT_SIZE} (Mint) or ${TOKEN_ACCOUNT_SIZE} (Token Account)`, - statusCode: 500, - }) - } - } else { - layout = programToBufferLayoutMap[programAddress.toString()] - - if (!layout) { - throw new AdapterInputError({ - message: `No layout known for program address '${programAddress}'`, - statusCode: 500, - }) - } - } + const layout = getLayout(programAddress.toString(), data.length) const dataDecoded = layout.decode(data) as Record const resultValue = dataDecoded[field] diff --git a/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts b/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts index 0137d88ed6..82fe7b9e95 100644 --- a/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts +++ b/packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts @@ -114,7 +114,7 @@ describe('buffer-layout-accounts', () => { rpc, }), ).rejects.toThrow( - 'Unsupported Token Program account size: 4 bytes. Expected 82 (Mint) or 165 (Token Account)', + `No layout with matching data length (4) for program address 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'. Available layouts have lengths: [165, 82]`, ) expect(getAccountInfoMock).toHaveBeenCalledWith(stateAccountAddress, { encoding: 'base64' })