diff --git a/.changeset/nine-wolves-shop.md b/.changeset/nine-wolves-shop.md new file mode 100644 index 0000000000..c3e8529d66 --- /dev/null +++ b/.changeset/nine-wolves-shop.md @@ -0,0 +1,5 @@ +--- +'@chainlink/avalanche-platform-adapter': minor +--- + +Add totalBalance endpoint diff --git a/.pnp.cjs b/.pnp.cjs index a7127bd113..5d5672ea4c 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -5351,6 +5351,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/external-adapter-framework", "npm:2.7.0"],\ ["@types/jest", "npm:29.5.14"],\ ["@types/node", "npm:22.14.1"],\ + ["axios", "npm:1.9.0"],\ ["nock", "npm:13.5.6"],\ ["tslib", "npm:2.4.1"],\ ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"]\ diff --git a/packages/sources/avalanche-platform/package.json b/packages/sources/avalanche-platform/package.json index e486887507..f2804f93ac 100644 --- a/packages/sources/avalanche-platform/package.json +++ b/packages/sources/avalanche-platform/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "22.14.1", + "axios": "1.9.0", "nock": "13.5.6", "typescript": "5.8.3" }, diff --git a/packages/sources/avalanche-platform/src/config/index.ts b/packages/sources/avalanche-platform/src/config/index.ts index 77b0d1286f..d7ad4d6f05 100644 --- a/packages/sources/avalanche-platform/src/config/index.ts +++ b/packages/sources/avalanche-platform/src/config/index.ts @@ -8,6 +8,18 @@ export const config = new AdapterConfig( type: 'string', required: true, }, + GROUP_SIZE: { + description: + 'Number of requests to execute asynchronously before the adapter waits to execute the next group of requests.', + type: 'number', + default: 10, + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 10_000, + }, }, { envDefaultOverrides: { diff --git a/packages/sources/avalanche-platform/src/endpoint/index.ts b/packages/sources/avalanche-platform/src/endpoint/index.ts index 5093778215..13acea77a8 100644 --- a/packages/sources/avalanche-platform/src/endpoint/index.ts +++ b/packages/sources/avalanche-platform/src/endpoint/index.ts @@ -1 +1,2 @@ export { endpoint as balance } from './balance' +export { endpoint as totalBalance } from './totalBalance' diff --git a/packages/sources/avalanche-platform/src/endpoint/totalBalance.ts b/packages/sources/avalanche-platform/src/endpoint/totalBalance.ts new file mode 100644 index 0000000000..5c2f49bc48 --- /dev/null +++ b/packages/sources/avalanche-platform/src/endpoint/totalBalance.ts @@ -0,0 +1,58 @@ +import { + PoRBalanceEndpoint, + PoRBalanceResponse, +} from '@chainlink/external-adapter-framework/adapter/por' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { totalBalanceTransport } from '../transport/totalBalance' + +export const inputParameters = new InputParameters( + { + addresses: { + aliases: ['result'], + array: true, + type: { + address: { + type: 'string', + description: 'an address to get the balance of', + required: true, + }, + }, + description: + 'An array of addresses to get the balances of (as an object with string `address` as an attribute)', + required: true, + }, + assetId: { + type: 'string', + description: 'The ID of the asset to get the balance for', + default: 'FvwEAhmxKfeiG8SnEvq42hc6whRyY3EFYAvebMqDNDGCgxN5Z', // AVAX asset ID + }, + }, + [ + { + addresses: [ + { + address: 'P-avax1tnuesf6cqwnjw7fxjyk7lhch0vhf0v95wj5jvy', + }, + ], + assetId: 'FvwEAhmxKfeiG8SnEvq42hc6whRyY3EFYAvebMqDNDGCgxN5Z', + }, + ], +) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: PoRBalanceResponse & { + Data: { + decimals: number + } + } + Settings: typeof config.settings +} + +export const endpoint = new PoRBalanceEndpoint({ + name: 'totalBalance', + aliases: [], + transport: totalBalanceTransport, + inputParameters, +}) diff --git a/packages/sources/avalanche-platform/src/index.ts b/packages/sources/avalanche-platform/src/index.ts index 085a604e51..4f0ce86f83 100644 --- a/packages/sources/avalanche-platform/src/index.ts +++ b/packages/sources/avalanche-platform/src/index.ts @@ -1,21 +1,15 @@ import { expose, ServerInstance } from '@chainlink/external-adapter-framework' import { PoRAdapter } from '@chainlink/external-adapter-framework/adapter/por' import { config } from './config' -import { balance } from './endpoint' +import { balance, totalBalance } from './endpoint' export const adapter = new PoRAdapter({ defaultEndpoint: balance.name, name: 'AVALANCHE_PLATFORM', config, - endpoints: [balance], - rateLimiting: { - tiers: { - default: { - rateLimit1m: 6, - note: 'Considered unlimited tier, but setting reasonable limits', - }, - }, - }, + // TODO: The 'balance' endpoint seems to be unused. Delete it after + // confirming that it's not needed anymore. + endpoints: [balance, totalBalance], }) export const server = (): Promise => expose(adapter) diff --git a/packages/sources/avalanche-platform/src/transport/totalBalance.ts b/packages/sources/avalanche-platform/src/transport/totalBalance.ts new file mode 100644 index 0000000000..4427bf01c4 --- /dev/null +++ b/packages/sources/avalanche-platform/src/transport/totalBalance.ts @@ -0,0 +1,212 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { calculateHttpRequestKey } from '@chainlink/external-adapter-framework/cache' +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 { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' +import { BaseEndpointTypes, inputParameters } from '../endpoint/totalBalance' + +const logger = makeLogger('TotalBalanceTransport') + +type RequestParams = typeof inputParameters.validated + +export type GetBalanceResult = { + balance: string + unlocked: string + lockedStakeable: string + lockedNotStakeable: string + balances: Record + unlockeds: Record + lockedStakeables: Record + lockedNotStakeables: Record + utxoIDs: { + txID: string + outputIndex: number + }[] +} + +export type GetStakeResult = { + staked: string + stakeds: Record + stakedOutputs: string[] + encoding: string +} + +type PlatformResponse = { + result: T +} + +type BalanceResult = { + address: string + balance: string + unlocked: string + lockedStakeable: string + lockedNotStakeable: string + staked: string +} + +const RESULT_DECIMALS = 18 +const P_CHAIN_DECIMALS = 9 + +const scaleFactor = 10n ** BigInt(RESULT_DECIMALS - P_CHAIN_DECIMALS) +const scale = (n: string) => (BigInt(n) * scaleFactor).toString() + +export class TotalBalanceTransport extends SubscriptionTransport { + config!: BaseEndpointTypes['Settings'] + endpointName!: string + requester!: Requester + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.config = adapterSettings + this.endpointName = endpointName + this.requester = dependencies.requester + } + + 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) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: (e as AdapterError)?.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 result = await this.getTotalBalances({ + addresses: params.addresses, + assetId: params.assetId, + }) + + return { + data: { + result, + decimals: RESULT_DECIMALS, + }, + statusCode: 200, + result: null, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + async getTotalBalances({ + addresses, + assetId, + }: { + addresses: { address: string }[] + assetId: string + }): Promise { + const runner = new GroupRunner(this.config.GROUP_SIZE) + + const getBalance: (address: string) => Promise = runner.wrapFunction( + (address: string) => + this.callPlatformMethod({ + method: 'getBalance', + address, + }), + ) + + const getStake: (address: string) => Promise = runner.wrapFunction( + (address: string) => + this.callPlatformMethod({ + method: 'getStake', + address, + }), + ) + + return await Promise.all( + addresses.map(async ({ address }) => { + const [balanceResult, stakedResult] = await Promise.all([ + getBalance(address), + getStake(address), + ]) + const unlocked = scale(balanceResult.unlockeds[assetId] ?? '0') + const lockedStakeable = scale(balanceResult.lockedStakeables[assetId] ?? '0') + const lockedNotStakeable = scale(balanceResult.lockedNotStakeables[assetId] ?? '0') + const staked = scale(stakedResult.stakeds[assetId] ?? '0') + const balance = [unlocked, lockedStakeable, lockedNotStakeable, staked] + .reduce((a, b) => a + BigInt(b), 0n) + .toString() + return { + address, + balance, + unlocked, + lockedStakeable, + lockedNotStakeable, + staked, + } + }), + ) + } + + async callPlatformMethod({ + method, + address, + }: { + method: string + address: string + }): Promise { + const requestConfig = { + method: 'POST', + baseURL: this.config.P_CHAIN_RPC_URL, + data: { + jsonrpc: '2.0', + method: `platform.${method}`, + params: { addresses: [address] }, + id: '1', + }, + } + + const result = await this.requester.request>( + calculateHttpRequestKey({ + context: { + adapterSettings: this.config, + inputParameters, + endpointName: this.endpointName, + }, + data: requestConfig.data, + transportName: this.name, + }), + requestConfig, + ) + + return result.response.data.result + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const totalBalanceTransport = new TotalBalanceTransport() diff --git a/packages/sources/avalanche-platform/test/integration/__snapshots__/totalBalance.test.ts.snap b/packages/sources/avalanche-platform/test/integration/__snapshots__/totalBalance.test.ts.snap new file mode 100644 index 0000000000..7e471421f0 --- /dev/null +++ b/packages/sources/avalanche-platform/test/integration/__snapshots__/totalBalance.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute totalBalance endpoint should return success 1`] = ` +{ + "data": { + "decimals": 18, + "result": [ + { + "address": "P-fuji1vd9sddlllrlk9fvj9lhntpw8t00lmvtnqkl2jt", + "balance": "5000000000000000000000", + "lockedNotStakeable": "0", + "lockedStakeable": "0", + "staked": "2000000000000000000000", + "unlocked": "3000000000000000000000", + }, + ], + }, + "result": null, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; diff --git a/packages/sources/avalanche-platform/test/integration/adapter.test.ts b/packages/sources/avalanche-platform/test/integration/adapter.test.ts index 0887919baf..adcc26c82f 100644 --- a/packages/sources/avalanche-platform/test/integration/adapter.test.ts +++ b/packages/sources/avalanche-platform/test/integration/adapter.test.ts @@ -3,7 +3,7 @@ import { setEnvVariables, } from '@chainlink/external-adapter-framework/util/testing-utils' import * as nock from 'nock' -import { mockBalanceSuccess } from './fixtures' +import { mockStakeSuccess } from './fixtures' describe('execute', () => { let spy: jest.SpyInstance @@ -41,7 +41,7 @@ describe('execute', () => { }, ], } - mockBalanceSuccess() + mockStakeSuccess() const response = await testAdapter.request(data) expect(response.statusCode).toBe(200) expect(response.json()).toMatchSnapshot() diff --git a/packages/sources/avalanche-platform/test/integration/fixtures.ts b/packages/sources/avalanche-platform/test/integration/fixtures.ts index e223bd263e..5a834798a3 100644 --- a/packages/sources/avalanche-platform/test/integration/fixtures.ts +++ b/packages/sources/avalanche-platform/test/integration/fixtures.ts @@ -1,6 +1,54 @@ import nock from 'nock' export const mockBalanceSuccess = (): nock.Scope => + nock('http://localhost:3500') + .persist() + .post('/ext/bc/P', { + jsonrpc: '2.0', + method: 'platform.getBalance', + params: { + addresses: ['P-fuji1vd9sddlllrlk9fvj9lhntpw8t00lmvtnqkl2jt'], + }, + id: '1', + }) + .reply( + 200, + { + jsonrpc: '2.0', + result: { + balance: '3000000000000', + unlocked: '3000000000000', + lockedStakeable: '0', + lockedNotStakeable: '0', + balances: { + U8iRqJoiJm8xZHAacmvYyZVwqQx6uDNtQeP3CQ6fcgQk3JqnK: '3000000000000', + }, + unlockeds: { + U8iRqJoiJm8xZHAacmvYyZVwqQx6uDNtQeP3CQ6fcgQk3JqnK: '3000000000000', + }, + lockedStakeables: {}, + lockedNotStakeables: {}, + utxoIDs: [ + { + txID: 'fby7iAPBFQPAYK2xQcXGESViHfpNhfJfVLcW7w67Bsd2gEgK2', + outputIndex: 0, + }, + ], + }, + id: 1, + }, + [ + 'content-type', + 'application/json', + 'server', + 'Lighthouse/v3.1.0-aa022f4/x86_64-linux', + 'date', + 'Wed, 21 Sep 2022 10:58:34 GMT', + ], + ) + .persist() + +export const mockStakeSuccess = (): nock.Scope => nock('http://localhost:3500') .persist() .post('/ext/bc/P', { diff --git a/packages/sources/avalanche-platform/test/integration/totalBalance.test.ts b/packages/sources/avalanche-platform/test/integration/totalBalance.test.ts new file mode 100644 index 0000000000..b77dcd3bfe --- /dev/null +++ b/packages/sources/avalanche-platform/test/integration/totalBalance.test.ts @@ -0,0 +1,53 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { mockBalanceSuccess, mockStakeSuccess } from './fixtures' + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.P_CHAIN_RPC_URL = process.env.P_CHAIN_RPC_URL ?? 'http://localhost:3500/ext/bc/P' + 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() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('totalBalance endpoint', () => { + it('should return success', async () => { + const data = { + endpoint: 'totalBalance', + addresses: [ + { + address: 'P-fuji1vd9sddlllrlk9fvj9lhntpw8t00lmvtnqkl2jt', + }, + ], + assetId: 'U8iRqJoiJm8xZHAacmvYyZVwqQx6uDNtQeP3CQ6fcgQk3JqnK', + } + mockBalanceSuccess() + mockStakeSuccess() + const response = await testAdapter.request(data) + expect(response.json()).toMatchSnapshot() + expect(response.statusCode).toBe(200) + }) + }) +}) diff --git a/packages/sources/avalanche-platform/test/unit/totalBalance.test.ts b/packages/sources/avalanche-platform/test/unit/totalBalance.test.ts new file mode 100644 index 0000000000..13510bed8e --- /dev/null +++ b/packages/sources/avalanche-platform/test/unit/totalBalance.test.ts @@ -0,0 +1,380 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { deferredPromise, LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' +import { AxiosRequestConfig } from 'axios' +import { BaseEndpointTypes } from '../../src/endpoint/totalBalance' +import { + GetBalanceResult, + GetStakeResult, + TotalBalanceTransport, +} from '../../src/transport/totalBalance' + +const RESULT_DECIMALS = 18 + +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 log = jest.fn() +const logger = { + fatal: log, + error: log, + warn: log, + info: log, + debug: log, + trace: log, +} + +const loggerFactory = { child: () => logger } + +LoggerFactoryProvider.set(loggerFactory) + +describe('TotalBalanceTransport', () => { + const transportName = 'default_single_transport' + const endpointName = 'totalBalance' + const P_CHAIN_RPC_URL = 'https://p-chain.avalanche.url' + const BACKGROUND_EXECUTE_MS = 1500 + const GROUP_SIZE = 3 + const assetId = 'FvwEAhmxKfeiG8SnEvq42hc6whRyY3EFYAvebMqDNDGCgxN5Z' + + const adapterSettings = makeStub('adapterSettings', { + P_CHAIN_RPC_URL, + WARMUP_SUBSCRIPTION_TTL: 10_000, + BACKGROUND_EXECUTE_MS, + GROUP_SIZE, + MAX_COMMON_KEY_SIZE: 300, + } as unknown as BaseEndpointTypes['Settings']) + + const context = makeStub('context', { + adapterSettings, + } as EndpointContext) + + const requester = makeStub('requester', { + request: jest.fn(), + }) + + const responseCache = { + write: jest.fn(), + } + + const dependencies = makeStub('dependencies', { + requester, + responseCache, + subscriptionSetFactory: { + buildSet: jest.fn(), + }, + } as unknown as TransportDependencies) + + let transport: TotalBalanceTransport + + let mockedBalanceResultByAddress: Record> = {} + let mockedStakeResultByAddress: Record> = {} + + const mockBalanceRpc = ({ + address, + unlocked = '0', + lockedStakeable = '0', + lockedNotStakeable = '0', + }: { + address: string + unlocked?: string | Promise + lockedStakeable?: string | Promise + lockedNotStakeable?: string | Promise + }) => { + mockedBalanceResultByAddress[address] = (async () => { + const balance = ( + Number(await unlocked) + + Number(await lockedStakeable) + + Number(await lockedNotStakeable) + ).toString() + return { + balance, + unlocked: await unlocked, + lockedStakeable: await lockedStakeable, + lockedNotStakeable: await lockedNotStakeable, + balances: { + [assetId]: balance, + }, + unlockeds: { + [assetId]: await unlocked, + }, + lockedStakeables: { + [assetId]: await lockedStakeable, + }, + lockedNotStakeables: { + [assetId]: await lockedNotStakeable, + }, + } as unknown as GetBalanceResult + })() + } + + const mockStakeRpc = ({ + address, + staked = '0', + }: { + address: string + staked?: string | Promise + }) => { + mockedStakeResultByAddress[address] = (async () => { + return { + staked: (await staked).toString(), + stakeds: { + [assetId]: (await staked).toString(), + }, + } as unknown as GetStakeResult + })() + } + + beforeEach(async () => { + restoreEnv() + jest.resetAllMocks() + jest.useFakeTimers() + + mockedBalanceResultByAddress = {} + mockedStakeResultByAddress = {} + + requester.request.mockImplementation( + async (_cacheKey: string, requestConfig: AxiosRequestConfig) => { + const method = requestConfig.data.method + let result: Promise + const address = requestConfig.data.params.addresses[0] + if (method === 'platform.getBalance') { + result = mockedBalanceResultByAddress[address] + if (!result) { + throw new Error(`No mocked balance for address ${address}`) + } + } else if (method === 'platform.getStake') { + result = mockedStakeResultByAddress[address] + if (!result) { + throw new Error(`No mocked stake for address ${address}`) + } + } else { + throw new Error(`Unexpected method '${method}'`) + } + return { + response: { + data: { + result: await result, + }, + }, + } + }, + ) + + transport = new TotalBalanceTransport() + + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + }) + + 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 response', async () => { + const address = 'P-avax101' + const unlocked = '123' + const staked = '456' + + mockBalanceRpc({ address, unlocked }) + mockStakeRpc({ address, staked }) + + const param = makeStub('param', { + addresses: [{ address }], + assetId, + }) + await transport.handleRequest(param) + + const expectedResult = [ + { + address, + balance: (579_000_000_000).toString(), + staked: (456_000_000_000).toString(), + unlocked: (123_000_000_000).toString(), + lockedStakeable: '0', + lockedNotStakeable: '0', + }, + ] + + const expectedResponse = { + statusCode: 200, + result: null, + data: { + decimals: RESULT_DECIMALS, + result: expectedResult, + }, + 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 return balances for multiple addresses', async () => { + const address1 = 'P-avax101' + const address2 = 'P-avax102' + const unlocked1 = '100' + const unlocked2 = '200' + + mockBalanceRpc({ address: address1, unlocked: unlocked1 }) + mockBalanceRpc({ address: address2, unlocked: unlocked2 }) + mockStakeRpc({ address: address1 }) + mockStakeRpc({ address: address2 }) + + const param = makeStub('param', { + addresses: [{ address: address1 }, { address: address2 }], + assetId, + }) + const response = await transport._handleRequest(param) + + const expectedResult = [ + { + address: address1, + balance: '100000000000', + staked: '0', + unlocked: '100000000000', + lockedStakeable: '0', + lockedNotStakeable: '0', + }, + { + address: address2, + balance: '200000000000', + staked: '0', + unlocked: '200000000000', + lockedStakeable: '0', + lockedNotStakeable: '0', + }, + ] + + expect(response).toEqual({ + statusCode: 200, + result: null, + data: { + decimals: RESULT_DECIMALS, + result: expectedResult, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + }) + }) + + it('should add up different types of balance', async () => { + const address = 'P-avax101' + const unlocked = '101' + const lockedStakeable = '201' + const lockedNotStakeable = '301' + const staked = '401' + + mockBalanceRpc({ address, unlocked, lockedStakeable, lockedNotStakeable }) + mockStakeRpc({ address, staked }) + + const param = makeStub('param', { + addresses: [{ address }], + assetId, + }) + const response = await transport._handleRequest(param) + + const expectedResult = [ + { + address, + balance: '1004000000000', + staked: '401000000000', + unlocked: '101000000000', + lockedStakeable: '201000000000', + lockedNotStakeable: '301000000000', + }, + ] + + expect(response).toEqual({ + statusCode: 200, + result: null, + data: { + decimals: RESULT_DECIMALS, + result: expectedResult, + }, + timestamps: { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + }) + }) + + it('should record received timestamp separate from requested timestamp', async () => { + const address = 'P-avax101' + const unlocked = '1' + + const [unlockedPromise, resolveUnlocked] = deferredPromise() + mockBalanceRpc({ address, unlocked: unlockedPromise }) + mockStakeRpc({ address }) + + const param = makeStub('param', { + addresses: [{ address }], + assetId, + }) + + const requestTimestamp = Date.now() + const responsePromise = transport._handleRequest(param) + jest.advanceTimersByTime(1234) + const responseTimestamp = Date.now() + expect(responseTimestamp).toBeGreaterThan(requestTimestamp) + + resolveUnlocked(unlocked) + + const expectedResult = [ + { + address, + balance: '1000000000', + staked: '0', + unlocked: '1000000000', + lockedStakeable: '0', + lockedNotStakeable: '0', + }, + ] + expect(await responsePromise).toEqual({ + statusCode: 200, + result: null, + data: { + decimals: RESULT_DECIMALS, + result: expectedResult, + }, + timestamps: { + providerDataRequestedUnixMs: requestTimestamp, + providerDataReceivedUnixMs: responseTimestamp, + providerIndicatedTimeUnixMs: undefined, + }, + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 7b489f2131..a65192177f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2580,6 +2580,7 @@ __metadata: "@chainlink/external-adapter-framework": "npm:2.7.0" "@types/jest": "npm:^29.5.14" "@types/node": "npm:22.14.1" + axios: "npm:1.9.0" nock: "npm:13.5.6" tslib: "npm:2.4.1" typescript: "npm:5.8.3"