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/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.
2 changes: 1 addition & 1 deletion .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions packages/sources/solana-functions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

---

Expand Down
2 changes: 1 addition & 1 deletion packages/sources/solana-functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/sources/solana-functions/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
13 changes: 5 additions & 8 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 All @@ -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
}
}
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 Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 = 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,
})
}

return { minRate, maxRate }
}

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,
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)
}
127 changes: 127 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,127 @@
import {
AdapterDataProviderError,
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'

// 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

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 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)
} 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 (typeof encodedData !== 'string' || encodedData.length === 0) {
throw providerError(`No account data found for ${description}`)
}

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 providerError(
`Expected ${description} to be owned by ${ownerDescription} [${expectedOwners.join(
', ',
)}], found '${owner}'`,
)
}
}
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 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 providerError(
`Expected ${description} discriminator to be ${discriminator.toString('hex')}, found ${data
.subarray(0, discriminator.length)
.toString('hex')}`,
)
}
}

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'),
)
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 providerError(
`Expected ${addresses.length} account responses, received ${resp.value?.length ?? 0}`,
)
}

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: string) => value),
getProgramDerivedAddress: jest.fn(),
}))

Expand Down
Loading
Loading