Skip to content

Commit 2f9d34f

Browse files
Add solana-balance endpoint to token-balance EA (#4020)
* Copy xrp * Add solana-balance endpoint to token-balance * Copy solana integration test * Solana balance integration test * Copy xrp unit test * Solana-balance unit test * changeset * this.getConnection() --------- Co-authored-by: app-token-issuer-data-feeds[bot] <134377064+app-token-issuer-data-feeds[bot]@users.noreply.github.com>
1 parent c06b5ea commit 2f9d34f

File tree

9 files changed

+650
-2
lines changed

9 files changed

+650
-2
lines changed

.changeset/big-parrots-join.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@chainlink/token-balance-adapter': minor
3+
---
4+
5+
Add solana-balance endpoint

packages/sources/token-balance/src/endpoint/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { endpoint as etherFi } from './etherFi'
22
export { endpoint as evm } from './evm'
33
export { endpoint as solana } from './solana'
4+
export { endpoint as solanaBalance } from './solana-balance'
45
export { endpoint as solvJlp } from './solvJlp'
56
export { endpoint as tbill } from './tbill'
67
export { endpoint as xrp } from './xrp'
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter'
2+
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
3+
import { AdapterError } from '@chainlink/external-adapter-framework/validation/error'
4+
import { config } from '../config'
5+
import { solanaBalanceTransport } from '../transport/solana-balance'
6+
import { getSolanaRpcUrl } from '../transport/solana-utils'
7+
8+
export const inputParameters = new InputParameters(
9+
{
10+
addresses: {
11+
required: true,
12+
type: {
13+
address: {
14+
required: true,
15+
type: 'string',
16+
description: 'Address of the account to fetch the balance of',
17+
},
18+
},
19+
array: true,
20+
description: 'List of addresses to read',
21+
},
22+
},
23+
[
24+
{
25+
addresses: [
26+
{
27+
address: '7d73NFxuWQ2F248NA4XwxE95oFfbWZrc1sg4wcDJjzTq',
28+
},
29+
],
30+
},
31+
],
32+
)
33+
34+
export type AddressWithBalance = {
35+
address: string
36+
balance: string
37+
}
38+
39+
export type BaseEndpointTypes = {
40+
Parameters: typeof inputParameters.definition
41+
Response: {
42+
Result: null
43+
Data: {
44+
result: AddressWithBalance[]
45+
decimals: number
46+
}
47+
}
48+
Settings: typeof config.settings
49+
}
50+
51+
export const endpoint = new AdapterEndpoint({
52+
name: 'solana-balance',
53+
transport: solanaBalanceTransport,
54+
inputParameters,
55+
customInputValidation: (_request, settings): AdapterError | undefined => {
56+
// Make sure the RPC URL is set.
57+
getSolanaRpcUrl(settings)
58+
return
59+
},
60+
})
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { expose, ServerInstance } from '@chainlink/external-adapter-framework'
22
import { Adapter } from '@chainlink/external-adapter-framework/adapter'
33
import { config } from './config'
4-
import { etherFi, evm, solana, solvJlp, tbill, xrp, xrpl } from './endpoint'
4+
import { etherFi, evm, solana, solanaBalance, solvJlp, tbill, xrp, xrpl } from './endpoint'
55

66
export const adapter = new Adapter({
77
defaultEndpoint: evm.name,
88
name: 'TOKEN_BALANCE',
99
config,
10-
endpoints: [evm, solvJlp, etherFi, tbill, xrp, xrpl, solana],
10+
endpoints: [evm, solvJlp, etherFi, tbill, xrp, xrpl, solana, solanaBalance],
1111
})
1212

1313
export const server = (): Promise<ServerInstance | undefined> => expose(adapter)
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { EndpointContext } from '@chainlink/external-adapter-framework/adapter'
2+
import { TransportDependencies } from '@chainlink/external-adapter-framework/transports'
3+
import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription'
4+
import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util'
5+
import { GroupRunner } from '@chainlink/external-adapter-framework/util/group-runner'
6+
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
7+
import { Commitment, Connection, PublicKey } from '@solana/web3.js'
8+
import { AddressWithBalance, BaseEndpointTypes, inputParameters } from '../endpoint/solana-balance'
9+
import { getSolanaRpcUrl } from './solana-utils'
10+
11+
const logger = makeLogger('Token Balance - Salana Balance')
12+
13+
type RequestParams = typeof inputParameters.validated
14+
15+
const RESULT_DECIMALS = 9
16+
17+
export class SolanaBalanceTransport extends SubscriptionTransport<BaseEndpointTypes> {
18+
config!: BaseEndpointTypes['Settings']
19+
connection!: Connection
20+
21+
async initialize(
22+
dependencies: TransportDependencies<BaseEndpointTypes>,
23+
adapterSettings: BaseEndpointTypes['Settings'],
24+
endpointName: string,
25+
transportName: string,
26+
): Promise<void> {
27+
await super.initialize(dependencies, adapterSettings, endpointName, transportName)
28+
this.config = adapterSettings
29+
if (!adapterSettings.SOLANA_RPC_URL) {
30+
logger.warn('SOLANA_RPC_URL is missing')
31+
}
32+
}
33+
34+
async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
35+
await Promise.all(entries.map(async (param) => this.handleRequest(context, param)))
36+
await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
37+
}
38+
39+
async handleRequest(_context: EndpointContext<BaseEndpointTypes>, param: RequestParams) {
40+
let response: AdapterResponse<BaseEndpointTypes['Response']>
41+
try {
42+
response = await this._handleRequest(param)
43+
} catch (e: unknown) {
44+
const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred'
45+
logger.error(e, errorMessage)
46+
response = {
47+
statusCode: (e as AdapterInputError)?.statusCode || 502,
48+
errorMessage,
49+
timestamps: {
50+
providerDataRequestedUnixMs: 0,
51+
providerDataReceivedUnixMs: 0,
52+
providerIndicatedTimeUnixMs: undefined,
53+
},
54+
}
55+
}
56+
await this.responseCache.write(this.name, [{ params: param, response }])
57+
}
58+
59+
async _handleRequest(
60+
param: RequestParams,
61+
): Promise<AdapterResponse<BaseEndpointTypes['Response']>> {
62+
const providerDataRequestedUnixMs = Date.now()
63+
const result = await this.getTokenBalances(param.addresses)
64+
65+
return {
66+
data: {
67+
result,
68+
decimals: RESULT_DECIMALS,
69+
},
70+
statusCode: 200,
71+
result: null,
72+
timestamps: {
73+
providerDataRequestedUnixMs,
74+
providerDataReceivedUnixMs: Date.now(),
75+
providerIndicatedTimeUnixMs: undefined,
76+
},
77+
}
78+
}
79+
80+
async getTokenBalances(
81+
addresses: {
82+
address: string
83+
}[],
84+
): Promise<AddressWithBalance[]> {
85+
const runner = new GroupRunner(this.config.GROUP_SIZE)
86+
const getBalance = runner.wrapFunction(
87+
async ({ address }: { address: string }): Promise<AddressWithBalance> => {
88+
const balance = await this.getTokenBalance(address)
89+
return {
90+
address,
91+
balance: balance.toString(),
92+
}
93+
},
94+
)
95+
return await Promise.all(addresses.map(getBalance))
96+
}
97+
98+
async getTokenBalance(address: string): Promise<number> {
99+
const result = await this.getConnection().getAccountInfo(new PublicKey(address))
100+
if (!result) {
101+
throw new AdapterInputError({
102+
statusCode: 400,
103+
message: `Account not found for address ${address}`,
104+
})
105+
}
106+
return result.lamports
107+
}
108+
109+
getConnection(): Connection {
110+
return (this.connection ??= new Connection(
111+
getSolanaRpcUrl(this.config),
112+
this.config.SOLANA_COMMITMENT as Commitment,
113+
))
114+
}
115+
116+
getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number {
117+
return adapterSettings.WARMUP_SUBSCRIPTION_TTL
118+
}
119+
}
120+
121+
export const solanaBalanceTransport = new SolanaBalanceTransport()

packages/sources/token-balance/src/transport/solana-utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error'
22
import { Connection, PublicKey } from '@solana/web3.js'
3+
import { config } from '../config'
34
import { inputParameters } from '../endpoint/solvJlp'
45

56
export const getToken = async (
@@ -51,3 +52,13 @@ export const getToken = async (
5152
formattedResponse,
5253
}
5354
}
55+
56+
export const getSolanaRpcUrl = (settings: typeof config.settings) => {
57+
if (!settings.SOLANA_RPC_URL) {
58+
throw new AdapterInputError({
59+
statusCode: 400,
60+
message: 'Environment variable SOLANA_RPC_URL is missing',
61+
})
62+
}
63+
return settings.SOLANA_RPC_URL
64+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`execute SolanaBalanceTransport endpoint returns success 1`] = `
4+
{
5+
"data": {
6+
"decimals": 9,
7+
"result": [
8+
{
9+
"address": "G7v3P9yPtBj1e3JN7B6dq4zbkrrW3e2ovdwAkSTKuUFG",
10+
"balance": "123000000000",
11+
},
12+
],
13+
},
14+
"result": null,
15+
"statusCode": 200,
16+
"timestamps": {
17+
"providerDataReceivedUnixMs": 978347471111,
18+
"providerDataRequestedUnixMs": 978347471111,
19+
},
20+
}
21+
`;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
TestAdapter,
3+
setEnvVariables,
4+
} from '@chainlink/external-adapter-framework/util/testing-utils'
5+
import { PublicKey } from '@solana/web3.js'
6+
import * as nock from 'nock'
7+
8+
const accountBalance = 123_000_000_000
9+
const ownerAddress = 'G7v3P9yPtBj1e3JN7B6dq4zbkrrW3e2ovdwAkSTKuUFG'
10+
11+
jest.mock('@solana/web3.js', () => ({
12+
PublicKey: function (): PublicKey {
13+
return {} as PublicKey
14+
},
15+
Connection: class {
16+
async getAccountInfo() {
17+
return {
18+
lamports: accountBalance,
19+
}
20+
}
21+
},
22+
}))
23+
24+
describe('execute', () => {
25+
let spy: jest.SpyInstance
26+
let testAdapter: TestAdapter
27+
let oldEnv: NodeJS.ProcessEnv
28+
29+
beforeAll(async () => {
30+
oldEnv = JSON.parse(JSON.stringify(process.env))
31+
process.env.SOLANA_RPC_URL = process.env.SOLANA_RPC_URL ?? 'http://mock-solana'
32+
process.env.BACKGROUND_EXECUTE_MS = '0'
33+
34+
const mockDate = new Date('2001-01-01T11:11:11.111Z')
35+
spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime())
36+
37+
const adapter = (await import('./../../src')).adapter
38+
adapter.rateLimiting = undefined
39+
40+
testAdapter = await TestAdapter.startWithMockedCache(adapter, {
41+
testAdapter: {} as TestAdapter<never>,
42+
})
43+
})
44+
45+
afterAll(async () => {
46+
setEnvVariables(oldEnv)
47+
await testAdapter.api.close()
48+
nock.restore()
49+
nock.cleanAll()
50+
spy.mockRestore()
51+
})
52+
53+
describe('SolanaBalanceTransport endpoint', () => {
54+
it('returns success', async () => {
55+
const data = {
56+
endpoint: 'solana-balance',
57+
addresses: [
58+
{
59+
address: ownerAddress,
60+
},
61+
],
62+
}
63+
64+
const response = await testAdapter.request(data)
65+
console.log('DEBUG response:', response.json()) // helpful if it fails
66+
67+
expect(response.statusCode).toBe(200)
68+
expect(response.json()).toMatchSnapshot()
69+
})
70+
})
71+
})

0 commit comments

Comments
 (0)