diff --git a/CHANGELOG.md b/CHANGELOG.md index ff1318015..55b6d236b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ the `@nibiruchain/solidity` package. - [#2154](https://github.com/NibiruChain/nibiru/pull/2154) - fix(evm): JSON encoding for the `EIP55Addr` struct was not following the Go conventions and needed to include double quotes around the hexadecimal string. +- [#2156](https://github.com/NibiruChain/nibiru/pull/2156) - test(evm-e2e): add E2E test using the Nibiru Oracle's ChainLink impl #### Nibiru EVM | Before Audit 2 - 2024-12-06 diff --git a/evm-e2e/contracts/NibiruOracleChainLinkLike.sol b/evm-e2e/contracts/NibiruOracleChainLinkLike.sol new file mode 100644 index 000000000..f81b38837 --- /dev/null +++ b/evm-e2e/contracts/NibiruOracleChainLinkLike.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.19; + +import '@nibiruchain/solidity/contracts/IOracle.sol'; + +/// @title NibiruOracleChainLinkLike +/// @notice This contract serves as a ChainLink-like data feed that sources its +/// "answer" value from the Nibiru Oracle system. The Nibiru Oracle gives price +/// data with 18 decimals universally, and that 18-decimal answer is scaled to +/// have the number of decimals specified by "decimals()". This is set at the +/// time of deployment. +/// _ _ _____ ____ _____ _____ _ _ +/// | \ | ||_ _|| _ \|_ _|| __ \ | | | | +/// | \| | | | | |_) | | | | |__) || | | | +/// | . ` | | | | _ < | | | _ / | | | | +/// | |\ | _| |_ | |_) |_| |_ | | \ \ | |__| | +/// |_| \_||_____||____/|_____||_| \_\ \____/ +/// +contract NibiruOracleChainLinkLike is ChainLinkAggregatorV3Interface { + string public pair; + uint8 public _decimals; + + constructor(string memory _pair, uint8 _dec) { + require(_dec <= 18, 'Decimals cannot exceed 18'); + require(bytes(_pair).length > 0, 'Pair string cannot be empty'); + pair = _pair; + _decimals = _dec; + } + + function decimals() external view override returns (uint8) { + return _decimals; + } + + /// @notice Returns a human-readable description of the oracle and its data + /// feed identifier (pair) in the Nibiru Oracle system + function description() external view override returns (string memory) { + return string.concat('Nibiru Oracle ChainLink-like price feed for ', pair); + } + + /// @notice Oracle version number. Hardcoded to 1. + function version() external pure override returns (uint256) { + return 1; + } + + /// @notice Returns the latest data from the Nibiru Oracle. + /// @return roundId The block number when the answer was published onchain. + /// @return answer Data feed result scaled to the precision specified by + /// "decimals()" + /// @return startedAt UNIX timestamp in seconds when "answer" was published. + /// @return updatedAt UNIX timestamp in seconds when "answer" was published. + /// @return answeredInRound The ID of the round where the answer was computed. + /// Since the Nibiru Oracle does not have ChainLink's system of voting + /// rounds, this argument is a meaningless, arbitrary constant. + function latestRoundData() + public + view + override + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + ( + uint80 _roundId, + int256 answer18Dec, + uint256 _startedAt, + uint256 _updatedAt, + uint80 _answeredInRound + ) = NIBIRU_ORACLE.chainLinkLatestRoundData(pair); + answer = scaleAnswerToDecimals(answer18Dec); + return (_roundId, answer, _startedAt, _updatedAt, _answeredInRound); + } + + /// @notice Returns the latest data from the Nibiru Oracle. Historical round + /// retrieval is not supported. This method is a duplicate of + /// "latestRoundData". + /// @return roundId The block number when the answer was published onchain. + /// @return answer Data feed result scaled to the precision specified by + /// "decimals()" + /// @return startedAt UNIX timestamp in seconds when "answer" was published. + /// @return updatedAt UNIX timestamp in seconds when "answer" was published. + /// @return answeredInRound The ID of the round where the answer was computed. + /// Since the Nibiru Oracle does not have ChainLink's system of voting + /// rounds, this argument is a meaningless, arbitrary constant. + function getRoundData( + uint80 + ) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + return latestRoundData(); + } + + function scaleAnswerToDecimals(int256 answer18Dec) internal view returns (int256 answer) { + // Default answers are in 18 decimals. + // Scale down to the decimals specified in the constructor. + uint8 pow10 = 18 - _decimals; + return answer18Dec / int256(10 ** pow10); + } +} diff --git a/evm-e2e/test/nibiru_oracle.test.ts b/evm-e2e/test/nibiru_oracle.test.ts new file mode 100644 index 000000000..28a5f05be --- /dev/null +++ b/evm-e2e/test/nibiru_oracle.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@jest/globals'; +import { toBigInt } from 'ethers'; +import { deployContractNibiruOracleChainLinkLike } from './utils'; + +test('NibiruOracleChainLinkLike implements ChainLink AggregatorV3Interface', async () => { + const { oraclePair, contract } = await deployContractNibiruOracleChainLinkLike(); + + const oracleAddr = await contract.getAddress(); + expect(oracleAddr).not.toBeFalsy(); + + const decimals = await contract.decimals(); + expect(decimals).toEqual(BigInt(8)); + + const description = await contract.description(); + expect(description).toEqual(`Nibiru Oracle ChainLink-like price feed for ${oraclePair}`); + + const version = await contract.version(); + expect(version).toEqual(1n); + + // latestRoundData + const genesisEthUsdPrice = 2000n; + { + const { roundId, answer, startedAt, updatedAt, answeredInRound } = await contract.latestRoundData(); + expect(roundId).toEqual(0n); // price is from genesis block + expect(startedAt).toBeGreaterThan(1n); + expect(updatedAt).toBeGreaterThan(1n); + expect(answeredInRound).toEqual(420n); + expect(answer).toEqual(genesisEthUsdPrice * toBigInt(1e8)); + } + + // getRoundData + { + const { roundId, answer, startedAt, updatedAt, answeredInRound } = await contract.getRoundData(0n); + expect(roundId).toEqual(0n); // price is from genesis block + expect(startedAt).toBeGreaterThan(1n); + expect(updatedAt).toBeGreaterThan(1n); + expect(answeredInRound).toEqual(420n); + expect(answer).toEqual(genesisEthUsdPrice * toBigInt(1e8)); + } +}); diff --git a/evm-e2e/test/utils.ts b/evm-e2e/test/utils.ts index a50528f91..dbf8f4c1c 100644 --- a/evm-e2e/test/utils.ts +++ b/evm-e2e/test/utils.ts @@ -1,11 +1,13 @@ import { account } from './setup'; -import { parseEther, toBigInt, TransactionRequest, Wallet } from 'ethers'; +import { ContractTransactionResponse, parseEther, toBigInt, TransactionRequest, Wallet } from 'ethers'; import { InifiniteLoopGas__factory, SendNibi__factory, TestERC20__factory, EventsEmitter__factory, TransactionReverter__factory, + NibiruOracleChainLinkLike__factory, + NibiruOracleChainLinkLike, } from '../types'; export const alice = Wallet.createRandom(); @@ -66,3 +68,16 @@ export const sendTestNibi = async () => { console.log(txResponse); return txResponse; }; + +export const deployContractNibiruOracleChainLinkLike = async (): Promise<{ + oraclePair: string; + contract: NibiruOracleChainLinkLike & { + deploymentTransaction(): ContractTransactionResponse; + }; +}> => { + const oraclePair = 'ueth:uuusd'; + const factory = new NibiruOracleChainLinkLike__factory(account); + const contract = await factory.deploy(oraclePair, toBigInt(8)); + await contract.waitForDeployment(); + return { oraclePair, contract }; +}; diff --git a/justfile b/justfile index 8bf1c0ad6..cf36ea473 100644 --- a/justfile +++ b/justfile @@ -21,7 +21,7 @@ clean-cache: go clean -cache -testcache -modcache # Generate protobuf-based types in Golang -proto-gen: +gen-proto: #!/usr/bin/env bash make proto-gen @@ -42,8 +42,6 @@ gen-embeds: go run "gen-abi/main.go" log_success "Saved ABI JSON files to $embeds_dir/abi for npm publishing" -alias gen-proto := proto-gen - # Generate the Nibiru Token Registry files gen-token-registry: go run token-registry/main/main.go @@ -121,6 +119,10 @@ test-chaosnet: which_ok nibid bash contrib/scripts/chaosnet.sh +# Alias for "gen-proto" +proto-gen: + just gen-proto + # Stops any `nibid` processes, even if they're running in the background. stop: kill $(pgrep -x nibid) || true