Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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/quiet-ducks-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/solana-functions-adapter': patch
---

Add shared Solana exchange-rate utilities.
11 changes: 4 additions & 7 deletions packages/sources/solana-functions/src/shared/account-reader.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
rpc: Rpc<SolanaRpcApi>,
programAddress: Address,
seeds: any[],
seeds: PdaSeed[],
accountName: string,
idl: Idl,
): Promise<T> {
const [pda] = await getProgramDerivedAddress({
programAddress,
seeds,
})
const pda = await derivePda(programAddress, seeds)

return this.fetchAccountInformation(rpc, pda, accountName, idl)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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()
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: { toString(): string }
owner: { toString(): string }
amount: bigint
}

interface SanctumPoolState {
total_sol_value: bigint
Expand Down Expand Up @@ -35,7 +67,7 @@ const SanctumPoolStateLayout = BufferLayout.struct<SanctumPoolState>([
BufferLayout.blob(32, 'lp_token_mint'),
])

const solanaTokenProgramAddress = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
const solanaTokenProgramAddress = LEGACY_TOKEN_PROGRAM_ADDRESS
const solanaStakePoolProgramAddress = 'SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy'
const sanctumControllerProgramAddress = '5ocnV1qiCgaQR8Jb8xWnVbApfaygJ8tNoZfgPwsgx9kx'

Expand All @@ -45,6 +77,33 @@ const programToBufferLayoutMap: Record<string, BufferLayout.Layout<unknown>[]> =
[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

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: decoded.mint.toString(),
ownerAddress: decoded.owner.toString(),
amount: decoded.amount,
}
}

const getLayout = (programAddress: string, dataLength: number): BufferLayout.Layout<unknown> => {
const layoutCandidates = programToBufferLayoutMap[programAddress]
if (!layoutCandidates) {
Expand All @@ -54,7 +113,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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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) => {
if (!POSITIVE_INTEGER_PATTERN.test(value)) {
throw new AdapterInputError({
message: `${name} must be a positive base-10 integer string`,
statusCode: 400,
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please consider validation using customInputValidation, example

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@magiodev-cll I think this one was missed, please let me know if it's unclear.

}

return BigInt(value)
}

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)
}
136 changes: 136 additions & 0 deletions packages/sources/solana-functions/src/shared/solana-account-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
import { address, getProgramDerivedAddress } from '@solana/addresses'
import { type Rpc, type SolanaRpcApi } from '@solana/rpc'

export const CLOCK_SYSVAR_ADDRESS = 'SysvarC1ock11111111111111111111111111111111'

const CLOCK_ACCOUNT_LENGTH = 40
const CLOCK_UNIX_TIMESTAMP_OFFSET = 32

type MultipleAccountsResponse = Awaited<
ReturnType<ReturnType<Rpc<SolanaRpcApi>['getMultipleAccounts']>['send']>
>

export type AccountInfo = NonNullable<MultipleAccountsResponse['value']>[number]

export type PdaSeed = Parameters<typeof getProgramDerivedAddress>[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,
})
}
}

export const derivePda = async (programAddress: string, seeds: PdaSeed[]) => {
const [pda] = await getProgramDerivedAddress({
programAddress: parseSolanaAddress(programAddress, 'programAddress'),
seeds,
})

return pda
}
Comment on lines +46 to +53

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Now that derivePda lives here (and PdaSeed is exported just above), account-reader.ts should consume it rather than keeping its own derivation path. Today SolanaAccountReader.fetchAccountInformationByAddressAndSeeds still does:

async fetchAccountInformationByAddressAndSeeds<T>(
  rpc, programAddress: Address, seeds: any[], accountName, idl,
): Promise<T> {
  const [pda] = await getProgramDerivedAddress({ programAddress, seeds })
}

which is the same getProgramDerivedAddress call derivePda wraps. So after this PR there are two derivation paths in the EA: the new shared one (used by the strcUSX/stSLX transports) and this inline one (used by eusx-price/anchor-data via the reader).

Two asks:

  • Drop the any. The seeds: any[] has a stale comment ("typed as any due to type not being exported by @solana/addresses"), but that's now obsolete since this PR exports PdaSeed. So seeds: any[] -> seeds: PdaSeed[] removes an any with zero behavioral change.
  • Route the reader through derivePda so there's a single derivation function (const pda = await derivePda(programAddress, seeds)). One impedance note: derivePda takes a string and re-validates via parseSolanaAddress, while the reader already has a typed Address, passing the Address through works (it's a branded string, re-validation is a no-op), or derivePda could accept Address | string.


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 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,
})
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Remove dead exports.** assertNameMatches and assertAddressMatches are no longer used by any endpoint (strcUSX dropped the redundant equality checks). Remove them and their unit tests.

Comment on lines +55 to +81

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fix the error taxonomy in solana-account-utils.ts. The account/decode helpers throw AdapterInputError with statusCode: 500, but these are upstream/data failures, not input errors. They should throw AdapterDataProviderError (502) via the providerError factory:

  • getAccountDataBuffer
  • assertOwnerProgram
  • assertDataLength
  • assertDiscriminator
  • fetchMultipleAccounts
    Keep parseSolanaAddress on AdapterInputError 400 since that's genuine input validation. This fix lets the stSLX PR delete its asProviderError wrapper and makes both endpoints emit consistent 502s for the same failures (today strcUSX emits 500 for these).


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 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<SolanaRpcApi>, addresses: string[]) => {
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({
message: `Expected ${addresses.length} account responses, received ${
resp.value?.length ?? 0
}`,
statusCode: 500,
})
}

return resp.value
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jest.mock('@coral-xyz/anchor', () => {
})

jest.mock('@solana/addresses', () => ({
address: jest.fn((value) => value),
getProgramDerivedAddress: jest.fn(),
}))

Expand Down Expand Up @@ -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,
Expand All @@ -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)
})
})
})
Loading
Loading