From b04b6a5fb901f3767d629408bd570ef9320c95a6 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:20:14 +0200 Subject: [PATCH 01/12] refactor: add Solana exchange rate helpers --- .changeset/quiet-ducks-compare.md | 5 ++ .../src/shared/buffer-layout-accounts.ts | 65 +++++++++++++- .../src/shared/exchange-rate-utils.ts | 78 +++++++++++++++++ .../src/shared/solana-account-utils.ts | 84 ++++++++++++++++++ .../test/unit/buffer-layout-accounts.test.ts | 73 +++++++++++++++- .../test/unit/exchange-rate-utils.test.ts | 78 +++++++++++++++++ .../test/unit/solana-account-utils.test.ts | 86 +++++++++++++++++++ 7 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 .changeset/quiet-ducks-compare.md create mode 100644 packages/sources/solana-functions/src/shared/exchange-rate-utils.ts create mode 100644 packages/sources/solana-functions/src/shared/solana-account-utils.ts create mode 100644 packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts create mode 100644 packages/sources/solana-functions/test/unit/solana-account-utils.test.ts diff --git a/.changeset/quiet-ducks-compare.md b/.changeset/quiet-ducks-compare.md new file mode 100644 index 00000000000..808d46d05e3 --- /dev/null +++ b/.changeset/quiet-ducks-compare.md @@ -0,0 +1,5 @@ +--- +'@chainlink/solana-functions-adapter': patch +--- + +Add shared Solana exchange-rate utilities. 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 e6bc8393539..2354585fda6 100644 --- a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts +++ b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts @@ -3,7 +3,39 @@ import { type Address } from '@solana/addresses' import * as BufferLayout from '@solana/buffer-layout' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { StakePoolLayout } from '@solana/spl-stake-pool' -import { AccountLayout, MintLayout } from '@solana/spl-token' +import { + AccountLayout, + MintLayout, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token' +import { assertDataLength } from './solana-account-utils' + +export const LEGACY_TOKEN_PROGRAM_ADDRESS = TOKEN_PROGRAM_ID.toBase58() +export const TOKEN_2022_PROGRAM_ADDRESS = TOKEN_2022_PROGRAM_ID.toBase58() +export const TOKEN_PROGRAM_ADDRESSES = [LEGACY_TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS] + +export type MintInfo = { + supply: bigint + decimals: number +} + +export type TokenAccountInfo = { + mintAddress: string + ownerAddress: string + amount: bigint +} + +type DecodedMint = { + supply: bigint + decimals: number +} + +type DecodedTokenAccount = { + mint: { toBase58?(): string; toString(): string } + owner: { toBase58?(): string; toString(): string } + amount: bigint +} interface SanctumPoolState { total_sol_value: bigint @@ -35,16 +67,42 @@ const SanctumPoolStateLayout = BufferLayout.struct([ BufferLayout.blob(32, 'lp_token_mint'), ]) -const solanaTokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' +const solanaTokenProgramAddress = LEGACY_TOKEN_PROGRAM_ADDRESS +const solanaToken2022ProgramAddress = TOKEN_2022_PROGRAM_ADDRESS const solanaStakePoolProgramAddress = 'SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy' const sanctumControllerProgramAddress = '5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx' const programToBufferLayoutMap: Record[]> = { [solanaTokenProgramAddress]: [AccountLayout, MintLayout], + [solanaToken2022ProgramAddress]: [AccountLayout, MintLayout], [solanaStakePoolProgramAddress]: [StakePoolLayout], [sanctumControllerProgramAddress]: [SanctumPoolStateLayout], } +const publicKeyToString = (value: { toBase58?(): string; toString(): string }) => + value.toBase58 ? value.toBase58() : value.toString() + +export const decodeMintInfo = (data: Buffer, description: string): MintInfo => { + assertDataLength(data, description, MintLayout.span) + const decoded = MintLayout.decode(data) as DecodedMint + + return { + supply: decoded.supply, + decimals: decoded.decimals, + } +} + +export const decodeTokenAccountInfo = (data: Buffer, description: string): TokenAccountInfo => { + assertDataLength(data, description, AccountLayout.span) + const decoded = AccountLayout.decode(data) as DecodedTokenAccount + + return { + mintAddress: publicKeyToString(decoded.mint), + ownerAddress: publicKeyToString(decoded.owner), + amount: decoded.amount, + } +} + const getLayout = (programAddress: string, dataLength: number): BufferLayout.Layout => { const layoutCandidates = programToBufferLayoutMap[programAddress] if (!layoutCandidates) { @@ -54,7 +112,8 @@ const getLayout = (programAddress: string, dataLength: number): BufferLayout.Lay }) } if (layoutCandidates.length === 1) { - return layoutCandidates[0]! + const layout = layoutCandidates[0] + if (layout) return layout } for (const layout of layoutCandidates) { if (layout.span === dataLength) { diff --git a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts new file mode 100644 index 00000000000..2ca6c1fd3d0 --- /dev/null +++ b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts @@ -0,0 +1,78 @@ +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' + +export const RESULT_DECIMALS = 18 + +export const parseRateBound = (value: string, name: string) => { + let parsed: bigint + try { + parsed = BigInt(value) + } catch { + throw new AdapterInputError({ + message: `${name} must be a positive base-10 integer string`, + statusCode: 400, + }) + } + + if (parsed <= 0n || parsed.toString() !== value) { + throw new AdapterInputError({ + message: `${name} must be a positive base-10 integer string`, + statusCode: 400, + }) + } + + return parsed +} + +export const parseRateBounds = (minRateValue: string, maxRateValue: string) => { + const minRate = parseRateBound(minRateValue, 'minRate') + const maxRate = parseRateBound(maxRateValue, 'maxRate') + if (minRate > maxRate) { + throw new AdapterInputError({ + message: 'minRate must be less than or equal to maxRate', + statusCode: 400, + }) + } + + return { minRate, maxRate } +} + +export const applyRateBounds = (computedRate: bigint, minRate: bigint, maxRate: bigint) => { + const rate = computedRate < minRate ? minRate : computedRate > maxRate ? maxRate : computedRate + + return { + rate, + boundsApplied: rate !== computedRate, + } +} + +export const calculateNormalizedRate = ( + assets: bigint, + shares: bigint, + assetDecimals: number, + shareDecimals: number, +) => { + if (shares === 0n) { + return null + } + + return ( + (assets * 10n ** BigInt(RESULT_DECIMALS + shareDecimals)) / + (shares * 10n ** BigInt(assetDecimals)) + ) +} + +export const calculateUnvestedAssets = ( + assets: bigint, + unixTimestamp: bigint, + vestingStartTime: bigint, + vestingEndTime: bigint, +) => { + if (assets === 0n || vestingEndTime <= vestingStartTime || unixTimestamp >= vestingEndTime) { + return 0n + } + if (unixTimestamp <= vestingStartTime) { + return assets + } + + return (assets * (vestingEndTime - unixTimestamp)) / (vestingEndTime - vestingStartTime) +} diff --git a/packages/sources/solana-functions/src/shared/solana-account-utils.ts b/packages/sources/solana-functions/src/shared/solana-account-utils.ts new file mode 100644 index 00000000000..c45fc83b321 --- /dev/null +++ b/packages/sources/solana-functions/src/shared/solana-account-utils.ts @@ -0,0 +1,84 @@ +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { type Address } from '@solana/addresses' +import { type Rpc, type SolanaRpcApi } from '@solana/rpc' + +export type EncodedAccountData = readonly [string, string] + +export type AccountInfo = { + data?: EncodedAccountData + owner?: { toString(): string } | string +} + +export type MultipleAccountsRpcResponse = { + value?: (AccountInfo | null)[] +} + +export const getAccountDataBuffer = ( + accountInfo: AccountInfo | null | undefined, + description: string, +) => { + const encodedData = accountInfo?.data?.[0] + if (!encodedData) { + throw new AdapterInputError({ + message: `No account data found for ${description}`, + statusCode: 500, + }) + } + + return Buffer.from(encodedData, 'base64') +} + +export const assertOwnerProgram = ( + accountInfo: AccountInfo | null | undefined, + description: string, + expectedOwners: string[], + ownerDescription: string, +) => { + const owner = accountInfo?.owner?.toString() + if (!owner || !expectedOwners.includes(owner)) { + throw new AdapterInputError({ + message: `Expected ${description} to be owned by ${ownerDescription} [${expectedOwners.join( + ', ', + )}], found '${owner}'`, + statusCode: 500, + }) + } +} + +export const assertDataLength = (data: Buffer, description: string, minLength: number) => { + if (data.length < minLength) { + throw new AdapterInputError({ + message: `Expected ${description} account data to be at least ${minLength} bytes, found ${data.length}`, + statusCode: 500, + }) + } +} + +export const assertDiscriminator = (data: Buffer, description: string, discriminator: Buffer) => { + if (!data.subarray(0, discriminator.length).equals(discriminator)) { + throw new AdapterInputError({ + message: `Expected ${description} discriminator to be ${discriminator.toString( + 'hex', + )}, found ${data.subarray(0, discriminator.length).toString('hex')}`, + statusCode: 500, + }) + } +} + +export const fetchMultipleAccounts = async (rpc: Rpc, addresses: string[]) => { + const encoding = 'base64' + const resp = (await rpc + .getMultipleAccounts(addresses as Address[], { encoding }) + .send()) as MultipleAccountsRpcResponse + + if (!resp.value || resp.value.length !== addresses.length) { + throw new AdapterInputError({ + message: `Expected ${addresses.length} account responses, received ${ + resp.value?.length ?? 0 + }`, + statusCode: 500, + }) + } + + return resp.value +} 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 6a5cef86934..afe30fbd4d7 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 @@ -1,6 +1,12 @@ import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' -import { fetchFieldFromBufferLayoutStateAccount } from '../../src/shared/buffer-layout-accounts' +import { AccountLayout, MintLayout } from '@solana/spl-token' +import { PublicKey } from '@solana/web3.js' +import { + decodeMintInfo, + decodeTokenAccountInfo, + 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' @@ -10,6 +16,44 @@ describe('buffer-layout-accounts', () => { const getAccountInfoMock = jest.fn() const rpc = { getAccountInfo: getAccountInfoMock } as unknown as Rpc + const encodeMint = (supply: bigint, decimals: number) => { + const buffer = Buffer.alloc(MintLayout.span) + MintLayout.encode( + { + mintAuthorityOption: 0, + mintAuthority: PublicKey.default, + supply, + decimals, + isInitialized: true, + freezeAuthorityOption: 0, + freezeAuthority: PublicKey.default, + }, + buffer, + ) + return buffer + } + + const encodeTokenAccount = (mintAddress: string, ownerAddress: string, amount: bigint) => { + const buffer = Buffer.alloc(AccountLayout.span) + AccountLayout.encode( + { + mint: new PublicKey(mintAddress), + owner: new PublicKey(ownerAddress), + amount, + delegateOption: 0, + delegate: PublicKey.default, + state: 1, + isNativeOption: 0, + isNative: 0n, + delegatedAmount: 0n, + closeAuthorityOption: 0, + closeAuthority: PublicKey.default, + }, + buffer, + ) + return buffer + } + beforeEach(() => { jest.resetAllMocks() jest.useFakeTimers() @@ -173,4 +217,31 @@ describe('buffer-layout-accounts', () => { expect(getAccountInfoMock).toHaveBeenCalledTimes(1) }) }) + + describe('decodeMintInfo', () => { + it('should decode SPL mint supply and decimals', () => { + expect(decodeMintInfo(encodeMint(123n, 6), 'test mint')).toEqual({ + supply: 123n, + decimals: 6, + }) + }) + }) + + describe('decodeTokenAccountInfo', () => { + it('should decode SPL token account mint, owner, and amount', () => { + const mintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' + const ownerAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' + + expect( + decodeTokenAccountInfo( + encodeTokenAccount(mintAddress, ownerAddress, 456n), + 'test token account', + ), + ).toEqual({ + mintAddress, + ownerAddress, + amount: 456n, + }) + }) + }) }) diff --git a/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts b/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts new file mode 100644 index 00000000000..2f43bace060 --- /dev/null +++ b/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts @@ -0,0 +1,78 @@ +import { + applyRateBounds, + calculateNormalizedRate, + calculateUnvestedAssets, + parseRateBound, + parseRateBounds, +} from '../../src/shared/exchange-rate-utils' + +describe('exchange-rate-utils', () => { + describe('parseRateBound', () => { + it('should parse positive base-10 integer strings', () => { + expect(parseRateBound('1000000000000000000', 'minRate')).toBe(1000000000000000000n) + }) + + it('should reject non-positive or non-canonical values', () => { + expect(() => parseRateBound('0', 'minRate')).toThrow( + 'minRate must be a positive base-10 integer string', + ) + expect(() => parseRateBound('-1', 'minRate')).toThrow( + 'minRate must be a positive base-10 integer string', + ) + expect(() => parseRateBound('01', 'minRate')).toThrow( + 'minRate must be a positive base-10 integer string', + ) + expect(() => parseRateBound('not-a-rate', 'minRate')).toThrow( + 'minRate must be a positive base-10 integer string', + ) + }) + }) + + describe('parseRateBounds', () => { + it('should parse valid bounds', () => { + expect(parseRateBounds('1', '2')).toEqual({ minRate: 1n, maxRate: 2n }) + }) + + it('should reject inverted bounds', () => { + expect(() => parseRateBounds('2', '1')).toThrow( + 'minRate must be less than or equal to maxRate', + ) + }) + }) + + describe('applyRateBounds', () => { + it('should leave in-range rates unchanged', () => { + expect(applyRateBounds(10n, 1n, 20n)).toEqual({ rate: 10n, boundsApplied: false }) + }) + + it('should clamp below-minimum and above-maximum rates', () => { + expect(applyRateBounds(10n, 11n, 20n)).toEqual({ rate: 11n, boundsApplied: true }) + expect(applyRateBounds(21n, 1n, 20n)).toEqual({ rate: 20n, boundsApplied: true }) + }) + }) + + describe('calculateNormalizedRate', () => { + it('should calculate an 18-decimal normalized rate', () => { + expect(calculateNormalizedRate(1_500_000_000n, 1_000_000n, 9, 6)).toBe( + 1_500_000_000_000_000_000n, + ) + }) + + it('should return null when shares are zero', () => { + expect(calculateNormalizedRate(1n, 0n, 6, 6)).toBeNull() + }) + }) + + describe('calculateUnvestedAssets', () => { + it('should use direct unvested formula with floor rounding', () => { + expect(calculateUnvestedAssets(10n, 1n, 0n, 3n)).toBe(6n) + }) + + it('should handle inactive, pending, complete, and empty vesting schedules', () => { + expect(calculateUnvestedAssets(10n, 1n, 3n, 1n)).toBe(0n) + expect(calculateUnvestedAssets(10n, 1n, 2n, 3n)).toBe(10n) + expect(calculateUnvestedAssets(10n, 3n, 1n, 3n)).toBe(0n) + expect(calculateUnvestedAssets(0n, 1n, 0n, 3n)).toBe(0n) + }) + }) +}) diff --git a/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts new file mode 100644 index 00000000000..8b99a310042 --- /dev/null +++ b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts @@ -0,0 +1,86 @@ +import { type Rpc, type SolanaRpcApi } from '@solana/rpc' +import { + assertDataLength, + assertDiscriminator, + assertOwnerProgram, + fetchMultipleAccounts, + getAccountDataBuffer, +} from '../../src/shared/solana-account-utils' + +describe('solana-account-utils', () => { + describe('getAccountDataBuffer', () => { + it('should decode base64 account data', () => { + const data = Buffer.from('hello').toString('base64') + + expect(getAccountDataBuffer({ data: [data, 'base64'] }, 'test account').toString()).toBe( + 'hello', + ) + }) + + it('should throw when account data is missing', () => { + expect(() => getAccountDataBuffer(null, 'test account')).toThrow( + 'No account data found for test account', + ) + }) + }) + + describe('assertOwnerProgram', () => { + it('should accept an expected owner', () => { + expect(() => + assertOwnerProgram({ owner: 'owner-1' }, 'test account', ['owner-1'], 'test program'), + ).not.toThrow() + }) + + it('should throw for an unexpected owner', () => { + expect(() => + assertOwnerProgram({ owner: 'owner-2' }, 'test account', ['owner-1'], 'test program'), + ).toThrow("Expected test account to be owned by test program [owner-1], found 'owner-2'") + }) + }) + + describe('assertDataLength', () => { + it('should assert minimum data length', () => { + expect(() => assertDataLength(Buffer.alloc(2), 'test account', 3)).toThrow( + 'Expected test account account data to be at least 3 bytes, found 2', + ) + }) + }) + + describe('assertDiscriminator', () => { + it('should assert discriminator bytes', () => { + expect(() => + assertDiscriminator(Buffer.from([1, 2]), 'test account', Buffer.from([1])), + ).not.toThrow() + expect(() => + assertDiscriminator(Buffer.from([1, 2]), 'test account', Buffer.from([2])), + ).toThrow('Expected test account discriminator to be 02, found 01') + }) + }) + + describe('fetchMultipleAccounts', () => { + const sendMock = jest.fn() + const getMultipleAccountsMock = jest.fn() + const rpc = { getMultipleAccounts: getMultipleAccountsMock } as unknown as Rpc + + beforeEach(() => { + jest.resetAllMocks() + getMultipleAccountsMock.mockReturnValue({ send: sendMock }) + }) + + it('should fetch base64 accounts in one request', async () => { + const accounts = [{ data: ['AA==', 'base64'], owner: 'owner' }] + sendMock.mockResolvedValue({ value: accounts }) + + await expect(fetchMultipleAccounts(rpc, ['address-1'])).resolves.toBe(accounts) + expect(getMultipleAccountsMock).toHaveBeenCalledWith(['address-1'], { encoding: 'base64' }) + }) + + it('should throw when response count does not match request count', async () => { + sendMock.mockResolvedValue({ value: [] }) + + await expect(fetchMultipleAccounts(rpc, ['address-1'])).rejects.toThrow( + 'Expected 1 account responses, received 0', + ) + }) + }) +}) From 98ac8875ff3fa4d7caf09263bd90141f66e96db0 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:28:03 +0200 Subject: [PATCH 02/12] refactor: simplify Solana exchange rate helpers --- .../src/shared/buffer-layout-accounts.ts | 11 ++- .../src/shared/exchange-rate-utils.ts | 15 +--- .../src/shared/solana-account-utils.ts | 2 +- .../test/unit/buffer-layout-accounts.test.ts | 69 ++++--------------- .../test/unit/exchange-rate-utils.test.ts | 17 ++--- 5 files changed, 27 insertions(+), 87 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 2354585fda6..12c7afa83c3 100644 --- a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts +++ b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts @@ -32,8 +32,8 @@ type DecodedMint = { } type DecodedTokenAccount = { - mint: { toBase58?(): string; toString(): string } - owner: { toBase58?(): string; toString(): string } + mint: { toString(): string } + owner: { toString(): string } amount: bigint } @@ -79,9 +79,6 @@ const programToBufferLayoutMap: Record[]> = [sanctumControllerProgramAddress]: [SanctumPoolStateLayout], } -const publicKeyToString = (value: { toBase58?(): string; toString(): string }) => - value.toBase58 ? value.toBase58() : value.toString() - export const decodeMintInfo = (data: Buffer, description: string): MintInfo => { assertDataLength(data, description, MintLayout.span) const decoded = MintLayout.decode(data) as DecodedMint @@ -97,8 +94,8 @@ export const decodeTokenAccountInfo = (data: Buffer, description: string): Token const decoded = AccountLayout.decode(data) as DecodedTokenAccount return { - mintAddress: publicKeyToString(decoded.mint), - ownerAddress: publicKeyToString(decoded.owner), + mintAddress: decoded.mint.toString(), + ownerAddress: decoded.owner.toString(), amount: decoded.amount, } } diff --git a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts index 2ca6c1fd3d0..ac934a9ea86 100644 --- a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts +++ b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts @@ -1,26 +1,17 @@ import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' export const RESULT_DECIMALS = 18 +const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/ export const parseRateBound = (value: string, name: string) => { - let parsed: bigint - try { - parsed = BigInt(value) - } catch { + if (!POSITIVE_INTEGER_PATTERN.test(value)) { throw new AdapterInputError({ message: `${name} must be a positive base-10 integer string`, statusCode: 400, }) } - if (parsed <= 0n || parsed.toString() !== value) { - throw new AdapterInputError({ - message: `${name} must be a positive base-10 integer string`, - statusCode: 400, - }) - } - - return parsed + return BigInt(value) } export const parseRateBounds = (minRateValue: string, maxRateValue: string) => { diff --git a/packages/sources/solana-functions/src/shared/solana-account-utils.ts b/packages/sources/solana-functions/src/shared/solana-account-utils.ts index c45fc83b321..57ed6e309a3 100644 --- a/packages/sources/solana-functions/src/shared/solana-account-utils.ts +++ b/packages/sources/solana-functions/src/shared/solana-account-utils.ts @@ -9,7 +9,7 @@ export type AccountInfo = { owner?: { toString(): string } | string } -export type MultipleAccountsRpcResponse = { +type MultipleAccountsRpcResponse = { value?: (AccountInfo | null)[] } 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 afe30fbd4d7..c722a2b9165 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 @@ -1,7 +1,5 @@ import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' -import { AccountLayout, MintLayout } from '@solana/spl-token' -import { PublicKey } from '@solana/web3.js' import { decodeMintInfo, decodeTokenAccountInfo, @@ -16,44 +14,6 @@ describe('buffer-layout-accounts', () => { const getAccountInfoMock = jest.fn() const rpc = { getAccountInfo: getAccountInfoMock } as unknown as Rpc - const encodeMint = (supply: bigint, decimals: number) => { - const buffer = Buffer.alloc(MintLayout.span) - MintLayout.encode( - { - mintAuthorityOption: 0, - mintAuthority: PublicKey.default, - supply, - decimals, - isInitialized: true, - freezeAuthorityOption: 0, - freezeAuthority: PublicKey.default, - }, - buffer, - ) - return buffer - } - - const encodeTokenAccount = (mintAddress: string, ownerAddress: string, amount: bigint) => { - const buffer = Buffer.alloc(AccountLayout.span) - AccountLayout.encode( - { - mint: new PublicKey(mintAddress), - owner: new PublicKey(ownerAddress), - amount, - delegateOption: 0, - delegate: PublicKey.default, - state: 1, - isNativeOption: 0, - isNative: 0n, - delegatedAmount: 0n, - closeAuthorityOption: 0, - closeAuthority: PublicKey.default, - }, - buffer, - ) - return buffer - } - beforeEach(() => { jest.resetAllMocks() jest.useFakeTimers() @@ -220,27 +180,26 @@ describe('buffer-layout-accounts', () => { describe('decodeMintInfo', () => { it('should decode SPL mint supply and decimals', () => { - expect(decodeMintInfo(encodeMint(123n, 6), 'test mint')).toEqual({ - supply: 123n, - decimals: 6, + const data = Buffer.from( + sanctumInfinityTokenAccountData.result.value.data[0] as string, + 'base64', + ) + + expect(decodeMintInfo(data, 'test mint')).toEqual({ + supply: 1_116_792_619_507_830n, + decimals: 9, }) }) }) describe('decodeTokenAccountInfo', () => { it('should decode SPL token account mint, owner, and amount', () => { - const mintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' - const ownerAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' - - expect( - decodeTokenAccountInfo( - encodeTokenAccount(mintAddress, ownerAddress, 456n), - 'test token account', - ), - ).toEqual({ - mintAddress, - ownerAddress, - amount: 456n, + const data = Buffer.from(tokenAccountData.result.value.data[0] as string, 'base64') + + expect(decodeTokenAccountInfo(data, 'test token account')).toEqual({ + mintAddress: '8fr7WGTVFszfyNWRMXj6fRjZZAnDwmXwEpCrtzmUkdih', + ownerAddress: 'DT7z9w9fGJ6sH7vmGbPCa5JLi2xp6XPrL61z2gctzmHb', + amount: 34_228_590_128n, }) }) }) diff --git a/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts b/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts index 2f43bace060..ad417672af4 100644 --- a/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts +++ b/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts @@ -13,18 +13,11 @@ describe('exchange-rate-utils', () => { }) it('should reject non-positive or non-canonical values', () => { - expect(() => parseRateBound('0', 'minRate')).toThrow( - 'minRate must be a positive base-10 integer string', - ) - expect(() => parseRateBound('-1', 'minRate')).toThrow( - 'minRate must be a positive base-10 integer string', - ) - expect(() => parseRateBound('01', 'minRate')).toThrow( - 'minRate must be a positive base-10 integer string', - ) - expect(() => parseRateBound('not-a-rate', 'minRate')).toThrow( - 'minRate must be a positive base-10 integer string', - ) + for (const value of ['0', '-1', '01', 'not-a-rate']) { + expect(() => parseRateBound(value, 'minRate')).toThrow( + 'minRate must be a positive base-10 integer string', + ) + } }) }) From bd429a7a0ebca2b58b618a9fbb52249a4a646cdc Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:27:22 +0200 Subject: [PATCH 03/12] refactor: expand Solana account helpers --- .../src/shared/account-reader.ts | 11 +-- .../src/shared/buffer-layout-accounts.ts | 10 +- .../src/shared/solana-account-utils.ts | 74 +++++++++++--- .../test/unit/account-reader.test.ts | 6 +- .../test/unit/buffer-layout-accounts.test.ts | 38 ++++++++ .../test/unit/solana-account-utils.test.ts | 96 +++++++++++++++++-- 6 files changed, 203 insertions(+), 32 deletions(-) diff --git a/packages/sources/solana-functions/src/shared/account-reader.ts b/packages/sources/solana-functions/src/shared/account-reader.ts index b04702901be..cc205ba985d 100644 --- a/packages/sources/solana-functions/src/shared/account-reader.ts +++ b/packages/sources/solana-functions/src/shared/account-reader.ts @@ -1,22 +1,19 @@ import { BorshCoder, Idl } from '@coral-xyz/anchor' -import { getProgramDerivedAddress, type Address } from '@solana/addresses' +import { type Address } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' +import { derivePda, type PdaSeed } from './solana-account-utils' export class SolanaAccountReader { // Fetch account information by deriving an address given a program address and a list of seeds // accountName must match the IDL exactly - // seeds typed as any due to type not being exported by @solana/addresses async fetchAccountInformationByAddressAndSeeds( rpc: Rpc, programAddress: Address, - seeds: any[], + seeds: PdaSeed[], accountName: string, idl: Idl, ): Promise { - const [pda] = await getProgramDerivedAddress({ - programAddress, - seeds, - }) + const pda = await derivePda(programAddress, seeds) return this.fetchAccountInformation(rpc, pda, accountName, idl) } 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 12c7afa83c3..3e254c7a7d4 100644 --- a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts +++ b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts @@ -9,7 +9,7 @@ import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, } from '@solana/spl-token' -import { assertDataLength } from './solana-account-utils' +import { assertDataLength, assertOwnerProgram, type AccountInfo } from './solana-account-utils' export const LEGACY_TOKEN_PROGRAM_ADDRESS = TOKEN_PROGRAM_ID.toBase58() export const TOKEN_2022_PROGRAM_ADDRESS = TOKEN_2022_PROGRAM_ID.toBase58() @@ -68,17 +68,21 @@ const SanctumPoolStateLayout = BufferLayout.struct([ ]) const solanaTokenProgramAddress = LEGACY_TOKEN_PROGRAM_ADDRESS -const solanaToken2022ProgramAddress = TOKEN_2022_PROGRAM_ADDRESS const solanaStakePoolProgramAddress = 'SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy' const sanctumControllerProgramAddress = '5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx' const programToBufferLayoutMap: Record[]> = { [solanaTokenProgramAddress]: [AccountLayout, MintLayout], - [solanaToken2022ProgramAddress]: [AccountLayout, MintLayout], [solanaStakePoolProgramAddress]: [StakePoolLayout], [sanctumControllerProgramAddress]: [SanctumPoolStateLayout], } +export const assertTokenProgramOwner = ( + accountInfo: AccountInfo | null | undefined, + description: string, +) => + assertOwnerProgram(accountInfo, description, TOKEN_PROGRAM_ADDRESSES, 'a supported token program') + export const decodeMintInfo = (data: Buffer, description: string): MintInfo => { assertDataLength(data, description, MintLayout.span) const decoded = MintLayout.decode(data) as DecodedMint diff --git a/packages/sources/solana-functions/src/shared/solana-account-utils.ts b/packages/sources/solana-functions/src/shared/solana-account-utils.ts index 57ed6e309a3..04b7f11c5e3 100644 --- a/packages/sources/solana-functions/src/shared/solana-account-utils.ts +++ b/packages/sources/solana-functions/src/shared/solana-account-utils.ts @@ -1,16 +1,38 @@ import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' -import { type Address } from '@solana/addresses' +import { address, getProgramDerivedAddress } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' -export type EncodedAccountData = readonly [string, string] +export const CLOCK_SYSVAR_ADDRESS = 'SysvarC1ock11111111111111111111111111111111' -export type AccountInfo = { - data?: EncodedAccountData - owner?: { toString(): string } | string +const CLOCK_ACCOUNT_LENGTH = 40 +const CLOCK_UNIX_TIMESTAMP_OFFSET = 32 + +type MultipleAccountsResponse = Awaited< + ReturnType['getMultipleAccounts']>['send']> +> + +export type AccountInfo = NonNullable[number] + +export type PdaSeed = Parameters[0]['seeds'][number] + +export const parseSolanaAddress = (value: string, name: string) => { + try { + return address(value) + } catch { + throw new AdapterInputError({ + message: `${name} must be a valid Solana address`, + statusCode: 400, + }) + } } -type MultipleAccountsRpcResponse = { - value?: (AccountInfo | null)[] +export const derivePda = async (programAddress: string, seeds: PdaSeed[]) => { + const [pda] = await getProgramDerivedAddress({ + programAddress: parseSolanaAddress(programAddress, 'programAddress'), + seeds, + }) + + return pda } export const getAccountDataBuffer = ( @@ -45,6 +67,28 @@ export const assertOwnerProgram = ( } } +export const assertNameMatches = ( + actualName: string, + expectedName: string, + description: string, +) => { + if (actualName !== expectedName) { + throw new AdapterInputError({ + message: `Expected ${description} name to be '${expectedName}', found '${actualName}'`, + statusCode: 500, + }) + } +} + +export const assertAddressMatches = (actual: string, expected: string, description: string) => { + if (actual !== expected) { + throw new AdapterInputError({ + message: `Expected ${description} to be '${expected}', found '${actual}'`, + statusCode: 500, + }) + } +} + export const assertDataLength = (data: Buffer, description: string, minLength: number) => { if (data.length < minLength) { throw new AdapterInputError({ @@ -65,11 +109,19 @@ export const assertDiscriminator = (data: Buffer, description: string, discrimin } } +export const decodeClockUnixTimestamp = (accountInfo: AccountInfo | null | undefined) => { + const data = getAccountDataBuffer(accountInfo, `Clock sysvar '${CLOCK_SYSVAR_ADDRESS}'`) + assertDataLength(data, 'Clock sysvar', CLOCK_ACCOUNT_LENGTH) + + return data.readBigInt64LE(CLOCK_UNIX_TIMESTAMP_OFFSET) +} + export const fetchMultipleAccounts = async (rpc: Rpc, addresses: string[]) => { - const encoding = 'base64' - const resp = (await rpc - .getMultipleAccounts(addresses as Address[], { encoding }) - .send()) as MultipleAccountsRpcResponse + const encoding = 'base64' as const + const validatedAddresses = addresses.map((accountAddress) => + parseSolanaAddress(accountAddress, 'address'), + ) + const resp = await rpc.getMultipleAccounts(validatedAddresses, { encoding }).send() if (!resp.value || resp.value.length !== addresses.length) { throw new AdapterInputError({ diff --git a/packages/sources/solana-functions/test/unit/account-reader.test.ts b/packages/sources/solana-functions/test/unit/account-reader.test.ts index abd3f48d3ec..2bdfaffcae2 100644 --- a/packages/sources/solana-functions/test/unit/account-reader.test.ts +++ b/packages/sources/solana-functions/test/unit/account-reader.test.ts @@ -13,6 +13,7 @@ jest.mock('@coral-xyz/anchor', () => { }) jest.mock('@solana/addresses', () => ({ + address: jest.fn((value) => value), getProgramDerivedAddress: jest.fn(), })) @@ -110,9 +111,10 @@ describe('SolanaAccountReader', () => { // Spy on the inner method to ensure delegation with the derived PDA const reader = new SolanaAccountReader() + const delegated = { delegated: true } const innerSpy = jest .spyOn(reader, 'fetchAccountInformation') - .mockResolvedValue({ delegated: true } as any) + .mockImplementation(async () => delegated) const result = await reader.fetchAccountInformationByAddressAndSeeds( rpc, @@ -133,7 +135,7 @@ describe('SolanaAccountReader', () => { expect(innerSpy).toHaveBeenCalledTimes(1) expect(innerSpy).toHaveBeenCalledWith(rpc, derivedPda, accountName, fakeIdl) - expect(result).toEqual({ delegated: true }) + expect(result).toEqual(delegated) }) }) }) 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 c722a2b9165..90a75f67d14 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 @@ -1,10 +1,13 @@ import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { + TOKEN_2022_PROGRAM_ADDRESS, + assertTokenProgramOwner, decodeMintInfo, decodeTokenAccountInfo, fetchFieldFromBufferLayoutStateAccount, } from '../../src/shared/buffer-layout-accounts' +import { type AccountInfo } from '../../src/shared/solana-account-utils' 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' @@ -21,6 +24,8 @@ describe('buffer-layout-accounts', () => { getAccountInfoMock.mockReturnValue({ send: sendMock }) }) + const makeAccountInfo = (owner: string) => ({ owner } as unknown as AccountInfo) + describe('fetchFieldFromBufferLayoutStateAccount', () => { it('should fetch and decode field from mint account', async () => { const response = makeStub('response', sanctumInfinityTokenAccountData.result) @@ -129,6 +134,25 @@ describe('buffer-layout-accounts', () => { expect(getAccountInfoMock).toHaveBeenCalledTimes(1) }) + it('should not use the exact-span layout map for Token-2022 accounts', async () => { + const response = makeStub('response', { + value: { + data: [Buffer.alloc(200).toString('base64'), 'base64'], + owner: TOKEN_2022_PROGRAM_ADDRESS, + }, + }) + + sendMock.mockResolvedValue(response) + + await expect(() => + fetchFieldFromBufferLayoutStateAccount({ + stateAccountAddress: '5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm', + field: 'amount', + rpc, + }), + ).rejects.toThrow(`No layout known for program address '${TOKEN_2022_PROGRAM_ADDRESS}'`) + }) + it('should throw for unknown program', async () => { const response = makeStub('response', { value: { @@ -192,6 +216,20 @@ describe('buffer-layout-accounts', () => { }) }) + describe('assertTokenProgramOwner', () => { + it('should accept supported SPL token programs', () => { + expect(() => + assertTokenProgramOwner(makeAccountInfo(TOKEN_2022_PROGRAM_ADDRESS), 'test mint'), + ).not.toThrow() + }) + + it('should reject unsupported owners', () => { + expect(() => assertTokenProgramOwner(makeAccountInfo('other-owner'), 'test mint')).toThrow( + 'Expected test mint to be owned by a supported token program', + ) + }) + }) + describe('decodeTokenAccountInfo', () => { it('should decode SPL token account mint, owner, and amount', () => { const data = Buffer.from(tokenAccountData.result.value.data[0] as string, 'base64') diff --git a/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts index 8b99a310042..47f2b044e36 100644 --- a/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts +++ b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts @@ -1,20 +1,55 @@ +import { address, getAddressEncoder } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { + CLOCK_SYSVAR_ADDRESS, + assertAddressMatches, assertDataLength, assertDiscriminator, + assertNameMatches, assertOwnerProgram, + decodeClockUnixTimestamp, + derivePda, fetchMultipleAccounts, getAccountDataBuffer, + parseSolanaAddress, + type AccountInfo, } from '../../src/shared/solana-account-utils' describe('solana-account-utils', () => { + const systemProgramAddress = '11111111111111111111111111111111' + + const makeAccountInfo = (data: string, owner = systemProgramAddress) => + ({ data: [data, 'base64'], owner } as unknown as AccountInfo) + + describe('parseSolanaAddress', () => { + it('should validate Solana addresses', () => { + expect(parseSolanaAddress(systemProgramAddress, 'programAddress')).toBe(systemProgramAddress) + expect(() => parseSolanaAddress('not-an-address', 'programAddress')).toThrow( + 'programAddress must be a valid Solana address', + ) + }) + }) + + describe('derivePda', () => { + it('should derive PDAs with @solana/addresses', async () => { + const addressEncoder = getAddressEncoder() + const glamProtocolProgramAddress = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' + const glamStateAddress = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' + + await expect( + derivePda(glamProtocolProgramAddress, [ + 'vault', + addressEncoder.encode(address(glamStateAddress)), + ]), + ).resolves.toBe('GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH') + }) + }) + describe('getAccountDataBuffer', () => { it('should decode base64 account data', () => { const data = Buffer.from('hello').toString('base64') - expect(getAccountDataBuffer({ data: [data, 'base64'] }, 'test account').toString()).toBe( - 'hello', - ) + expect(getAccountDataBuffer(makeAccountInfo(data), 'test account').toString()).toBe('hello') }) it('should throw when account data is missing', () => { @@ -27,13 +62,23 @@ describe('solana-account-utils', () => { describe('assertOwnerProgram', () => { it('should accept an expected owner', () => { expect(() => - assertOwnerProgram({ owner: 'owner-1' }, 'test account', ['owner-1'], 'test program'), + assertOwnerProgram( + makeAccountInfo('', 'owner-1'), + 'test account', + ['owner-1'], + 'test program', + ), ).not.toThrow() }) it('should throw for an unexpected owner', () => { expect(() => - assertOwnerProgram({ owner: 'owner-2' }, 'test account', ['owner-1'], 'test program'), + assertOwnerProgram( + makeAccountInfo('', 'owner-2'), + 'test account', + ['owner-1'], + 'test program', + ), ).toThrow("Expected test account to be owned by test program [owner-1], found 'owner-2'") }) }) @@ -46,6 +91,20 @@ describe('solana-account-utils', () => { }) }) + describe('generic assertions', () => { + it('should assert matching names and addresses', () => { + expect(() => assertNameMatches('strategy', 'strategy', 'Strategy')).not.toThrow() + expect(() => assertNameMatches('other', 'strategy', 'Strategy')).toThrow( + "Expected Strategy name to be 'strategy', found 'other'", + ) + + expect(() => assertAddressMatches('address-1', 'address-1', 'mint PDA')).not.toThrow() + expect(() => assertAddressMatches('address-2', 'address-1', 'mint PDA')).toThrow( + "Expected mint PDA to be 'address-1', found 'address-2'", + ) + }) + }) + describe('assertDiscriminator', () => { it('should assert discriminator bytes', () => { expect(() => @@ -57,6 +116,23 @@ describe('solana-account-utils', () => { }) }) + describe('decodeClockUnixTimestamp', () => { + it('should decode the Solana Clock sysvar timestamp', () => { + const data = Buffer.alloc(40) + data.writeBigInt64LE(1_781_704_234n, 32) + + expect(decodeClockUnixTimestamp(makeAccountInfo(data.toString('base64')))).toBe( + 1_781_704_234n, + ) + }) + + it('should reject missing Clock sysvar data', () => { + expect(() => decodeClockUnixTimestamp(null)).toThrow( + `No account data found for Clock sysvar '${CLOCK_SYSVAR_ADDRESS}'`, + ) + }) + }) + describe('fetchMultipleAccounts', () => { const sendMock = jest.fn() const getMultipleAccountsMock = jest.fn() @@ -68,17 +144,19 @@ describe('solana-account-utils', () => { }) it('should fetch base64 accounts in one request', async () => { - const accounts = [{ data: ['AA==', 'base64'], owner: 'owner' }] + const accounts = [makeAccountInfo('AA==')] sendMock.mockResolvedValue({ value: accounts }) - await expect(fetchMultipleAccounts(rpc, ['address-1'])).resolves.toBe(accounts) - expect(getMultipleAccountsMock).toHaveBeenCalledWith(['address-1'], { encoding: 'base64' }) + await expect(fetchMultipleAccounts(rpc, [systemProgramAddress])).resolves.toBe(accounts) + expect(getMultipleAccountsMock).toHaveBeenCalledWith([systemProgramAddress], { + encoding: 'base64', + }) }) it('should throw when response count does not match request count', async () => { sendMock.mockResolvedValue({ value: [] }) - await expect(fetchMultipleAccounts(rpc, ['address-1'])).rejects.toThrow( + await expect(fetchMultipleAccounts(rpc, [systemProgramAddress])).rejects.toThrow( 'Expected 1 account responses, received 0', ) }) From 2a2b1ef35a33a8c1b3ead28f1a54d472f1fe6202 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:49:49 +0200 Subject: [PATCH 04/12] chore: package json --- packages/sources/solana-functions/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sources/solana-functions/package.json b/packages/sources/solana-functions/package.json index be41c0648aa..0e110c485bb 100644 --- a/packages/sources/solana-functions/package.json +++ b/packages/sources/solana-functions/package.json @@ -34,7 +34,7 @@ "typescript": "5.8.3" }, "dependencies": { - "@chainlink/external-adapter-framework": "2.16.1", + "@chainlink/external-adapter-framework": "2.17.1", "@coral-xyz/anchor": "^0.31.1", "@solana/addresses": "^3.0.2", "@solana/buffer-layout": "^4.0.1", diff --git a/yarn.lock b/yarn.lock index 36d337b7002..70271e23886 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4626,7 +4626,7 @@ __metadata: version: 0.0.0-use.local resolution: "@chainlink/solana-functions-adapter@workspace:packages/sources/solana-functions" dependencies: - "@chainlink/external-adapter-framework": "npm:2.16.1" + "@chainlink/external-adapter-framework": "npm:2.17.1" "@coral-xyz/anchor": "npm:^0.31.1" "@solana/addresses": "npm:^3.0.2" "@solana/buffer-layout": "npm:^4.0.1" From f26783e6ccc5289fc67af2d3746ac72f0dbb697f Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:51:43 +0200 Subject: [PATCH 05/12] fix: data[0] string and clock magic values comments --- .../solana-functions/src/shared/solana-account-utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sources/solana-functions/src/shared/solana-account-utils.ts b/packages/sources/solana-functions/src/shared/solana-account-utils.ts index 04b7f11c5e3..9b5cc2c7db5 100644 --- a/packages/sources/solana-functions/src/shared/solana-account-utils.ts +++ b/packages/sources/solana-functions/src/shared/solana-account-utils.ts @@ -4,6 +4,7 @@ import { type Rpc, type SolanaRpcApi } from '@solana/rpc' export const CLOCK_SYSVAR_ADDRESS = 'SysvarC1ock11111111111111111111111111111111' +// Solana Clock sysvar layout stores unix_timestamp as an i64 at byte offset 32. const CLOCK_ACCOUNT_LENGTH = 40 const CLOCK_UNIX_TIMESTAMP_OFFSET = 32 @@ -40,7 +41,7 @@ export const getAccountDataBuffer = ( description: string, ) => { const encodedData = accountInfo?.data?.[0] - if (!encodedData) { + if (typeof encodedData !== 'string' || encodedData.length === 0) { throw new AdapterInputError({ message: `No account data found for ${description}`, statusCode: 500, From 2e4c93438a039f3baf1844479e22ae1765702a29 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:52:17 +0200 Subject: [PATCH 06/12] revert: getLayout change --- .../solana-functions/src/shared/buffer-layout-accounts.ts | 3 +-- 1 file changed, 1 insertion(+), 2 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 3e254c7a7d4..79dc86a1259 100644 --- a/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts +++ b/packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts @@ -113,8 +113,7 @@ const getLayout = (programAddress: string, dataLength: number): BufferLayout.Lay }) } if (layoutCandidates.length === 1) { - const layout = layoutCandidates[0] - if (layout) return layout + return layoutCandidates[0]! } for (const layout of layoutCandidates) { if (layout.span === dataLength) { From ddd4f56af66a5605554b203627c2be131558e59d Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:52:45 +0200 Subject: [PATCH 07/12] tests: solana acc test case --- .../test/unit/solana-account-utils.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts index 47f2b044e36..bd2415c7be8 100644 --- a/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts +++ b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts @@ -57,6 +57,14 @@ describe('solana-account-utils', () => { 'No account data found for test account', ) }) + + it('should throw when account data is not a base64 string', () => { + const accountInfo = { data: [123, 'base64'], owner: systemProgramAddress } + + expect(() => + getAccountDataBuffer(accountInfo as unknown as AccountInfo, 'test account'), + ).toThrow('No account data found for test account') + }) }) describe('assertOwnerProgram', () => { From cd5e81af02ebba9e1227528625b80542e685eb04 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:58:01 +0200 Subject: [PATCH 08/12] revert: unrelated changes --- .../solana-functions/src/shared/account-reader.ts | 11 +++++++---- .../solana-functions/test/unit/account-reader.test.ts | 6 ++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/sources/solana-functions/src/shared/account-reader.ts b/packages/sources/solana-functions/src/shared/account-reader.ts index cc205ba985d..b04702901be 100644 --- a/packages/sources/solana-functions/src/shared/account-reader.ts +++ b/packages/sources/solana-functions/src/shared/account-reader.ts @@ -1,19 +1,22 @@ import { BorshCoder, Idl } from '@coral-xyz/anchor' -import { type Address } from '@solana/addresses' +import { getProgramDerivedAddress, type Address } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' -import { derivePda, type PdaSeed } from './solana-account-utils' export class SolanaAccountReader { // Fetch account information by deriving an address given a program address and a list of seeds // accountName must match the IDL exactly + // seeds typed as any due to type not being exported by @solana/addresses async fetchAccountInformationByAddressAndSeeds( rpc: Rpc, programAddress: Address, - seeds: PdaSeed[], + seeds: any[], accountName: string, idl: Idl, ): Promise { - const pda = await derivePda(programAddress, seeds) + const [pda] = await getProgramDerivedAddress({ + programAddress, + seeds, + }) return this.fetchAccountInformation(rpc, pda, accountName, idl) } diff --git a/packages/sources/solana-functions/test/unit/account-reader.test.ts b/packages/sources/solana-functions/test/unit/account-reader.test.ts index 2bdfaffcae2..abd3f48d3ec 100644 --- a/packages/sources/solana-functions/test/unit/account-reader.test.ts +++ b/packages/sources/solana-functions/test/unit/account-reader.test.ts @@ -13,7 +13,6 @@ jest.mock('@coral-xyz/anchor', () => { }) jest.mock('@solana/addresses', () => ({ - address: jest.fn((value) => value), getProgramDerivedAddress: jest.fn(), })) @@ -111,10 +110,9 @@ describe('SolanaAccountReader', () => { // Spy on the inner method to ensure delegation with the derived PDA const reader = new SolanaAccountReader() - const delegated = { delegated: true } const innerSpy = jest .spyOn(reader, 'fetchAccountInformation') - .mockImplementation(async () => delegated) + .mockResolvedValue({ delegated: true } as any) const result = await reader.fetchAccountInformationByAddressAndSeeds( rpc, @@ -135,7 +133,7 @@ describe('SolanaAccountReader', () => { expect(innerSpy).toHaveBeenCalledTimes(1) expect(innerSpy).toHaveBeenCalledWith(rpc, derivedPda, accountName, fakeIdl) - expect(result).toEqual(delegated) + expect(result).toEqual({ delegated: true }) }) }) }) From f7f069bbe15fb350740885d234d6da5a682a05ec Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:01:46 +0200 Subject: [PATCH 09/12] chore: package json --- .pnp.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pnp.cjs b/.pnp.cjs index d74aa162ccc..9ee760c0754 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -7256,7 +7256,7 @@ const RAW_RUNTIME_STATE = ["workspace:packages/sources/solana-functions", {\ "packageLocation": "./packages/sources/solana-functions/",\ "packageDependencies": [\ - ["@chainlink/external-adapter-framework", "npm:2.16.1"],\ + ["@chainlink/external-adapter-framework", "npm:2.17.1"],\ ["@chainlink/solana-functions-adapter", "workspace:packages/sources/solana-functions"],\ ["@coral-xyz/anchor", "npm:0.31.1"],\ ["@solana/addresses", "virtual:ef003a5149e1a030be208742b73558a4617d3ff34222ae88f1b64560b15c21faffaf169d5b8672dbf9995cc6922dadf923aa2a19841e7237fcbcb97c65f6a492#npm:3.0.3"],\ From b5f794fee292b92e394b32bf7228a52d920d57b6 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:33:40 +0200 Subject: [PATCH 10/12] refactor: extend solana exchange rate helpers --- .../src/shared/account-reader.ts | 13 +++++-------- .../src/shared/exchange-rate-utils.ts | 18 +++++++++++++++++- .../test/unit/account-reader.test.ts | 1 + 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/sources/solana-functions/src/shared/account-reader.ts b/packages/sources/solana-functions/src/shared/account-reader.ts index b04702901be..5587dbf92eb 100644 --- a/packages/sources/solana-functions/src/shared/account-reader.ts +++ b/packages/sources/solana-functions/src/shared/account-reader.ts @@ -1,22 +1,19 @@ import { BorshCoder, Idl } from '@coral-xyz/anchor' -import { getProgramDerivedAddress, type Address } from '@solana/addresses' +import { type Address } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' +import { derivePda, type PdaSeed } from './solana-account-utils' export class SolanaAccountReader { // Fetch account information by deriving an address given a program address and a list of seeds // accountName must match the IDL exactly - // seeds typed as any due to type not being exported by @solana/addresses async fetchAccountInformationByAddressAndSeeds( rpc: Rpc, programAddress: Address, - seeds: any[], + seeds: PdaSeed[], accountName: string, idl: Idl, ): Promise { - const [pda] = await getProgramDerivedAddress({ - programAddress, - seeds, - }) + const pda = await derivePda(programAddress, seeds) return this.fetchAccountInformation(rpc, pda, accountName, idl) } @@ -36,7 +33,7 @@ export class SolanaAccountReader { } const dataEncoded = value.data[0] as string const data = Buffer.from(dataEncoded, encoding) - const coder = new BorshCoder(idl as unknown as Idl) + const coder = new BorshCoder(idl) return coder.accounts.decode(accountName, data) as unknown as T } } diff --git a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts index ac934a9ea86..b508f9c9159 100644 --- a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts +++ b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts @@ -1,8 +1,24 @@ -import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { + AdapterDataProviderError, + AdapterInputError, +} from '@chainlink/external-adapter-framework/validation/error' export const RESULT_DECIMALS = 18 const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/ +export const providerError = (message: string) => + new AdapterDataProviderError( + { + message, + statusCode: 502, + }, + { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + ) + export const parseRateBound = (value: string, name: string) => { if (!POSITIVE_INTEGER_PATTERN.test(value)) { throw new AdapterInputError({ diff --git a/packages/sources/solana-functions/test/unit/account-reader.test.ts b/packages/sources/solana-functions/test/unit/account-reader.test.ts index abd3f48d3ec..56e6e022f65 100644 --- a/packages/sources/solana-functions/test/unit/account-reader.test.ts +++ b/packages/sources/solana-functions/test/unit/account-reader.test.ts @@ -13,6 +13,7 @@ jest.mock('@coral-xyz/anchor', () => { }) jest.mock('@solana/addresses', () => ({ + address: jest.fn((value: string) => value), getProgramDerivedAddress: jest.fn(), })) From 7aabb835d68ecd530f3daeb6f6e7980d929bced4 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:43:42 +0200 Subject: [PATCH 11/12] fix: support optional solana rate bounds --- packages/sources/solana-functions/README.md | 8 ++++---- .../solana-functions/src/config/index.ts | 2 +- .../src/shared/exchange-rate-utils.ts | 18 ++++++++++++------ .../test/unit/exchange-rate-utils.test.ts | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index 3e7d3633ec3..a7f30d67f11 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -25,10 +25,10 @@ version of 0.29.0 or earlier are not compatible with anchor 0.30.0 or later. ## Environment Variables -| Required? | Name | Description | Type | Options | Default | -| :-------: | :-------------------: | :---------------------------------------------------------------------------------------: | :----: | :-----: | :-----: | -| ✅ | RPC_URL | The RPC URL for the Solana cluster | string | | | -| | BACKGROUND_EXECUTE_MS | The amount of time the background execute should sleep before performing the next request | number | | `1000` | +| Required? | Name | Description | Type | Options | Default | +| :-------: | :-------------------: | :---------------------------------------------------------------------------------------: | :----: | :-----: | :-----------------------------------: | +| | RPC_URL | The RPC URL for the Solana cluster | string | | `https://api.mainnet-beta.solana.com` | +| | BACKGROUND_EXECUTE_MS | The amount of time the background execute should sleep before performing the next request | number | | `1000` | --- diff --git a/packages/sources/solana-functions/src/config/index.ts b/packages/sources/solana-functions/src/config/index.ts index 3e34f7cd27b..f9696b0d98b 100644 --- a/packages/sources/solana-functions/src/config/index.ts +++ b/packages/sources/solana-functions/src/config/index.ts @@ -4,7 +4,7 @@ export const config = new AdapterConfig({ RPC_URL: { description: 'The RPC URL for the Solana cluster', type: 'string', - required: true, + default: 'https://api.mainnet-beta.solana.com', sensitive: true, }, BACKGROUND_EXECUTE_MS: { diff --git a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts index b508f9c9159..2b43733bb19 100644 --- a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts +++ b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts @@ -30,10 +30,10 @@ export const parseRateBound = (value: string, name: string) => { return BigInt(value) } -export const parseRateBounds = (minRateValue: string, maxRateValue: string) => { - const minRate = parseRateBound(minRateValue, 'minRate') - const maxRate = parseRateBound(maxRateValue, 'maxRate') - if (minRate > maxRate) { +export const parseRateBounds = (minRateValue?: string, maxRateValue?: string) => { + const minRate = minRateValue === undefined ? undefined : parseRateBound(minRateValue, 'minRate') + const maxRate = maxRateValue === undefined ? undefined : parseRateBound(maxRateValue, 'maxRate') + if (minRate !== undefined && maxRate !== undefined && minRate > maxRate) { throw new AdapterInputError({ message: 'minRate must be less than or equal to maxRate', statusCode: 400, @@ -43,8 +43,14 @@ export const parseRateBounds = (minRateValue: string, maxRateValue: string) => { return { minRate, maxRate } } -export const applyRateBounds = (computedRate: bigint, minRate: bigint, maxRate: bigint) => { - const rate = computedRate < minRate ? minRate : computedRate > maxRate ? maxRate : computedRate +export const applyRateBounds = (computedRate: bigint, minRate?: bigint, maxRate?: bigint) => { + let rate = computedRate + if (minRate !== undefined && rate < minRate) { + rate = minRate + } + if (maxRate !== undefined && rate > maxRate) { + rate = maxRate + } return { rate, diff --git a/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts b/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts index ad417672af4..080e72f87c0 100644 --- a/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts +++ b/packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts @@ -26,6 +26,15 @@ describe('exchange-rate-utils', () => { expect(parseRateBounds('1', '2')).toEqual({ minRate: 1n, maxRate: 2n }) }) + it('should allow omitted bounds', () => { + expect(parseRateBounds(undefined, '2')).toEqual({ minRate: undefined, maxRate: 2n }) + expect(parseRateBounds('1', undefined)).toEqual({ minRate: 1n, maxRate: undefined }) + expect(parseRateBounds(undefined, undefined)).toEqual({ + minRate: undefined, + maxRate: undefined, + }) + }) + it('should reject inverted bounds', () => { expect(() => parseRateBounds('2', '1')).toThrow( 'minRate must be less than or equal to maxRate', @@ -36,11 +45,17 @@ describe('exchange-rate-utils', () => { describe('applyRateBounds', () => { it('should leave in-range rates unchanged', () => { expect(applyRateBounds(10n, 1n, 20n)).toEqual({ rate: 10n, boundsApplied: false }) + expect(applyRateBounds(10n, undefined, undefined)).toEqual({ + rate: 10n, + boundsApplied: false, + }) }) it('should clamp below-minimum and above-maximum rates', () => { expect(applyRateBounds(10n, 11n, 20n)).toEqual({ rate: 11n, boundsApplied: true }) expect(applyRateBounds(21n, 1n, 20n)).toEqual({ rate: 20n, boundsApplied: true }) + expect(applyRateBounds(10n, 11n, undefined)).toEqual({ rate: 11n, boundsApplied: true }) + expect(applyRateBounds(21n, undefined, 20n)).toEqual({ rate: 20n, boundsApplied: true }) }) }) From fb3738ac35229ac206df65f639e2d42fb779b823 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:32:26 +0200 Subject: [PATCH 12/12] fix: align solana helper errors --- .../src/shared/exchange-rate-utils.ts | 18 +--- .../src/shared/solana-account-utils.ts | 86 ++++++++----------- .../test/unit/solana-account-utils.test.ts | 16 ---- 3 files changed, 39 insertions(+), 81 deletions(-) diff --git a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts index 2b43733bb19..d15e5a37c4f 100644 --- a/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts +++ b/packages/sources/solana-functions/src/shared/exchange-rate-utils.ts @@ -1,24 +1,8 @@ -import { - AdapterDataProviderError, - AdapterInputError, -} from '@chainlink/external-adapter-framework/validation/error' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' export const RESULT_DECIMALS = 18 const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/ -export const providerError = (message: string) => - new AdapterDataProviderError( - { - message, - statusCode: 502, - }, - { - providerDataRequestedUnixMs: 0, - providerDataReceivedUnixMs: 0, - providerIndicatedTimeUnixMs: undefined, - }, - ) - export const parseRateBound = (value: string, name: string) => { if (!POSITIVE_INTEGER_PATTERN.test(value)) { throw new AdapterInputError({ diff --git a/packages/sources/solana-functions/src/shared/solana-account-utils.ts b/packages/sources/solana-functions/src/shared/solana-account-utils.ts index 9b5cc2c7db5..46da282f619 100644 --- a/packages/sources/solana-functions/src/shared/solana-account-utils.ts +++ b/packages/sources/solana-functions/src/shared/solana-account-utils.ts @@ -1,4 +1,7 @@ -import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { + AdapterDataProviderError, + AdapterInputError, +} from '@chainlink/external-adapter-framework/validation/error' import { address, getProgramDerivedAddress } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' @@ -16,6 +19,19 @@ export type AccountInfo = NonNullable[number] export type PdaSeed = Parameters[0]['seeds'][number] +export const providerError = (message: string) => + new AdapterDataProviderError( + { + message, + statusCode: 502, + }, + { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + ) + export const parseSolanaAddress = (value: string, name: string) => { try { return address(value) @@ -42,10 +58,7 @@ export const getAccountDataBuffer = ( ) => { const encodedData = accountInfo?.data?.[0] if (typeof encodedData !== 'string' || encodedData.length === 0) { - throw new AdapterInputError({ - message: `No account data found for ${description}`, - statusCode: 500, - }) + throw providerError(`No account data found for ${description}`) } return Buffer.from(encodedData, 'base64') @@ -59,54 +72,29 @@ export const assertOwnerProgram = ( ) => { const owner = accountInfo?.owner?.toString() if (!owner || !expectedOwners.includes(owner)) { - throw new AdapterInputError({ - message: `Expected ${description} to be owned by ${ownerDescription} [${expectedOwners.join( + throw providerError( + `Expected ${description} to be owned by ${ownerDescription} [${expectedOwners.join( ', ', )}], found '${owner}'`, - statusCode: 500, - }) - } -} - -export const assertNameMatches = ( - actualName: string, - expectedName: string, - description: string, -) => { - if (actualName !== expectedName) { - throw new AdapterInputError({ - message: `Expected ${description} name to be '${expectedName}', found '${actualName}'`, - statusCode: 500, - }) - } -} - -export const assertAddressMatches = (actual: string, expected: string, description: string) => { - if (actual !== expected) { - throw new AdapterInputError({ - message: `Expected ${description} to be '${expected}', found '${actual}'`, - statusCode: 500, - }) + ) } } export const assertDataLength = (data: Buffer, description: string, minLength: number) => { if (data.length < minLength) { - throw new AdapterInputError({ - message: `Expected ${description} account data to be at least ${minLength} bytes, found ${data.length}`, - statusCode: 500, - }) + throw providerError( + `Expected ${description} account data to be at least ${minLength} bytes, found ${data.length}`, + ) } } export const assertDiscriminator = (data: Buffer, description: string, discriminator: Buffer) => { if (!data.subarray(0, discriminator.length).equals(discriminator)) { - throw new AdapterInputError({ - message: `Expected ${description} discriminator to be ${discriminator.toString( - 'hex', - )}, found ${data.subarray(0, discriminator.length).toString('hex')}`, - statusCode: 500, - }) + throw providerError( + `Expected ${description} discriminator to be ${discriminator.toString('hex')}, found ${data + .subarray(0, discriminator.length) + .toString('hex')}`, + ) } } @@ -122,15 +110,17 @@ export const fetchMultipleAccounts = async (rpc: Rpc, addresses: s const validatedAddresses = addresses.map((accountAddress) => parseSolanaAddress(accountAddress, 'address'), ) - const resp = await rpc.getMultipleAccounts(validatedAddresses, { encoding }).send() + let resp: MultipleAccountsResponse + try { + resp = await rpc.getMultipleAccounts(validatedAddresses, { encoding }).send() + } catch (e: unknown) { + throw providerError(e instanceof Error ? e.message : 'Failed to fetch Solana accounts') + } if (!resp.value || resp.value.length !== addresses.length) { - throw new AdapterInputError({ - message: `Expected ${addresses.length} account responses, received ${ - resp.value?.length ?? 0 - }`, - statusCode: 500, - }) + throw providerError( + `Expected ${addresses.length} account responses, received ${resp.value?.length ?? 0}`, + ) } return resp.value diff --git a/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts index bd2415c7be8..8c69a9b50b0 100644 --- a/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts +++ b/packages/sources/solana-functions/test/unit/solana-account-utils.test.ts @@ -2,10 +2,8 @@ import { address, getAddressEncoder } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { CLOCK_SYSVAR_ADDRESS, - assertAddressMatches, assertDataLength, assertDiscriminator, - assertNameMatches, assertOwnerProgram, decodeClockUnixTimestamp, derivePda, @@ -99,20 +97,6 @@ describe('solana-account-utils', () => { }) }) - describe('generic assertions', () => { - it('should assert matching names and addresses', () => { - expect(() => assertNameMatches('strategy', 'strategy', 'Strategy')).not.toThrow() - expect(() => assertNameMatches('other', 'strategy', 'Strategy')).toThrow( - "Expected Strategy name to be 'strategy', found 'other'", - ) - - expect(() => assertAddressMatches('address-1', 'address-1', 'mint PDA')).not.toThrow() - expect(() => assertAddressMatches('address-2', 'address-1', 'mint PDA')).toThrow( - "Expected mint PDA to be 'address-1', found 'address-2'", - ) - }) - }) - describe('assertDiscriminator', () => { it('should assert discriminator bytes', () => { expect(() =>