Skip to content

Commit ddc377c

Browse files
Subarna-Singhboxhockapp-token-issuer-data-feeds[bot]
authored
OPDATA-3708 USDO add Solana reserves to token balance (#3965)
* USDO - Support Solana reserves * USDO - Solana reserves * Add: Changeset * Add: unit and integration testing * change addresses structure * FIX: unit test * FIX: failing compilation * REDO: work post review * Revert change to getToken - not related to this PR * UPDATE: unit and integration test * FIX: unit test compilation * FIX: Lint error * REWORK: work post review * FIX: unit and integration tests * FIX: compilation error * FIX: compilation error * REWORK: post review work * REWORK: post review * FIX: compiling error * FIX: compiling error * UPDATE: test cases * REWORK: work post review * FIX: compiling issue * FIX: compiling issue * Error Handling: If account does not exist * REWORK * FIX: test --------- Co-authored-by: Jonas Hals <jonas@smartcontract.com> Co-authored-by: app-token-issuer-data-feeds[bot] <134377064+app-token-issuer-data-feeds[bot]@users.noreply.github.com>
1 parent 0a67973 commit ddc377c

File tree

8 files changed

+690
-2
lines changed

8 files changed

+690
-2
lines changed

.changeset/mighty-tools-float.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': major
3+
---
4+
5+
USDO - Add Solana Reserves
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { endpoint as etherFi } from './etherFi'
22
export { endpoint as evm } from './evm'
3+
export { endpoint as solana } from './solana'
34
export { endpoint as solvJlp } from './solvJlp'
45
export { endpoint as tbill } from './tbill'
56
export { endpoint as xrpl } from './xrpl'
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter'
2+
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
3+
import { config } from '../config'
4+
import { solanaTransport } from '../transport/solana'
5+
6+
export const inputParameters = new InputParameters(
7+
{
8+
addresses: {
9+
required: true,
10+
description:
11+
'List of wallet addresses to query. The balances of all provided wallets will be retrieved and summed together.',
12+
type: {
13+
address: {
14+
required: true,
15+
type: 'string',
16+
description: 'Public wallet address whose token balance will be queried.',
17+
},
18+
},
19+
array: true,
20+
},
21+
tokenMint: {
22+
required: true,
23+
description:
24+
'A token mint is the canonical on-chain account that defines the token’s metadata (name, symbol, supply rules).',
25+
type: {
26+
token: {
27+
required: true,
28+
type: 'string',
29+
description: 'token symbol of token mint.',
30+
},
31+
contractAddress: {
32+
required: true,
33+
type: 'string',
34+
description: 'On-chain contract address of the token mint.',
35+
},
36+
},
37+
},
38+
priceOracle: {
39+
required: true,
40+
description:
41+
'Configuration of the on-chain price oracle that provides real-time token valuations.',
42+
type: {
43+
contractAddress: {
44+
required: true,
45+
type: 'string',
46+
description: 'Contract address of the price oracle used to fetch token price data.',
47+
},
48+
network: {
49+
required: true,
50+
type: 'string',
51+
description:
52+
'Blockchain network of the price oracle contract (e.g., ETHEREUM, ARBITRUM).',
53+
},
54+
},
55+
},
56+
},
57+
[
58+
{
59+
addresses: [
60+
{
61+
address: 'G7v3P9yPtBj1e3JN7B6dq4zbkrrW3e2ovdwAkSTKuUFG',
62+
},
63+
],
64+
tokenMint: {
65+
token: 'tbill',
66+
contractAddress: '4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6 ',
67+
},
68+
priceOracle: {
69+
contractAddress: '0xCe9a6626Eb99eaeA829D7fA613d5D0A2eaE45F40',
70+
network: 'ETHEREUM',
71+
},
72+
},
73+
],
74+
)
75+
76+
export type BaseEndpointTypes = {
77+
Parameters: typeof inputParameters.definition
78+
Response: {
79+
Result: string
80+
Data: {
81+
result: string
82+
decimals: number
83+
}
84+
}
85+
Settings: typeof config.settings
86+
}
87+
88+
export const endpoint = new AdapterEndpoint({
89+
name: 'solana',
90+
transport: solanaTransport,
91+
inputParameters,
92+
})
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, solvJlp, tbill, xrpl } from './endpoint'
4+
import { etherFi, evm, solana, solvJlp, tbill, 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, xrpl],
10+
endpoints: [evm, solvJlp, etherFi, tbill, xrpl, solana],
1111
})
1212

1313
export const server = (): Promise<ServerInstance | undefined> => expose(adapter)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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 {
6+
AdapterError,
7+
AdapterInputError,
8+
} from '@chainlink/external-adapter-framework/validation/error'
9+
import { Commitment, Connection } from '@solana/web3.js'
10+
import { BaseEndpointTypes, inputParameters } from '../endpoint/solana'
11+
import { getTokenPrice } from './priceFeed'
12+
import { getToken } from './solana-utils'
13+
14+
const logger = makeLogger('Token Balance - Solana')
15+
16+
type RequestParams = typeof inputParameters.validated
17+
18+
const RESULT_DECIMALS = 18
19+
20+
export class SolanaTransport extends SubscriptionTransport<BaseEndpointTypes> {
21+
connection!: Connection
22+
23+
async initialize(
24+
dependencies: TransportDependencies<BaseEndpointTypes>,
25+
adapterSettings: BaseEndpointTypes['Settings'],
26+
endpointName: string,
27+
transportName: string,
28+
): Promise<void> {
29+
await super.initialize(dependencies, adapterSettings, endpointName, transportName)
30+
31+
if (!adapterSettings.SOLANA_RPC_URL) {
32+
logger.warn('SOLANA_RPC_URL is missing')
33+
} else {
34+
this.connection = new Connection(
35+
adapterSettings.SOLANA_RPC_URL,
36+
adapterSettings.SOLANA_COMMITMENT as Commitment,
37+
)
38+
}
39+
}
40+
41+
async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, entries: RequestParams[]) {
42+
await Promise.all(entries.map(async (param) => this.handleRequest(param)))
43+
await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS)
44+
}
45+
46+
async handleRequest(param: RequestParams) {
47+
let response: AdapterResponse<BaseEndpointTypes['Response']>
48+
49+
try {
50+
response = await this._handleRequest(param)
51+
} catch (e: unknown) {
52+
const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred'
53+
logger.error(e, errorMessage)
54+
55+
response = {
56+
statusCode: (e as AdapterInputError)?.statusCode || 502,
57+
errorMessage,
58+
timestamps: {
59+
providerDataRequestedUnixMs: 0,
60+
providerDataReceivedUnixMs: 0,
61+
providerIndicatedTimeUnixMs: undefined,
62+
},
63+
}
64+
}
65+
await this.responseCache.write(this.name, [{ params: param, response }])
66+
}
67+
68+
async _handleRequest(
69+
param: RequestParams,
70+
): Promise<AdapterResponse<BaseEndpointTypes['Response']>> {
71+
const { addresses, tokenMint } = param
72+
const providerDataRequestedUnixMs = Date.now()
73+
74+
// 1. Fetch token price ONCE from oracle contract
75+
const tokenPrice = await getTokenPrice({
76+
priceOracleAddress: param.priceOracle.contractAddress,
77+
priceOracleNetwork: param.priceOracle.network,
78+
})
79+
80+
// 2. Fetch balances for each Solana wallet and calculate their USD value using the SINGLE tokenPrice
81+
const totalTokenUSD = await this.calculateTokenAumUSD(addresses, tokenMint, tokenPrice)
82+
83+
// 3. Build adapter response object
84+
return {
85+
data: {
86+
result: String(totalTokenUSD), // formatted as string for API
87+
decimals: RESULT_DECIMALS,
88+
},
89+
statusCode: 200,
90+
result: String(totalTokenUSD),
91+
timestamps: {
92+
providerDataRequestedUnixMs,
93+
providerDataReceivedUnixMs: Date.now(),
94+
providerIndicatedTimeUnixMs: undefined,
95+
},
96+
}
97+
}
98+
99+
async calculateTokenAumUSD(
100+
addresses: typeof inputParameters.validated.addresses,
101+
tokenMint: typeof inputParameters.validated.tokenMint,
102+
tokenPrice: { value: bigint; decimal: number },
103+
): Promise<bigint> {
104+
// 1. Transform new schema → getToken schema
105+
const addressesForGetToken = [
106+
{
107+
token: tokenMint.token,
108+
contractAddress: tokenMint.contractAddress,
109+
wallets: addresses.map((a) => a.address),
110+
},
111+
]
112+
113+
// 2. Fetch token balances for the given address on Solana
114+
const { result: balances } = await getToken(
115+
addressesForGetToken,
116+
tokenMint.token,
117+
this.connection,
118+
)
119+
120+
// 3. Sum raw balances (all balances are for the same mint, so same decimals)
121+
let totalRaw = 0n
122+
123+
let tokenDecimals = undefined
124+
for (const bal of balances) {
125+
totalRaw += bal.value
126+
if (!bal.decimals) {
127+
throw new AdapterError({
128+
statusCode: 400,
129+
message: 'Missing decimals on balance response',
130+
})
131+
}
132+
if (tokenDecimals !== undefined && bal.decimals !== tokenDecimals) {
133+
throw new AdapterError({
134+
statusCode: 400,
135+
message: `Inconsistent balance decimals: ${tokenDecimals} != ${bal.decimals}`,
136+
})
137+
}
138+
tokenDecimals = bal.decimals
139+
}
140+
tokenDecimals ??= RESULT_DECIMALS
141+
142+
// 4. Calculate AUM
143+
const totalAumUSD =
144+
(totalRaw * tokenPrice.value * 10n ** BigInt(RESULT_DECIMALS)) /
145+
10n ** BigInt(tokenDecimals) /
146+
10n ** BigInt(tokenPrice.decimal)
147+
148+
// 5. Return total USD value for this address
149+
return totalAumUSD
150+
}
151+
152+
getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number {
153+
return adapterSettings.WARMUP_SUBSCRIPTION_TTL
154+
}
155+
}
156+
157+
export const solanaTransport = new SolanaTransport()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`execute SolanaTransport endpoint returns success 1`] = `
4+
{
5+
"data": {
6+
"decimals": 18,
7+
"result": "1500000000000000000000",
8+
},
9+
"result": "1500000000000000000000",
10+
"statusCode": 200,
11+
"timestamps": {
12+
"providerDataReceivedUnixMs": 978347471111,
13+
"providerDataRequestedUnixMs": 978347471111,
14+
},
15+
}
16+
`;

0 commit comments

Comments
 (0)