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 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..9565d5a6d2 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,12 @@ 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]: MintLayout, [sanctumControllerProgramAddress]: SanctumPoolStateLayout, } @@ -61,16 +65,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: {