diff --git a/src/oracle_core/oracle_interfaces_def.h b/src/oracle_core/oracle_interfaces_def.h index 64ba5977b..b0facd203 100644 --- a/src/oracle_core/oracle_interfaces_def.h +++ b/src/oracle_core/oracle_interfaces_def.h @@ -17,6 +17,10 @@ namespace OI #include "oracle_interfaces/DogeShareValidation.h" #undef ORACLE_INTERFACE_INDEX +#define ORACLE_INTERFACE_INDEX 3 +#include "oracle_interfaces/EvmErc20Transfer.h" +#undef ORACLE_INTERFACE_INDEX + // add new interface above this line (define ORACLE_INTERFACE_INDEX, include the header file and undef ORACLE_INTERFACE_INDEX) #define DEFINE_ORACLE_INTERFACE(Interface) {sizeof(Interface::OracleQuery), sizeof(Interface::OracleReply)} @@ -28,6 +32,7 @@ namespace OI DEFINE_ORACLE_INTERFACE(Price), DEFINE_ORACLE_INTERFACE(Mock), DEFINE_ORACLE_INTERFACE(DogeShareValidation), + DEFINE_ORACLE_INTERFACE(EvmErc20Transfer), // add new interface above this line (with DEFINE_ORACLE_INTERFACE; the order must match the interfaces indices) }; @@ -58,6 +63,7 @@ namespace OI REGISTER_ORACLE_INTERFACE(Price); REGISTER_ORACLE_INTERFACE(Mock); REGISTER_ORACLE_INTERFACE(DogeShareValidation); + REGISTER_ORACLE_INTERFACE(EvmErc20Transfer); // add new interface above this line (with REGISTER_ORACLE_INTERFACE) for (uint32_t idx = 0; idx < oracleInterfacesCount; ++idx) diff --git a/src/oracle_interfaces/EvmCommon.h b/src/oracle_interfaces/EvmCommon.h new file mode 100644 index 000000000..a404cdca6 --- /dev/null +++ b/src/oracle_interfaces/EvmCommon.h @@ -0,0 +1,56 @@ +#pragma once + +using namespace QPI; + +/** +* Shared building blocks for all EVM cross-chain oracle interfaces. +* +* Keep generic types here (chain ids, address/word representations, helpers) so that future EVM +* oracle interfaces (e.g. reading event logs, storage slots, balances) can reuse them instead of +* redefining their own. Each concrete EVM interface (such as EvmErc20Transfer) includes this header. +* +* Representation conventions (chosen for determinism and to mirror the EVM ABI): +* - All hashes are raw 32-byte big-endian values. +* - All EVM addresses are 20 bytes, stored left-zero-padded into a 32-byte word (the same way the +* ABI encodes an `address`). The QPI Array<> capacity must be a power of two, so 20-byte arrays +* are not representable directly anyway. +* - All EVM 256-bit integers (uint256) are stored as 32-byte big-endian values. +*/ +namespace Evm +{ + /// Raw 32-byte big-endian value: tx hash, block hash, ABI word, etc. + typedef Array Bytes32; + + /// EVM address (20 bytes) left-zero-padded into a 32-byte ABI word. + typedef Array Address; + + /// EVM 256-bit unsigned integer, big-endian (e.g. an ERC20 token amount). + typedef Array Uint256; + + /// Chain ids (decimal) of supported EVM networks. + struct ChainId + { + static constexpr uint64 ethereum = 1; // 0x1 + static constexpr uint64 optimism = 10; // 0xa + static constexpr uint64 bsc = 56; // 0x38 + static constexpr uint64 polygon = 137; // 0x89 + static constexpr uint64 fantom = 250; // 0xfa + static constexpr uint64 base = 8453; // 0x2105 + static constexpr uint64 avalanche = 43114; // 0xa86a + static constexpr uint64 arbitrum = 42161; // 0xa4b1 + // add new chain ids above this line + }; + + /// Return true if the chain id is one this oracle is expected to serve. + static bool isSupportedChain(uint64 chainId) + { + return chainId == ChainId::ethereum + || chainId == ChainId::optimism + || chainId == ChainId::bsc + || chainId == ChainId::polygon + || chainId == ChainId::fantom + || chainId == ChainId::base + || chainId == ChainId::avalanche + || chainId == ChainId::arbitrum; + } +} diff --git a/src/oracle_interfaces/EvmErc20Transfer.h b/src/oracle_interfaces/EvmErc20Transfer.h new file mode 100644 index 000000000..0d092c9e4 --- /dev/null +++ b/src/oracle_interfaces/EvmErc20Transfer.h @@ -0,0 +1,113 @@ +using namespace QPI; + +#include "oracle_interfaces/EvmCommon.h" + +/** +* Oracle interface "EvmErc20Transfer" (see Price.h for general documentation about oracle interfaces). +* +* Cross-chain read of a single ERC20 token transfer (deposit) contained in a given EVM transaction. +* +* Given a transaction hash and chain id (plus optional constraints), the oracle reads the transaction +* on the target EVM chain and reports which ERC20 token was transferred and how much. The intended use +* is bridge/deposit verification: "did tx X on chain Y deposit some ERC20 token to address Z, and how +* much?". +* +* Determinism requirement (see Price.h): consecutive queries with the same OracleQuery must always +* yield the exact same OracleReply, because each computor queries the oracle independently and a quorum +* must agree on the exact reply bytes. A transaction is immutable once finalized, so the oracle machine +* MUST only answer for transactions that are final (enough confirmations / past the chain's finality), +* otherwise a re-org could make computors disagree. Near the chain head the OM should treat the tx as +* not-yet-confirmed (RESULT_TX_NOT_CONFIRMED). +* +* Extending EVM oracles later: add a new interface header that also includes EvmCommon.h and reuses +* Evm::Address / Evm::Uint256 / Evm::ChainId, then register it in oracle_interfaces_def.h with a new +* index. Keep one interface per read kind so each has a fixed OracleQuery/OracleReply layout. +*/ +struct EvmErc20Transfer +{ + //------------------------------------------------------------------------- + // Mandatory oracle interface definitions + + /// Oracle interface index + static constexpr uint32 oracleInterfaceIndex = ORACLE_INTERFACE_INDEX; + + //--- Constraint flags: bitmask in OracleQuery.constraintFlags selecting which optional + // constraints are active. A constraint whose bit is 0 is ignored (its fields need not be set). + static constexpr uint64 CONSTRAINT_NONE = 0; + static constexpr uint64 CONSTRAINT_TIME_RANGE = 1ULL << 0; ///< check block timestamp in [minTimestamp, maxTimestamp] + static constexpr uint64 CONSTRAINT_BLOCK_HEIGHT = 1ULL << 1; ///< check block height in [minBlockHeight, maxBlockHeight] + static constexpr uint64 CONSTRAINT_SOURCE = 1ULL << 2; ///< check token sender (transfer "from") equals sourceAddress + static constexpr uint64 CONSTRAINT_DEST = 1ULL << 3; ///< check token recipient (transfer "to") equals destAddress + + //--- Result codes returned in OracleReply.code. 0 means success; any non-zero value is a failure + // reason and implies tokenAddress and amount are all-zero. New reasons may be appended later. + static constexpr uint64 RESULT_SUCCESS = 0; ///< all constraints passed; exactly one ERC20 transfer found + static constexpr uint64 RESULT_BAD_QUERY = 1; ///< malformed query (zero tx hash, conflicting fields, ...) + static constexpr uint64 RESULT_CHAIN_UNSUPPORTED = 2; ///< chainId not served by this oracle + static constexpr uint64 RESULT_TX_NOT_FOUND = 3; ///< no such transaction on the chain + static constexpr uint64 RESULT_TX_NOT_CONFIRMED = 4; ///< tx pending / reverted / not yet final + static constexpr uint64 RESULT_NO_ERC20_TRANSFER = 5; ///< tx contains no matching ERC20 transfer + static constexpr uint64 RESULT_MULTIPLE_ERC20_TRANSFER = 6; ///< more than one matching ERC20 transfer (ambiguous) + static constexpr uint64 RESULT_CONSTRAINT_TIME_RANGE = 7; ///< block timestamp outside requested range + static constexpr uint64 RESULT_CONSTRAINT_BLOCK_HEIGHT = 8; ///< block height outside requested range + static constexpr uint64 RESULT_CONSTRAINT_SOURCE = 9; ///< token sender does not match sourceAddress + static constexpr uint64 RESULT_CONSTRAINT_DEST = 10; ///< token recipient does not match destAddress + // add new result codes above this line + + /// Oracle query data / input to the oracle machine + struct OracleQuery + { + /// Transaction hash to inspect (32-byte big-endian EVM tx hash). + Evm::Bytes32 txHash; + + /// EVM chain id (decimal), e.g. Evm::ChainId::ethereum (1) or Evm::ChainId::bsc (56). + uint64 chainId; + + /// Bitmask of active optional constraints (see CONSTRAINT_* above). 0 = no extra constraints. + uint64 constraintFlags; + + /// CONSTRAINT_TIME_RANGE: inclusive block-timestamp range the tx must fall within. + DateAndTime minTimestamp; + DateAndTime maxTimestamp; + + /// CONSTRAINT_BLOCK_HEIGHT: inclusive block-height range the tx must fall within. + uint64 minBlockHeight; + uint64 maxBlockHeight; + + /// CONSTRAINT_SOURCE: required token sender (ERC20 transfer "from"), ABI-padded 32-byte address. + Evm::Address sourceAddress; + + /// CONSTRAINT_DEST: required token recipient (ERC20 transfer "to"), ABI-padded 32-byte address. + Evm::Address destAddress; + }; + + /// Oracle reply data / output of the oracle machine. + /// On failure (code != RESULT_SUCCESS) tokenAddress and amount MUST be all-zero so the reply is + /// canonical across computors. + struct OracleReply + { + /// One of the RESULT_* codes above. + uint64 code; + + /// ERC20 token contract address (ABI-padded 32-byte). Valid only if code == RESULT_SUCCESS. + Evm::Address tokenAddress; + + /// Transferred amount as 32-byte big-endian uint256. Valid only if code == RESULT_SUCCESS. + Evm::Uint256 amount; + }; + + /// Return query fee. Cross-chain EVM reads are comparatively expensive. + static sint64 getQueryFee(const OracleQuery& query) + { + return 1000; + } + + //------------------------------------------------------------------------- + // Optional: convenience features for contracts using the oracle interface + + /// True if the reply carries a usable token address + amount. + static bool replyIsValid(const OracleReply& reply) + { + return reply.code == RESULT_SUCCESS; + } +}; diff --git a/test/oracle_engine.cpp b/test/oracle_engine.cpp index 9a32e654c..729902e38 100644 --- a/test/oracle_engine.cpp +++ b/test/oracle_engine.cpp @@ -1924,3 +1924,166 @@ TEST(PriceOracle, SubscriptionFee) EXPECT_EQ(Price::getSubscriptionFee(query, 1024 * 60000), 133); EXPECT_EQ(Price::getSubscriptionFee(query, 2047 * 60000), 133); } + +// --- EvmErc20Transfer oracle interface (cross-chain ERC20 transfer read) --- + +// The query/reply structs must fit in a transaction and must agree with the size table registered +// in oracle_interfaces_def.h. Their sizes are also pinned so accidental layout changes (which would +// break the reply digest that computors must agree on) are caught. +TEST(EvmErc20TransferOracle, DataContract) +{ + using OI::EvmErc20Transfer; + + // registered at index 3 + EXPECT_EQ(EvmErc20Transfer::oracleInterfaceIndex, 3u); + EXPECT_LT(EvmErc20Transfer::oracleInterfaceIndex, OI::oracleInterfacesCount); + + // fits within transaction limits + EXPECT_LE(sizeof(EvmErc20Transfer::OracleQuery), (size_t)MAX_ORACLE_QUERY_SIZE); + EXPECT_LE(sizeof(EvmErc20Transfer::OracleReply), (size_t)MAX_ORACLE_REPLY_SIZE); + + // pinned sizes (no padding holes -> canonical bytes for the reply digest) + EXPECT_EQ(sizeof(EvmErc20Transfer::OracleQuery), 144u); + EXPECT_EQ(sizeof(EvmErc20Transfer::OracleReply), 72u); + + // registry size table matches the actual structs + EXPECT_EQ(OI::oracleInterfaces[EvmErc20Transfer::oracleInterfaceIndex].querySize, sizeof(EvmErc20Transfer::OracleQuery)); + EXPECT_EQ(OI::oracleInterfaces[EvmErc20Transfer::oracleInterfaceIndex].replySize, sizeof(EvmErc20Transfer::OracleReply)); +} + +// The fee must be accepted by the engine (>= MIN_ORACLE_QUERY_FEE) and must be reachable through the +// type-erased function pointer registered by initOracleInterfaces(). +TEST(EvmErc20TransferOracle, QueryFee) +{ + using OI::EvmErc20Transfer; + + EXPECT_TRUE(OI::initOracleInterfaces()); + + EvmErc20Transfer::OracleQuery query{}; + query.chainId = OI::Evm::ChainId::ethereum; + + EXPECT_EQ(EvmErc20Transfer::getQueryFee(query), 1000); + EXPECT_GE(EvmErc20Transfer::getQueryFee(query), MIN_ORACLE_QUERY_FEE); + + // dispatched through the registry pointer gives the same value + auto* feeFunc = OI::getOracleQueryFeeFunc[EvmErc20Transfer::oracleInterfaceIndex]; + ASSERT_NE(feeFunc, nullptr); + EXPECT_EQ(feeFunc(&query), 1000); +} + +TEST(EvmErc20TransferOracle, ChainIds) +{ + namespace Evm = OI::Evm; + + // values from the chain-id table + EXPECT_EQ(Evm::ChainId::ethereum, 1u); + EXPECT_EQ(Evm::ChainId::optimism, 10u); + EXPECT_EQ(Evm::ChainId::bsc, 56u); + EXPECT_EQ(Evm::ChainId::polygon, 137u); + EXPECT_EQ(Evm::ChainId::fantom, 250u); + EXPECT_EQ(Evm::ChainId::base, 8453u); + EXPECT_EQ(Evm::ChainId::avalanche, 43114u); + EXPECT_EQ(Evm::ChainId::arbitrum, 42161u); + + // every listed chain is recognized + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::ethereum)); + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::optimism)); + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::bsc)); + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::polygon)); + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::fantom)); + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::base)); + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::avalanche)); + EXPECT_TRUE(Evm::isSupportedChain(Evm::ChainId::arbitrum)); + + // unknown chain ids are rejected + EXPECT_FALSE(Evm::isSupportedChain(0)); + EXPECT_FALSE(Evm::isSupportedChain(2)); // a real but unsupported chain id + EXPECT_FALSE(Evm::isSupportedChain(999999)); +} + +// Constraint flags must be distinct single bits so they can be OR-combined; result codes must have +// success == 0 and all failure reasons distinct and non-zero. +TEST(EvmErc20TransferOracle, FlagsAndResultCodes) +{ + using E = OI::EvmErc20Transfer; + + const QPI::uint64 flags[] = { + E::CONSTRAINT_TIME_RANGE, E::CONSTRAINT_BLOCK_HEIGHT, E::CONSTRAINT_SOURCE, E::CONSTRAINT_DEST, + }; + EXPECT_EQ(E::CONSTRAINT_NONE, 0u); + for (unsigned i = 0; i < 4; ++i) + { + // power of two (single bit) + EXPECT_NE(flags[i], 0u); + EXPECT_EQ(flags[i] & (flags[i] - 1), 0u); + // pairwise disjoint + for (unsigned j = i + 1; j < 4; ++j) + EXPECT_EQ(flags[i] & flags[j], 0u); + } + // combinable into a mask + const QPI::uint64 all = E::CONSTRAINT_TIME_RANGE | E::CONSTRAINT_BLOCK_HEIGHT | E::CONSTRAINT_SOURCE | E::CONSTRAINT_DEST; + EXPECT_EQ(all, 0xfu); + + EXPECT_EQ(E::RESULT_SUCCESS, 0u); + const QPI::uint64 failureCodes[] = { + E::RESULT_BAD_QUERY, E::RESULT_CHAIN_UNSUPPORTED, E::RESULT_TX_NOT_FOUND, E::RESULT_TX_NOT_CONFIRMED, + E::RESULT_NO_ERC20_TRANSFER, E::RESULT_MULTIPLE_ERC20_TRANSFER, E::RESULT_CONSTRAINT_TIME_RANGE, + E::RESULT_CONSTRAINT_BLOCK_HEIGHT, E::RESULT_CONSTRAINT_SOURCE, E::RESULT_CONSTRAINT_DEST, + }; + const unsigned n = sizeof(failureCodes) / sizeof(failureCodes[0]); + for (unsigned i = 0; i < n; ++i) + { + EXPECT_NE(failureCodes[i], E::RESULT_SUCCESS); + for (unsigned j = i + 1; j < n; ++j) + EXPECT_NE(failureCodes[i], failureCodes[j]); + } +} + +TEST(EvmErc20TransferOracle, ReplyIsValid) +{ + using E = OI::EvmErc20Transfer; + + E::OracleReply reply{}; + reply.code = E::RESULT_SUCCESS; + EXPECT_TRUE(E::replyIsValid(reply)); + + // any non-success code means no usable token/amount + reply.code = E::RESULT_MULTIPLE_ERC20_TRANSFER; + EXPECT_FALSE(E::replyIsValid(reply)); + reply.code = E::RESULT_TX_NOT_FOUND; + EXPECT_FALSE(E::replyIsValid(reply)); +} + +// Two queries built with identical logical values must serialize to identical bytes. This guards the +// determinism requirement: every computor must produce the exact same query bytes (and therefore the +// OM must produce the exact same reply digest), so no padding byte may be left uninitialized. +TEST(EvmErc20TransferOracle, DeterministicQueryBytes) +{ + using E = OI::EvmErc20Transfer; + + auto build = [](E::OracleQuery& q) + { + setMem(&q, sizeof(q), 0); + for (QPI::uint64 i = 0; i < 32; ++i) + q.txHash.set(i, (QPI::uint8)(i + 1)); + q.chainId = OI::Evm::ChainId::bsc; + q.constraintFlags = E::CONSTRAINT_DEST | E::CONSTRAINT_BLOCK_HEIGHT; + q.minBlockHeight = 1000; + q.maxBlockHeight = 2000; + for (QPI::uint64 i = 0; i < 32; ++i) + q.destAddress.set(i, (QPI::uint8)(0xa0 + i)); + }; + + E::OracleQuery a, b; + build(a); + build(b); + EXPECT_EQ(compareMem(&a, &b, sizeof(E::OracleQuery)), 0); + + // survives a copy through a raw wire buffer of the maximum query size + unsigned char buffer[MAX_ORACLE_QUERY_SIZE]; + setMem(buffer, sizeof(buffer), 0); + copyMem(buffer, &a, sizeof(a)); + E::OracleQuery roundtrip{}; + copyMem(&roundtrip, buffer, sizeof(roundtrip)); + EXPECT_EQ(compareMem(&a, &roundtrip, sizeof(E::OracleQuery)), 0); +}