Skip to content
5 changes: 5 additions & 0 deletions .changeset/big-parrots-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/token-balance-adapter': minor
---

Add solana-balance endpoint
1 change: 1 addition & 0 deletions packages/sources/token-balance/src/endpoint/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { endpoint as etherFi } from './etherFi'
export { endpoint as evm } from './evm'
export { endpoint as solana } from './solana'
export { endpoint as solanaBalance } from './solana-balance'
export { endpoint as solvJlp } from './solvJlp'
export { endpoint as tbill } from './tbill'
export { endpoint as xrp } from './xrp'
Expand Down
60 changes: 60 additions & 0 deletions packages/sources/token-balance/src/endpoint/solana-balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter'
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
import { AdapterError } from '@chainlink/external-adapter-framework/validation/error'
import { config } from '../config'
import { solanaBalanceTransport } from '../transport/solana-balance'
import { getSolanaRpcUrl } from '../transport/solana-utils'

export const inputParameters = new InputParameters(
{
addresses: {
required: true,
type: {
address: {
required: true,
type: 'string',
description: 'Address of the account to fetch the balance of',
},
},
array: true,
description: 'List of addresses to read',
},
},
[
{
addresses: [
{
address: '7d73NFxuWQ2F248NA4XwxE95oFfbWZrc1sg4wcDJjzTq',
},
],
},
],
)

export type AddressWithBalance = {
address: string
balance: string
}

export type BaseEndpointTypes = {
Parameters: typeof inputParameters.definition
Response: {
Result: null
Data: {
result: AddressWithBalance[]
decimals: number
}
}
Settings: typeof config.settings
}

export const endpoint = new AdapterEndpoint({
name: 'solana-balance',
transport: solanaBalanceTransport,
inputParameters,
customInputValidation: (_request, settings): AdapterError | undefined => {
// Make sure the RPC URL is set.
getSolanaRpcUrl(settings)
return
},
})
4 changes: 2 additions & 2 deletions packages/sources/token-balance/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { expose, ServerInstance } from '@chainlink/external-adapter-framework'
import { Adapter } from '@chainlink/external-adapter-framework/adapter'
import { config } from './config'
import { etherFi, evm, solana, solvJlp, tbill, xrp, xrpl } from './endpoint'
import { etherFi, evm, solana, solanaBalance, solvJlp, tbill, xrp, xrpl } from './endpoint'

export const adapter = new Adapter({
defaultEndpoint: evm.name,
name: 'TOKEN_BALANCE',
config,
endpoints: [evm, solvJlp, etherFi, tbill, xrp, xrpl, solana],
endpoints: [evm, solvJlp, etherFi, tbill, xrp, xrpl, solana, solanaBalance],
})

export const server = (): Promise<ServerInstance | undefined> => expose(adapter)
121 changes: 121 additions & 0 deletions packages/sources/token-balance/src/transport/solana-balance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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 { GroupRunner } from '@chainlink/external-adapter-framework/util/group-runner'
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
import { Commitment, Connection, PublicKey } from '@solana/web3.js'
import { AddressWithBalance, BaseEndpointTypes, inputParameters } from '../endpoint/solana-balance'
import { getSolanaRpcUrl } from './solana-utils'

const logger = makeLogger('Token Balance - Salana Balance')

type RequestParams = typeof inputParameters.validated

const RESULT_DECIMALS = 9

export class SolanaBalanceTransport extends SubscriptionTransport<BaseEndpointTypes> {
config!: BaseEndpointTypes['Settings']
connection!: Connection

async initialize(
dependencies: TransportDependencies<BaseEndpointTypes>,
adapterSettings: BaseEndpointTypes['Settings'],
endpointName: string,
transportName: string,
): Promise<void> {
await super.initialize(dependencies, adapterSettings, endpointName, transportName)
this.config = adapterSettings
if (!adapterSettings.SOLANA_RPC_URL) {
logger.warn('SOLANA_RPC_URL is missing')
}
}

async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
await Promise.all(entries.map(async (param) => this.handleRequest(context, param)))
await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
}

async handleRequest(_context: EndpointContext<BaseEndpointTypes>, param: RequestParams) {
let response: AdapterResponse<BaseEndpointTypes['Response']>
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(
param: RequestParams,
): Promise<AdapterResponse<BaseEndpointTypes['Response']>> {
const providerDataRequestedUnixMs = Date.now()
const result = await this.getTokenBalances(param.addresses)

return {
data: {
result,
decimals: RESULT_DECIMALS,
},
statusCode: 200,
result: null,
timestamps: {
providerDataRequestedUnixMs,
providerDataReceivedUnixMs: Date.now(),
providerIndicatedTimeUnixMs: undefined,
},
}
}

async getTokenBalances(
addresses: {
address: string
}[],
): Promise<AddressWithBalance[]> {
const runner = new GroupRunner(this.config.GROUP_SIZE)
const getBalance = runner.wrapFunction(
async ({ address }: { address: string }): Promise<AddressWithBalance> => {
const balance = await this.getTokenBalance(address)
return {
address,
balance: balance.toString(),
}
},
)
return await Promise.all(addresses.map(getBalance))
}

async getTokenBalance(address: string): Promise<number> {
const result = await this.getConnection().getAccountInfo(new PublicKey(address))
if (!result) {
throw new AdapterInputError({
statusCode: 400,
message: `Account not found for address ${address}`,
})
}
return result.lamports
}

getConnection(): Connection {
return (this.connection ??= new Connection(
getSolanaRpcUrl(this.config),
this.config.SOLANA_COMMITMENT as Commitment,
))
}

getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number {
return adapterSettings.WARMUP_SUBSCRIPTION_TTL
}
}

export const solanaBalanceTransport = new SolanaBalanceTransport()
11 changes: 11 additions & 0 deletions packages/sources/token-balance/src/transport/solana-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
import { Connection, PublicKey } from '@solana/web3.js'
import { config } from '../config'
import { inputParameters } from '../endpoint/solvJlp'

export const getToken = async (
Expand Down Expand Up @@ -51,3 +52,13 @@ export const getToken = async (
formattedResponse,
}
}

export const getSolanaRpcUrl = (settings: typeof config.settings) => {
if (!settings.SOLANA_RPC_URL) {
throw new AdapterInputError({
statusCode: 400,
message: 'Environment variable SOLANA_RPC_URL is missing',
})
}
return settings.SOLANA_RPC_URL
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`execute SolanaBalanceTransport endpoint returns success 1`] = `
{
"data": {
"decimals": 9,
"result": [
{
"address": "G7v3P9yPtBj1e3JN7B6dq4zbkrrW3e2ovdwAkSTKuUFG",
"balance": "123000000000",
},
],
},
"result": null,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 978347471111,
"providerDataRequestedUnixMs": 978347471111,
},
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
TestAdapter,
setEnvVariables,
} from '@chainlink/external-adapter-framework/util/testing-utils'
import { PublicKey } from '@solana/web3.js'
import * as nock from 'nock'

const accountBalance = 123_000_000_000
const ownerAddress = 'G7v3P9yPtBj1e3JN7B6dq4zbkrrW3e2ovdwAkSTKuUFG'

jest.mock('@solana/web3.js', () => ({
PublicKey: function (): PublicKey {
return {} as PublicKey
},
Connection: class {
async getAccountInfo() {
return {
lamports: accountBalance,
}
}
},
}))

describe('execute', () => {
let spy: jest.SpyInstance
let testAdapter: TestAdapter
let oldEnv: NodeJS.ProcessEnv

beforeAll(async () => {
oldEnv = JSON.parse(JSON.stringify(process.env))
process.env.SOLANA_RPC_URL = process.env.SOLANA_RPC_URL ?? 'http://mock-solana'
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<never>,
})
})

afterAll(async () => {
setEnvVariables(oldEnv)
await testAdapter.api.close()
nock.restore()
nock.cleanAll()
spy.mockRestore()
})

describe('SolanaBalanceTransport endpoint', () => {
it('returns success', async () => {
const data = {
endpoint: 'solana-balance',
addresses: [
{
address: ownerAddress,
},
],
}

const response = await testAdapter.request(data)
console.log('DEBUG response:', response.json()) // helpful if it fails

expect(response.statusCode).toBe(200)
expect(response.json()).toMatchSnapshot()
})
})
})
Loading
Loading