Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fresh-fans-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/solana-functions-adapter': minor
---

Added support for Token Accounts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,8 +37,12 @@ const SanctumPoolStateLayout = BufferLayout.struct<SanctumPoolState>([
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
Copy link
Contributor

Choose a reason for hiding this comment

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

This PR is not necessary because you can get the data you want using the extension endpoint as I described here: https://chainlink-core.slack.com/archives/C09N0DEH6M8/p1764661162177669?thread_ts=1764082643.124029&cid=C09N0DEH6M8

But if you want to extend the buffer-layout endpoint to support AccountLayout, let's check the layout.span instead of hard coding the lengths.
We can just provide an array of layouts for each program address in the map and loop to find the one with the correct length.


const programToBufferLayoutMap: Record<string, BufferLayout.Layout<unknown>> = {
[solanaTokenProgramAddress]: MintLayout,
[sanctumControllerProgramAddress]: SanctumPoolStateLayout,
}

Expand All @@ -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<unknown>

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<string, unknown>
const resultValue = dataDecoded[field]

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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: {
Expand Down
Loading