From ece552e4005ce57a294c532976b743a584fff2f9 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:17:24 +0200 Subject: [PATCH 01/22] OPDATA-7578 Add stSLX exchange rate endpoint --- .changeset/sharp-lions-serve.md | 5 + packages/sources/solana-functions/README.md | 28 +- .../solana-functions/src/endpoint/index.ts | 1 + .../src/endpoint/stslx-exchange-rate.ts | 32 ++ .../sources/solana-functions/src/index.ts | 11 +- .../src/transport/stslx-exchange-rate.ts | 286 +++++++++++++ .../stslx-exchange-rate.test.ts.snap | 22 + .../integration/stslx-exchange-rate.test.ts | 128 ++++++ .../test/unit/stslx-exchange-rate.test.ts | 376 ++++++++++++++++++ 9 files changed, 885 insertions(+), 4 deletions(-) create mode 100644 .changeset/sharp-lions-serve.md create mode 100644 packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts create mode 100644 packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts create mode 100644 packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap create mode 100644 packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts create mode 100644 packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts diff --git a/.changeset/sharp-lions-serve.md b/.changeset/sharp-lions-serve.md new file mode 100644 index 00000000000..9c4014c1b39 --- /dev/null +++ b/.changeset/sharp-lions-serve.md @@ -0,0 +1,5 @@ +--- +'@chainlink/solana-functions-adapter': minor +--- + +Add stSLX exchange rate endpoint diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index a7f30d67f11..57e94c432d4 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -40,9 +40,9 @@ There are no rate limits for this adapter. ## Input Parameters -| Required? | Name | Description | Type | Options | Default | -| :-------: | :------: | :-----------------: | :----: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------: | -| | endpoint | The endpoint to use | string | [anchor-data](#anchor-data-endpoint), [buffer-layout](#buffer-layout-endpoint), [eusx-price](#eusx-price-endpoint), [extension](#extension-endpoint), [pool-token-rate](#pool-token-rate-endpoint), [sanctum-infinity](#sanctum-infinity-endpoint) | `eusx-price` | +| Required? | Name | Description | Type | Options | Default | +| :-------: | :------: | :-----------------: | :----: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------: | +| | endpoint | The endpoint to use | string | [anchor-data](#anchor-data-endpoint), [buffer-layout](#buffer-layout-endpoint), [eusx-price](#eusx-price-endpoint), [extension](#extension-endpoint), [pool-token-rate](#pool-token-rate-endpoint), [sanctum-infinity](#sanctum-infinity-endpoint), [stslx-exchange-rate](#stslx-exchange-rate-endpoint) | `eusx-price` | ## Eusx-price Endpoint @@ -239,4 +239,26 @@ Request: --- +## Stslx-exchange-rate Endpoint + +`stslx-exchange-rate` is the only supported name for this endpoint. + +### Input Params + +There are no input parameters for this endpoint. + +### Example + +Request: + +```json +{ + "data": { + "endpoint": "stslx-exchange-rate" + } +} +``` + +--- + MIT License diff --git a/packages/sources/solana-functions/src/endpoint/index.ts b/packages/sources/solana-functions/src/endpoint/index.ts index afbfd1f5477..76d6cf8013c 100644 --- a/packages/sources/solana-functions/src/endpoint/index.ts +++ b/packages/sources/solana-functions/src/endpoint/index.ts @@ -4,3 +4,4 @@ export { endpoint as eusxPrice } from './eusx-price' export { endpoint as extension } from './extension' export { endpoint as poolTokenRate } from './pool-token-rate' export { endpoint as sanctumInfinity } from './sanctum-infinity' +export { endpoint as stslxExchangeRate } from './stslx-exchange-rate' diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts new file mode 100644 index 00000000000..b2ba9e8ed35 --- /dev/null +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -0,0 +1,32 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' + +// TODO: Add optional address inputs if future stSLX-like feeds need configurable accounts. +export const inputParameters = new InputParameters({}, [{}]) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: { + Result: string + Data: { + rate: string + decimals: number + slxBalance: string + stslxSupply: string + slxMintDecimals: number + stslxMintDecimals: number + vaultAddress: string + slxTokenAccountAddress: string + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'stslx-exchange-rate', + aliases: [], + transport: stslxExchangeRateTransport, + inputParameters, +}) diff --git a/packages/sources/solana-functions/src/index.ts b/packages/sources/solana-functions/src/index.ts index 13a693a8d5e..ec8cb22c812 100644 --- a/packages/sources/solana-functions/src/index.ts +++ b/packages/sources/solana-functions/src/index.ts @@ -8,13 +8,22 @@ import { extension, poolTokenRate, sanctumInfinity, + stslxExchangeRate, } from './endpoint' export const adapter = new Adapter({ defaultEndpoint: eusxPrice.name, name: 'SOLANA_FUNCTIONS', config, - endpoints: [eusxPrice, anchorData, sanctumInfinity, bufferLayout, extension, poolTokenRate], + endpoints: [ + eusxPrice, + anchorData, + sanctumInfinity, + bufferLayout, + extension, + poolTokenRate, + stslxExchangeRate, + ], }) export const server = (): Promise => expose(adapter) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts new file mode 100644 index 00000000000..5f3ac50e21f --- /dev/null +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -0,0 +1,286 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { type Address } from '@solana/addresses' +import { type Rpc, type SolanaRpcApi } from '@solana/rpc' +import { + AccountLayout, + getAssociatedTokenAddressSync, + MintLayout, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token' +import { PublicKey } from '@solana/web3.js' +import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' +import { SolanaRpcFactory } from '../shared/solana-rpc-factory' + +const logger = makeLogger('StslxExchangeRateTransport') + +const RESULT_DECIMALS = 18 + +// stSLX-specific accounts. The endpoint is intentionally hardcoded for this feed today; +// optional input plumbing can be added later if we need to reuse the same logic elsewhere. +const GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' +const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' +const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' +const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +const TOKEN_PROGRAM_ADDRESSES = [TOKEN_PROGRAM_ID.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()] + +type RequestParams = typeof inputParameters.validated + +type EncodedAccountData = readonly [string, string] + +type AccountInfo = { + data?: EncodedAccountData + owner?: { toString(): string } | string +} + +type AccountInfoRpcResponse = { + value?: AccountInfo | null +} + +type DecodedMint = { + supply: bigint + decimals: number +} + +type DecodedTokenAccount = { + mint: PublicKey + owner: PublicKey + amount: bigint +} + +type MintInfo = { + supply: bigint + decimals: number +} + +const getAccountDataBuffer = (accountInfo: AccountInfo | null | undefined, description: string) => { + // All account reads request raw base64 data so we can decode integer fields directly. + const encodedData = accountInfo?.data?.[0] + if (!encodedData) { + throw new AdapterInputError({ + message: `No account data found for ${description}`, + statusCode: 500, + }) + } + + return Buffer.from(encodedData, 'base64') +} + +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, + }) + } +} + +const assertTokenProgramOwner = ( + accountInfo: AccountInfo | null | undefined, + description: string, +) => + assertOwnerProgram(accountInfo, description, TOKEN_PROGRAM_ADDRESSES, 'a supported token program') + +const assertLegacyTokenProgramOwner = ( + accountInfo: AccountInfo | null | undefined, + description: string, +) => + assertOwnerProgram( + accountInfo, + description, + [TOKEN_PROGRAM_ID.toBase58()], + 'the legacy SPL Token program', + ) + +export const deriveVaultAddress = () => { + // GLAM stores token assets in a vault PDA derived from the state account and protocol program. + const [vaultAddress] = PublicKey.findProgramAddressSync( + [Buffer.from('vault'), new PublicKey(GLAM_STATE_ADDRESS).toBuffer()], + new PublicKey(GLAM_PROTOCOL_PROGRAM_ADDRESS), + ) + + return vaultAddress.toBase58() +} + +export const deriveSlxTokenAccountAddress = (vaultAddress = deriveVaultAddress()) => + getAssociatedTokenAddressSync( + new PublicKey(SLX_MINT_ADDRESS), + new PublicKey(vaultAddress), + true, + TOKEN_PROGRAM_ID, + ).toBase58() + +export class StslxExchangeRateTransport extends SubscriptionTransport { + rpc!: Rpc + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.rpc = new SolanaRpcFactory().create(adapterSettings.RPC_URL) + } + + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (param) => this.handleRequest(param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(param: RequestParams) { + let response: AdapterResponse + try { + response = await this._handleRequest(param) + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: (e as AdapterInputError)?.statusCode || 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + params: RequestParams, + ): Promise> { + void params + const providerDataRequestedUnixMs = Date.now() + const vaultAddress = deriveVaultAddress() + const slxTokenAccountAddress = deriveSlxTokenAccountAddress(vaultAddress) + + // This implements Path B: derive the exchange rate from raw on-chain balances. + // GLAM can write a PricedProtocol AUM entry after priceSingleAssetVault runs, but that + // field is only useful for Path A if an external actor keeps it populated and fresh. + // The current stSLX state does not expose a passive current price/share field, so this + // transport reads GLAM's base_asset_ata directly instead of relying on cached pricing state. + // Fetch the three independent inputs in parallel: both mint accounts and the vault's SLX ATA. + const [slxMint, stslxMint, slxBalance] = await Promise.all([ + this.fetchMintInfo(SLX_MINT_ADDRESS, 'SLX mint'), + this.fetchMintInfo(STSLX_MINT_ADDRESS, 'stSLX mint'), + this.fetchSlxBalance(vaultAddress, slxTokenAccountAddress), + ]) + + if (stslxMint.supply === 0n) { + throw new AdapterInputError({ + message: `stSLX mint '${STSLX_MINT_ADDRESS}' has zero supply`, + statusCode: 500, + }) + } + + // Rate is returned as an integer with 18 decimals: + // SLX balance / stSLX supply, normalized by each mint's native decimals. + const rate = + (slxBalance.balance * 10n ** BigInt(RESULT_DECIMALS + stslxMint.decimals)) / + (stslxMint.supply * 10n ** BigInt(slxMint.decimals)) + const result = rate.toString() + + return { + data: { + rate: result, + decimals: RESULT_DECIMALS, + slxBalance: slxBalance.balance.toString(), + stslxSupply: stslxMint.supply.toString(), + slxMintDecimals: slxMint.decimals, + stslxMintDecimals: stslxMint.decimals, + vaultAddress, + slxTokenAccountAddress, + }, + statusCode: 200, + result, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + private async fetchMintInfo(mintAddress: string, description: string): Promise { + const encoding = 'base64' + const resp = (await this.rpc + .getAccountInfo(mintAddress as Address, { encoding }) + .send()) as AccountInfoRpcResponse + const accountInfo = resp.value + assertTokenProgramOwner(accountInfo, description) + + // MintLayout gives us the raw SPL mint supply and token decimals without using uiAmount floats. + const data = getAccountDataBuffer(accountInfo, description) + if (data.length < MintLayout.span) { + throw new AdapterInputError({ + message: `Expected ${description} account data to be at least ${MintLayout.span} bytes, found ${data.length}`, + statusCode: 500, + }) + } + + const decoded = MintLayout.decode(data) as DecodedMint + return { + supply: decoded.supply, + decimals: decoded.decimals, + } + } + + private async fetchSlxBalance(vaultAddress: string, tokenAccountAddress: string) { + const encoding = 'base64' + const resp = (await this.rpc + .getAccountInfo(tokenAccountAddress as Address, { encoding }) + .send()) as AccountInfoRpcResponse + const accountInfo = resp.value + assertLegacyTokenProgramOwner(accountInfo, `SLX token account '${tokenAccountAddress}'`) + + const data = getAccountDataBuffer(accountInfo, `SLX token account '${tokenAccountAddress}'`) + if (data.length < AccountLayout.span) { + throw new AdapterInputError({ + message: `Expected SLX token account '${tokenAccountAddress}' data to be at least ${AccountLayout.span} bytes, found ${data.length}`, + statusCode: 500, + }) + } + + const decoded = AccountLayout.decode(data) as DecodedTokenAccount + const mintAddress = decoded.mint.toBase58() + if (mintAddress !== SLX_MINT_ADDRESS) { + throw new AdapterInputError({ + message: `Expected SLX token account '${tokenAccountAddress}' mint to be '${SLX_MINT_ADDRESS}', found '${mintAddress}'`, + statusCode: 500, + }) + } + + const ownerAddress = decoded.owner.toBase58() + if (ownerAddress !== vaultAddress) { + throw new AdapterInputError({ + message: `Expected SLX token account '${tokenAccountAddress}' owner to be '${vaultAddress}', found '${ownerAddress}'`, + statusCode: 500, + }) + } + + return { + balance: decoded.amount, + } + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const stslxExchangeRateTransport = new StslxExchangeRateTransport() diff --git a/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap b/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap new file mode 100644 index 00000000000..bd84a80e6f1 --- /dev/null +++ b/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute stslx-exchange-rate should return success 1`] = ` +{ + "data": { + "decimals": 18, + "rate": "1500000000000000000", + "slxBalance": "1500000000", + "slxMintDecimals": 9, + "slxTokenAccountAddress": "7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf", + "stslxMintDecimals": 6, + "stslxSupply": "1000000", + "vaultAddress": "GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH", + }, + "result": "1500000000000000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; diff --git a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts new file mode 100644 index 00000000000..979f6f05941 --- /dev/null +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -0,0 +1,128 @@ +import { + TestAdapter, + makeStub, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import { + AccountLayout, + MintLayout, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token' +import { PublicKey } from '@solana/web3.js' + +const slxMintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' +const stslxMintAddress = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +const vaultAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' +const slxTokenAccountAddress = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' +const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() +const token2022ProgramAddress = TOKEN_2022_PROGRAM_ID.toBase58() + +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.toString('base64') +} + +const encodeTokenAccount = (amount: bigint) => { + const buffer = Buffer.alloc(AccountLayout.span) + AccountLayout.encode( + { + mint: new PublicKey(slxMintAddress), + owner: new PublicKey(vaultAddress), + amount, + delegateOption: 0, + delegate: PublicKey.default, + state: 1, + isNativeOption: 0, + isNative: 0n, + delegatedAmount: 0n, + closeAuthorityOption: 0, + closeAuthority: PublicKey.default, + }, + buffer, + ) + + return buffer.toString('base64') +} + +const makeAccountInfoResponse = (data: string, owner = tokenProgramAddress) => ({ + value: { + data: [data, 'base64'], + owner, + }, +}) + +const solanaRpc = makeStub('solanaRpc', { + getAccountInfo: (address: string) => ({ + async send() { + if (address === slxMintAddress) { + return makeAccountInfoResponse(encodeMint(100_000_000_000n, 9)) + } + if (address === stslxMintAddress) { + return makeAccountInfoResponse(encodeMint(1_000_000n, 6), token2022ProgramAddress) + } + if (address === slxTokenAccountAddress) { + return makeAccountInfoResponse(encodeTokenAccount(1_500_000_000n)) + } + throw new Error(`Unexpected getAccountInfo address: ${address}`) + }, + }), +}) + +const createSolanaRpc = () => solanaRpc + +jest.mock('@solana/rpc', () => ({ + createSolanaRpc() { + return createSolanaRpc() + }, +})) + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.RPC_URL = 'solana.rpc.url' + process.env.BACKGROUND_EXECUTE_MS = process.env.BACKGROUND_EXECUTE_MS ?? '0' + const mockDate = new Date('2001-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + spy.mockRestore() + }) + + describe('stslx-exchange-rate', () => { + it('should return success', async () => { + const data = { + endpoint: 'stslx-exchange-rate', + } + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(200) + }) + }) +}) diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts new file mode 100644 index 00000000000..3419bb32693 --- /dev/null +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -0,0 +1,376 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' +import { + AccountLayout, + MintLayout, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token' +import { PublicKey } from '@solana/web3.js' +import { BaseEndpointTypes } from '../../src/endpoint/stslx-exchange-rate' +import { + deriveSlxTokenAccountAddress, + deriveVaultAddress, + StslxExchangeRateTransport, +} from '../../src/transport/stslx-exchange-rate' + +const originalEnv = { ...process.env } + +const restoreEnv = () => { + for (const key of Object.keys(process.env)) { + if (key in originalEnv) { + process.env[key] = originalEnv[key] + } else { + delete process.env[key] + } + } +} + +const slxMintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' +const stslxMintAddress = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +const vaultAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' +const slxTokenAccountAddress = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' +const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() +const token2022ProgramAddress = TOKEN_2022_PROGRAM_ID.toBase58() + +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.toString('base64') +} + +const encodeTokenAccount = ( + amount: bigint, + mintAddress = slxMintAddress, + ownerAddress = vaultAddress, +) => { + 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.toString('base64') +} + +const makeAccountInfoResponse = (data: string, owner = tokenProgramAddress) => ({ + value: { + data: [data, 'base64'], + owner, + }, +}) + +const getAccountInfoSendMock = jest.fn() +const getAccountInfoRequestMock = jest.fn() + +const mockRpcRequests = () => { + getAccountInfoRequestMock.mockImplementation((address: string, config: { encoding: string }) => ({ + send() { + return getAccountInfoSendMock(address, config) + }, + })) +} + +const solanaRpc = makeStub('solanaRpc', { + getAccountInfo: getAccountInfoRequestMock, +}) + +const createSolanaRpc = () => solanaRpc + +jest.mock('@solana/rpc', () => ({ + createSolanaRpc() { + return createSolanaRpc() + }, +})) + +const log = jest.fn() +const logger = { + fatal: log, + error: log, + warn: log, + info: log, + debug: log, + trace: log, + msgPrefix: 'mock-logger', +} + +const loggerFactory = { child: () => logger } + +LoggerFactoryProvider.set(loggerFactory) + +describe('StslxExchangeRateTransport', () => { + const transportName = 'default_single_transport' + const endpointName = 'stslx-exchange-rate' + const RPC_URL = 'https://solana.rpc.url' + const BACKGROUND_EXECUTE_MS = 1500 + const slxBalance = 1_500_000_000n + const stslxSupply = 1_000_000n + const slxMintDecimals = 9 + const stslxMintDecimals = 6 + const expectedRate = ( + (slxBalance * 10n ** BigInt(18 + stslxMintDecimals)) / + (stslxSupply * 10n ** BigInt(slxMintDecimals)) + ).toString() + + const adapterSettings = makeStub('adapterSettings', { + RPC_URL, + SOLANA_COMMITMENT: 'finalized', + WARMUP_SUBSCRIPTION_TTL: 10_000, + BACKGROUND_EXECUTE_MS, + MAX_COMMON_KEY_SIZE: 300, + } as unknown as BaseEndpointTypes['Settings']) + + const context = makeStub('context', { + adapterSettings, + } as EndpointContext) + + const responseCache = { + write: jest.fn(), + } + + const dependencies = makeStub('dependencies', { + responseCache, + subscriptionSetFactory: { + buildSet: jest.fn(), + }, + } as unknown as TransportDependencies) + + const param = makeStub('param', { + endpoint: 'stslx-exchange-rate', + }) + + let transport: StslxExchangeRateTransport + + const mockValidAccountData = () => { + getAccountInfoSendMock.mockImplementation((address: string) => { + if (address === slxMintAddress) { + return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) + } + if (address === stslxMintAddress) { + return makeAccountInfoResponse( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ) + } + if (address === slxTokenAccountAddress) { + return makeAccountInfoResponse(encodeTokenAccount(slxBalance)) + } + throw new Error(`Unexpected getAccountInfo address: ${address}`) + }) + } + + beforeEach(async () => { + restoreEnv() + jest.resetAllMocks() + mockRpcRequests() + jest.useFakeTimers() + + transport = new StslxExchangeRateTransport() + + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + }) + + afterEach(() => { + expect(log).not.toBeCalled() + }) + + describe('deriveVaultAddress', () => { + it('should derive the expected GLAM vault PDA', () => { + expect(deriveVaultAddress()).toBe(vaultAddress) + }) + }) + + describe('deriveSlxTokenAccountAddress', () => { + it('should derive the expected GLAM SLX base-asset ATA', () => { + expect(deriveSlxTokenAccountAddress(vaultAddress)).toBe(slxTokenAccountAddress) + }) + }) + + describe('backgroundHandler', () => { + it('should sleep after handleRequest', async () => { + const t0 = Date.now() + let t1 = 0 + transport.backgroundHandler(context, []).then(() => { + t1 = Date.now() + }) + await jest.runAllTimersAsync() + expect(t1 - t0).toBe(BACKGROUND_EXECUTE_MS) + }) + }) + + describe('handleRequest', () => { + it('should cache exchange rate response', async () => { + mockValidAccountData() + + await transport.handleRequest(param) + + const expectedResponse = { + statusCode: 200, + result: expectedRate, + data: { + rate: expectedRate, + decimals: 18, + slxBalance: slxBalance.toString(), + stslxSupply: stslxSupply.toString(), + slxMintDecimals, + stslxMintDecimals, + vaultAddress, + slxTokenAccountAddress, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + + expect(responseCache.write).toBeCalledWith(transportName, [ + { + params: param, + response: expectedResponse, + }, + ]) + expect(responseCache.write).toBeCalledTimes(1) + }) + }) + + describe('_handleRequest', () => { + it('should read the SLX base-asset ATA and return the normalized exchange rate', async () => { + mockValidAccountData() + + const response = await transport._handleRequest(param) + + expect(response).toEqual({ + statusCode: 200, + result: expectedRate, + data: { + rate: expectedRate, + decimals: 18, + slxBalance: slxBalance.toString(), + stslxSupply: stslxSupply.toString(), + slxMintDecimals, + stslxMintDecimals, + vaultAddress, + slxTokenAccountAddress, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + }) + expect(getAccountInfoRequestMock).toBeCalledWith(slxTokenAccountAddress, { + encoding: 'base64', + }) + }) + + it('should error when the stSLX mint has zero supply', async () => { + mockValidAccountData() + getAccountInfoSendMock.mockImplementation((address: string) => { + if (address === slxMintAddress) { + return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) + } + if (address === stslxMintAddress) { + return makeAccountInfoResponse(encodeMint(0n, stslxMintDecimals), token2022ProgramAddress) + } + if (address === slxTokenAccountAddress) { + return makeAccountInfoResponse(encodeTokenAccount(slxBalance)) + } + throw new Error(`Unexpected getAccountInfo address: ${address}`) + }) + + await expect(transport._handleRequest(param)).rejects.toThrow('has zero supply') + }) + + it('should error when the derived SLX base-asset ATA is missing', async () => { + mockValidAccountData() + getAccountInfoSendMock.mockImplementation((address: string) => { + if (address === slxMintAddress) { + return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) + } + if (address === stslxMintAddress) { + return makeAccountInfoResponse( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ) + } + if (address === slxTokenAccountAddress) { + return { value: null } + } + throw new Error(`Unexpected getAccountInfo address: ${address}`) + }) + + await expect(transport._handleRequest(param)).rejects.toThrow('legacy SPL Token program') + }) + + it('should error when the SLX base-asset ATA has the wrong mint', async () => { + mockValidAccountData() + getAccountInfoSendMock.mockImplementation((address: string) => { + if (address === slxMintAddress) { + return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) + } + if (address === stslxMintAddress) { + return makeAccountInfoResponse( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ) + } + if (address === slxTokenAccountAddress) { + return makeAccountInfoResponse(encodeTokenAccount(slxBalance, stslxMintAddress)) + } + throw new Error(`Unexpected getAccountInfo address: ${address}`) + }) + + await expect(transport._handleRequest(param)).rejects.toThrow('mint to be') + }) + + it('should error when the SLX base-asset ATA has the wrong owner', async () => { + mockValidAccountData() + getAccountInfoSendMock.mockImplementation((address: string) => { + if (address === slxMintAddress) { + return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) + } + if (address === stslxMintAddress) { + return makeAccountInfoResponse( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ) + } + if (address === slxTokenAccountAddress) { + return makeAccountInfoResponse( + encodeTokenAccount(slxBalance, slxMintAddress, PublicKey.default.toBase58()), + ) + } + throw new Error(`Unexpected getAccountInfo address: ${address}`) + }) + + await expect(transport._handleRequest(param)).rejects.toThrow('owner to be') + }) + }) +}) From aa855c95077be4ad574d3b5707510822f5f52441 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:30:58 +0200 Subject: [PATCH 02/22] refactor: cleanup --- .../src/transport/stslx-exchange-rate.ts | 23 ++++++++----------- .../test/unit/stslx-exchange-rate.test.ts | 2 +- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 5f3ac50e21f..5eade9e002d 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -169,16 +169,12 @@ export class StslxExchangeRateTransport extends SubscriptionTransport { throw new Error(`Unexpected getAccountInfo address: ${address}`) }) - await expect(transport._handleRequest(param)).rejects.toThrow('legacy SPL Token program') + await expect(transport._handleRequest(param)).rejects.toThrow('No account data found') }) it('should error when the SLX base-asset ATA has the wrong mint', async () => { From eafeaa5577d9dcef2da816d568c5f249d8bf6b5b Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:29:45 +0200 Subject: [PATCH 03/22] refactor: data.result --- .../solana-functions/src/endpoint/stslx-exchange-rate.ts | 2 +- .../solana-functions/src/transport/stslx-exchange-rate.ts | 2 +- .../__snapshots__/stslx-exchange-rate.test.ts.snap | 2 +- .../solana-functions/test/unit/stslx-exchange-rate.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index b2ba9e8ed35..859ad40a7b5 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -11,7 +11,7 @@ export type BaseEndpointTypes = { Response: { Result: string Data: { - rate: string + result: string decimals: number slxBalance: string stslxSupply: string diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 5eade9e002d..7d4121e4216 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -193,7 +193,7 @@ export class StslxExchangeRateTransport extends SubscriptionTransport { statusCode: 200, result: expectedRate, data: { - rate: expectedRate, + result: expectedRate, decimals: 18, slxBalance: slxBalance.toString(), stslxSupply: stslxSupply.toString(), @@ -270,7 +270,7 @@ describe('StslxExchangeRateTransport', () => { statusCode: 200, result: expectedRate, data: { - rate: expectedRate, + result: expectedRate, decimals: 18, slxBalance: slxBalance.toString(), stslxSupply: stslxSupply.toString(), From dd79be11f44fb9917dc482ee9487e8aec0f21979 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:55:27 +0200 Subject: [PATCH 04/22] feat: input param --- packages/sources/solana-functions/README.md | 7 +++++-- .../src/endpoint/stslx-exchange-rate.ts | 20 +++++++++++++++++-- .../src/transport/stslx-exchange-rate.ts | 12 +++++------ .../stslx-exchange-rate.test.ts.snap | 1 + .../test/unit/stslx-exchange-rate.test.ts | 4 ++++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index 57e94c432d4..4eb34efd593 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -104,7 +104,9 @@ Request: ### Input Params -There are no input parameters for this endpoint. +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :--------------: | :-----: | :------------------------------------------------: | :----: | :-----: | :--------------------------------------------: | :--------: | :------------: | +| | glamStateAddress | | The GLAM state account address for the stSLX vault | string | | `5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB` | | | ### Example @@ -254,7 +256,8 @@ Request: ```json { "data": { - "endpoint": "stslx-exchange-rate" + "endpoint": "stslx-exchange-rate", + "glamStateAddress": "5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB" } } ``` diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 859ad40a7b5..f8ed7c2358f 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -3,8 +3,23 @@ import { InputParameters } from '@chainlink/external-adapter-framework/validatio import { config } from '../config' import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' -// TODO: Add optional address inputs if future stSLX-like feeds need configurable accounts. -export const inputParameters = new InputParameters({}, [{}]) +const DEFAULT_GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' + +export const inputParameters = new InputParameters( + { + glamStateAddress: { + description: 'The GLAM state account address for the stSLX vault', + type: 'string', + required: false, + default: DEFAULT_GLAM_STATE_ADDRESS, + }, + }, + [ + { + glamStateAddress: DEFAULT_GLAM_STATE_ADDRESS, + }, + ], +) export type BaseEndpointTypes = { Parameters: typeof inputParameters.definition @@ -17,6 +32,7 @@ export type BaseEndpointTypes = { stslxSupply: string slxMintDecimals: number stslxMintDecimals: number + glamStateAddress: string vaultAddress: string slxTokenAccountAddress: string } diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 7d4121e4216..1bd972dc642 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -20,8 +20,7 @@ const logger = makeLogger('StslxExchangeRateTransport') const RESULT_DECIMALS = 18 -// stSLX-specific accounts. The endpoint is intentionally hardcoded for this feed today; -// optional input plumbing can be added later if we need to reuse the same logic elsewhere. +// stSLX-specific accounts. Only the GLAM state is request-configurable for this feed today. const GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' @@ -104,10 +103,10 @@ const assertLegacyTokenProgramOwner = ( 'the legacy SPL Token program', ) -export const deriveVaultAddress = () => { +export const deriveVaultAddress = (glamStateAddress = GLAM_STATE_ADDRESS) => { // GLAM stores token assets in a vault PDA derived from the state account and protocol program. const [vaultAddress] = PublicKey.findProgramAddressSync( - [Buffer.from('vault'), new PublicKey(GLAM_STATE_ADDRESS).toBuffer()], + [Buffer.from('vault'), new PublicKey(glamStateAddress).toBuffer()], new PublicKey(GLAM_PROTOCOL_PROGRAM_ADDRESS), ) @@ -164,9 +163,9 @@ export class StslxExchangeRateTransport extends SubscriptionTransport> { - void params const providerDataRequestedUnixMs = Date.now() - const vaultAddress = deriveVaultAddress() + const glamStateAddress = params.glamStateAddress ?? GLAM_STATE_ADDRESS + const vaultAddress = deriveVaultAddress(glamStateAddress) const slxTokenAccountAddress = deriveSlxTokenAccountAddress(vaultAddress) // Compute the rate from raw on-chain state: SLX held in GLAM's base-asset ATA @@ -199,6 +198,7 @@ export class StslxExchangeRateTransport extends SubscriptionTransport { const slxMintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' const stslxMintAddress = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +const glamStateAddress = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' const vaultAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' const slxTokenAccountAddress = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() @@ -163,6 +164,7 @@ describe('StslxExchangeRateTransport', () => { const param = makeStub('param', { endpoint: 'stslx-exchange-rate', + glamStateAddress, }) let transport: StslxExchangeRateTransport @@ -240,6 +242,7 @@ describe('StslxExchangeRateTransport', () => { stslxSupply: stslxSupply.toString(), slxMintDecimals, stslxMintDecimals, + glamStateAddress, vaultAddress, slxTokenAccountAddress, }, @@ -276,6 +279,7 @@ describe('StslxExchangeRateTransport', () => { stslxSupply: stslxSupply.toString(), slxMintDecimals, stslxMintDecimals, + glamStateAddress, vaultAddress, slxTokenAccountAddress, }, From 040471765de2b466db0d4723dcb23fdd26dcebb3 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:04:31 +0200 Subject: [PATCH 05/22] feat: input param mandatory --- packages/sources/solana-functions/README.md | 8 ++++---- .../solana-functions/src/endpoint/stslx-exchange-rate.ts | 7 ++----- .../src/transport/stslx-exchange-rate.ts | 9 ++++----- .../test/integration/stslx-exchange-rate.test.ts | 2 ++ .../test/unit/stslx-exchange-rate.test.ts | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index 4eb34efd593..5a28a6c6217 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -104,9 +104,7 @@ Request: ### Input Params -| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | -| :-------: | :--------------: | :-----: | :------------------------------------------------: | :----: | :-----: | :--------------------------------------------: | :--------: | :------------: | -| | glamStateAddress | | The GLAM state account address for the stSLX vault | string | | `5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB` | | | +There are no input parameters for this endpoint. ### Example @@ -247,7 +245,9 @@ Request: ### Input Params -There are no input parameters for this endpoint. +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :--------------: | :-----: | :------------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | +| ✅ | glamStateAddress | | The GLAM state account address for the stSLX vault | string | | | | | ### Example diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index f8ed7c2358f..67f9078cd56 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -3,20 +3,17 @@ import { InputParameters } from '@chainlink/external-adapter-framework/validatio import { config } from '../config' import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' -const DEFAULT_GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' - export const inputParameters = new InputParameters( { glamStateAddress: { description: 'The GLAM state account address for the stSLX vault', type: 'string', - required: false, - default: DEFAULT_GLAM_STATE_ADDRESS, + required: true, }, }, [ { - glamStateAddress: DEFAULT_GLAM_STATE_ADDRESS, + glamStateAddress: '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB', }, ], ) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 1bd972dc642..726c1c64888 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -20,8 +20,7 @@ const logger = makeLogger('StslxExchangeRateTransport') const RESULT_DECIMALS = 18 -// stSLX-specific accounts. Only the GLAM state is request-configurable for this feed today. -const GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' +// stSLX-specific accounts. The GLAM state is supplied by the request. const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' @@ -103,7 +102,7 @@ const assertLegacyTokenProgramOwner = ( 'the legacy SPL Token program', ) -export const deriveVaultAddress = (glamStateAddress = GLAM_STATE_ADDRESS) => { +export const deriveVaultAddress = (glamStateAddress: string) => { // GLAM stores token assets in a vault PDA derived from the state account and protocol program. const [vaultAddress] = PublicKey.findProgramAddressSync( [Buffer.from('vault'), new PublicKey(glamStateAddress).toBuffer()], @@ -113,7 +112,7 @@ export const deriveVaultAddress = (glamStateAddress = GLAM_STATE_ADDRESS) => { return vaultAddress.toBase58() } -export const deriveSlxTokenAccountAddress = (vaultAddress = deriveVaultAddress()) => +export const deriveSlxTokenAccountAddress = (vaultAddress: string) => getAssociatedTokenAddressSync( new PublicKey(SLX_MINT_ADDRESS), new PublicKey(vaultAddress), @@ -164,7 +163,7 @@ export class StslxExchangeRateTransport extends SubscriptionTransport> { const providerDataRequestedUnixMs = Date.now() - const glamStateAddress = params.glamStateAddress ?? GLAM_STATE_ADDRESS + const glamStateAddress = params.glamStateAddress const vaultAddress = deriveVaultAddress(glamStateAddress) const slxTokenAccountAddress = deriveSlxTokenAccountAddress(vaultAddress) diff --git a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts index 979f6f05941..d27d2568c22 100644 --- a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -13,6 +13,7 @@ import { PublicKey } from '@solana/web3.js' const slxMintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' const stslxMintAddress = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +const glamStateAddress = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' const vaultAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' const slxTokenAccountAddress = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() @@ -119,6 +120,7 @@ describe('execute', () => { it('should return success', async () => { const data = { endpoint: 'stslx-exchange-rate', + glamStateAddress, } const response = await testAdapter.request(data) expect(response.json()).toMatchSnapshot() diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index 7a838464418..de571f218d0 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -204,7 +204,7 @@ describe('StslxExchangeRateTransport', () => { describe('deriveVaultAddress', () => { it('should derive the expected GLAM vault PDA', () => { - expect(deriveVaultAddress()).toBe(vaultAddress) + expect(deriveVaultAddress(glamStateAddress)).toBe(vaultAddress) }) }) From e704fc5a6ada2d8de3905bd3a999d07872aa126e Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:13:57 +0200 Subject: [PATCH 06/22] feat: bounds --- packages/sources/solana-functions/README.md | 12 ++-- .../src/endpoint/stslx-exchange-rate.ts | 16 +++++ .../src/transport/stslx-exchange-rate.ts | 39 +++++++++- .../stslx-exchange-rate.test.ts.snap | 4 ++ .../integration/stslx-exchange-rate.test.ts | 4 ++ .../test/unit/stslx-exchange-rate.test.ts | 72 +++++++++++++++++++ 6 files changed, 142 insertions(+), 5 deletions(-) diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index 5a28a6c6217..fe02e9d6713 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -245,9 +245,11 @@ Request: ### Input Params -| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | -| :-------: | :--------------: | :-----: | :------------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | -| ✅ | glamStateAddress | | The GLAM state account address for the stSLX vault | string | | | | | +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :--------------: | :-----: | :--------------------------------------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | +| ✅ | glamStateAddress | | The GLAM state account address for the stSLX vault | string | | | | | +| ✅ | minRate | | Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | +| ✅ | maxRate | | Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | ### Example @@ -257,7 +259,9 @@ Request: { "data": { "endpoint": "stslx-exchange-rate", - "glamStateAddress": "5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB" + "glamStateAddress": "5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB", + "minRate": "950000000000000000", + "maxRate": "1050000000000000000" } } ``` diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 67f9078cd56..705810e7c6f 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -10,10 +10,22 @@ export const inputParameters = new InputParameters( type: 'string', required: true, }, + minRate: { + description: 'Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer', + type: 'string', + required: true, + }, + maxRate: { + description: 'Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer', + type: 'string', + required: true, + }, }, [ { glamStateAddress: '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB', + minRate: '950000000000000000', + maxRate: '1050000000000000000', }, ], ) @@ -24,7 +36,11 @@ export type BaseEndpointTypes = { Result: string Data: { result: string + computedResult: string decimals: number + minRate: string + maxRate: string + boundsApplied: boolean slxBalance: string stslxSupply: string slxMintDecimals: number diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 726c1c64888..3aaddd77476 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -55,6 +55,25 @@ type MintInfo = { decimals: number } +const parseRateBound = (value: string, name: string) => { + if (!/^\d+$/.test(value)) { + throw new AdapterInputError({ + message: `${name} must be a positive integer string`, + statusCode: 400, + }) + } + + const parsed = BigInt(value) + if (parsed === 0n) { + throw new AdapterInputError({ + message: `${name} must be greater than zero`, + statusCode: 400, + }) + } + + return parsed +} + const getAccountDataBuffer = (accountInfo: AccountInfo | null | undefined, description: string) => { // All account reads request raw base64 data so we can decode integer fields directly. const encodedData = accountInfo?.data?.[0] @@ -164,6 +183,15 @@ export class StslxExchangeRateTransport extends SubscriptionTransport> { const providerDataRequestedUnixMs = Date.now() const glamStateAddress = params.glamStateAddress + const minRate = parseRateBound(params.minRate, 'minRate') + const maxRate = parseRateBound(params.maxRate, 'maxRate') + if (minRate > maxRate) { + throw new AdapterInputError({ + message: `minRate must be less than or equal to maxRate`, + statusCode: 400, + }) + } + const vaultAddress = deriveVaultAddress(glamStateAddress) const slxTokenAccountAddress = deriveSlxTokenAccountAddress(vaultAddress) @@ -184,15 +212,24 @@ export class StslxExchangeRateTransport extends SubscriptionTransport maxRate ? maxRate : computedRate const result = rate.toString() + const computedResult = computedRate.toString() return { data: { result, + computedResult, decimals: RESULT_DECIMALS, + minRate: minRate.toString(), + maxRate: maxRate.toString(), + boundsApplied: result !== computedResult, slxBalance: slxBalance.toString(), stslxSupply: stslxMint.supply.toString(), slxMintDecimals: slxMint.decimals, diff --git a/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap b/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap index ec265596695..d0da1205c60 100644 --- a/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap +++ b/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap @@ -3,8 +3,12 @@ exports[`execute stslx-exchange-rate should return success 1`] = ` { "data": { + "boundsApplied": false, + "computedResult": "1500000000000000000", "decimals": 18, "glamStateAddress": "5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB", + "maxRate": "2000000000000000000", + "minRate": "1000000000000000000", "result": "1500000000000000000", "slxBalance": "1500000000", "slxMintDecimals": 9, diff --git a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts index d27d2568c22..cfc2c90f095 100644 --- a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -16,6 +16,8 @@ const stslxMintAddress = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' const glamStateAddress = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' const vaultAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' const slxTokenAccountAddress = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' +const minRate = '1000000000000000000' +const maxRate = '2000000000000000000' const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() const token2022ProgramAddress = TOKEN_2022_PROGRAM_ID.toBase58() @@ -121,6 +123,8 @@ describe('execute', () => { const data = { endpoint: 'stslx-exchange-rate', glamStateAddress, + minRate, + maxRate, } const response = await testAdapter.request(data) expect(response.json()).toMatchSnapshot() diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index de571f218d0..7803391c2a9 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -134,6 +134,8 @@ describe('StslxExchangeRateTransport', () => { const stslxSupply = 1_000_000n const slxMintDecimals = 9 const stslxMintDecimals = 6 + const minRate = '1000000000000000000' + const maxRate = '2000000000000000000' const expectedRate = ( (slxBalance * 10n ** BigInt(18 + stslxMintDecimals)) / (stslxSupply * 10n ** BigInt(slxMintDecimals)) @@ -165,6 +167,8 @@ describe('StslxExchangeRateTransport', () => { const param = makeStub('param', { endpoint: 'stslx-exchange-rate', glamStateAddress, + minRate, + maxRate, }) let transport: StslxExchangeRateTransport @@ -237,7 +241,11 @@ describe('StslxExchangeRateTransport', () => { result: expectedRate, data: { result: expectedRate, + computedResult: expectedRate, decimals: 18, + minRate, + maxRate, + boundsApplied: false, slxBalance: slxBalance.toString(), stslxSupply: stslxSupply.toString(), slxMintDecimals, @@ -274,7 +282,11 @@ describe('StslxExchangeRateTransport', () => { result: expectedRate, data: { result: expectedRate, + computedResult: expectedRate, decimals: 18, + minRate, + maxRate, + boundsApplied: false, slxBalance: slxBalance.toString(), stslxSupply: stslxSupply.toString(), slxMintDecimals, @@ -294,6 +306,66 @@ describe('StslxExchangeRateTransport', () => { }) }) + it('should clamp the exchange rate to minRate', async () => { + mockValidAccountData() + const minClampedRate = (BigInt(expectedRate) + 1n).toString() + + const response = await transport._handleRequest({ + ...param, + minRate: minClampedRate, + }) + + if (!response.data) { + throw new Error('Expected response data') + } + expect(response.result).toBe(minClampedRate) + expect(response.data.result).toBe(minClampedRate) + expect(response.data.computedResult).toBe(expectedRate) + expect(response.data.boundsApplied).toBe(true) + }) + + it('should clamp the exchange rate to maxRate', async () => { + mockValidAccountData() + const maxClampedRate = (BigInt(expectedRate) - 1n).toString() + + const response = await transport._handleRequest({ + ...param, + maxRate: maxClampedRate, + }) + + if (!response.data) { + throw new Error('Expected response data') + } + expect(response.result).toBe(maxClampedRate) + expect(response.data.result).toBe(maxClampedRate) + expect(response.data.computedResult).toBe(expectedRate) + expect(response.data.boundsApplied).toBe(true) + }) + + it('should error when rate bounds are invalid', async () => { + await expect( + transport._handleRequest({ + ...param, + minRate: maxRate, + maxRate: minRate, + }), + ).rejects.toThrow('minRate must be less than or equal to maxRate') + + await expect( + transport._handleRequest({ + ...param, + minRate: '0', + }), + ).rejects.toThrow('minRate must be greater than zero') + + await expect( + transport._handleRequest({ + ...param, + maxRate: 'not-a-rate', + }), + ).rejects.toThrow('maxRate must be a positive integer string') + }) + it('should error when the stSLX mint has zero supply', async () => { mockValidAccountData() getAccountInfoSendMock.mockImplementation((address: string) => { From 097f9226f8ff197440f1b07d28312e5e69a9f6f6 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:16:01 +0200 Subject: [PATCH 07/22] feat: bigint parse --- .../src/transport/stslx-exchange-rate.ts | 12 +++++++----- .../test/unit/stslx-exchange-rate.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 3aaddd77476..2f0e4c323a3 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -56,17 +56,19 @@ type MintInfo = { } const parseRateBound = (value: string, name: string) => { - if (!/^\d+$/.test(value)) { + let parsed: bigint + try { + parsed = BigInt(value) + } catch { throw new AdapterInputError({ - message: `${name} must be a positive integer string`, + message: `${name} must be a positive base-10 integer string`, statusCode: 400, }) } - const parsed = BigInt(value) - if (parsed === 0n) { + if (parsed <= 0n || parsed.toString() !== value) { throw new AdapterInputError({ - message: `${name} must be greater than zero`, + message: `${name} must be a positive base-10 integer string`, statusCode: 400, }) } diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index 7803391c2a9..1f4c6827714 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -356,14 +356,14 @@ describe('StslxExchangeRateTransport', () => { ...param, minRate: '0', }), - ).rejects.toThrow('minRate must be greater than zero') + ).rejects.toThrow('minRate must be a positive base-10 integer string') await expect( transport._handleRequest({ ...param, maxRate: 'not-a-rate', }), - ).rejects.toThrow('maxRate must be a positive integer string') + ).rejects.toThrow('maxRate must be a positive base-10 integer string') }) it('should error when the stSLX mint has zero supply', async () => { From fcfeb7a9627f7259ddee606b0b2140b177b8ad9a Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:18:04 +0200 Subject: [PATCH 08/22] tests --- .../__snapshots__/stslx-exchange-rate.test.ts.snap | 11 +++++++++++ .../test/integration/stslx-exchange-rate.test.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap b/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap index d0da1205c60..94f766438b9 100644 --- a/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap +++ b/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap @@ -1,5 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`execute stslx-exchange-rate should reject requests missing required bounds 1`] = ` +{ + "error": { + "message": "[Param: minRate] param is required but no value was provided", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 400, +} +`; + exports[`execute stslx-exchange-rate should return success 1`] = ` { "data": { diff --git a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts index cfc2c90f095..6e42321fcba 100644 --- a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -130,5 +130,16 @@ describe('execute', () => { expect(response.json()).toMatchSnapshot() expect(response.statusCode).toBe(200) }) + + it('should reject requests missing required bounds', async () => { + const response = await testAdapter.request({ + endpoint: 'stslx-exchange-rate', + glamStateAddress, + maxRate, + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toMatchSnapshot() + }) }) }) From b4ed8d55dbfb95cef96e8a9ad8e711cdfa1ec224 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:55:58 +0200 Subject: [PATCH 09/22] reafactor: trim response fields --- .../src/endpoint/stslx-exchange-rate.ts | 7 ------- .../src/transport/stslx-exchange-rate.ts | 7 ------- .../__snapshots__/stslx-exchange-rate.test.ts.snap | 7 ------- .../test/unit/stslx-exchange-rate.test.ts | 14 -------------- 4 files changed, 35 deletions(-) diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 705810e7c6f..5f628bfabad 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -41,13 +41,6 @@ export type BaseEndpointTypes = { minRate: string maxRate: string boundsApplied: boolean - slxBalance: string - stslxSupply: string - slxMintDecimals: number - stslxMintDecimals: number - glamStateAddress: string - vaultAddress: string - slxTokenAccountAddress: string } } Settings: typeof config.settings diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 2f0e4c323a3..ce88c3617c4 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -232,13 +232,6 @@ export class StslxExchangeRateTransport extends SubscriptionTransport { minRate, maxRate, boundsApplied: false, - slxBalance: slxBalance.toString(), - stslxSupply: stslxSupply.toString(), - slxMintDecimals, - stslxMintDecimals, - glamStateAddress, - vaultAddress, - slxTokenAccountAddress, }, timestamps: { providerDataRequestedUnixMs: Date.now(), @@ -287,13 +280,6 @@ describe('StslxExchangeRateTransport', () => { minRate, maxRate, boundsApplied: false, - slxBalance: slxBalance.toString(), - stslxSupply: stslxSupply.toString(), - slxMintDecimals, - stslxMintDecimals, - glamStateAddress, - vaultAddress, - slxTokenAccountAddress, }, timestamps: { providerDataRequestedUnixMs: Date.now(), From f4f2a15261b28a51b83a1c5fde55b26672c2f4c6 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:58:39 +0200 Subject: [PATCH 10/22] chore: remove solana-functions dependency bump --- 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 79b99b8d23c..814780831e6 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.17.1", + "@chainlink/external-adapter-framework": "2.16.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 5dce8dd6b67..17d90545ba8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4591,7 +4591,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.17.1" + "@chainlink/external-adapter-framework": "npm:2.16.1" "@coral-xyz/anchor": "npm:^0.31.1" "@solana/addresses": "npm:^3.0.2" "@solana/buffer-layout": "npm:^4.0.1" From fb2c78c5ab1eae3f9ac52abe9b2bb57dbd2f50d5 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:52:08 +0200 Subject: [PATCH 11/22] refactor: use shared stSLX exchange rate helpers --- packages/sources/solana-functions/README.md | 10 +- .../src/endpoint/stslx-exchange-rate.ts | 6 - .../src/transport/stslx-exchange-rate.ts | 286 +++++------------- .../integration/stslx-exchange-rate.test.ts | 29 +- .../test/unit/stslx-exchange-rate.test.ts | 216 ++++++------- 5 files changed, 199 insertions(+), 348 deletions(-) diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index fe02e9d6713..fd67294c245 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -245,11 +245,10 @@ Request: ### Input Params -| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | -| :-------: | :--------------: | :-----: | :--------------------------------------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | -| ✅ | glamStateAddress | | The GLAM state account address for the stSLX vault | string | | | | | -| ✅ | minRate | | Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | -| ✅ | maxRate | | Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :-----: | :-----: | :--------------------------------------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | +| ✅ | minRate | | Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | +| ✅ | maxRate | | Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | ### Example @@ -259,7 +258,6 @@ Request: { "data": { "endpoint": "stslx-exchange-rate", - "glamStateAddress": "5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB", "minRate": "950000000000000000", "maxRate": "1050000000000000000" } diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 5f628bfabad..8953259ed2b 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -5,11 +5,6 @@ import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' export const inputParameters = new InputParameters( { - glamStateAddress: { - description: 'The GLAM state account address for the stSLX vault', - type: 'string', - required: true, - }, minRate: { description: 'Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer', type: 'string', @@ -23,7 +18,6 @@ export const inputParameters = new InputParameters( }, [ { - glamStateAddress: '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB', minRate: '950000000000000000', maxRate: '1050000000000000000', }, diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index ce88c3617c4..e3c2b53d302 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -3,128 +3,41 @@ import { TransportDependencies } from '@chainlink/external-adapter-framework/tra import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' -import { type Address } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' -import { - AccountLayout, - getAssociatedTokenAddressSync, - MintLayout, - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, -} from '@solana/spl-token' +import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token' import { PublicKey } from '@solana/web3.js' import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' +import { + decodeMintInfo, + decodeTokenAccountInfo, + LEGACY_TOKEN_PROGRAM_ADDRESS, + TOKEN_PROGRAM_ADDRESSES, +} from '../shared/buffer-layout-accounts' +import { + applyRateBounds, + calculateNormalizedRate, + parseRateBounds, + RESULT_DECIMALS, +} from '../shared/exchange-rate-utils' +import { + assertOwnerProgram, + fetchMultipleAccounts, + getAccountDataBuffer, +} from '../shared/solana-account-utils' import { SolanaRpcFactory } from '../shared/solana-rpc-factory' const logger = makeLogger('StslxExchangeRateTransport') -const RESULT_DECIMALS = 18 - -// stSLX-specific accounts. The GLAM state is supplied by the request. -const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' -const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' -const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' -const TOKEN_PROGRAM_ADDRESSES = [TOKEN_PROGRAM_ID.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()] +export const GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' +export const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' +export const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' +export const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +export const GLAM_VAULT_ADDRESS = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' +export const SLX_TOKEN_ACCOUNT_ADDRESS = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' type RequestParams = typeof inputParameters.validated -type EncodedAccountData = readonly [string, string] - -type AccountInfo = { - data?: EncodedAccountData - owner?: { toString(): string } | string -} - -type AccountInfoRpcResponse = { - value?: AccountInfo | null -} - -type DecodedMint = { - supply: bigint - decimals: number -} - -type DecodedTokenAccount = { - mint: PublicKey - owner: PublicKey - amount: bigint -} - -type MintInfo = { - supply: bigint - decimals: number -} - -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 -} - -const getAccountDataBuffer = (accountInfo: AccountInfo | null | undefined, description: string) => { - // All account reads request raw base64 data so we can decode integer fields directly. - const encodedData = accountInfo?.data?.[0] - if (!encodedData) { - throw new AdapterInputError({ - message: `No account data found for ${description}`, - statusCode: 500, - }) - } - - return Buffer.from(encodedData, 'base64') -} - -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, - }) - } -} - -const assertTokenProgramOwner = ( - accountInfo: AccountInfo | null | undefined, - description: string, -) => - assertOwnerProgram(accountInfo, description, TOKEN_PROGRAM_ADDRESSES, 'a supported token program') - -const assertLegacyTokenProgramOwner = ( - accountInfo: AccountInfo | null | undefined, - description: string, -) => - assertOwnerProgram( - accountInfo, - description, - [TOKEN_PROGRAM_ID.toBase58()], - 'the legacy SPL Token program', - ) - -export const deriveVaultAddress = (glamStateAddress: string) => { - // GLAM stores token assets in a vault PDA derived from the state account and protocol program. +export const deriveVaultAddress = (glamStateAddress = GLAM_STATE_ADDRESS) => { const [vaultAddress] = PublicKey.findProgramAddressSync( [Buffer.from('vault'), new PublicKey(glamStateAddress).toBuffer()], new PublicKey(GLAM_PROTOCOL_PROGRAM_ADDRESS), @@ -133,7 +46,7 @@ export const deriveVaultAddress = (glamStateAddress: string) => { return vaultAddress.toBase58() } -export const deriveSlxTokenAccountAddress = (vaultAddress: string) => +export const deriveSlxTokenAccountAddress = (vaultAddress = GLAM_VAULT_ADDRESS) => getAssociatedTokenAddressSync( new PublicKey(SLX_MINT_ADDRESS), new PublicKey(vaultAddress), @@ -184,43 +97,73 @@ export class StslxExchangeRateTransport extends SubscriptionTransport> { const providerDataRequestedUnixMs = Date.now() - const glamStateAddress = params.glamStateAddress - const minRate = parseRateBound(params.minRate, 'minRate') - const maxRate = parseRateBound(params.maxRate, 'maxRate') - if (minRate > maxRate) { + const { minRate, maxRate } = parseRateBounds(params.minRate, params.maxRate) + + // The stSLX feed reads GLAM vault's canonical SLX ATA as its SLX balance source. + const [slxMintAccount, stslxMintAccount, slxTokenAccount] = await fetchMultipleAccounts( + this.rpc, + [SLX_MINT_ADDRESS, STSLX_MINT_ADDRESS, SLX_TOKEN_ACCOUNT_ADDRESS], + ) + + assertOwnerProgram( + slxMintAccount, + `SLX mint '${SLX_MINT_ADDRESS}'`, + TOKEN_PROGRAM_ADDRESSES, + 'a supported token program', + ) + assertOwnerProgram( + stslxMintAccount, + `stSLX mint '${STSLX_MINT_ADDRESS}'`, + TOKEN_PROGRAM_ADDRESSES, + 'a supported token program', + ) + assertOwnerProgram( + slxTokenAccount, + `SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}'`, + [LEGACY_TOKEN_PROGRAM_ADDRESS], + 'the legacy SPL Token program', + ) + + const slxMint = decodeMintInfo( + getAccountDataBuffer(slxMintAccount, `SLX mint '${SLX_MINT_ADDRESS}'`), + `SLX mint '${SLX_MINT_ADDRESS}'`, + ) + const stslxMint = decodeMintInfo( + getAccountDataBuffer(stslxMintAccount, `stSLX mint '${STSLX_MINT_ADDRESS}'`), + `stSLX mint '${STSLX_MINT_ADDRESS}'`, + ) + const slxToken = decodeTokenAccountInfo( + getAccountDataBuffer(slxTokenAccount, `SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}'`), + `SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}'`, + ) + + if (slxToken.mintAddress !== SLX_MINT_ADDRESS) { throw new AdapterInputError({ - message: `minRate must be less than or equal to maxRate`, - statusCode: 400, + message: `Expected SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}' mint to be '${SLX_MINT_ADDRESS}', found '${slxToken.mintAddress}'`, + statusCode: 500, + }) + } + if (slxToken.ownerAddress !== GLAM_VAULT_ADDRESS) { + throw new AdapterInputError({ + message: `Expected SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}' owner to be '${GLAM_VAULT_ADDRESS}', found '${slxToken.ownerAddress}'`, + statusCode: 500, }) } - const vaultAddress = deriveVaultAddress(glamStateAddress) - const slxTokenAccountAddress = deriveSlxTokenAccountAddress(vaultAddress) - - // Compute the rate from raw on-chain state: SLX held in GLAM's base-asset ATA - // divided by stSLX mint supply, normalized by each mint's native decimals. - const [slxMint, stslxMint, slxBalance] = await Promise.all([ - this.fetchMintInfo(SLX_MINT_ADDRESS, 'SLX mint'), - this.fetchMintInfo(STSLX_MINT_ADDRESS, 'stSLX mint'), - this.fetchBaseAssetBalance(vaultAddress, slxTokenAccountAddress), - ]) - - if (stslxMint.supply === 0n) { + const computedRate = calculateNormalizedRate( + slxToken.amount, + stslxMint.supply, + slxMint.decimals, + stslxMint.decimals, + ) + if (computedRate === null) { throw new AdapterInputError({ message: `stSLX mint '${STSLX_MINT_ADDRESS}' has zero supply`, statusCode: 500, }) } - // Rate is returned as an integer with 18 decimals: - // SLX balance / stSLX supply, normalized by each mint's native decimals. - const computedRate = - (slxBalance * 10n ** BigInt(RESULT_DECIMALS + stslxMint.decimals)) / - (stslxMint.supply * 10n ** BigInt(slxMint.decimals)) - - // Bounds are an explicit jobspec safeguard against transient RPC/read-skew spikes. - // The EA is stateless, so it clamps against configured absolute limits instead of a cached rate. - const rate = computedRate < minRate ? minRate : computedRate > maxRate ? maxRate : computedRate + const { rate, boundsApplied } = applyRateBounds(computedRate, minRate, maxRate) const result = rate.toString() const computedResult = computedRate.toString() @@ -231,7 +174,7 @@ export class StslxExchangeRateTransport extends SubscriptionTransport { - const encoding = 'base64' - const resp = (await this.rpc - .getAccountInfo(mintAddress as Address, { encoding }) - .send()) as AccountInfoRpcResponse - const accountInfo = resp.value - assertTokenProgramOwner(accountInfo, description) - - // MintLayout gives us the raw SPL mint supply and token decimals without using uiAmount floats. - const data = getAccountDataBuffer(accountInfo, description) - if (data.length < MintLayout.span) { - throw new AdapterInputError({ - message: `Expected ${description} account data to be at least ${MintLayout.span} bytes, found ${data.length}`, - statusCode: 500, - }) - } - - const decoded = MintLayout.decode(data) as DecodedMint - return { - supply: decoded.supply, - decimals: decoded.decimals, - } - } - - private async fetchBaseAssetBalance(vaultAddress: string, tokenAccountAddress: string) { - const encoding = 'base64' - const resp = (await this.rpc - .getAccountInfo(tokenAccountAddress as Address, { encoding }) - .send()) as AccountInfoRpcResponse - const accountInfo = resp.value - - const data = getAccountDataBuffer(accountInfo, `SLX token account '${tokenAccountAddress}'`) - assertLegacyTokenProgramOwner(accountInfo, `SLX token account '${tokenAccountAddress}'`) - - if (data.length < AccountLayout.span) { - throw new AdapterInputError({ - message: `Expected SLX token account '${tokenAccountAddress}' data to be at least ${AccountLayout.span} bytes, found ${data.length}`, - statusCode: 500, - }) - } - - const decoded = AccountLayout.decode(data) as DecodedTokenAccount - const mintAddress = decoded.mint.toBase58() - if (mintAddress !== SLX_MINT_ADDRESS) { - throw new AdapterInputError({ - message: `Expected SLX token account '${tokenAccountAddress}' mint to be '${SLX_MINT_ADDRESS}', found '${mintAddress}'`, - statusCode: 500, - }) - } - - const ownerAddress = decoded.owner.toBase58() - if (ownerAddress !== vaultAddress) { - throw new AdapterInputError({ - message: `Expected SLX token account '${tokenAccountAddress}' owner to be '${vaultAddress}', found '${ownerAddress}'`, - statusCode: 500, - }) - } - - return decoded.amount - } - getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { return adapterSettings.WARMUP_SUBSCRIPTION_TTL } diff --git a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts index 6e42321fcba..a299c2ab8b2 100644 --- a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -13,7 +13,6 @@ import { PublicKey } from '@solana/web3.js' const slxMintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' const stslxMintAddress = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' -const glamStateAddress = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' const vaultAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' const slxTokenAccountAddress = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' const minRate = '1000000000000000000' @@ -62,25 +61,25 @@ const encodeTokenAccount = (amount: bigint) => { } const makeAccountInfoResponse = (data: string, owner = tokenProgramAddress) => ({ - value: { - data: [data, 'base64'], - owner, - }, + data: [data, 'base64'], + owner, }) const solanaRpc = makeStub('solanaRpc', { - getAccountInfo: (address: string) => ({ + getMultipleAccounts: (addresses: string[]) => ({ async send() { - if (address === slxMintAddress) { - return makeAccountInfoResponse(encodeMint(100_000_000_000n, 9)) - } - if (address === stslxMintAddress) { - return makeAccountInfoResponse(encodeMint(1_000_000n, 6), token2022ProgramAddress) + const accountsByAddress: Record> = { + [slxMintAddress]: makeAccountInfoResponse(encodeMint(100_000_000_000n, 9)), + [stslxMintAddress]: makeAccountInfoResponse( + encodeMint(1_000_000n, 6), + token2022ProgramAddress, + ), + [slxTokenAccountAddress]: makeAccountInfoResponse(encodeTokenAccount(1_500_000_000n)), } - if (address === slxTokenAccountAddress) { - return makeAccountInfoResponse(encodeTokenAccount(1_500_000_000n)) + + return { + value: addresses.map((address) => accountsByAddress[address] ?? null), } - throw new Error(`Unexpected getAccountInfo address: ${address}`) }, }), }) @@ -122,7 +121,6 @@ describe('execute', () => { it('should return success', async () => { const data = { endpoint: 'stslx-exchange-rate', - glamStateAddress, minRate, maxRate, } @@ -134,7 +132,6 @@ describe('execute', () => { it('should reject requests missing required bounds', async () => { const response = await testAdapter.request({ endpoint: 'stslx-exchange-rate', - glamStateAddress, maxRate, }) diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index 2e536a87a0e..2f907708830 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -13,6 +13,11 @@ import { BaseEndpointTypes } from '../../src/endpoint/stslx-exchange-rate' import { deriveSlxTokenAccountAddress, deriveVaultAddress, + GLAM_STATE_ADDRESS, + GLAM_VAULT_ADDRESS, + SLX_MINT_ADDRESS, + SLX_TOKEN_ACCOUNT_ADDRESS, + STSLX_MINT_ADDRESS, StslxExchangeRateTransport, } from '../../src/transport/stslx-exchange-rate' @@ -28,11 +33,6 @@ const restoreEnv = () => { } } -const slxMintAddress = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' -const stslxMintAddress = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' -const glamStateAddress = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' -const vaultAddress = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' -const slxTokenAccountAddress = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() const token2022ProgramAddress = TOKEN_2022_PROGRAM_ID.toBase58() @@ -56,8 +56,8 @@ const encodeMint = (supply: bigint, decimals: number) => { const encodeTokenAccount = ( amount: bigint, - mintAddress = slxMintAddress, - ownerAddress = vaultAddress, + mintAddress = SLX_MINT_ADDRESS, + ownerAddress = GLAM_VAULT_ADDRESS, ) => { const buffer = Buffer.alloc(AccountLayout.span) AccountLayout.encode( @@ -80,26 +80,26 @@ const encodeTokenAccount = ( return buffer.toString('base64') } -const makeAccountInfoResponse = (data: string, owner = tokenProgramAddress) => ({ - value: { - data: [data, 'base64'], - owner, - }, +const makeAccountInfo = (data: string, owner = tokenProgramAddress) => ({ + data: [data, 'base64'], + owner, }) -const getAccountInfoSendMock = jest.fn() -const getAccountInfoRequestMock = jest.fn() +const getMultipleAccountsSendMock = jest.fn() +const getMultipleAccountsRequestMock = jest.fn() const mockRpcRequests = () => { - getAccountInfoRequestMock.mockImplementation((address: string, config: { encoding: string }) => ({ - send() { - return getAccountInfoSendMock(address, config) - }, - })) + getMultipleAccountsRequestMock.mockImplementation( + (addresses: string[], config: { encoding: string }) => ({ + send() { + return getMultipleAccountsSendMock(addresses, config) + }, + }), + ) } const solanaRpc = makeStub('solanaRpc', { - getAccountInfo: getAccountInfoRequestMock, + getMultipleAccounts: getMultipleAccountsRequestMock, }) const createSolanaRpc = () => solanaRpc @@ -166,7 +166,6 @@ describe('StslxExchangeRateTransport', () => { const param = makeStub('param', { endpoint: 'stslx-exchange-rate', - glamStateAddress, minRate, maxRate, }) @@ -174,21 +173,18 @@ describe('StslxExchangeRateTransport', () => { let transport: StslxExchangeRateTransport const mockValidAccountData = () => { - getAccountInfoSendMock.mockImplementation((address: string) => { - if (address === slxMintAddress) { - return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) - } - if (address === stslxMintAddress) { - return makeAccountInfoResponse( - encodeMint(stslxSupply, stslxMintDecimals), - token2022ProgramAddress, - ) - } - if (address === slxTokenAccountAddress) { - return makeAccountInfoResponse(encodeTokenAccount(slxBalance)) - } - throw new Error(`Unexpected getAccountInfo address: ${address}`) - }) + const accountsByAddress: Record> = { + [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), + [STSLX_MINT_ADDRESS]: makeAccountInfo( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ), + [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo(encodeTokenAccount(slxBalance)), + } + + getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ + value: addresses.map((address) => accountsByAddress[address] ?? null), + })) } beforeEach(async () => { @@ -208,13 +204,15 @@ describe('StslxExchangeRateTransport', () => { describe('deriveVaultAddress', () => { it('should derive the expected GLAM vault PDA', () => { - expect(deriveVaultAddress(glamStateAddress)).toBe(vaultAddress) + expect(deriveVaultAddress(GLAM_STATE_ADDRESS)).toBe(GLAM_VAULT_ADDRESS) + expect(deriveVaultAddress()).toBe(GLAM_VAULT_ADDRESS) }) }) describe('deriveSlxTokenAccountAddress', () => { it('should derive the expected GLAM SLX base-asset ATA', () => { - expect(deriveSlxTokenAccountAddress(vaultAddress)).toBe(slxTokenAccountAddress) + expect(deriveSlxTokenAccountAddress(GLAM_VAULT_ADDRESS)).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) + expect(deriveSlxTokenAccountAddress()).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) }) }) @@ -265,7 +263,7 @@ describe('StslxExchangeRateTransport', () => { }) describe('_handleRequest', () => { - it('should read the SLX base-asset ATA and return the normalized exchange rate', async () => { + it('should read all accounts atomically and return the normalized exchange rate', async () => { mockValidAccountData() const response = await transport._handleRequest(param) @@ -287,9 +285,10 @@ describe('StslxExchangeRateTransport', () => { providerIndicatedTimeUnixMs: undefined, }, }) - expect(getAccountInfoRequestMock).toBeCalledWith(slxTokenAccountAddress, { - encoding: 'base64', - }) + expect(getMultipleAccountsRequestMock).toBeCalledWith( + [SLX_MINT_ADDRESS, STSLX_MINT_ADDRESS, SLX_TOKEN_ACCOUNT_ADDRESS], + { encoding: 'base64' }, + ) }) it('should clamp the exchange rate to minRate', async () => { @@ -301,13 +300,10 @@ describe('StslxExchangeRateTransport', () => { minRate: minClampedRate, }) - if (!response.data) { - throw new Error('Expected response data') - } expect(response.result).toBe(minClampedRate) - expect(response.data.result).toBe(minClampedRate) - expect(response.data.computedResult).toBe(expectedRate) - expect(response.data.boundsApplied).toBe(true) + expect(response.data?.result).toBe(minClampedRate) + expect(response.data?.computedResult).toBe(expectedRate) + expect(response.data?.boundsApplied).toBe(true) }) it('should clamp the exchange rate to maxRate', async () => { @@ -319,13 +315,10 @@ describe('StslxExchangeRateTransport', () => { maxRate: maxClampedRate, }) - if (!response.data) { - throw new Error('Expected response data') - } expect(response.result).toBe(maxClampedRate) - expect(response.data.result).toBe(maxClampedRate) - expect(response.data.computedResult).toBe(expectedRate) - expect(response.data.boundsApplied).toBe(true) + expect(response.data?.result).toBe(maxClampedRate) + expect(response.data?.computedResult).toBe(expectedRate) + expect(response.data?.boundsApplied).toBe(true) }) it('should error when rate bounds are invalid', async () => { @@ -353,84 +346,71 @@ describe('StslxExchangeRateTransport', () => { }) it('should error when the stSLX mint has zero supply', async () => { - mockValidAccountData() - getAccountInfoSendMock.mockImplementation((address: string) => { - if (address === slxMintAddress) { - return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) - } - if (address === stslxMintAddress) { - return makeAccountInfoResponse(encodeMint(0n, stslxMintDecimals), token2022ProgramAddress) - } - if (address === slxTokenAccountAddress) { - return makeAccountInfoResponse(encodeTokenAccount(slxBalance)) - } - throw new Error(`Unexpected getAccountInfo address: ${address}`) - }) + const accountsByAddress: Record> = { + [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), + [STSLX_MINT_ADDRESS]: makeAccountInfo( + encodeMint(0n, stslxMintDecimals), + token2022ProgramAddress, + ), + [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo(encodeTokenAccount(slxBalance)), + } + getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ + value: addresses.map((address) => accountsByAddress[address] ?? null), + })) await expect(transport._handleRequest(param)).rejects.toThrow('has zero supply') }) it('should error when the derived SLX base-asset ATA is missing', async () => { - mockValidAccountData() - getAccountInfoSendMock.mockImplementation((address: string) => { - if (address === slxMintAddress) { - return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) - } - if (address === stslxMintAddress) { - return makeAccountInfoResponse( - encodeMint(stslxSupply, stslxMintDecimals), - token2022ProgramAddress, - ) - } - if (address === slxTokenAccountAddress) { - return { value: null } - } - throw new Error(`Unexpected getAccountInfo address: ${address}`) - }) + const accountsByAddress: Record | null> = { + [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), + [STSLX_MINT_ADDRESS]: makeAccountInfo( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ), + [SLX_TOKEN_ACCOUNT_ADDRESS]: null, + } + getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ + value: addresses.map((address) => accountsByAddress[address] ?? null), + })) - await expect(transport._handleRequest(param)).rejects.toThrow('No account data found') + await expect(transport._handleRequest(param)).rejects.toThrow( + `Expected SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}' to be owned by the legacy SPL Token program`, + ) }) it('should error when the SLX base-asset ATA has the wrong mint', async () => { - mockValidAccountData() - getAccountInfoSendMock.mockImplementation((address: string) => { - if (address === slxMintAddress) { - return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) - } - if (address === stslxMintAddress) { - return makeAccountInfoResponse( - encodeMint(stslxSupply, stslxMintDecimals), - token2022ProgramAddress, - ) - } - if (address === slxTokenAccountAddress) { - return makeAccountInfoResponse(encodeTokenAccount(slxBalance, stslxMintAddress)) - } - throw new Error(`Unexpected getAccountInfo address: ${address}`) - }) + const accountsByAddress: Record> = { + [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), + [STSLX_MINT_ADDRESS]: makeAccountInfo( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ), + [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo( + encodeTokenAccount(slxBalance, STSLX_MINT_ADDRESS), + ), + } + getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ + value: addresses.map((address) => accountsByAddress[address] ?? null), + })) await expect(transport._handleRequest(param)).rejects.toThrow('mint to be') }) it('should error when the SLX base-asset ATA has the wrong owner', async () => { - mockValidAccountData() - getAccountInfoSendMock.mockImplementation((address: string) => { - if (address === slxMintAddress) { - return makeAccountInfoResponse(encodeMint(100_000_000_000n, slxMintDecimals)) - } - if (address === stslxMintAddress) { - return makeAccountInfoResponse( - encodeMint(stslxSupply, stslxMintDecimals), - token2022ProgramAddress, - ) - } - if (address === slxTokenAccountAddress) { - return makeAccountInfoResponse( - encodeTokenAccount(slxBalance, slxMintAddress, PublicKey.default.toBase58()), - ) - } - throw new Error(`Unexpected getAccountInfo address: ${address}`) - }) + const accountsByAddress: Record> = { + [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), + [STSLX_MINT_ADDRESS]: makeAccountInfo( + encodeMint(stslxSupply, stslxMintDecimals), + token2022ProgramAddress, + ), + [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo( + encodeTokenAccount(slxBalance, SLX_MINT_ADDRESS, PublicKey.default.toBase58()), + ), + } + getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ + value: addresses.map((address) => accountsByAddress[address] ?? null), + })) await expect(transport._handleRequest(param)).rejects.toThrow('owner to be') }) From 42241a6f12bb644d2f0e297c0cf9f56fbd2c4444 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:06:25 +0200 Subject: [PATCH 12/22] test: simplify stSLX exchange rate coverage --- .../src/transport/stslx-exchange-rate.ts | 21 --- .../stslx-exchange-rate.test.ts.snap | 31 --- .../integration/stslx-exchange-rate.test.ts | 26 ++- .../test/unit/stslx-exchange-rate.test.ts | 178 +++--------------- 4 files changed, 45 insertions(+), 211 deletions(-) delete mode 100644 packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index e3c2b53d302..639b3ae20c2 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -4,8 +4,6 @@ import { SubscriptionTransport } from '@chainlink/external-adapter-framework/tra import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' -import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID } from '@solana/spl-token' -import { PublicKey } from '@solana/web3.js' import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' import { decodeMintInfo, @@ -28,8 +26,6 @@ import { SolanaRpcFactory } from '../shared/solana-rpc-factory' const logger = makeLogger('StslxExchangeRateTransport') -export const GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' -export const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' export const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' export const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' export const GLAM_VAULT_ADDRESS = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' @@ -37,23 +33,6 @@ export const SLX_TOKEN_ACCOUNT_ADDRESS = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6G type RequestParams = typeof inputParameters.validated -export const deriveVaultAddress = (glamStateAddress = GLAM_STATE_ADDRESS) => { - const [vaultAddress] = PublicKey.findProgramAddressSync( - [Buffer.from('vault'), new PublicKey(glamStateAddress).toBuffer()], - new PublicKey(GLAM_PROTOCOL_PROGRAM_ADDRESS), - ) - - return vaultAddress.toBase58() -} - -export const deriveSlxTokenAccountAddress = (vaultAddress = GLAM_VAULT_ADDRESS) => - getAssociatedTokenAddressSync( - new PublicKey(SLX_MINT_ADDRESS), - new PublicKey(vaultAddress), - true, - TOKEN_PROGRAM_ID, - ).toBase58() - export class StslxExchangeRateTransport extends SubscriptionTransport { rpc!: Rpc diff --git a/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap b/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap deleted file mode 100644 index 39bc34da897..00000000000 --- a/packages/sources/solana-functions/test/integration/__snapshots__/stslx-exchange-rate.test.ts.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`execute stslx-exchange-rate should reject requests missing required bounds 1`] = ` -{ - "error": { - "message": "[Param: minRate] param is required but no value was provided", - "name": "AdapterError", - }, - "status": "errored", - "statusCode": 400, -} -`; - -exports[`execute stslx-exchange-rate should return success 1`] = ` -{ - "data": { - "boundsApplied": false, - "computedResult": "1500000000000000000", - "decimals": 18, - "maxRate": "2000000000000000000", - "minRate": "1000000000000000000", - "result": "1500000000000000000", - }, - "result": "1500000000000000000", - "statusCode": 200, - "timestamps": { - "providerDataReceivedUnixMs": 978347471111, - "providerDataRequestedUnixMs": 978347471111, - }, -} -`; diff --git a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts index a299c2ab8b2..dd03abc8680 100644 --- a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -125,8 +125,23 @@ describe('execute', () => { maxRate, } const response = await testAdapter.request(data) - expect(response.json()).toMatchSnapshot() expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + data: { + boundsApplied: false, + computedResult: '1500000000000000000', + decimals: 18, + maxRate, + minRate, + result: '1500000000000000000', + }, + result: '1500000000000000000', + statusCode: 200, + timestamps: { + providerDataReceivedUnixMs: 978347471111, + providerDataRequestedUnixMs: 978347471111, + }, + }) }) it('should reject requests missing required bounds', async () => { @@ -136,7 +151,14 @@ describe('execute', () => { }) expect(response.statusCode).toBe(400) - expect(response.json()).toMatchSnapshot() + expect(response.json()).toEqual({ + error: { + message: '[Param: minRate] param is required but no value was provided', + name: 'AdapterError', + }, + status: 'errored', + statusCode: 400, + }) }) }) }) diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index 2f907708830..d3ee8a1bdba 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -1,6 +1,4 @@ -import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' -import { LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' import { AccountLayout, @@ -11,9 +9,6 @@ import { import { PublicKey } from '@solana/web3.js' import { BaseEndpointTypes } from '../../src/endpoint/stslx-exchange-rate' import { - deriveSlxTokenAccountAddress, - deriveVaultAddress, - GLAM_STATE_ADDRESS, GLAM_VAULT_ADDRESS, SLX_MINT_ADDRESS, SLX_TOKEN_ACCOUNT_ADDRESS, @@ -21,18 +16,6 @@ import { StslxExchangeRateTransport, } from '../../src/transport/stslx-exchange-rate' -const originalEnv = { ...process.env } - -const restoreEnv = () => { - for (const key of Object.keys(process.env)) { - if (key in originalEnv) { - process.env[key] = originalEnv[key] - } else { - delete process.env[key] - } - } -} - const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() const token2022ProgramAddress = TOKEN_2022_PROGRAM_ID.toBase58() @@ -110,55 +93,28 @@ jest.mock('@solana/rpc', () => ({ }, })) -const log = jest.fn() -const logger = { - fatal: log, - error: log, - warn: log, - info: log, - debug: log, - trace: log, - msgPrefix: 'mock-logger', -} - -const loggerFactory = { child: () => logger } - -LoggerFactoryProvider.set(loggerFactory) - describe('StslxExchangeRateTransport', () => { const transportName = 'default_single_transport' const endpointName = 'stslx-exchange-rate' const RPC_URL = 'https://solana.rpc.url' - const BACKGROUND_EXECUTE_MS = 1500 const slxBalance = 1_500_000_000n const stslxSupply = 1_000_000n const slxMintDecimals = 9 const stslxMintDecimals = 6 const minRate = '1000000000000000000' const maxRate = '2000000000000000000' - const expectedRate = ( - (slxBalance * 10n ** BigInt(18 + stslxMintDecimals)) / - (stslxSupply * 10n ** BigInt(slxMintDecimals)) - ).toString() + const expectedRate = '1500000000000000000' const adapterSettings = makeStub('adapterSettings', { RPC_URL, SOLANA_COMMITMENT: 'finalized', WARMUP_SUBSCRIPTION_TTL: 10_000, - BACKGROUND_EXECUTE_MS, + BACKGROUND_EXECUTE_MS: 1500, MAX_COMMON_KEY_SIZE: 300, } as unknown as BaseEndpointTypes['Settings']) - const context = makeStub('context', { - adapterSettings, - } as EndpointContext) - - const responseCache = { - write: jest.fn(), - } - const dependencies = makeStub('dependencies', { - responseCache, + responseCache: { write: jest.fn() }, subscriptionSetFactory: { buildSet: jest.fn(), }, @@ -172,14 +128,17 @@ describe('StslxExchangeRateTransport', () => { let transport: StslxExchangeRateTransport - const mockValidAccountData = () => { - const accountsByAddress: Record> = { + type AccountInfo = ReturnType | null + + const mockAccountData = (overrides: Record = {}) => { + const accountsByAddress: Record = { [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), [STSLX_MINT_ADDRESS]: makeAccountInfo( encodeMint(stslxSupply, stslxMintDecimals), token2022ProgramAddress, ), [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo(encodeTokenAccount(slxBalance)), + ...overrides, } getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ @@ -188,83 +147,17 @@ describe('StslxExchangeRateTransport', () => { } beforeEach(async () => { - restoreEnv() jest.resetAllMocks() mockRpcRequests() - jest.useFakeTimers() transport = new StslxExchangeRateTransport() await transport.initialize(dependencies, adapterSettings, endpointName, transportName) }) - afterEach(() => { - expect(log).not.toBeCalled() - }) - - describe('deriveVaultAddress', () => { - it('should derive the expected GLAM vault PDA', () => { - expect(deriveVaultAddress(GLAM_STATE_ADDRESS)).toBe(GLAM_VAULT_ADDRESS) - expect(deriveVaultAddress()).toBe(GLAM_VAULT_ADDRESS) - }) - }) - - describe('deriveSlxTokenAccountAddress', () => { - it('should derive the expected GLAM SLX base-asset ATA', () => { - expect(deriveSlxTokenAccountAddress(GLAM_VAULT_ADDRESS)).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) - expect(deriveSlxTokenAccountAddress()).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) - }) - }) - - describe('backgroundHandler', () => { - it('should sleep after handleRequest', async () => { - const t0 = Date.now() - let t1 = 0 - transport.backgroundHandler(context, []).then(() => { - t1 = Date.now() - }) - await jest.runAllTimersAsync() - expect(t1 - t0).toBe(BACKGROUND_EXECUTE_MS) - }) - }) - - describe('handleRequest', () => { - it('should cache exchange rate response', async () => { - mockValidAccountData() - - await transport.handleRequest(param) - - const expectedResponse = { - statusCode: 200, - result: expectedRate, - data: { - result: expectedRate, - computedResult: expectedRate, - decimals: 18, - minRate, - maxRate, - boundsApplied: false, - }, - timestamps: { - providerDataRequestedUnixMs: Date.now(), - providerDataReceivedUnixMs: Date.now(), - providerIndicatedTimeUnixMs: undefined, - }, - } - - expect(responseCache.write).toBeCalledWith(transportName, [ - { - params: param, - response: expectedResponse, - }, - ]) - expect(responseCache.write).toBeCalledTimes(1) - }) - }) - describe('_handleRequest', () => { it('should read all accounts atomically and return the normalized exchange rate', async () => { - mockValidAccountData() + mockAccountData() const response = await transport._handleRequest(param) @@ -280,8 +173,8 @@ describe('StslxExchangeRateTransport', () => { boundsApplied: false, }, timestamps: { - providerDataRequestedUnixMs: Date.now(), - providerDataReceivedUnixMs: Date.now(), + providerDataRequestedUnixMs: expect.any(Number), + providerDataReceivedUnixMs: expect.any(Number), providerIndicatedTimeUnixMs: undefined, }, }) @@ -292,7 +185,7 @@ describe('StslxExchangeRateTransport', () => { }) it('should clamp the exchange rate to minRate', async () => { - mockValidAccountData() + mockAccountData() const minClampedRate = (BigInt(expectedRate) + 1n).toString() const response = await transport._handleRequest({ @@ -307,7 +200,7 @@ describe('StslxExchangeRateTransport', () => { }) it('should clamp the exchange rate to maxRate', async () => { - mockValidAccountData() + mockAccountData() const maxClampedRate = (BigInt(expectedRate) - 1n).toString() const response = await transport._handleRequest({ @@ -346,33 +239,20 @@ describe('StslxExchangeRateTransport', () => { }) it('should error when the stSLX mint has zero supply', async () => { - const accountsByAddress: Record> = { - [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), + mockAccountData({ [STSLX_MINT_ADDRESS]: makeAccountInfo( encodeMint(0n, stslxMintDecimals), token2022ProgramAddress, ), - [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo(encodeTokenAccount(slxBalance)), - } - getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ - value: addresses.map((address) => accountsByAddress[address] ?? null), - })) + }) await expect(transport._handleRequest(param)).rejects.toThrow('has zero supply') }) it('should error when the derived SLX base-asset ATA is missing', async () => { - const accountsByAddress: Record | null> = { - [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), - [STSLX_MINT_ADDRESS]: makeAccountInfo( - encodeMint(stslxSupply, stslxMintDecimals), - token2022ProgramAddress, - ), + mockAccountData({ [SLX_TOKEN_ACCOUNT_ADDRESS]: null, - } - getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ - value: addresses.map((address) => accountsByAddress[address] ?? null), - })) + }) await expect(transport._handleRequest(param)).rejects.toThrow( `Expected SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}' to be owned by the legacy SPL Token program`, @@ -380,37 +260,21 @@ describe('StslxExchangeRateTransport', () => { }) it('should error when the SLX base-asset ATA has the wrong mint', async () => { - const accountsByAddress: Record> = { - [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), - [STSLX_MINT_ADDRESS]: makeAccountInfo( - encodeMint(stslxSupply, stslxMintDecimals), - token2022ProgramAddress, - ), + mockAccountData({ [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo( encodeTokenAccount(slxBalance, STSLX_MINT_ADDRESS), ), - } - getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ - value: addresses.map((address) => accountsByAddress[address] ?? null), - })) + }) await expect(transport._handleRequest(param)).rejects.toThrow('mint to be') }) it('should error when the SLX base-asset ATA has the wrong owner', async () => { - const accountsByAddress: Record> = { - [SLX_MINT_ADDRESS]: makeAccountInfo(encodeMint(100_000_000_000n, slxMintDecimals)), - [STSLX_MINT_ADDRESS]: makeAccountInfo( - encodeMint(stslxSupply, stslxMintDecimals), - token2022ProgramAddress, - ), + mockAccountData({ [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo( encodeTokenAccount(slxBalance, SLX_MINT_ADDRESS, PublicKey.default.toBase58()), ), - } - getMultipleAccountsSendMock.mockImplementation((addresses: string[]) => ({ - value: addresses.map((address) => accountsByAddress[address] ?? null), - })) + }) await expect(transport._handleRequest(param)).rejects.toThrow('owner to be') }) From 44c9088ee25ce9ef2b4ac48f17e0410732f48172 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:03:41 +0200 Subject: [PATCH 13/22] refactor: use shared stSLX Solana helpers --- .../src/transport/stslx-exchange-rate.ts | 44 +++++++++++++------ .../test/unit/stslx-exchange-rate.test.ts | 15 +++++++ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 639b3ae20c2..3a8ba8955fb 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -3,13 +3,14 @@ import { TransportDependencies } from '@chainlink/external-adapter-framework/tra import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { getAddressEncoder } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' import { + assertTokenProgramOwner, decodeMintInfo, decodeTokenAccountInfo, LEGACY_TOKEN_PROGRAM_ADDRESS, - TOKEN_PROGRAM_ADDRESSES, } from '../shared/buffer-layout-accounts' import { applyRateBounds, @@ -19,8 +20,10 @@ import { } from '../shared/exchange-rate-utils' import { assertOwnerProgram, + derivePda, fetchMultipleAccounts, getAccountDataBuffer, + parseSolanaAddress, } from '../shared/solana-account-utils' import { SolanaRpcFactory } from '../shared/solana-rpc-factory' @@ -28,11 +31,36 @@ const logger = makeLogger('StslxExchangeRateTransport') export const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' export const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +export const GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' +export const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' export const GLAM_VAULT_ADDRESS = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' export const SLX_TOKEN_ACCOUNT_ADDRESS = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' +export const ASSOCIATED_TOKEN_PROGRAM_ADDRESS = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' + +const GLAM_VAULT_SEED = 'vault' +const addressEncoder = getAddressEncoder() type RequestParams = typeof inputParameters.validated +export const deriveVaultAddress = async (glamStateAddress = GLAM_STATE_ADDRESS) => { + const vaultAddress = await derivePda(GLAM_PROTOCOL_PROGRAM_ADDRESS, [ + GLAM_VAULT_SEED, + addressEncoder.encode(parseSolanaAddress(glamStateAddress, 'glamStateAddress')), + ]) + + return vaultAddress.toString() +} + +export const deriveSlxTokenAccountAddress = async (vaultAddress = GLAM_VAULT_ADDRESS) => { + const tokenAccountAddress = await derivePda(ASSOCIATED_TOKEN_PROGRAM_ADDRESS, [ + addressEncoder.encode(parseSolanaAddress(vaultAddress, 'vaultAddress')), + addressEncoder.encode(parseSolanaAddress(LEGACY_TOKEN_PROGRAM_ADDRESS, 'tokenProgramAddress')), + addressEncoder.encode(parseSolanaAddress(SLX_MINT_ADDRESS, 'slxMintAddress')), + ]) + + return tokenAccountAddress.toString() +} + export class StslxExchangeRateTransport extends SubscriptionTransport { rpc!: Rpc @@ -84,18 +112,8 @@ export class StslxExchangeRateTransport extends SubscriptionTransport { await transport.initialize(dependencies, adapterSettings, endpointName, transportName) }) + describe('account derivation', () => { + it('should derive the expected GLAM vault PDA', async () => { + expect(await deriveVaultAddress(GLAM_STATE_ADDRESS)).toBe(GLAM_VAULT_ADDRESS) + expect(await deriveVaultAddress()).toBe(GLAM_VAULT_ADDRESS) + }) + + it('should derive the expected GLAM SLX base-asset ATA', async () => { + expect(await deriveSlxTokenAccountAddress(GLAM_VAULT_ADDRESS)).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) + expect(await deriveSlxTokenAccountAddress()).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) + }) + }) + describe('_handleRequest', () => { it('should read all accounts atomically and return the normalized exchange rate', async () => { mockAccountData() From 1d622334133f0f0ff825da633623aac94312fc31 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:16:56 +0200 Subject: [PATCH 14/22] chore: preserve solana-functions framework bump --- 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 814780831e6..79b99b8d23c 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 17d90545ba8..5dce8dd6b67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4591,7 +4591,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 1bddedfcd35407dfcb6c8861a15465359f96f8a7 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:34:13 +0200 Subject: [PATCH 15/22] refactor: remove unused stSLX derivation helpers --- .../src/transport/stslx-exchange-rate.ts | 28 ---------------- .../test/unit/stslx-exchange-rate.test.ts | 33 +++++++++++++------ 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 3a8ba8955fb..9bf43d71a48 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -3,7 +3,6 @@ import { TransportDependencies } from '@chainlink/external-adapter-framework/tra import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' -import { getAddressEncoder } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' import { @@ -20,10 +19,8 @@ import { } from '../shared/exchange-rate-utils' import { assertOwnerProgram, - derivePda, fetchMultipleAccounts, getAccountDataBuffer, - parseSolanaAddress, } from '../shared/solana-account-utils' import { SolanaRpcFactory } from '../shared/solana-rpc-factory' @@ -31,36 +28,11 @@ const logger = makeLogger('StslxExchangeRateTransport') export const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' export const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' -export const GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' -export const GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' export const GLAM_VAULT_ADDRESS = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' export const SLX_TOKEN_ACCOUNT_ADDRESS = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' -export const ASSOCIATED_TOKEN_PROGRAM_ADDRESS = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' - -const GLAM_VAULT_SEED = 'vault' -const addressEncoder = getAddressEncoder() type RequestParams = typeof inputParameters.validated -export const deriveVaultAddress = async (glamStateAddress = GLAM_STATE_ADDRESS) => { - const vaultAddress = await derivePda(GLAM_PROTOCOL_PROGRAM_ADDRESS, [ - GLAM_VAULT_SEED, - addressEncoder.encode(parseSolanaAddress(glamStateAddress, 'glamStateAddress')), - ]) - - return vaultAddress.toString() -} - -export const deriveSlxTokenAccountAddress = async (vaultAddress = GLAM_VAULT_ADDRESS) => { - const tokenAccountAddress = await derivePda(ASSOCIATED_TOKEN_PROGRAM_ADDRESS, [ - addressEncoder.encode(parseSolanaAddress(vaultAddress, 'vaultAddress')), - addressEncoder.encode(parseSolanaAddress(LEGACY_TOKEN_PROGRAM_ADDRESS, 'tokenProgramAddress')), - addressEncoder.encode(parseSolanaAddress(SLX_MINT_ADDRESS, 'slxMintAddress')), - ]) - - return tokenAccountAddress.toString() -} - export class StslxExchangeRateTransport extends SubscriptionTransport { rpc!: Rpc diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index 89a875da5b4..580a659aafb 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -2,6 +2,7 @@ import { TransportDependencies } from '@chainlink/external-adapter-framework/tra import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' import { AccountLayout, + ASSOCIATED_TOKEN_PROGRAM_ID, MintLayout, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, @@ -9,9 +10,6 @@ import { import { PublicKey } from '@solana/web3.js' import { BaseEndpointTypes } from '../../src/endpoint/stslx-exchange-rate' import { - deriveSlxTokenAccountAddress, - deriveVaultAddress, - GLAM_STATE_ADDRESS, GLAM_VAULT_ADDRESS, SLX_MINT_ADDRESS, SLX_TOKEN_ACCOUNT_ADDRESS, @@ -21,6 +19,8 @@ import { const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() const token2022ProgramAddress = TOKEN_2022_PROGRAM_ID.toBase58() +const glamStateAddress = new PublicKey('5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB') +const glamProtocolProgramAddress = new PublicKey('GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz') const encodeMint = (supply: bigint, decimals: number) => { const buffer = Buffer.alloc(MintLayout.span) @@ -159,14 +159,27 @@ describe('StslxExchangeRateTransport', () => { }) describe('account derivation', () => { - it('should derive the expected GLAM vault PDA', async () => { - expect(await deriveVaultAddress(GLAM_STATE_ADDRESS)).toBe(GLAM_VAULT_ADDRESS) - expect(await deriveVaultAddress()).toBe(GLAM_VAULT_ADDRESS) - }) + it('should match the canonical GLAM vault and SLX token account addresses', () => { + const [vaultAddress] = PublicKey.findProgramAddressSync( + [Buffer.from('vault'), glamStateAddress.toBuffer()], + glamProtocolProgramAddress, + ) + const [slxTokenAccountAddress] = PublicKey.findProgramAddressSync( + [ + vaultAddress.toBuffer(), + TOKEN_PROGRAM_ID.toBuffer(), + new PublicKey(SLX_MINT_ADDRESS).toBuffer(), + ], + ASSOCIATED_TOKEN_PROGRAM_ID, + ) - it('should derive the expected GLAM SLX base-asset ATA', async () => { - expect(await deriveSlxTokenAccountAddress(GLAM_VAULT_ADDRESS)).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) - expect(await deriveSlxTokenAccountAddress()).toBe(SLX_TOKEN_ACCOUNT_ADDRESS) + expect({ + vaultAddress: vaultAddress.toBase58(), + slxTokenAccountAddress: slxTokenAccountAddress.toBase58(), + }).toEqual({ + vaultAddress: GLAM_VAULT_ADDRESS, + slxTokenAccountAddress: SLX_TOKEN_ACCOUNT_ADDRESS, + }) }) }) From b3ad68e7e5174cdad77f53962c4ec9b297a329ba Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:50:19 +0200 Subject: [PATCH 16/22] fix: derive stSLX token account addresses --- packages/sources/solana-functions/README.md | 12 +- .../src/endpoint/stslx-exchange-rate.ts | 35 ++++- .../src/transport/stslx-exchange-rate.ts | 128 ++++++++++++------ .../integration/stslx-exchange-rate.test.ts | 2 - .../test/unit/stslx-exchange-rate.test.ts | 72 ++-------- 5 files changed, 143 insertions(+), 106 deletions(-) diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index fd67294c245..1fdd432ea23 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -245,10 +245,14 @@ Request: ### Input Params -| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | -| :-------: | :-----: | :-----: | :--------------------------------------------------------------------------: | :----: | :-----: | :-----: | :--------: | :------------: | -| ✅ | minRate | | Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | -| ✅ | maxRate | | Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | +| Required? | Name | Aliases | Description | Type | Options | Default | Depends On | Not Valid With | +| :-------: | :------------------------: | :-----: | :--------------------------------------------------------------------------: | :----: | :-----: | :--------------------------------------------: | :--------: | :------------: | +| | slxMintAddress | | SLX mint address | string | | `SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq` | | | +| | stslxMintAddress | | stSLX mint address | string | | `GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq` | | | +| | glamStateAddress | | GLAM state address used to derive the vault PDA | string | | `5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB` | | | +| | glamProtocolProgramAddress | | GLAM protocol program address used to derive the vault PDA | string | | `GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz` | | | +| ✅ | minRate | | Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | +| ✅ | maxRate | | Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | ### Example diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 8953259ed2b..8f0a01cacf1 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -3,8 +3,37 @@ import { InputParameters } from '@chainlink/external-adapter-framework/validatio import { config } from '../config' import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' +export const DEFAULT_SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' +export const DEFAULT_STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' +export const DEFAULT_GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' +export const DEFAULT_GLAM_PROTOCOL_PROGRAM_ADDRESS = 'GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz' + export const inputParameters = new InputParameters( { + slxMintAddress: { + description: 'SLX mint address', + type: 'string', + required: false, + default: DEFAULT_SLX_MINT_ADDRESS, + }, + stslxMintAddress: { + description: 'stSLX mint address', + type: 'string', + required: false, + default: DEFAULT_STSLX_MINT_ADDRESS, + }, + glamStateAddress: { + description: 'GLAM state address used to derive the vault PDA', + type: 'string', + required: false, + default: DEFAULT_GLAM_STATE_ADDRESS, + }, + glamProtocolProgramAddress: { + description: 'GLAM protocol program address used to derive the vault PDA', + type: 'string', + required: false, + default: DEFAULT_GLAM_PROTOCOL_PROGRAM_ADDRESS, + }, minRate: { description: 'Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer', type: 'string', @@ -18,6 +47,10 @@ export const inputParameters = new InputParameters( }, [ { + slxMintAddress: DEFAULT_SLX_MINT_ADDRESS, + stslxMintAddress: DEFAULT_STSLX_MINT_ADDRESS, + glamStateAddress: DEFAULT_GLAM_STATE_ADDRESS, + glamProtocolProgramAddress: DEFAULT_GLAM_PROTOCOL_PROGRAM_ADDRESS, minRate: '950000000000000000', maxRate: '1050000000000000000', }, @@ -32,8 +65,6 @@ export type BaseEndpointTypes = { result: string computedResult: string decimals: number - minRate: string - maxRate: string boundsApplied: boolean } } diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 9bf43d71a48..817385fdc8b 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -2,7 +2,11 @@ import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' -import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { + AdapterDataProviderError, + AdapterInputError, +} from '@chainlink/external-adapter-framework/validation/error' +import { address, getAddressEncoder } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' import { @@ -19,20 +23,55 @@ import { } from '../shared/exchange-rate-utils' import { assertOwnerProgram, + derivePda, fetchMultipleAccounts, getAccountDataBuffer, + parseSolanaAddress, } from '../shared/solana-account-utils' import { SolanaRpcFactory } from '../shared/solana-rpc-factory' const logger = makeLogger('StslxExchangeRateTransport') -export const SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' -export const STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' -export const GLAM_VAULT_ADDRESS = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' -export const SLX_TOKEN_ACCOUNT_ADDRESS = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' +const ASSOCIATED_TOKEN_PROGRAM_ADDRESS = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' +const GLAM_VAULT_SEED = 'vault' +const addressEncoder = getAddressEncoder() type RequestParams = typeof inputParameters.validated +const providerError = (message: string) => + new AdapterDataProviderError( + { + message, + statusCode: 502, + }, + { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + ) + +const asProviderError = (callback: () => T) => { + try { + return callback() + } catch (e: unknown) { + throw providerError(e instanceof Error ? e.message : 'Unknown provider error') + } +} + +const deriveVaultAddress = (glamStateAddress: string, glamProtocolProgramAddress: string) => + derivePda(glamProtocolProgramAddress, [ + GLAM_VAULT_SEED, + addressEncoder.encode(parseSolanaAddress(glamStateAddress, 'glamStateAddress')), + ]) + +const deriveSlxTokenAccountAddress = (vaultAddress: string, slxMintAddress: string) => + derivePda(ASSOCIATED_TOKEN_PROGRAM_ADDRESS, [ + addressEncoder.encode(parseSolanaAddress(vaultAddress, 'vaultAddress')), + addressEncoder.encode(address(LEGACY_TOKEN_PROGRAM_ADDRESS)), + addressEncoder.encode(parseSolanaAddress(slxMintAddress, 'slxMintAddress')), + ]) + export class StslxExchangeRateTransport extends SubscriptionTransport { rpc!: Rpc @@ -76,49 +115,61 @@ export class StslxExchangeRateTransport extends SubscriptionTransport> { const providerDataRequestedUnixMs = Date.now() + const slxMintAddress = parseSolanaAddress(params.slxMintAddress, 'slxMintAddress').toString() + const stslxMintAddress = parseSolanaAddress( + params.stslxMintAddress, + 'stslxMintAddress', + ).toString() + const glamStateAddress = parseSolanaAddress( + params.glamStateAddress, + 'glamStateAddress', + ).toString() + const glamProtocolProgramAddress = parseSolanaAddress( + params.glamProtocolProgramAddress, + 'glamProtocolProgramAddress', + ).toString() const { minRate, maxRate } = parseRateBounds(params.minRate, params.maxRate) + const vaultAddress = await deriveVaultAddress(glamStateAddress, glamProtocolProgramAddress) + const slxTokenAccountAddress = await deriveSlxTokenAccountAddress(vaultAddress, slxMintAddress) // The stSLX feed reads GLAM vault's canonical SLX ATA as its SLX balance source. const [slxMintAccount, stslxMintAccount, slxTokenAccount] = await fetchMultipleAccounts( this.rpc, - [SLX_MINT_ADDRESS, STSLX_MINT_ADDRESS, SLX_TOKEN_ACCOUNT_ADDRESS], + [slxMintAddress, stslxMintAddress, slxTokenAccountAddress], ) - assertTokenProgramOwner(slxMintAccount, `SLX mint '${SLX_MINT_ADDRESS}'`) - assertTokenProgramOwner(stslxMintAccount, `stSLX mint '${STSLX_MINT_ADDRESS}'`) - assertOwnerProgram( - slxTokenAccount, - `SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}'`, - [LEGACY_TOKEN_PROGRAM_ADDRESS], - 'the legacy SPL Token program', + asProviderError(() => assertTokenProgramOwner(slxMintAccount, `SLX mint '${slxMintAddress}'`)) + asProviderError(() => + assertTokenProgramOwner(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`), + ) + asProviderError(() => + assertOwnerProgram( + slxTokenAccount, + `SLX token account '${slxTokenAccountAddress}'`, + [LEGACY_TOKEN_PROGRAM_ADDRESS], + 'the legacy SPL Token program', + ), ) - const slxMint = decodeMintInfo( - getAccountDataBuffer(slxMintAccount, `SLX mint '${SLX_MINT_ADDRESS}'`), - `SLX mint '${SLX_MINT_ADDRESS}'`, + const slxMint = asProviderError(() => + decodeMintInfo( + getAccountDataBuffer(slxMintAccount, `SLX mint '${slxMintAddress}'`), + `SLX mint '${slxMintAddress}'`, + ), ) - const stslxMint = decodeMintInfo( - getAccountDataBuffer(stslxMintAccount, `stSLX mint '${STSLX_MINT_ADDRESS}'`), - `stSLX mint '${STSLX_MINT_ADDRESS}'`, + const stslxMint = asProviderError(() => + decodeMintInfo( + getAccountDataBuffer(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`), + `stSLX mint '${stslxMintAddress}'`, + ), ) - const slxToken = decodeTokenAccountInfo( - getAccountDataBuffer(slxTokenAccount, `SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}'`), - `SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}'`, + const slxToken = asProviderError(() => + decodeTokenAccountInfo( + getAccountDataBuffer(slxTokenAccount, `SLX token account '${slxTokenAccountAddress}'`), + `SLX token account '${slxTokenAccountAddress}'`, + ), ) - if (slxToken.mintAddress !== SLX_MINT_ADDRESS) { - throw new AdapterInputError({ - message: `Expected SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}' mint to be '${SLX_MINT_ADDRESS}', found '${slxToken.mintAddress}'`, - statusCode: 500, - }) - } - if (slxToken.ownerAddress !== GLAM_VAULT_ADDRESS) { - throw new AdapterInputError({ - message: `Expected SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}' owner to be '${GLAM_VAULT_ADDRESS}', found '${slxToken.ownerAddress}'`, - statusCode: 500, - }) - } - const computedRate = calculateNormalizedRate( slxToken.amount, stslxMint.supply, @@ -126,10 +177,7 @@ export class StslxExchangeRateTransport extends SubscriptionTransport { boundsApplied: false, computedResult: '1500000000000000000', decimals: 18, - maxRate, - minRate, result: '1500000000000000000', }, result: '1500000000000000000', diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index 580a659aafb..a266f0b3d48 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -2,25 +2,24 @@ import { TransportDependencies } from '@chainlink/external-adapter-framework/tra import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' import { AccountLayout, - ASSOCIATED_TOKEN_PROGRAM_ID, MintLayout, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, } from '@solana/spl-token' import { PublicKey } from '@solana/web3.js' -import { BaseEndpointTypes } from '../../src/endpoint/stslx-exchange-rate' import { - GLAM_VAULT_ADDRESS, - SLX_MINT_ADDRESS, - SLX_TOKEN_ACCOUNT_ADDRESS, - STSLX_MINT_ADDRESS, - StslxExchangeRateTransport, -} from '../../src/transport/stslx-exchange-rate' + BaseEndpointTypes, + DEFAULT_GLAM_PROTOCOL_PROGRAM_ADDRESS as GLAM_PROTOCOL_PROGRAM_ADDRESS, + DEFAULT_GLAM_STATE_ADDRESS as GLAM_STATE_ADDRESS, + DEFAULT_SLX_MINT_ADDRESS as SLX_MINT_ADDRESS, + DEFAULT_STSLX_MINT_ADDRESS as STSLX_MINT_ADDRESS, +} from '../../src/endpoint/stslx-exchange-rate' +import { StslxExchangeRateTransport } from '../../src/transport/stslx-exchange-rate' const tokenProgramAddress = TOKEN_PROGRAM_ID.toBase58() const token2022ProgramAddress = TOKEN_2022_PROGRAM_ID.toBase58() -const glamStateAddress = new PublicKey('5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB') -const glamProtocolProgramAddress = new PublicKey('GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz') +const GLAM_VAULT_ADDRESS = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' +const SLX_TOKEN_ACCOUNT_ADDRESS = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' const encodeMint = (supply: bigint, decimals: number) => { const buffer = Buffer.alloc(MintLayout.span) @@ -125,6 +124,10 @@ describe('StslxExchangeRateTransport', () => { const param = makeStub('param', { endpoint: 'stslx-exchange-rate', + slxMintAddress: SLX_MINT_ADDRESS, + stslxMintAddress: STSLX_MINT_ADDRESS, + glamStateAddress: GLAM_STATE_ADDRESS, + glamProtocolProgramAddress: GLAM_PROTOCOL_PROGRAM_ADDRESS, minRate, maxRate, }) @@ -158,31 +161,6 @@ describe('StslxExchangeRateTransport', () => { await transport.initialize(dependencies, adapterSettings, endpointName, transportName) }) - describe('account derivation', () => { - it('should match the canonical GLAM vault and SLX token account addresses', () => { - const [vaultAddress] = PublicKey.findProgramAddressSync( - [Buffer.from('vault'), glamStateAddress.toBuffer()], - glamProtocolProgramAddress, - ) - const [slxTokenAccountAddress] = PublicKey.findProgramAddressSync( - [ - vaultAddress.toBuffer(), - TOKEN_PROGRAM_ID.toBuffer(), - new PublicKey(SLX_MINT_ADDRESS).toBuffer(), - ], - ASSOCIATED_TOKEN_PROGRAM_ID, - ) - - expect({ - vaultAddress: vaultAddress.toBase58(), - slxTokenAccountAddress: slxTokenAccountAddress.toBase58(), - }).toEqual({ - vaultAddress: GLAM_VAULT_ADDRESS, - slxTokenAccountAddress: SLX_TOKEN_ACCOUNT_ADDRESS, - }) - }) - }) - describe('_handleRequest', () => { it('should read all accounts atomically and return the normalized exchange rate', async () => { mockAccountData() @@ -196,8 +174,6 @@ describe('StslxExchangeRateTransport', () => { result: expectedRate, computedResult: expectedRate, decimals: 18, - minRate, - maxRate, boundsApplied: false, }, timestamps: { @@ -224,6 +200,7 @@ describe('StslxExchangeRateTransport', () => { expect(response.result).toBe(minClampedRate) expect(response.data?.result).toBe(minClampedRate) expect(response.data?.computedResult).toBe(expectedRate) + expect(response.data).not.toHaveProperty('minRate') expect(response.data?.boundsApplied).toBe(true) }) @@ -239,6 +216,7 @@ describe('StslxExchangeRateTransport', () => { expect(response.result).toBe(maxClampedRate) expect(response.data?.result).toBe(maxClampedRate) expect(response.data?.computedResult).toBe(expectedRate) + expect(response.data).not.toHaveProperty('maxRate') expect(response.data?.boundsApplied).toBe(true) }) @@ -286,25 +264,5 @@ describe('StslxExchangeRateTransport', () => { `Expected SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}' to be owned by the legacy SPL Token program`, ) }) - - it('should error when the SLX base-asset ATA has the wrong mint', async () => { - mockAccountData({ - [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo( - encodeTokenAccount(slxBalance, STSLX_MINT_ADDRESS), - ), - }) - - await expect(transport._handleRequest(param)).rejects.toThrow('mint to be') - }) - - it('should error when the SLX base-asset ATA has the wrong owner', async () => { - mockAccountData({ - [SLX_TOKEN_ACCOUNT_ADDRESS]: makeAccountInfo( - encodeTokenAccount(slxBalance, SLX_MINT_ADDRESS, PublicKey.default.toBase58()), - ), - }) - - await expect(transport._handleRequest(param)).rejects.toThrow('owner to be') - }) }) }) From ad46925d6cc96a71fcb59c0aec355c061ae90a80 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:43:38 +0200 Subject: [PATCH 17/22] fix: address stSLX review followups --- .../src/endpoint/stslx-exchange-rate.ts | 7 +++ .../src/transport/stslx-exchange-rate.ts | 50 ++++++------------- .../integration/stslx-exchange-rate.test.ts | 26 +++++++++- .../test/unit/stslx-exchange-rate.test.ts | 2 + 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 8f0a01cacf1..1a9a93ca0a6 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -1,6 +1,7 @@ import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' import { InputParameters } from '@chainlink/external-adapter-framework/validation' import { config } from '../config' +import { parseRateBounds } from '../shared/exchange-rate-utils' import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' export const DEFAULT_SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' @@ -66,6 +67,8 @@ export type BaseEndpointTypes = { computedResult: string decimals: number boundsApplied: boolean + slxBalance: string + stslxSupply: string } } Settings: typeof config.settings @@ -76,4 +79,8 @@ export const endpoint = new AdapterEndpoint({ aliases: [], transport: stslxExchangeRateTransport, inputParameters, + customInputValidation: (req) => { + parseRateBounds(req.requestContext.data.minRate, req.requestContext.data.maxRate) + return undefined + }, }) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 817385fdc8b..43d3512852f 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -2,11 +2,8 @@ import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' -import { - AdapterDataProviderError, - AdapterInputError, -} from '@chainlink/external-adapter-framework/validation/error' -import { address, getAddressEncoder } from '@solana/addresses' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { address, getAddressEncoder, type Address } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' import { @@ -19,6 +16,7 @@ import { applyRateBounds, calculateNormalizedRate, parseRateBounds, + providerError, RESULT_DECIMALS, } from '../shared/exchange-rate-utils' import { @@ -38,19 +36,6 @@ const addressEncoder = getAddressEncoder() type RequestParams = typeof inputParameters.validated -const providerError = (message: string) => - new AdapterDataProviderError( - { - message, - statusCode: 502, - }, - { - providerDataRequestedUnixMs: 0, - providerDataReceivedUnixMs: 0, - providerIndicatedTimeUnixMs: undefined, - }, - ) - const asProviderError = (callback: () => T) => { try { return callback() @@ -59,17 +44,14 @@ const asProviderError = (callback: () => T) => { } } -const deriveVaultAddress = (glamStateAddress: string, glamProtocolProgramAddress: string) => - derivePda(glamProtocolProgramAddress, [ - GLAM_VAULT_SEED, - addressEncoder.encode(parseSolanaAddress(glamStateAddress, 'glamStateAddress')), - ]) +const deriveVaultAddress = (glamStateAddress: Address, glamProtocolProgramAddress: Address) => + derivePda(glamProtocolProgramAddress, [GLAM_VAULT_SEED, addressEncoder.encode(glamStateAddress)]) -const deriveSlxTokenAccountAddress = (vaultAddress: string, slxMintAddress: string) => +const deriveSlxTokenAccountAddress = (vaultAddress: Address, slxMintAddress: Address) => derivePda(ASSOCIATED_TOKEN_PROGRAM_ADDRESS, [ - addressEncoder.encode(parseSolanaAddress(vaultAddress, 'vaultAddress')), + addressEncoder.encode(vaultAddress), addressEncoder.encode(address(LEGACY_TOKEN_PROGRAM_ADDRESS)), - addressEncoder.encode(parseSolanaAddress(slxMintAddress, 'slxMintAddress')), + addressEncoder.encode(slxMintAddress), ]) export class StslxExchangeRateTransport extends SubscriptionTransport { @@ -115,19 +97,13 @@ export class StslxExchangeRateTransport extends SubscriptionTransport> { const providerDataRequestedUnixMs = Date.now() - const slxMintAddress = parseSolanaAddress(params.slxMintAddress, 'slxMintAddress').toString() - const stslxMintAddress = parseSolanaAddress( - params.stslxMintAddress, - 'stslxMintAddress', - ).toString() - const glamStateAddress = parseSolanaAddress( - params.glamStateAddress, - 'glamStateAddress', - ).toString() + const slxMintAddress = parseSolanaAddress(params.slxMintAddress, 'slxMintAddress') + const stslxMintAddress = parseSolanaAddress(params.stslxMintAddress, 'stslxMintAddress') + const glamStateAddress = parseSolanaAddress(params.glamStateAddress, 'glamStateAddress') const glamProtocolProgramAddress = parseSolanaAddress( params.glamProtocolProgramAddress, 'glamProtocolProgramAddress', - ).toString() + ) const { minRate, maxRate } = parseRateBounds(params.minRate, params.maxRate) const vaultAddress = await deriveVaultAddress(glamStateAddress, glamProtocolProgramAddress) const slxTokenAccountAddress = await deriveSlxTokenAccountAddress(vaultAddress, slxMintAddress) @@ -190,6 +166,8 @@ export class StslxExchangeRateTransport extends SubscriptionTransport> = { [slxMintAddress]: makeAccountInfoResponse(encodeMint(100_000_000_000n, 9)), [stslxMintAddress]: makeAccountInfoResponse( - encodeMint(1_000_000n, 6), + encodeMint(stslxSupply, 6), token2022ProgramAddress, ), - [slxTokenAccountAddress]: makeAccountInfoResponse(encodeTokenAccount(1_500_000_000n)), + [slxTokenAccountAddress]: makeAccountInfoResponse(encodeTokenAccount(slxBalance)), } return { @@ -132,6 +134,8 @@ describe('execute', () => { computedResult: '1500000000000000000', decimals: 18, result: '1500000000000000000', + slxBalance: slxBalance.toString(), + stslxSupply: stslxSupply.toString(), }, result: '1500000000000000000', statusCode: 200, @@ -158,5 +162,23 @@ describe('execute', () => { statusCode: 400, }) }) + + it('should reject inverted bounds', async () => { + const response = await testAdapter.request({ + endpoint: 'stslx-exchange-rate', + minRate: maxRate, + maxRate: minRate, + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + error: { + message: 'minRate must be less than or equal to maxRate', + name: 'AdapterError', + }, + status: 'errored', + statusCode: 400, + }) + }) }) }) diff --git a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts index a266f0b3d48..5fbb00a744e 100644 --- a/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -175,6 +175,8 @@ describe('StslxExchangeRateTransport', () => { computedResult: expectedRate, decimals: 18, boundsApplied: false, + slxBalance: slxBalance.toString(), + stslxSupply: stslxSupply.toString(), }, timestamps: { providerDataRequestedUnixMs: expect.any(Number), From f1aba31ea5bded7dbdaf885294bb8be63b6e1867 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:53:13 +0200 Subject: [PATCH 18/22] style: align stSLX validation return --- .../solana-functions/src/endpoint/stslx-exchange-rate.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 1a9a93ca0a6..37305377c26 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -1,5 +1,6 @@ import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { type AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' import { config } from '../config' import { parseRateBounds } from '../shared/exchange-rate-utils' import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' @@ -79,8 +80,9 @@ export const endpoint = new AdapterEndpoint({ aliases: [], transport: stslxExchangeRateTransport, inputParameters, - customInputValidation: (req) => { + customInputValidation: (req): AdapterInputError | undefined => { parseRateBounds(req.requestContext.data.minRate, req.requestContext.data.maxRate) - return undefined + + return }, }) From 1ff029a9747b474680ad180f26abb36db626a2d3 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:09:23 +0200 Subject: [PATCH 19/22] fix: make stSLX rate bounds optional --- packages/sources/solana-functions/README.md | 8 ++++++-- .../src/endpoint/stslx-exchange-rate.ts | 4 ++-- .../test/integration/stslx-exchange-rate.test.ts | 15 ++++----------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/sources/solana-functions/README.md b/packages/sources/solana-functions/README.md index 1fdd432ea23..c7e8669ddba 100644 --- a/packages/sources/solana-functions/README.md +++ b/packages/sources/solana-functions/README.md @@ -251,8 +251,8 @@ Request: | | stslxMintAddress | | stSLX mint address | string | | `GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq` | | | | | glamStateAddress | | GLAM state address used to derive the vault PDA | string | | `5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB` | | | | | glamProtocolProgramAddress | | GLAM protocol program address used to derive the vault PDA | string | | `GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz` | | | -| ✅ | minRate | | Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | -| ✅ | maxRate | | Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | +| | minRate | | Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | +| | maxRate | | Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer | string | | | | | ### Example @@ -262,6 +262,10 @@ Request: { "data": { "endpoint": "stslx-exchange-rate", + "slxMintAddress": "SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq", + "stslxMintAddress": "GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq", + "glamStateAddress": "5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB", + "glamProtocolProgramAddress": "GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz", "minRate": "950000000000000000", "maxRate": "1050000000000000000" } diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 37305377c26..8bf7befdbe2 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -39,12 +39,12 @@ export const inputParameters = new InputParameters( minRate: { description: 'Minimum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer', type: 'string', - required: true, + required: false, }, maxRate: { description: 'Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer', type: 'string', - required: true, + required: false, }, }, [ diff --git a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts index 7ac1c93944d..3beeb908d9d 100644 --- a/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -146,21 +146,14 @@ describe('execute', () => { }) }) - it('should reject requests missing required bounds', async () => { + it('should return success when bounds are omitted', async () => { const response = await testAdapter.request({ endpoint: 'stslx-exchange-rate', - maxRate, }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - error: { - message: '[Param: minRate] param is required but no value was provided', - name: 'AdapterError', - }, - status: 'errored', - statusCode: 400, - }) + expect(response.statusCode).toBe(200) + expect(response.json().data.boundsApplied).toBe(false) + expect(response.json().result).toBe('1500000000000000000') }) it('should reject inverted bounds', async () => { From 894d2348401df9721667b0d9981353c69d9d83f6 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:42:36 +0200 Subject: [PATCH 20/22] refactor: simplify stSLX provider errors --- .../src/transport/stslx-exchange-rate.ts | 52 ++++++------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 43d3512852f..f72e14ed4a6 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -16,7 +16,6 @@ import { applyRateBounds, calculateNormalizedRate, parseRateBounds, - providerError, RESULT_DECIMALS, } from '../shared/exchange-rate-utils' import { @@ -25,6 +24,7 @@ import { fetchMultipleAccounts, getAccountDataBuffer, parseSolanaAddress, + providerError, } from '../shared/solana-account-utils' import { SolanaRpcFactory } from '../shared/solana-rpc-factory' @@ -36,14 +36,6 @@ const addressEncoder = getAddressEncoder() type RequestParams = typeof inputParameters.validated -const asProviderError = (callback: () => T) => { - try { - return callback() - } catch (e: unknown) { - throw providerError(e instanceof Error ? e.message : 'Unknown provider error') - } -} - const deriveVaultAddress = (glamStateAddress: Address, glamProtocolProgramAddress: Address) => derivePda(glamProtocolProgramAddress, [GLAM_VAULT_SEED, addressEncoder.encode(glamStateAddress)]) @@ -114,36 +106,26 @@ export class StslxExchangeRateTransport extends SubscriptionTransport assertTokenProgramOwner(slxMintAccount, `SLX mint '${slxMintAddress}'`)) - asProviderError(() => - assertTokenProgramOwner(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`), - ) - asProviderError(() => - assertOwnerProgram( - slxTokenAccount, - `SLX token account '${slxTokenAccountAddress}'`, - [LEGACY_TOKEN_PROGRAM_ADDRESS], - 'the legacy SPL Token program', - ), + assertTokenProgramOwner(slxMintAccount, `SLX mint '${slxMintAddress}'`) + assertTokenProgramOwner(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`) + assertOwnerProgram( + slxTokenAccount, + `SLX token account '${slxTokenAccountAddress}'`, + [LEGACY_TOKEN_PROGRAM_ADDRESS], + 'the legacy SPL Token program', ) - const slxMint = asProviderError(() => - decodeMintInfo( - getAccountDataBuffer(slxMintAccount, `SLX mint '${slxMintAddress}'`), - `SLX mint '${slxMintAddress}'`, - ), + const slxMint = decodeMintInfo( + getAccountDataBuffer(slxMintAccount, `SLX mint '${slxMintAddress}'`), + `SLX mint '${slxMintAddress}'`, ) - const stslxMint = asProviderError(() => - decodeMintInfo( - getAccountDataBuffer(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`), - `stSLX mint '${stslxMintAddress}'`, - ), + const stslxMint = decodeMintInfo( + getAccountDataBuffer(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`), + `stSLX mint '${stslxMintAddress}'`, ) - const slxToken = asProviderError(() => - decodeTokenAccountInfo( - getAccountDataBuffer(slxTokenAccount, `SLX token account '${slxTokenAccountAddress}'`), - `SLX token account '${slxTokenAccountAddress}'`, - ), + const slxToken = decodeTokenAccountInfo( + getAccountDataBuffer(slxTokenAccount, `SLX token account '${slxTokenAccountAddress}'`), + `SLX token account '${slxTokenAccountAddress}'`, ) const computedRate = calculateNormalizedRate( From 8c6e243955e6d5fec04adbc31750ef876874a01f Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:28:38 +0200 Subject: [PATCH 21/22] docs: document stSLX default addresses --- .../solana-functions/src/endpoint/stslx-exchange-rate.ts | 9 +++++++++ .../src/transport/stslx-exchange-rate.ts | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts index 8bf7befdbe2..eb627af929f 100644 --- a/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -5,6 +5,15 @@ import { config } from '../config' import { parseRateBounds } from '../shared/exchange-rate-utils' import { stslxExchangeRateTransport } from '../transport/stslx-exchange-rate' +// Defaults come from Solstice's OPDATA-7578 production stSLX feed config. They +// are request/job-spec fallbacks; if Solstice migrates, override these params +// first and update defaults only when the production feed defaults change. The +// GLAM vault and SLX ATA are derived from these source addresses in transport. +// Explorer checks: +// - https://explorer.solana.com/address/SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq +// - https://explorer.solana.com/address/GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq +// - https://explorer.solana.com/address/5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB +// - https://explorer.solana.com/address/GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz export const DEFAULT_SLX_MINT_ADDRESS = 'SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq' export const DEFAULT_STSLX_MINT_ADDRESS = 'GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq' export const DEFAULT_GLAM_STATE_ADDRESS = '5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB' diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index f72e14ed4a6..8c528c358cc 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -5,6 +5,7 @@ import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter- import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' import { address, getAddressEncoder, type Address } from '@solana/addresses' import { type Rpc, type SolanaRpcApi } from '@solana/rpc' +import { ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token' import { BaseEndpointTypes, inputParameters } from '../endpoint/stslx-exchange-rate' import { assertTokenProgramOwner, @@ -30,7 +31,7 @@ import { SolanaRpcFactory } from '../shared/solana-rpc-factory' const logger = makeLogger('StslxExchangeRateTransport') -const ASSOCIATED_TOKEN_PROGRAM_ADDRESS = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' +const ASSOCIATED_TOKEN_PROGRAM_ADDRESS = ASSOCIATED_TOKEN_PROGRAM_ID.toBase58() const GLAM_VAULT_SEED = 'vault' const addressEncoder = getAddressEncoder() From 8f1f6bfef852714dbbbb87da81e63dbdf67f9e57 Mon Sep 17 00:00:00 2001 From: Stefano Magini <265085634+magiodev-cll@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:00:32 +0200 Subject: [PATCH 22/22] fix: warn when stSLX bounds apply --- .../src/transport/stslx-exchange-rate.ts | 11 ++++++++ .../test/unit/stslx-exchange-rate.test.ts | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts index 8c528c358cc..72180d4ceb5 100644 --- a/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -142,6 +142,17 @@ export class StslxExchangeRateTransport extends SubscriptionTransport ({ }, })) +const log = jest.fn() +const logger = { + fatal: log, + error: log, + warn: log, + info: log, + debug: log, + trace: log, + msgPrefix: 'mock-logger', +} + +const loggerFactory = { child: () => logger } + +LoggerFactoryProvider.set(loggerFactory) + describe('StslxExchangeRateTransport', () => { const transportName = 'default_single_transport' const endpointName = 'stslx-exchange-rate' @@ -204,6 +220,15 @@ describe('StslxExchangeRateTransport', () => { expect(response.data?.computedResult).toBe(expectedRate) expect(response.data).not.toHaveProperty('minRate') expect(response.data?.boundsApplied).toBe(true) + expect(log).toHaveBeenCalledWith( + { + computedResult: expectedRate, + result: minClampedRate, + minRate: minClampedRate, + maxRate, + }, + 'stSLX exchange rate bounds applied', + ) }) it('should clamp the exchange rate to maxRate', async () => {