Skip to content

OPDATA-7578: Refactor Solana exchange-rate shared utilities#5128

Open
magiodev-cll wants to merge 13 commits into
mainfrom
refactor/solana-exchange-rate-shared
Open

OPDATA-7578: Refactor Solana exchange-rate shared utilities#5128
magiodev-cll wants to merge 13 commits into
mainfrom
refactor/solana-exchange-rate-shared

Conversation

@magiodev-cll

Copy link
Copy Markdown
Contributor

Summary

  • Add shared Solana exchange-rate helpers for rate bounds, 18-decimal normalization, vesting math, and atomic account reads
  • Extend existing buffer-layout account helpers with SPL mint/token-account decode helpers
  • Add focused unit coverage for the shared helpers

Context

This is the shared-components base PR requested in review for #5095 and #5097. It intentionally adds no endpoint; the stSLX and strcUSX endpoint PRs will be stacked on top and refactored to reuse these helpers.

Testing

  • yarn test packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts packages/sources/solana-functions/test/unit/solana-account-utils.test.ts packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts
  • yarn tsc -p packages/sources/solana-functions/tsconfig.test.json
  • yarn workspace @chainlink/solana-functions-adapter build
  • yarn eslint --ext ts
  • yarn prettier --check --plugin=prettier-plugin-organize-imports

@changeset-bot

changeset-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fb3738a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@chainlink/solana-functions-adapter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@mohamed-mehany

mohamed-mehany commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Thank you @magiodev-cll for the refactor!

A few gaps to close before #5095/#5097 stack on this, mostly in the on-chain access
layer

Note: apologies if by the time I post this some of these are already addressed.

1. Extract derivePda and put it on @solana/addresses, not web3.js.

Both endpoint PRs hand-roll PDA derivation (#5097 has 5 near-identical
findProgramAddressSync wrappers, #5095 has the vault + ATA derivation). None of that
is abstracted here, so the duplication survives the rebase. Just as important: those
wrappers use legacy @solana/web3.js PublicKey.findProgramAddressSync, but on main
solana-functions uses @solana/addresses exclusively (getProgramDerivedAddress in
account-reader.ts), web3.js isn't imported anywhere in this EA's src. Please add a
shared derivePda(programAddress, seeds) built on @solana/addresses so the whole EA
stays on one stack.

2. Extract a shared Clock-sysvar reader.

The vesting math (calculateUnvestedAssets) was extracted, but the on-chain
timestamp read that feeds it (#5097's CLOCK_SYSVAR_ADDRESS / offset /
decodeClockUnixTimestamp) was left inline. Reading the on-chain clock is the core
determinism primitive and is reused by strcUSX (and a future eUSX retrofit), it belongs
in shared next to the vesting math.

3. Fold in the remaining duplicated helpers.

assertTokenProgramOwner was duplicated verbatim in both PRs, the constant moved here
but not the wrapper. parsePublicKey (input validation) and assertNameMatches /
assertAddressMatches are generic and were left behind too. Either extract them or
consciously drop them.

4. Manual byte readers

assertDiscriminator was extracted but the readers that accompany it
(readU128LE/readU64LE/readPublicKey/readPaddedString) were not, so half the
manual-decode toolkit is shared and half isn't. Preferred direction is still to decode
strcUSX via the program IDL (or a named buffer-layout struct) and commit the IDL,
which makes most of these readers unnecessary.

5. Token-2022 + the exact-span getLayout map (correctness).

Adding token-2022 -> [AccountLayout, MintLayout] to programToBufferLayoutMap is
fragile: getLayout matches on exact layout.span === dataLength, but Token-2022
accounts can carry extension bytes (and the stSLX mint is Token-2022), so that path will
throw. The new decodeMintInfo/decodeTokenAccountInfo are fine (they assert >= span
and decode the prefix), so prefer those and either drop the token-2022 entry from the
exact-span map or make getLayout tolerate >= span for token programs.

**6. the hand-rolled AccountInfo type + as MultipleAccountsRpcResponse cast
diverges slightly from how the existing readers lean on the @solana/rpc SDK types, are the custom/handrolled types needed?

@magiodev-cll

Copy link
Copy Markdown
Contributor Author

Thank you @magiodev-cll for the refactor!

A few gaps to close before #5095/#5097 stack on this, mostly in the on-chain access layer Note: apologies if by the time I post this some of these are already addressed.

1. Extract derivePda and put it on @solana/addresses, not web3.js.

Both endpoint PRs hand-roll PDA derivation (#5097 has 5 near-identical findProgramAddressSync wrappers, #5095 has the vault + ATA derivation). None of that is abstracted here, so the duplication survives the rebase. Just as important: those wrappers use legacy @solana/web3.js PublicKey.findProgramAddressSync, but on main solana-functions uses @solana/addresses exclusively (getProgramDerivedAddress in account-reader.ts), web3.js isn't imported anywhere in this EA's src. Please add a shared derivePda(programAddress, seeds) built on @solana/addresses so the whole EA stays on one stack.

2. Extract a shared Clock-sysvar reader.

The vesting math (calculateUnvestedAssets) was extracted, but the on-chain timestamp read that feeds it (#5097's CLOCK_SYSVAR_ADDRESS / offset / decodeClockUnixTimestamp) was left inline. Reading the on-chain clock is the core determinism primitive and is reused by strcUSX (and a future eUSX retrofit), it belongs in shared next to the vesting math.

3. Fold in the remaining duplicated helpers.

assertTokenProgramOwner was duplicated verbatim in both PRs, the constant moved here but not the wrapper. parsePublicKey (input validation) and assertNameMatches / assertAddressMatches are generic and were left behind too. Either extract them or consciously drop them.

4. Manual byte readers

assertDiscriminator was extracted but the readers that accompany it (readU128LE/readU64LE/readPublicKey/readPaddedString) were not, so half the manual-decode toolkit is shared and half isn't. Preferred direction is still to decode strcUSX via the program IDL (or a named buffer-layout struct) and commit the IDL, which makes most of these readers unnecessary.

5. Token-2022 + the exact-span getLayout map (correctness).

Adding token-2022 -> [AccountLayout, MintLayout] to programToBufferLayoutMap is fragile: getLayout matches on exact layout.span === dataLength, but Token-2022 accounts can carry extension bytes (and the stSLX mint is Token-2022), so that path will throw. The new decodeMintInfo/decodeTokenAccountInfo are fine (they assert >= span and decode the prefix), so prefer those and either drop the token-2022 entry from the exact-span map or make getLayout tolerate >= span for token programs.

**6. the hand-rolled AccountInfo type + as MultipleAccountsRpcResponse cast diverges slightly from how the existing readers lean on the @solana/rpc SDK types, are the custom/handrolled types needed?

Addressed in [bd429a7a0](https://github.com/smartcontractkit/external-adapters-js/pull/5128/changes/bd429a7a0ebca2b58b618a9fbb52249a4a646cdc).

For point 5: removed Token-2022 from the exact-span programToBufferLayoutMap, so generic getLayout no longer tries to match Token-2022 accounts by exact length. The prefix-safe decodeMintInfo / decodeTokenAccountInfo helpers remain the intended path for SPL/Token-2022 mint/account decoding and still assert >= span.

For point 6: removed the hand-rolled MultipleAccountsRpcResponse cast, and changed AccountInfo to be derived from the @solana/rpc getMultipleAccounts(...).send() return type instead of maintaining our own response shape. fetchMultipleAccounts now validates addresses through @solana/addresses before calling the SDK.

Also added the shared access-layer pieces from the earlier points: derivePda on @solana/addresses, shared Clock sysvar decoding, shared token-program owner assertion, and generic address/name assertions.

@danwilliams-cll

danwilliams-cll commented Jun 24, 2026

Copy link
Copy Markdown

Code Review: PR #5128 — "refactor: expand Solana account helpers"

Verdict: APPROVE — Focused, clean refactoring. Stronger typing from SDK derivation, address validation added to the RPC path, clock sysvar decoding centralised. Two minor points worth noting before merge.


What Changed (actual + lines)

  • solana-account-utils.ts — AccountInfo replaced with a type derived from the actual getMultipleAccounts RPC return type (strictly stronger than the hand-rolled version). Added parseSolanaAddress, derivePda, assertNameMatches, assertAddressMatches, decodeClockUnixTimestamp. fetchMultipleAccounts now validates addresses before the RPC call.
  • account-reader.ts — seeds: any[] tightened to PdaSeed[]. Inline PDA derivation replaced with the new derivePda util.
  • buffer-layout-accounts.ts — Token 2022 removed from programToBufferLayoutMap. assertTokenProgramOwner wrapper added.

Findings

buffer-layout-accounts.ts:80-84: [LOW] yagni: assertTokenProgramOwner wraps assertOwnerProgram with two args baked in — one caller in this PR. Inline at the call site; export when a second caller exists.

solana-account-utils.ts:7-8: [LOW] correctness: CLOCK_ACCOUNT_LENGTH = 40 and CLOCK_UNIX_TIMESTAMP_OFFSET = 32 are correct but unattributed to the Solana clock sysvar layout. A comment or link makes decodeClockUnixTimestamp self-auditable without knowing the spec.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces shared Solana exchange-rate and account utility helpers intended to be reused by upcoming Solana exchange-rate endpoints (e.g., stSLX and strcUSX), along with focused unit tests to validate the new shared behavior.

Changes:

  • Added shared exchange-rate helpers (rate bounds parsing/clamping, 18-decimal normalization, vesting/unvested asset math).
  • Added shared Solana account helpers (address validation, PDA derivation, base64 account buffer decoding, common account assertions, clock sysvar timestamp decode, batched multi-account reads).
  • Extended buffer-layout account utilities with SPL mint + token-account decode helpers and token program owner validation, plus unit coverage.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/sources/solana-functions/src/shared/solana-account-utils.ts New shared Solana account validation/decoding/assertion helpers and multi-account fetch.
packages/sources/solana-functions/src/shared/exchange-rate-utils.ts New shared bigint-based exchange-rate helpers (bounds, normalization, vesting math).
packages/sources/solana-functions/src/shared/buffer-layout-accounts.ts Adds SPL mint/token-account decode helpers and token-program owner validation alongside existing buffer-layout fetching.
packages/sources/solana-functions/src/shared/account-reader.ts Refactors PDA derivation to reuse the new shared PDA helper and tightens seed typing.
packages/sources/solana-functions/test/unit/solana-account-utils.test.ts Unit coverage for the new shared Solana account helpers.
packages/sources/solana-functions/test/unit/exchange-rate-utils.test.ts Unit coverage for the new exchange-rate helpers.
packages/sources/solana-functions/test/unit/buffer-layout-accounts.test.ts Extends unit coverage for new SPL decode + token program owner helpers.
packages/sources/solana-functions/test/unit/account-reader.test.ts Updates mocks/expectations to align with refactored PDA derivation behavior.
.changeset/quiet-ducks-compare.md Patch changeset for the solana-functions adapter to ship the shared utilities.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@magiodev-cll magiodev-cll changed the title Refactor Solana exchange-rate shared utilities OPDATA-7578: Refactor Solana exchange-rate shared utilities Jun 24, 2026
@danwilliams-cll danwilliams-cll self-requested a review June 24, 2026 16:03
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.

Comment on lines +30 to +37
export const derivePda = async (programAddress: string, seeds: PdaSeed[]) => {
const [pda] = await getProgramDerivedAddress({
programAddress: parseSolanaAddress(programAddress, 'programAddress'),
seeds,
})

return pda
}

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.

Comment on lines +71 to +91
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.

providerIndicatedTimeUnixMs: undefined,
},
)

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.

Relocate providerError. It currently lives in exchange-rate-utils.ts but it's an error/account concern, not rate math. Move it into solana-account-utils.ts (or a small errors.ts) so exchange-rate-utils.ts is purely rate math + bounds.

Comment on lines +39 to +91
export const getAccountDataBuffer = (
accountInfo: AccountInfo | null | undefined,
description: string,
) => {
const encodedData = accountInfo?.data?.[0]
if (typeof encodedData !== 'string' || encodedData.length === 0) {
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.

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants