Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/oracle_core/oracle_interfaces_def.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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)
};

Expand Down Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions src/oracle_interfaces/EvmCommon.h
Original file line number Diff line number Diff line change
@@ -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<uint8, 32> Bytes32;

/// EVM address (20 bytes) left-zero-padded into a 32-byte ABI word.
typedef Array<uint8, 32> Address;

/// EVM 256-bit unsigned integer, big-endian (e.g. an ERC20 token amount).
typedef Array<uint8, 32> 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;
}
}
113 changes: 113 additions & 0 deletions src/oracle_interfaces/EvmErc20Transfer.h
Original file line number Diff line number Diff line change
@@ -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;
}
};
163 changes: 163 additions & 0 deletions test/oracle_engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading