diff --git a/.changeset/tame-pans-drive.md b/.changeset/tame-pans-drive.md new file mode 100644 index 0000000000..9aa5e760e2 --- /dev/null +++ b/.changeset/tame-pans-drive.md @@ -0,0 +1,5 @@ +--- +'@chainlink/por-address-list-adapter': minor +--- + +Add multichainAddress endpoint diff --git a/packages/sources/por-address-list/src/config/MultiEVMPoRAddressList.json b/packages/sources/por-address-list/src/config/MultiEVMPoRAddressList.json new file mode 100644 index 0000000000..ec30c52dbd --- /dev/null +++ b/packages/sources/por-address-list/src/config/MultiEVMPoRAddressList.json @@ -0,0 +1,32 @@ +[ + { + "inputs": [ + { "internalType": "uint256", "name": "startIndex", "type": "uint256" }, + { "internalType": "uint256", "name": "endIndex", "type": "uint256" } + ], + "name": "getPoRAddressList", + "outputs": [ + { + "components": [ + { "internalType": "string", "name": "chain", "type": "string" }, + { "internalType": "uint256", "name": "chainId", "type": "uint256" }, + { "internalType": "string", "name": "tokenSymbol", "type": "string" }, + { "internalType": "address", "name": "tokenAddress", "type": "address" }, + { "internalType": "address", "name": "vaultAddress", "type": "address" } + ], + "internalType": "struct IMultiEVMPoRAddressList.PoRInfo[]", + "name": "", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPoRAddressListLength", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/packages/sources/por-address-list/src/config/PoRAddressListMulti.json b/packages/sources/por-address-list/src/config/PoRAddressListMulti.json new file mode 100644 index 0000000000..da8af723ef --- /dev/null +++ b/packages/sources/por-address-list/src/config/PoRAddressListMulti.json @@ -0,0 +1,32 @@ +[ + { + "inputs": [ + { "internalType": "uint256", "name": "startIndex_", "type": "uint256" }, + { "internalType": "uint256", "name": "endIndex_", "type": "uint256" } + ], + "name": "getPoRAddressList", + "outputs": [ + { + "components": [ + { "internalType": "string", "name": "tokenSymbol", "type": "string" }, + { "internalType": "string", "name": "chain", "type": "string" }, + { "internalType": "uint64", "name": "chainId", "type": "uint64" }, + { "internalType": "address", "name": "tokenAddress", "type": "address" }, + { "internalType": "address", "name": "vaultAddress", "type": "address" } + ], + "internalType": "struct IPoRAddressListMulti.TokenVaultInfo[]", + "name": "tokenVaultInfos_", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getPoRAddressListLength", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/packages/sources/por-address-list/src/endpoint/index.ts b/packages/sources/por-address-list/src/endpoint/index.ts index e5fd436387..18ca8464fb 100644 --- a/packages/sources/por-address-list/src/endpoint/index.ts +++ b/packages/sources/por-address-list/src/endpoint/index.ts @@ -1,3 +1,4 @@ export { endpoint as address } from './address' export { endpoint as solvBTC } from './solvBTC' export { endpoint as bedrockBTC } from './bedrockBTC' +export { endpoint as multichainAddress } from './multichainAddress' diff --git a/packages/sources/por-address-list/src/endpoint/multichainAddress.ts b/packages/sources/por-address-list/src/endpoint/multichainAddress.ts new file mode 100644 index 0000000000..15ab979c93 --- /dev/null +++ b/packages/sources/por-address-list/src/endpoint/multichainAddress.ts @@ -0,0 +1,55 @@ +import { + PoRTokenAddressEndpoint, + PoRTokenAddressResponse, +} from '@chainlink/external-adapter-framework/adapter/por' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { addressTransport } from '../transport/multichainAddress' + +export const inputParameters = new InputParameters( + { + contractAddress: { + description: 'The contract address holding the custodial addresses', + type: 'string', + required: true, + }, + contractAddressNetwork: { + description: + 'The network of the contract, used to match {NETWORK}_RPC_URL and {NETWORK}_RPC_CHAIN_ID in env var', + type: 'string', + required: true, + }, + confirmations: { + description: 'The number of confirmations to query data from', + type: 'number', + default: 0, + }, + batchSize: { + description: 'The number of addresses to fetch from the contract at a time', + type: 'number', + default: 10, + }, + }, + [ + { + contractAddress: '0xb7C0817Dd23DE89E4204502dd2C2EF7F57d3A3B8', + contractAddressNetwork: 'BINANCE', + confirmations: 0, + batchSize: 10, + }, + ], +) + +type ResponseSchema = PoRTokenAddressResponse + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: ResponseSchema + Settings: typeof config.settings +} + +export const endpoint = new PoRTokenAddressEndpoint({ + name: 'multichainAddress', + transport: addressTransport, + inputParameters, +}) diff --git a/packages/sources/por-address-list/src/index.ts b/packages/sources/por-address-list/src/index.ts index bec6c70556..09a8543f89 100644 --- a/packages/sources/por-address-list/src/index.ts +++ b/packages/sources/por-address-list/src/index.ts @@ -1,13 +1,13 @@ import { expose, ServerInstance } from '@chainlink/external-adapter-framework' import { PoRAdapter } from '@chainlink/external-adapter-framework/adapter/por' import { config } from './config' -import { address, solvBTC, bedrockBTC } from './endpoint' +import { address, solvBTC, bedrockBTC, multichainAddress } from './endpoint' export const adapter = new PoRAdapter({ defaultEndpoint: address.name, name: 'POR_ADDRESS_LIST', config, - endpoints: [address, solvBTC, bedrockBTC], + endpoints: [address, solvBTC, bedrockBTC, multichainAddress], }) export const server = (): Promise => expose(adapter) diff --git a/packages/sources/por-address-list/src/transport/address.ts b/packages/sources/por-address-list/src/transport/address.ts index b1bc98854c..1641a3de5c 100644 --- a/packages/sources/por-address-list/src/transport/address.ts +++ b/packages/sources/por-address-list/src/transport/address.ts @@ -78,7 +78,7 @@ export class AddressTransport extends SubscriptionTransport( addressManager, latestBlockNum, confirmations, diff --git a/packages/sources/por-address-list/src/transport/multichainAddress.ts b/packages/sources/por-address-list/src/transport/multichainAddress.ts new file mode 100644 index 0000000000..d50ccff6fb --- /dev/null +++ b/packages/sources/por-address-list/src/transport/multichainAddress.ts @@ -0,0 +1,125 @@ +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { AdapterResponse, sleep } from '@chainlink/external-adapter-framework/util' +import ABI from '../config/PoRAddressListMulti.json' +import PolygonABI from '../config/MultiEVMPoRAddressList.json' +import { BaseEndpointTypes, inputParameters } from '../endpoint/multichainAddress' +import { ethers } from 'ethers' +import { fetchAddressList, addProvider, getProvider } from './utils' + +export type AddressTransportTypes = BaseEndpointTypes + +type RequestParams = typeof inputParameters.validated + +interface ResponseSchema { + tokenSymbol: string + chain: string + chainId: bigint + tokenAddress: string + vaultAddress: string +} + +export class AddressTransport extends SubscriptionTransport { + providersMap: Record = {} + settings!: AddressTransportTypes['Settings'] + + async initialize( + dependencies: TransportDependencies, + adapterSettings: AddressTransportTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.settings = adapterSettings + } + + 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' + response = { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + param: RequestParams, + ): Promise> { + const { confirmations, contractAddress, contractAddressNetwork, batchSize } = param + + this.providersMap = addProvider(contractAddressNetwork, this.providersMap) + const provider = getProvider(contractAddressNetwork, this.providersMap) + + const addressManager = new ethers.Contract( + contractAddress, + contractAddressNetwork == 'POLYGON' ? PolygonABI : ABI, + provider, + ) + const latestBlockNum = await provider.getBlockNumber() + + const providerDataRequestedUnixMs = Date.now() + const addressList = await fetchAddressList( + addressManager, + latestBlockNum, + confirmations, + batchSize, + this.settings.GROUP_SIZE, + ) + + const addressByChain = Map.groupBy( + addressList, + (address) => address.chainId.toString() + address.tokenAddress, + ) + + const response = Array.from( + new Map( + Array.from(addressByChain, ([k, v]) => [ + k, + { + chainId: v[0].chainId.toString(), + contractAddress: v[0].tokenAddress, + wallets: v.map((v) => v.vaultAddress), + }, + ]), + ).values(), + ).sort() + + return { + data: { + result: response, + }, + statusCode: 200, + result: null, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const addressTransport = new AddressTransport() diff --git a/packages/sources/por-address-list/src/transport/utils.ts b/packages/sources/por-address-list/src/transport/utils.ts index b30add1b40..69f0a28c9a 100644 --- a/packages/sources/por-address-list/src/transport/utils.ts +++ b/packages/sources/por-address-list/src/transport/utils.ts @@ -1,23 +1,24 @@ import { ethers } from 'ethers' import { makeLogger } from '@chainlink/external-adapter-framework/util' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' const logger = makeLogger('utils') -export const fetchAddressList = async ( +export const fetchAddressList = async ( addressManager: ethers.Contract, latestBlockNum: number, confirmations = 0, batchSize = 10, batchGroupSize = 10, -): Promise => { +): Promise => { const blockTag = latestBlockNum - confirmations const numAddresses = await addressManager.getPoRAddressListLength({ blockTag, }) let totalRequestedAddressesCount = 0 let startIdx = ethers.BigNumber.from(0) - const addresses: string[] = [] - let batchRequests: Promise[] = [] + const addresses: T[] = [] + let batchRequests: Promise[] = [] while (totalRequestedAddressesCount < numAddresses.toNumber()) { const nextEndIdx = startIdx.add(batchSize) @@ -68,10 +69,17 @@ export const addProvider = ( export const getProvider = ( networkName: string, providers: Record, - provider: ethers.providers.JsonRpcProvider, + provider?: ethers.providers.JsonRpcProvider, ) => { if (!providers[networkName]) { - return provider + if (provider) { + return provider + } else { + throw new AdapterInputError({ + statusCode: 400, + message: `Missing ${networkName}_RPC_URL or ${networkName}_RPC_URL environment variables`, + }) + } } else { return providers[networkName] } diff --git a/packages/sources/por-address-list/test-payload.json b/packages/sources/por-address-list/test-payload.json index dc59d9684c..7c69179873 100644 --- a/packages/sources/por-address-list/test-payload.json +++ b/packages/sources/por-address-list/test-payload.json @@ -14,5 +14,9 @@ "endpoint": "bedrockBtcAddress" }, { "endpoint": "solvBtcAddress" + }, { + "endpoint": "multichainAddress", + "contractAddress": "0xb7C0817Dd23DE89E4204502dd2C2EF7F57d3A3B8", + "contractAddressNetwork": "BINANCE" }] } diff --git a/packages/sources/por-address-list/test/integration/__snapshots__/adapter-multichain.test.ts.snap b/packages/sources/por-address-list/test/integration/__snapshots__/adapter-multichain.test.ts.snap new file mode 100644 index 0000000000..8d06055162 --- /dev/null +++ b/packages/sources/por-address-list/test/integration/__snapshots__/adapter-multichain.test.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute multichainAddress endpoint should return success 1`] = ` +{ + "data": { + "result": [ + { + "chainId": "56", + "contractAddress": "token1", + "wallets": [ + "vault1", + "vault2", + ], + }, + { + "chainId": "223", + "contractAddress": "token3", + "wallets": [ + "vault3", + ], + }, + ], + }, + "result": null, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; diff --git a/packages/sources/por-address-list/test/integration/adapter-multichain.test.ts b/packages/sources/por-address-list/test/integration/adapter-multichain.test.ts new file mode 100644 index 0000000000..ed99c0a00e --- /dev/null +++ b/packages/sources/por-address-list/test/integration/adapter-multichain.test.ts @@ -0,0 +1,101 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { ethers } from 'ethers' + +const mockExpectedAddresses = [ + { + tokenSymbol: 'BTCB', + chain: 'bnb', + chainId: 56, + tokenAddress: 'token1', + vaultAddress: 'vault1', + }, + { + tokenSymbol: 'BTCB', + chain: 'bnb', + chainId: 56, + tokenAddress: 'token1', + vaultAddress: 'vault2', + }, + { + tokenSymbol: 'B2 BTC', + chain: 'b2', + chainId: 223, + tokenAddress: 'token3', + vaultAddress: 'vault3', + }, +] + +const mockAddressListLength = ethers.BigNumber.from(mockExpectedAddresses.length) + +jest.mock('ethers', () => { + const actualModule = jest.requireActual('ethers') + return { + ...actualModule, + ethers: { + ...actualModule.ethers, + providers: { + JsonRpcProvider: function () { + return { + getBlockNumber: jest.fn().mockReturnValue(1000), + } + }, + }, + Contract: function () { + return { + getPoRAddressListLength: jest.fn().mockReturnValue(mockAddressListLength), + getPoRAddressList: jest.fn().mockImplementation((startIdx, endIdx) => { + const start = startIdx.toNumber() + const end = endIdx.toNumber() + 1 + return mockExpectedAddresses.slice(start, end) + }), + } + }, + }, + } +}) + +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 = process.env.RPC_URL ?? 'http://localhost:8545' + process.env.BSC_RPC_URL = process.env.BSC_RPC_URL ?? 'http://bsc' + process.env.BSC_RPC_CHAIN_ID = process.env.BSC_RPC_CHAIN_ID ?? '56' + 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 + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('multichainAddress endpoint', () => { + it('should return success', async () => { + const data = { + endpoint: 'multichainAddress', + contractAddress: 'mock-contract-address', + contractAddressNetwork: 'BSC', + } + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/por-address-list/tsconfig.json b/packages/sources/por-address-list/tsconfig.json index f59363fd76..a3c83b5433 100644 --- a/packages/sources/por-address-list/tsconfig.json +++ b/packages/sources/por-address-list/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "target": "ESNext" }, "include": ["src/**/*", "src/**/*.json"], "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"]