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..c7e8669ddba 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,39 @@ Request: --- +## Stslx-exchange-rate Endpoint + +`stslx-exchange-rate` is the only supported name for this endpoint. + +### Input Params + +| 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 + +Request: + +```json +{ + "data": { + "endpoint": "stslx-exchange-rate", + "slxMintAddress": "SLXdx4BUt2v9uJQNzWqSfzTJ9UKLUDsvxHFMEEdrfgq", + "stslxMintAddress": "GxHksENo754dKj6kv5d2z7ey9KwE7YSRYgRCtoFYd2yq", + "glamStateAddress": "5E2scHi8LyZAqZeVHnXLeFhwoePxD2CTdSruWmjgVEoB", + "glamProtocolProgramAddress": "GLAMpaME8wdTEzxtiYEAa5yD8fZbxZiz2hNtV58RZiEz", + "minRate": "950000000000000000", + "maxRate": "1050000000000000000" + } +} +``` + +--- + 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..eb627af929f --- /dev/null +++ b/packages/sources/solana-functions/src/endpoint/stslx-exchange-rate.ts @@ -0,0 +1,97 @@ +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' + +// 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' +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', + required: false, + }, + maxRate: { + description: 'Maximum allowed stSLX-SLX exchange rate as an 18-decimal fixed-point integer', + type: 'string', + required: false, + }, + }, + [ + { + 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', + }, + ], +) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: { + Result: string + Data: { + result: string + computedResult: string + decimals: number + boundsApplied: boolean + slxBalance: string + stslxSupply: string + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'stslx-exchange-rate', + aliases: [], + transport: stslxExchangeRateTransport, + inputParameters, + customInputValidation: (req): AdapterInputError | undefined => { + parseRateBounds(req.requestContext.data.minRate, req.requestContext.data.maxRate) + + return + }, +}) 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..9ec3e0512b0 --- /dev/null +++ b/packages/sources/solana-functions/src/transport/stslx-exchange-rate.ts @@ -0,0 +1,174 @@ +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 { 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, + decodeMintInfo, + decodeTokenAccountInfo, + LEGACY_TOKEN_PROGRAM_ADDRESS, +} from '../shared/buffer-layout-accounts' +import { + applyRateBounds, + calculateNormalizedRate, + parseRateBounds, + RESULT_DECIMALS, +} from '../shared/exchange-rate-utils' +import { + derivePda, + fetchMultipleAccounts, + getAccountDataBuffer, + parseSolanaAddress, + providerError, +} from '../shared/solana-account-utils' +import { SolanaRpcFactory } from '../shared/solana-rpc-factory' + +const logger = makeLogger('StslxExchangeRateTransport') + +const ASSOCIATED_TOKEN_PROGRAM_ADDRESS = ASSOCIATED_TOKEN_PROGRAM_ID.toBase58() +const GLAM_VAULT_SEED = 'vault' +const addressEncoder = getAddressEncoder() + +type RequestParams = typeof inputParameters.validated + +const deriveVaultAddress = (glamStateAddress: Address, glamProtocolProgramAddress: Address) => + derivePda(glamProtocolProgramAddress, [GLAM_VAULT_SEED, addressEncoder.encode(glamStateAddress)]) + +const deriveSlxTokenAccountAddress = (vaultAddress: Address, slxMintAddress: Address) => + derivePda(ASSOCIATED_TOKEN_PROGRAM_ADDRESS, [ + addressEncoder.encode(vaultAddress), + addressEncoder.encode(address(LEGACY_TOKEN_PROGRAM_ADDRESS)), + addressEncoder.encode(slxMintAddress), + ]) + +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> { + const providerDataRequestedUnixMs = Date.now() + const slxMintAddress = parseSolanaAddress(params.slxMintAddress, 'slxMintAddress') + const stslxMintAddress = parseSolanaAddress(params.stslxMintAddress, 'stslxMintAddress') + const glamStateAddress = parseSolanaAddress(params.glamStateAddress, 'glamStateAddress') + const glamProtocolProgramAddress = parseSolanaAddress( + params.glamProtocolProgramAddress, + 'glamProtocolProgramAddress', + ) + 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, + [slxMintAddress, stslxMintAddress, slxTokenAccountAddress], + ) + + assertTokenProgramOwner(slxMintAccount, `SLX mint '${slxMintAddress}'`) + assertTokenProgramOwner(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`) + + const slxMint = decodeMintInfo( + getAccountDataBuffer(slxMintAccount, `SLX mint '${slxMintAddress}'`), + `SLX mint '${slxMintAddress}'`, + ) + const stslxMint = decodeMintInfo( + getAccountDataBuffer(stslxMintAccount, `stSLX mint '${stslxMintAddress}'`), + `stSLX mint '${stslxMintAddress}'`, + ) + const slxToken = decodeTokenAccountInfo( + getAccountDataBuffer(slxTokenAccount, `SLX token account '${slxTokenAccountAddress}'`), + `SLX token account '${slxTokenAccountAddress}'`, + ) + + const computedRate = calculateNormalizedRate( + slxToken.amount, + stslxMint.supply, + slxMint.decimals, + stslxMint.decimals, + ) + if (computedRate === null) { + throw providerError(`stSLX mint '${stslxMintAddress}' has zero supply`) + } + + const { rate, boundsApplied } = applyRateBounds(computedRate, minRate, maxRate) + const result = rate.toString() + const computedResult = computedRate.toString() + if (boundsApplied) { + logger.warn( + { + computedResult, + result, + minRate: minRate?.toString(), + maxRate: maxRate?.toString(), + }, + 'stSLX exchange rate bounds applied', + ) + } + + return { + data: { + result, + computedResult, + decimals: RESULT_DECIMALS, + boundsApplied, + slxBalance: slxToken.amount.toString(), + stslxSupply: stslxMint.supply.toString(), + }, + statusCode: 200, + result, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const stslxExchangeRateTransport = new StslxExchangeRateTransport() 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..3beeb908d9d --- /dev/null +++ b/packages/sources/solana-functions/test/integration/stslx-exchange-rate.test.ts @@ -0,0 +1,177 @@ +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 minRate = '1000000000000000000' +const maxRate = '2000000000000000000' +const slxBalance = 1_500_000_000n +const stslxSupply = 1_000_000n +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) => ({ + data: [data, 'base64'], + owner, +}) + +const solanaRpc = makeStub('solanaRpc', { + getMultipleAccounts: (addresses: string[]) => ({ + async send() { + const accountsByAddress: Record> = { + [slxMintAddress]: makeAccountInfoResponse(encodeMint(100_000_000_000n, 9)), + [stslxMintAddress]: makeAccountInfoResponse( + encodeMint(stslxSupply, 6), + token2022ProgramAddress, + ), + [slxTokenAccountAddress]: makeAccountInfoResponse(encodeTokenAccount(slxBalance)), + } + + return { + value: addresses.map((address) => accountsByAddress[address] ?? null), + } + }, + }), +}) + +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', + minRate, + maxRate, + } + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + data: { + boundsApplied: false, + computedResult: '1500000000000000000', + decimals: 18, + result: '1500000000000000000', + slxBalance: slxBalance.toString(), + stslxSupply: stslxSupply.toString(), + }, + result: '1500000000000000000', + statusCode: 200, + timestamps: { + providerDataReceivedUnixMs: 978347471111, + providerDataRequestedUnixMs: 978347471111, + }, + }) + }) + + it('should return success when bounds are omitted', async () => { + const response = await testAdapter.request({ + endpoint: 'stslx-exchange-rate', + }) + + expect(response.statusCode).toBe(200) + expect(response.json().data.boundsApplied).toBe(false) + expect(response.json().result).toBe('1500000000000000000') + }) + + 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 new file mode 100644 index 00000000000..a1d7017d7af --- /dev/null +++ b/packages/sources/solana-functions/test/unit/stslx-exchange-rate.test.ts @@ -0,0 +1,295 @@ +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, + 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 GLAM_VAULT_ADDRESS = 'GMwdh2jTdTrrhA7dMR7Cc2zC6gV38UePzAXeoFHrXnfH' +const SLX_TOKEN_ACCOUNT_ADDRESS = '7CssRFNePpnDiCzjRC5kPRDpEJn87JMeDG7s6Gww9CTf' + +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 = SLX_MINT_ADDRESS, + ownerAddress = GLAM_VAULT_ADDRESS, +) => { + 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 makeAccountInfo = (data: string, owner = tokenProgramAddress) => ({ + data: [data, 'base64'], + owner, +}) + +const getMultipleAccountsSendMock = jest.fn() +const getMultipleAccountsRequestMock = jest.fn() + +const mockRpcRequests = () => { + getMultipleAccountsRequestMock.mockImplementation( + (addresses: string[], config: { encoding: string }) => ({ + send() { + return getMultipleAccountsSendMock(addresses, config) + }, + }), + ) +} + +const solanaRpc = makeStub('solanaRpc', { + getMultipleAccounts: getMultipleAccountsRequestMock, +}) + +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 slxBalance = 1_500_000_000n + const stslxSupply = 1_000_000n + const slxMintDecimals = 9 + const stslxMintDecimals = 6 + const minRate = '1000000000000000000' + const maxRate = '2000000000000000000' + const expectedRate = '1500000000000000000' + + const adapterSettings = makeStub('adapterSettings', { + RPC_URL, + SOLANA_COMMITMENT: 'finalized', + WARMUP_SUBSCRIPTION_TTL: 10_000, + BACKGROUND_EXECUTE_MS: 1500, + MAX_COMMON_KEY_SIZE: 300, + } as unknown as BaseEndpointTypes['Settings']) + + const dependencies = makeStub('dependencies', { + responseCache: { write: jest.fn() }, + subscriptionSetFactory: { + buildSet: jest.fn(), + }, + } as unknown as TransportDependencies) + + 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, + }) + + let transport: StslxExchangeRateTransport + + 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[]) => ({ + value: addresses.map((address) => accountsByAddress[address] ?? null), + })) + } + + beforeEach(async () => { + jest.resetAllMocks() + mockRpcRequests() + + transport = new StslxExchangeRateTransport() + + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + }) + + describe('_handleRequest', () => { + it('should read all accounts atomically and return the normalized exchange rate', async () => { + mockAccountData() + + const response = await transport._handleRequest(param) + + expect(response).toEqual({ + statusCode: 200, + result: expectedRate, + data: { + result: expectedRate, + computedResult: expectedRate, + decimals: 18, + boundsApplied: false, + slxBalance: slxBalance.toString(), + stslxSupply: stslxSupply.toString(), + }, + timestamps: { + providerDataRequestedUnixMs: expect.any(Number), + providerDataReceivedUnixMs: expect.any(Number), + providerIndicatedTimeUnixMs: undefined, + }, + }) + expect(getMultipleAccountsRequestMock).toBeCalledWith( + [SLX_MINT_ADDRESS, STSLX_MINT_ADDRESS, SLX_TOKEN_ACCOUNT_ADDRESS], + { encoding: 'base64' }, + ) + }) + + it('should clamp the exchange rate to minRate', async () => { + mockAccountData() + const minClampedRate = (BigInt(expectedRate) + 1n).toString() + + const response = await transport._handleRequest({ + ...param, + minRate: minClampedRate, + }) + + 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) + expect(log).toHaveBeenCalledWith( + { + computedResult: expectedRate, + result: minClampedRate, + minRate: minClampedRate, + maxRate, + }, + 'stSLX exchange rate bounds applied', + ) + }) + + it('should clamp the exchange rate to maxRate', async () => { + mockAccountData() + const maxClampedRate = (BigInt(expectedRate) - 1n).toString() + + const response = await transport._handleRequest({ + ...param, + maxRate: maxClampedRate, + }) + + 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) + }) + + 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 a positive base-10 integer string') + + await expect( + transport._handleRequest({ + ...param, + maxRate: 'not-a-rate', + }), + ).rejects.toThrow('maxRate must be a positive base-10 integer string') + }) + + it('should error when the stSLX mint has zero supply', async () => { + mockAccountData({ + [STSLX_MINT_ADDRESS]: makeAccountInfo( + encodeMint(0n, stslxMintDecimals), + token2022ProgramAddress, + ), + }) + + await expect(transport._handleRequest(param)).rejects.toThrow('has zero supply') + }) + + it('should error when the derived SLX base-asset ATA is missing', async () => { + mockAccountData({ + [SLX_TOKEN_ACCOUNT_ADDRESS]: null, + }) + + await expect(transport._handleRequest(param)).rejects.toThrow( + `No account data found for SLX token account '${SLX_TOKEN_ACCOUNT_ADDRESS}'`, + ) + }) + }) +})