diff --git a/.pnp.cjs b/.pnp.cjs index eef0fd263af..1ef4bfa427c 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -26,6 +26,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/crypto-volatility-index-adapter",\ "reference": "workspace:packages/composites/crypto-volatility-index"\ },\ + {\ + "name": "@chainlink/cusd-feed-adapter",\ + "reference": "workspace:packages/composites/cusd-feed"\ + },\ {\ "name": "@chainlink/glv-token-adapter",\ "reference": "workspace:packages/composites/glv-token"\ @@ -816,6 +820,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/crypto-volatility-index-adapter", ["workspace:packages/composites/crypto-volatility-index"]],\ ["@chainlink/cryptoapis-adapter", ["workspace:packages/sources/cryptoapis"]],\ ["@chainlink/cryptocompare-adapter", ["workspace:packages/sources/cryptocompare"]],\ + ["@chainlink/cusd-feed-adapter", ["workspace:packages/composites/cusd-feed"]],\ ["@chainlink/data-engine-adapter", ["workspace:packages/sources/data-engine"]],\ ["@chainlink/deep-blue-adapter", ["workspace:packages/sources/deep-blue"]],\ ["@chainlink/deribit-adapter", ["workspace:packages/sources/deribit"]],\ @@ -5546,6 +5551,23 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/cusd-feed-adapter", [\ + ["workspace:packages/composites/cusd-feed", {\ + "packageLocation": "./packages/composites/cusd-feed/",\ + "packageDependencies": [\ + ["@chainlink/cusd-feed-adapter", "workspace:packages/composites/cusd-feed"],\ + ["@chainlink/external-adapter-framework", "npm:2.11.4"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["decimal.js", "npm:10.5.0"],\ + ["ethers", "npm:6.16.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"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/data-engine-adapter", [\ ["workspace:packages/sources/data-engine", {\ "packageLocation": "./packages/sources/data-engine/",\ @@ -6153,7 +6175,7 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }],\ ["npm:2.9.0", {\ - "packageLocation": "./.yarn/cache/@chainlink-external-adapter-framework-npm-2.9.0-664e8a533b-36152824af.zip/node_modules/@chainlink/external-adapter-framework/",\ + "packageLocation": "./.yarn/unplugged/@chainlink-external-adapter-framework-npm-2.9.0-664e8a533b/node_modules/@chainlink/external-adapter-framework/",\ "packageDependencies": [\ ["@chainlink/external-adapter-framework", "npm:2.9.0"],\ ["ajv", "npm:8.17.1"],\ @@ -18039,7 +18061,7 @@ const RAW_RUNTIME_STATE = ["@socket.io/component-emitter", "npm:3.1.2"],\ ["debug", "virtual:e376c6d25689d1413f13b759a5649fe969efab30320e886cab81ece2b6daf8c4c74f642faff7228a9a286b4b82bc7bac5773e45f1085910307cd111b19a8cd17#npm:4.3.7"],\ ["engine.io-parser", "npm:5.2.3"],\ - ["ws", "virtual:f266964bbf0a973b765b066fe1b1828807981016fc49075d7d14462508ec0b4c518650d9ae747c8b805b7e3e20b5b050695db51ba47ef5e8e240f1bec894a15f#npm:8.17.1"],\ + ["ws", "virtual:9f3270046395fad7049f40a1fc999033198f0270cd9f18e06c1c9f8356561f9d760eb272fa3799392ca89725dbdc5c8c4d9bb2694bbda26eed0fa6c797fbd554#npm:8.17.1"],\ ["xmlhttprequest-ssl", "npm:2.1.2"]\ ],\ "linkType": "HARD"\ @@ -18587,7 +18609,7 @@ const RAW_RUNTIME_STATE = ["@types/node", "npm:22.7.5"],\ ["aes-js", "npm:4.0.0-beta.5"],\ ["tslib", "npm:2.7.0"],\ - ["ws", "virtual:f266964bbf0a973b765b066fe1b1828807981016fc49075d7d14462508ec0b4c518650d9ae747c8b805b7e3e20b5b050695db51ba47ef5e8e240f1bec894a15f#npm:8.17.1"]\ + ["ws", "virtual:9f3270046395fad7049f40a1fc999033198f0270cd9f18e06c1c9f8356561f9d760eb272fa3799392ca89725dbdc5c8c4d9bb2694bbda26eed0fa6c797fbd554#npm:8.17.1"]\ ],\ "linkType": "HARD"\ }],\ @@ -18601,7 +18623,21 @@ const RAW_RUNTIME_STATE = ["@types/node", "npm:22.7.5"],\ ["aes-js", "npm:4.0.0-beta.5"],\ ["tslib", "npm:2.7.0"],\ - ["ws", "virtual:f266964bbf0a973b765b066fe1b1828807981016fc49075d7d14462508ec0b4c518650d9ae747c8b805b7e3e20b5b050695db51ba47ef5e8e240f1bec894a15f#npm:8.17.1"]\ + ["ws", "virtual:9f3270046395fad7049f40a1fc999033198f0270cd9f18e06c1c9f8356561f9d760eb272fa3799392ca89725dbdc5c8c4d9bb2694bbda26eed0fa6c797fbd554#npm:8.17.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.16.0", {\ + "packageLocation": "./.yarn/cache/ethers-npm-6.16.0-9f32700463-7e980f0a77.zip/node_modules/ethers/",\ + "packageDependencies": [\ + ["ethers", "npm:6.16.0"],\ + ["@adraffy/ens-normalize", "npm:1.10.1"],\ + ["@noble/curves", "npm:1.2.0"],\ + ["@noble/hashes", "npm:1.3.2"],\ + ["@types/node", "npm:22.7.5"],\ + ["aes-js", "npm:4.0.0-beta.5"],\ + ["tslib", "npm:2.7.0"],\ + ["ws", "virtual:9f3270046395fad7049f40a1fc999033198f0270cd9f18e06c1c9f8356561f9d760eb272fa3799392ca89725dbdc5c8c4d9bb2694bbda26eed0fa6c797fbd554#npm:8.17.1"]\ ],\ "linkType": "HARD"\ }]\ @@ -29517,14 +29553,14 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ - ["virtual:a4d909a61e93daed8b0f848db71f77bdb5ea1775c18ad3db85de4172204ddb9f7f82dbc310d890029da6267fa4af96340d5ff43faec0b46ba34566f507673938#npm:8.18.3", {\ - "packageLocation": "./.yarn/__virtual__/ws-virtual-abdb4e3ecf/0/cache/ws-npm-8.18.3-665d39209d-725964438d.zip/node_modules/ws/",\ + ["virtual:9f3270046395fad7049f40a1fc999033198f0270cd9f18e06c1c9f8356561f9d760eb272fa3799392ca89725dbdc5c8c4d9bb2694bbda26eed0fa6c797fbd554#npm:8.17.1", {\ + "packageLocation": "./.yarn/__virtual__/ws-virtual-925e227f38/0/cache/ws-npm-8.17.1-f57fb24a2c-4264ae92c0.zip/node_modules/ws/",\ "packageDependencies": [\ - ["ws", "virtual:a4d909a61e93daed8b0f848db71f77bdb5ea1775c18ad3db85de4172204ddb9f7f82dbc310d890029da6267fa4af96340d5ff43faec0b46ba34566f507673938#npm:8.18.3"],\ + ["ws", "virtual:9f3270046395fad7049f40a1fc999033198f0270cd9f18e06c1c9f8356561f9d760eb272fa3799392ca89725dbdc5c8c4d9bb2694bbda26eed0fa6c797fbd554#npm:8.17.1"],\ ["@types/bufferutil", null],\ ["@types/utf-8-validate", null],\ - ["bufferutil", "npm:4.0.8"],\ - ["utf-8-validate", "npm:5.0.10"]\ + ["bufferutil", null],\ + ["utf-8-validate", null]\ ],\ "packagePeers": [\ "@types/bufferutil",\ @@ -29534,14 +29570,14 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ - ["virtual:f266964bbf0a973b765b066fe1b1828807981016fc49075d7d14462508ec0b4c518650d9ae747c8b805b7e3e20b5b050695db51ba47ef5e8e240f1bec894a15f#npm:8.17.1", {\ - "packageLocation": "./.yarn/__virtual__/ws-virtual-d0741043a0/0/cache/ws-npm-8.17.1-f57fb24a2c-4264ae92c0.zip/node_modules/ws/",\ + ["virtual:a4d909a61e93daed8b0f848db71f77bdb5ea1775c18ad3db85de4172204ddb9f7f82dbc310d890029da6267fa4af96340d5ff43faec0b46ba34566f507673938#npm:8.18.3", {\ + "packageLocation": "./.yarn/__virtual__/ws-virtual-abdb4e3ecf/0/cache/ws-npm-8.18.3-665d39209d-725964438d.zip/node_modules/ws/",\ "packageDependencies": [\ - ["ws", "virtual:f266964bbf0a973b765b066fe1b1828807981016fc49075d7d14462508ec0b4c518650d9ae747c8b805b7e3e20b5b050695db51ba47ef5e8e240f1bec894a15f#npm:8.17.1"],\ + ["ws", "virtual:a4d909a61e93daed8b0f848db71f77bdb5ea1775c18ad3db85de4172204ddb9f7f82dbc310d890029da6267fa4af96340d5ff43faec0b46ba34566f507673938#npm:8.18.3"],\ ["@types/bufferutil", null],\ ["@types/utf-8-validate", null],\ - ["bufferutil", null],\ - ["utf-8-validate", null]\ + ["bufferutil", "npm:4.0.8"],\ + ["utf-8-validate", "npm:5.0.10"]\ ],\ "packagePeers": [\ "@types/bufferutil",\ diff --git a/.yarn/cache/ethers-npm-6.16.0-9f32700463-7e980f0a77.zip b/.yarn/cache/ethers-npm-6.16.0-9f32700463-7e980f0a77.zip new file mode 100644 index 00000000000..1ef7d7f1ec0 Binary files /dev/null and b/.yarn/cache/ethers-npm-6.16.0-9f32700463-7e980f0a77.zip differ diff --git a/package.json b/package.json index ed3d28f3206..cdd6f919ca5 100644 --- a/package.json +++ b/package.json @@ -77,5 +77,10 @@ "resolutions": { "ethereum-cryptography@^1.1.2": "patch:ethereum-cryptography@npm%3A1.1.2#./.yarn/patches/ethereum-cryptography-npm-1.1.2-c16cfd7e8a.patch", "ethereum-cryptography@^1.0.3": "patch:ethereum-cryptography@npm%3A1.1.2#./.yarn/patches/ethereum-cryptography-npm-1.1.2-c16cfd7e8a.patch" + }, + "dependenciesMeta": { + "@chainlink/external-adapter-framework@2.9.0": { + "unplugged": true + } } } diff --git a/packages/composites/cusd-feed/CHANGELOG.md b/packages/composites/cusd-feed/CHANGELOG.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/composites/cusd-feed/README.md b/packages/composites/cusd-feed/README.md new file mode 100644 index 00000000000..91ab1d4455d --- /dev/null +++ b/packages/composites/cusd-feed/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for example-adapter + +This README will be generated automatically when code is merged to `main`. If you would like to generate a preview of the README, please run `yarn generate:readme example-adapter`. diff --git a/packages/composites/cusd-feed/package.json b/packages/composites/cusd-feed/package.json new file mode 100644 index 00000000000..d98adabe02c --- /dev/null +++ b/packages/composites/cusd-feed/package.json @@ -0,0 +1,42 @@ +{ + "name": "@chainlink/cusd-feed-adapter", + "version": "0.0.0", + "description": "Chainlink cusd-feed adapter. Calculates the CUSD feed price by dividing aggregated AUM by the total supply of the cUSD token on Ethereum.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "cusd-feed" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "url": "https://github.com/smartcontractkit/external-adapters-js", + "type": "git" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", + "prepack": "yarn build", + "build": "tsc -b", + "server": "node -e 'require(\"./index.js\").server()'", + "server:dist": "node -e 'require(\"./dist/index.js\").server()'", + "start": "yarn server:dist" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.11.4", + "decimal.js": "^10.3.1", + "ethers": "^6.13.0", + "tslib": "2.4.1" + } +} diff --git a/packages/composites/cusd-feed/src/config/index.ts b/packages/composites/cusd-feed/src/config/index.ts new file mode 100644 index 00000000000..5b8079a3dc1 --- /dev/null +++ b/packages/composites/cusd-feed/src/config/index.ts @@ -0,0 +1,35 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + ETHEREUM_RPC_URL: { + description: 'RPC URL for Ethereum mainnet', + type: 'string', + required: true, + }, + ETHEREUM_CHAIN_ID: { + description: 'Chain ID for Ethereum mainnet', + type: 'number', + default: 1, + }, + PROOF_OF_RESERVES_ADAPTER_URL: { + description: 'URL of the proof-of-reserves EA', + type: 'string', + required: true, + }, + CUSD_CONTRACT_ADDRESS: { + description: 'Address of the cUSD token contract on Ethereum', + type: 'string', + default: '0xcCcc62962d17b8914c62D74FfB843d73B2a3cccC', + }, + POR_ADDRESS_LIST_CONTRACT: { + description: 'Address of the CapChainlinkPoRAddressList contract on Ethereum', + type: 'string', + default: '0x69A22f0fc7b398e637BF830B862C75dd854b2BbF', + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 10_000, + }, +}) diff --git a/packages/composites/cusd-feed/src/config/overrides.json b/packages/composites/cusd-feed/src/config/overrides.json new file mode 100644 index 00000000000..def52cc2626 --- /dev/null +++ b/packages/composites/cusd-feed/src/config/overrides.json @@ -0,0 +1,3 @@ +{ + "cusd-feed": {} +} diff --git a/packages/composites/cusd-feed/src/endpoint/index.ts b/packages/composites/cusd-feed/src/endpoint/index.ts new file mode 100644 index 00000000000..11a44912b4b --- /dev/null +++ b/packages/composites/cusd-feed/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as price } from './price' diff --git a/packages/composites/cusd-feed/src/endpoint/price.ts b/packages/composites/cusd-feed/src/endpoint/price.ts new file mode 100644 index 00000000000..283b5a88335 --- /dev/null +++ b/packages/composites/cusd-feed/src/endpoint/price.ts @@ -0,0 +1,23 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { EmptyInputParameters } from '@chainlink/external-adapter-framework/validation/input-params' +import { config } from '../config' +import { cusdFeedTransport } from '../transport/price' + +export type BaseEndpointTypes = { + Parameters: EmptyInputParameters + Response: { + Result: string + Data: { + result: string + aum: string + totalSupply: string + ratio: string + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'price', + transport: cusdFeedTransport, +}) diff --git a/packages/composites/cusd-feed/src/index.ts b/packages/composites/cusd-feed/src/index.ts new file mode 100644 index 00000000000..239601a77ce --- /dev/null +++ b/packages/composites/cusd-feed/src/index.ts @@ -0,0 +1,13 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { price } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: price.name, + name: 'CUSD_FEED', + config, + endpoints: [price], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/composites/cusd-feed/src/transport/price.ts b/packages/composites/cusd-feed/src/transport/price.ts new file mode 100644 index 00000000000..09caa444796 --- /dev/null +++ b/packages/composites/cusd-feed/src/transport/price.ts @@ -0,0 +1,235 @@ +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 { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterDataProviderError } from '@chainlink/external-adapter-framework/validation/error' +import { TypeFromDefinition } from '@chainlink/external-adapter-framework/validation/input-params' +import Decimal from 'decimal.js' +import { ethers } from 'ethers' +import { BaseEndpointTypes } from '../endpoint/price' + +const logger = makeLogger('CUSD Feed') + +type RequestParams = TypeFromDefinition + +Decimal.set({ precision: 36 }) + +const TOTAL_SUPPLY_ABI = ['function totalSupply() external view returns (uint256)'] + +/** + * Calculates the collateralization ratio scaled by 1e18. + * @param aum - Total AUM as an 18-decimal scaled integer string + * @param totalSupply - Total supply as an 18-decimal scaled integer string + * @returns Object with result (scaled by 1e18), and ratio as decimal string + * @throws Error if totalSupply is zero + */ +export const calculateRatio = ( + aum: string, + totalSupply: string, +): { result: string; ratio: string } => { + const aumDecimal = new Decimal(aum) + const totalSupplyDecimal = new Decimal(totalSupply) + + if (totalSupplyDecimal.isZero()) { + throw new Error('Total supply is zero, cannot calculate ratio') + } + + const ratio = aumDecimal.div(totalSupplyDecimal) + // Scale ratio by 1e18 to return as 18-decimal integer string + const result = ratio.mul(new Decimal('1e18')).toFixed(0) + + return { result, ratio: ratio.toString() } +} + +export const buildPorInputConfig = (settings: BaseEndpointTypes['Settings']) => [ + { + protocol: 'por_address_list', + protocolEndpoint: 'openedenAddress', + contractAddress: settings.POR_ADDRESS_LIST_CONTRACT, + contractAddressNetwork: 'ETHEREUM', + type: 'priced', + abiName: 'MultiEVMPoRAddressList', + indexer: 'token_balance', + indexerEndpoint: 'tbill', + disableDuplicateAddressFiltering: true, + disableAddressValidation: true, + }, + { + protocol: 'por_address_list', + protocolEndpoint: 'openedenAddress', + contractAddress: settings.POR_ADDRESS_LIST_CONTRACT, + contractAddressNetwork: 'ETHEREUM', + type: 'pegged', + abiName: 'MultiEVMPoRAddressList', + indexer: 'token_balance', + indexerEndpoint: 'evm', + disableDuplicateAddressFiltering: true, + disableAddressValidation: true, + }, + { + protocol: 'list', + addresses: [''], + indexer: 'view_function_multi_chain', + indexerEndpoint: 'function', + indexerParams: { + signature: + 'function totalBorrows(address _asset) external view returns (uint256 totalBorrow)', + address: settings.CUSD_CONTRACT_ADDRESS, + network: 'ETHEREUM', + inputParams: ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'], + }, + disableDuplicateAddressFiltering: true, + viewFunctionIndexerResultDecimals: 6, + }, +] + +export class CusdFeedTransport extends SubscriptionTransport { + requester!: Requester + settings!: BaseEndpointTypes['Settings'] + provider!: ethers.JsonRpcProvider + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.settings = adapterSettings + this.requester = dependencies.requester + this.provider = new ethers.JsonRpcProvider( + this.settings.ETHEREUM_RPC_URL, + this.settings.ETHEREUM_CHAIN_ID, + ) + } + + 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() + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest(): Promise> { + const providerDataRequestedUnixMs = Date.now() + + const [aum, totalSupply] = await Promise.all([this.fetchAum(), this.fetchTotalSupply()]) + + try { + const { result, ratio } = calculateRatio(aum, totalSupply) + + return { + data: { + result, + aum, + totalSupply, + ratio, + }, + statusCode: 200, + result, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } catch (e) { + throw new AdapterDataProviderError( + { + statusCode: 502, + message: e instanceof Error ? e.message : 'Calculation error', + }, + { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + ) + } + } + + private async fetchAum(): Promise { + const requestConfig = { + url: this.settings.PROOF_OF_RESERVES_ADAPTER_URL, + method: 'POST', + data: { + data: { + endpoint: 'multiReserves', + input: buildPorInputConfig(this.settings), + outputDecimals: 18, + }, + }, + } + + try { + const response = await this.requester.request<{ result: string }>( + JSON.stringify(requestConfig), + requestConfig, + ) + return response.response.data.result + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + throw new AdapterDataProviderError( + { + statusCode: 502, + message: `Failed to fetch AUM from proof-of-reserves EA: ${errorMessage}`, + }, + { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + ) + } + } + + private async fetchTotalSupply(): Promise { + try { + const contract = new ethers.Contract( + this.settings.CUSD_CONTRACT_ADDRESS, + TOTAL_SUPPLY_ABI, + this.provider, + ) + const totalSupply: bigint = await contract.totalSupply() + return totalSupply.toString() + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + throw new AdapterDataProviderError( + { + statusCode: 502, + message: `Failed to fetch totalSupply from cUSD contract: ${errorMessage}`, + }, + { + providerDataRequestedUnixMs: Date.now(), + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + ) + } + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const cusdFeedTransport = new CusdFeedTransport() diff --git a/packages/composites/cusd-feed/test-payload.json b/packages/composites/cusd-feed/test-payload.json new file mode 100644 index 00000000000..510c0c8d629 --- /dev/null +++ b/packages/composites/cusd-feed/test-payload.json @@ -0,0 +1,7 @@ +{ + "requests": [ + { + "endpoint": "price" + } + ] +} diff --git a/packages/composites/cusd-feed/test/integration/__snapshots__/adapter.test.ts.snap b/packages/composites/cusd-feed/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 00000000000..4f831b2f2f8 --- /dev/null +++ b/packages/composites/cusd-feed/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,158 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute price endpoint different AUM scenarios should calculate correct ratio for exactly 1:1 collateralization (ratio = 1.0) 1`] = ` +{ + "data": { + "aum": "10000000000000000000000000", + "ratio": "1", + "result": "1000000000000000000", + "totalSupply": "10000000000000000000000000", + }, + "result": "1000000000000000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute price endpoint different AUM scenarios should calculate correct ratio for highly collateralized position (ratio = 1.5) 1`] = ` +{ + "data": { + "aum": "15000000000000000000000000", + "ratio": "1.5", + "result": "1500000000000000000", + "totalSupply": "10000000000000000000000000", + }, + "result": "1500000000000000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute price endpoint different AUM scenarios should calculate correct ratio for undercollateralized position (ratio < 1) 1`] = ` +{ + "data": { + "aum": "9000000000000000000000000", + "ratio": "0.9", + "result": "900000000000000000", + "totalSupply": "10000000000000000000000000", + }, + "result": "900000000000000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute price endpoint different AUM scenarios should handle zero AUM (ratio = 0) 1`] = ` +{ + "data": { + "aum": "0", + "ratio": "0", + "result": "0", + "totalSupply": "10000000000000000000000000", + }, + "result": "0", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute price endpoint happy path should return success for default endpoint 1`] = ` +{ + "data": { + "aum": "10500000000000000000000000", + "ratio": "1.05", + "result": "1050000000000000000", + "totalSupply": "10000000000000000000000000", + }, + "result": "1050000000000000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute price endpoint happy path should return success with correct ratio (1.05) 1`] = ` +{ + "data": { + "aum": "10500000000000000000000000", + "ratio": "1.05", + "result": "1050000000000000000", + "totalSupply": "10000000000000000000000000", + }, + "result": "1050000000000000000", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; + +exports[`execute price endpoint upstream failures should return 502 when proof-of-reserves adapter returns 400 1`] = ` +{ + "errorMessage": "Failed to fetch AUM from proof-of-reserves EA: Request failed with status code 400", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 0, + "providerDataRequestedUnixMs": 0, + }, +} +`; + +exports[`execute price endpoint upstream failures should return 502 when proof-of-reserves adapter returns 500 1`] = ` +{ + "errorMessage": "Failed to fetch AUM from proof-of-reserves EA: Request failed with status code 500", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 0, + "providerDataRequestedUnixMs": 0, + }, +} +`; + +exports[`execute price endpoint upstream failures should return 502 when proof-of-reserves adapter times out 1`] = ` +{ + "errorMessage": "Failed to fetch AUM from proof-of-reserves EA: Connection timed out", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 0, + "providerDataRequestedUnixMs": 0, + }, +} +`; + +exports[`execute price endpoint upstream failures should return 502 when totalSupply is zero (cannot calculate ratio) 1`] = ` +{ + "errorMessage": "Total supply is zero, cannot calculate ratio", + "statusCode": 502, + "timestamps": { + "providerDataReceivedUnixMs": 0, + "providerDataRequestedUnixMs": 0, + }, +} +`; + +exports[`execute price endpoint validation should handle unknown endpoint gracefully 1`] = ` +{ + "error": { + "message": "Adapter does not have a "unknown" endpoint.", + "name": "AdapterError", + }, + "status": "errored", + "statusCode": 404, +} +`; diff --git a/packages/composites/cusd-feed/test/integration/adapter.test.ts b/packages/composites/cusd-feed/test/integration/adapter.test.ts new file mode 100644 index 00000000000..bf9f0295c99 --- /dev/null +++ b/packages/composites/cusd-feed/test/integration/adapter.test.ts @@ -0,0 +1,292 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { + MOCK_AUM_EQUAL, + MOCK_AUM_HIGH, + MOCK_AUM_LOW, + MOCK_TOTAL_SUPPLY, + mockPorResponseFailure400, + mockPorResponseFailure500, + mockPorResponseSuccess, + mockPorResponseTimeout, + mockPorResponseWithAum, + mockPorResponseZeroAum, +} from './fixtures' + +// Mock ethers before any imports - we'll control behavior per-test via mockImplementation +const mockTotalSupply = jest.fn() + +jest.mock('ethers', () => ({ + ethers: { + JsonRpcProvider: jest.fn().mockImplementation(() => ({})), + Contract: jest.fn().mockImplementation(() => ({ + totalSupply: mockTotalSupply, + })), + }, +})) + +describe('execute', () => { + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + let spy: jest.SpyInstance + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + + // Mock time for deterministic snapshots + const mockDate = new Date('2001-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + // Set required environment variables + process.env.ETHEREUM_RPC_URL = process.env.ETHEREUM_RPC_URL ?? 'http://localhost:8545' + process.env.ETHEREUM_CHAIN_ID = process.env.ETHEREUM_CHAIN_ID ?? '1' + process.env.PROOF_OF_RESERVES_ADAPTER_URL = + process.env.PROOF_OF_RESERVES_ADAPTER_URL ?? 'http://localhost:8081' + process.env.BACKGROUND_EXECUTE_MS = '0' + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + beforeEach(() => { + // Clear nock mocks from previous test + nock.cleanAll() + + // Clear the EA cache before each test to prevent stale data + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + // Reset mock - do NOT set default value here, let each test configure it + mockTotalSupply.mockReset() + }) + + afterEach(async () => { + // Clear nock mocks + nock.cleanAll() + + // Clear the EA cache between tests + const keys = testAdapter.mockCache?.cache.keys() + if (keys) { + for (const key of keys) { + testAdapter.mockCache?.delete(key) + } + } + + // Small delay to ensure background executor has completed + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('price endpoint', () => { + // IMPORTANT: Run failure tests that depend on ethers mock FIRST + // to avoid caching issues where successful responses persist + describe('upstream failures', () => { + it('should return 502 when totalSupply is zero (cannot calculate ratio)', async () => { + // Set zero totalSupply mock BEFORE any request to ensure the background executor uses it + mockTotalSupply.mockResolvedValue(BigInt(0)) + mockPorResponseSuccess() + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should return 502 when proof-of-reserves adapter returns 500', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseFailure500() + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should return 502 when proof-of-reserves adapter returns 400', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseFailure400() + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + + it('should return 502 when proof-of-reserves adapter times out', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseTimeout() + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(502) + expect(response.json()).toMatchSnapshot() + }) + }) + + describe('happy path', () => { + it('should return success with correct ratio (1.05)', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseSuccess() + const data = { endpoint: 'price' } + + // First call triggers background execution + await testAdapter.request(data) + // Second call retrieves cached result + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return success for default endpoint', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseSuccess() + // Request without specifying endpoint uses default (price) + await testAdapter.request({}) + const response = await testAdapter.request({}) + + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + + it('should return correct response structure', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseSuccess() + await testAdapter.request({ endpoint: 'price' }) + const response = await testAdapter.request({ endpoint: 'price' }) + + expect(response.statusCode).toBe(200) + const json = response.json() + + // Verify response structure + expect(json.data).toBeDefined() + expect(json.data.result).toBe('1050000000000000000') + expect(json.data.aum).toBe('10500000000000000000000000') + expect(json.data.totalSupply).toBe('10000000000000000000000000') + expect(json.data.ratio).toBe('1.05') + expect(json.result).toBe('1050000000000000000') + expect(json.statusCode).toBe(200) + }) + + it('should include timestamps in response', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseSuccess() + await testAdapter.request({ endpoint: 'price' }) + const response = await testAdapter.request({ endpoint: 'price' }) + + expect(response.statusCode).toBe(200) + const json = response.json() + + expect(json.timestamps).toBeDefined() + expect(json.timestamps.providerDataRequestedUnixMs).toBeDefined() + expect(json.timestamps.providerDataReceivedUnixMs).toBeDefined() + }) + }) + + describe('different AUM scenarios', () => { + it('should calculate correct ratio for undercollateralized position (ratio < 1)', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseWithAum(MOCK_AUM_LOW) + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(200) + const json = response.json() + // 9M / 10M = 0.9, scaled by 1e18 + expect(json.data.result).toBe('900000000000000000') + expect(json.data.ratio).toBe('0.9') + expect(json.data.aum).toBe(MOCK_AUM_LOW) + expect(response.json()).toMatchSnapshot() + }) + + it('should calculate correct ratio for highly collateralized position (ratio = 1.5)', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseWithAum(MOCK_AUM_HIGH) + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(200) + const json = response.json() + // 15M / 10M = 1.5, scaled by 1e18 + expect(json.data.result).toBe('1500000000000000000') + expect(json.data.ratio).toBe('1.5') + expect(json.data.aum).toBe(MOCK_AUM_HIGH) + expect(response.json()).toMatchSnapshot() + }) + + it('should calculate correct ratio for exactly 1:1 collateralization (ratio = 1.0)', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseWithAum(MOCK_AUM_EQUAL) + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(200) + const json = response.json() + // 10M / 10M = 1.0, scaled by 1e18 + expect(json.data.result).toBe('1000000000000000000') + expect(json.data.ratio).toBe('1') + expect(json.data.aum).toBe(MOCK_AUM_EQUAL) + expect(response.json()).toMatchSnapshot() + }) + + it('should handle zero AUM (ratio = 0)', async () => { + mockTotalSupply.mockResolvedValue(MOCK_TOTAL_SUPPLY) + mockPorResponseZeroAum() + const data = { endpoint: 'price' } + + await testAdapter.request(data) + const response = await testAdapter.request(data) + + expect(response.statusCode).toBe(200) + const json = response.json() + // 0 / 10M = 0, scaled by 1e18 + expect(json.data.result).toBe('0') + expect(json.data.ratio).toBe('0') + expect(response.json()).toMatchSnapshot() + }) + }) + + describe('validation', () => { + it('should handle unknown endpoint gracefully', async () => { + const data = { endpoint: 'unknown' } + + const response = await testAdapter.request(data) + + // Unknown endpoint returns 404 error + expect(response.statusCode).toBe(404) + expect(response.json()).toMatchSnapshot() + }) + }) + }) +}) diff --git a/packages/composites/cusd-feed/test/integration/fixtures.ts b/packages/composites/cusd-feed/test/integration/fixtures.ts new file mode 100644 index 00000000000..40380bef354 --- /dev/null +++ b/packages/composites/cusd-feed/test/integration/fixtures.ts @@ -0,0 +1,145 @@ +import nock from 'nock' + +// Test constants +export const MOCK_POR_URL = 'http://localhost:8081' +export const MOCK_AUM = '10500000000000000000000000' // 10.5M with 18 decimals +export const MOCK_TOTAL_SUPPLY = BigInt('10000000000000000000000000') // 10M with 18 decimals + +// Expected result: 10.5M / 10M = 1.05, scaled by 1e18 = 1050000000000000000 +export const EXPECTED_RESULT = '1050000000000000000' +export const EXPECTED_RATIO = '1.05' + +// Different AUM values for various test scenarios +export const MOCK_AUM_LOW = '9000000000000000000000000' // 9M - undercollateralized (ratio < 1) +export const MOCK_AUM_HIGH = '15000000000000000000000000' // 15M - highly collateralized (ratio = 1.5) +export const MOCK_AUM_EQUAL = '10000000000000000000000000' // 10M - exactly 1:1 (ratio = 1.0) + +/** + * Mock successful response from proof-of-reserves adapter + */ +export const mockPorResponseSuccess = (): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .reply( + 200, + { + result: MOCK_AUM, + data: { + result: MOCK_AUM, + decimals: 18, + }, + statusCode: 200, + }, + ['Content-Type', 'application/json'], + ) + +/** + * Mock proof-of-reserves response with custom AUM value + */ +export const mockPorResponseWithAum = (aum: string): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .reply( + 200, + { + result: aum, + data: { + result: aum, + decimals: 18, + }, + statusCode: 200, + }, + ['Content-Type', 'application/json'], + ) + +/** + * Mock proof-of-reserves adapter returning 500 error + */ +export const mockPorResponseFailure500 = (): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .reply(500, { error: 'Internal Server Error' }, ['Content-Type', 'application/json']) + +/** + * Mock proof-of-reserves adapter returning 400 error + */ +export const mockPorResponseFailure400 = (): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .reply(400, { error: 'Bad Request' }, ['Content-Type', 'application/json']) + +/** + * Mock proof-of-reserves adapter timeout (no response) + */ +export const mockPorResponseTimeout = (): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .replyWithError({ code: 'ETIMEDOUT', message: 'Connection timed out' }) + +/** + * Mock proof-of-reserves adapter returning invalid JSON + */ +export const mockPorResponseInvalidJson = (): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .reply(200, 'not valid json', ['Content-Type', 'application/json']) + +/** + * Mock proof-of-reserves adapter returning malformed response (missing result field) + */ +export const mockPorResponseMalformed = (): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .reply( + 200, + { + data: { + decimals: 18, + }, + statusCode: 200, + }, + ['Content-Type', 'application/json'], + ) + +/** + * Mock proof-of-reserves adapter returning zero AUM + */ +export const mockPorResponseZeroAum = (): nock.Scope => + nock(MOCK_POR_URL, { + encodedQueryParams: true, + }) + .persist() + .post('/', (body) => body.data?.endpoint === 'multiReserves') + .reply( + 200, + { + result: '0', + data: { + result: '0', + decimals: 18, + }, + statusCode: 200, + }, + ['Content-Type', 'application/json'], + ) diff --git a/packages/composites/cusd-feed/test/unit/transport.test.ts b/packages/composites/cusd-feed/test/unit/transport.test.ts new file mode 100644 index 00000000000..2eee8bf375e --- /dev/null +++ b/packages/composites/cusd-feed/test/unit/transport.test.ts @@ -0,0 +1,203 @@ +jest.mock('@chainlink/external-adapter-framework/transports/abstract/subscription', () => ({ + SubscriptionTransport: class {}, +})) + +import { buildPorInputConfig, calculateRatio } from '../../src/transport/price' + +describe('calculateRatio', () => { + describe('ratio calculation', () => { + it('calculates 1.05 ratio (105% collateralization) scaled by 1e18', () => { + const aum = '10500000000000000000000000' // 10.5M * 1e18 + const totalSupply = '10000000000000000000000000' // 10M * 1e18 + + const { result, ratio } = calculateRatio(aum, totalSupply) + + expect(result).toBe('1050000000000000000') + expect(ratio).toBe('1.05') + }) + + it('calculates 1:1 ratio (100% collateralization) scaled by 1e18', () => { + const aum = '10000000000000000000000000' + const totalSupply = '10000000000000000000000000' + + const { result, ratio } = calculateRatio(aum, totalSupply) + + expect(result).toBe('1000000000000000000') + expect(ratio).toBe('1') + }) + + it('calculates under-collateralized ratio (80%)', () => { + const aum = '8000000000000000000000000' + const totalSupply = '10000000000000000000000000' + + const { result, ratio } = calculateRatio(aum, totalSupply) + + expect(result).toBe('800000000000000000') + expect(ratio).toBe('0.8') + }) + + it('calculates over-collateralized ratio (200%)', () => { + const aum = '20000000000000000000000000' + const totalSupply = '10000000000000000000000000' + + const { result, ratio } = calculateRatio(aum, totalSupply) + + expect(result).toBe('2000000000000000000') + expect(ratio).toBe('2') + }) + + it('handles small values maintaining precision', () => { + const aum = '1000000000000000000' // 1e18 + const totalSupply = '3000000000000000000' // 3e18 + + const { result, ratio } = calculateRatio(aum, totalSupply) + + // 1/3 scaled by 1e18 should be approximately 333333333333333333 + expect(result).toBe('333333333333333333') + expect(ratio).toMatch(/^0\.333/) + }) + + it('handles large values without overflow', () => { + const aum = '100000000000000000000000000000000000' // 1e35 + const totalSupply = '50000000000000000000000000000000000' // 5e34 + + const { result, ratio } = calculateRatio(aum, totalSupply) + + expect(result).toBe('2000000000000000000') + expect(ratio).toBe('2') + }) + }) + + describe('error handling', () => { + it('throws error when total supply is zero', () => { + const aum = '10000000000000000000000000' + const totalSupply = '0' + + expect(() => calculateRatio(aum, totalSupply)).toThrow( + 'Total supply is zero, cannot calculate ratio', + ) + }) + + it('handles zero AUM with non-zero total supply', () => { + const aum = '0' + const totalSupply = '10000000000000000000000000' + + const { result, ratio } = calculateRatio(aum, totalSupply) + + expect(result).toBe('0') + expect(ratio).toBe('0') + }) + }) +}) + +describe('buildPorInputConfig', () => { + const mockSettings = { + POR_ADDRESS_LIST_CONTRACT: '0x69A22f0fc7b398e637BF830B862C75dd854b2BbF', + CUSD_CONTRACT_ADDRESS: '0xcCcc62962d17b8914c62D74FfB843d73B2a3cccC', + } as Parameters[0] + + it('generates config with three input entries', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config).toHaveLength(3) + }) + + describe('first entry (priced reserves)', () => { + it('has correct protocol configuration', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[0].protocol).toBe('por_address_list') + expect(config[0].protocolEndpoint).toBe('openedenAddress') + expect(config[0].type).toBe('priced') + }) + + it('uses POR_ADDRESS_LIST_CONTRACT setting', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[0].contractAddress).toBe('0x69A22f0fc7b398e637BF830B862C75dd854b2BbF') + }) + + it('has correct indexer configuration for tbill', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[0].indexer).toBe('token_balance') + expect(config[0].indexerEndpoint).toBe('tbill') + }) + }) + + describe('second entry (pegged reserves)', () => { + it('has correct protocol configuration', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[1].protocol).toBe('por_address_list') + expect(config[1].protocolEndpoint).toBe('openedenAddress') + expect(config[1].type).toBe('pegged') + }) + + it('uses POR_ADDRESS_LIST_CONTRACT setting', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[1].contractAddress).toBe('0x69A22f0fc7b398e637BF830B862C75dd854b2BbF') + }) + + it('has correct indexer configuration for evm', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[1].indexer).toBe('token_balance') + expect(config[1].indexerEndpoint).toBe('evm') + }) + }) + + describe('third entry (total borrows)', () => { + it('has correct protocol configuration', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[2].protocol).toBe('list') + expect(config[2].indexer).toBe('view_function_multi_chain') + expect(config[2].indexerEndpoint).toBe('function') + }) + + it('uses CUSD_CONTRACT_ADDRESS in indexerParams', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[2].indexerParams.address).toBe('0xcCcc62962d17b8914c62D74FfB843d73B2a3cccC') + }) + + it('has correct function signature for totalBorrows', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[2].indexerParams.signature).toBe( + 'function totalBorrows(address _asset) external view returns (uint256 totalBorrow)', + ) + }) + + it('passes USDC contract as input param', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[2].indexerParams.inputParams).toEqual([ + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ]) + }) + + it('has correct decimal configuration', () => { + const config = buildPorInputConfig(mockSettings) + + expect(config[2].viewFunctionIndexerResultDecimals).toBe(6) + }) + }) + + describe('with different settings values', () => { + it('uses provided contract addresses', () => { + const customSettings = { + POR_ADDRESS_LIST_CONTRACT: '0x1234567890123456789012345678901234567890', + CUSD_CONTRACT_ADDRESS: '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', + } as Parameters[0] + + const config = buildPorInputConfig(customSettings) + + expect(config[0].contractAddress).toBe('0x1234567890123456789012345678901234567890') + expect(config[1].contractAddress).toBe('0x1234567890123456789012345678901234567890') + expect(config[2].indexerParams.address).toBe('0xABCDEF1234567890ABCDEF1234567890ABCDEF12') + }) + }) +}) diff --git a/packages/composites/cusd-feed/tsconfig.json b/packages/composites/cusd-feed/tsconfig.json new file mode 100644 index 00000000000..f59363fd76c --- /dev/null +++ b/packages/composites/cusd-feed/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/composites/cusd-feed/tsconfig.test.json b/packages/composites/cusd-feed/tsconfig.test.json new file mode 100755 index 00000000000..e3de28cb5c0 --- /dev/null +++ b/packages/composites/cusd-feed/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index fdfd8590b06..1948a631ba2 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -11,6 +11,9 @@ { "path": "./composites/crypto-volatility-index" }, + { + "path": "./composites/cusd-feed" + }, { "path": "./composites/glv-token" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 34b47d0d3a5..aa24e9cef09 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -11,6 +11,9 @@ { "path": "./composites/crypto-volatility-index/tsconfig.test.json" }, + { + "path": "./composites/cusd-feed/tsconfig.test.json" + }, { "path": "./composites/glv-token/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 91d0f297f83..40bdc69cde6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2985,6 +2985,21 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/cusd-feed-adapter@workspace:packages/composites/cusd-feed": + version: 0.0.0-use.local + resolution: "@chainlink/cusd-feed-adapter@workspace:packages/composites/cusd-feed" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.11.4" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + decimal.js: "npm:^10.3.1" + ethers: "npm:^6.13.0" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/data-engine-adapter@workspace:*, @chainlink/data-engine-adapter@workspace:^, @chainlink/data-engine-adapter@workspace:packages/sources/data-engine": version: 0.0.0-use.local resolution: "@chainlink/data-engine-adapter@workspace:packages/sources/data-engine" @@ -3613,6 +3628,9 @@ __metadata: ts-node: "npm:10.9.2" typescript: "npm:5.8.3" yo: "npm:4.3.1" + dependenciesMeta: + "@chainlink/external-adapter-framework@2.9.0": + unplugged: true languageName: unknown linkType: soft @@ -13995,6 +14013,21 @@ __metadata: languageName: node linkType: hard +"ethers@npm:^6.13.0": + version: 6.16.0 + resolution: "ethers@npm:6.16.0" + dependencies: + "@adraffy/ens-normalize": "npm:1.10.1" + "@noble/curves": "npm:1.2.0" + "@noble/hashes": "npm:1.3.2" + "@types/node": "npm:22.7.5" + aes-js: "npm:4.0.0-beta.5" + tslib: "npm:2.7.0" + ws: "npm:8.17.1" + checksum: 10/7e980f0a77963fbe14321a3b9746c3ca3cad44932e28bb3506406a66c4b4d9dc1e60ed68d9d784224e9f2582a53d6a0a2e55a7c9559659681f4ad1f70e00e325 + languageName: node + linkType: hard + "ethers@npm:^6.13.2, ethers@npm:^6.14.3, ethers@npm:^6.15.0": version: 6.15.0 resolution: "ethers@npm:6.15.0"