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,9 +37,30 @@ const SanctumPoolStateLayout = BufferLayout.struct<SanctumPoolState>([
const solanaTokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
const sanctumControllerProgramAddress = '5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx'

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

const getLayout = (programAddress: string, dataLength: number): BufferLayout.Layout<unknown> => {
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 ({
Expand All @@ -61,16 +82,8 @@ export const fetchFieldFromBufferLayoutStateAccount = async ({
})
}

const layout = programToBufferLayoutMap[programAddress.toString()]

if (!layout) {
throw new AdapterInputError({
message: `No layout known for program address '${programAddress}'`,
statusCode: 500,
})
}

const data = Buffer.from(resp.value.data[0] as string, encoding)
const layout = getLayout(programAddress.toString(), data.length)
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(
`No layout with matching data length (4) for program address 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'. Available layouts have lengths: [165, 82]`,
)

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