diff --git a/node/derivation/config.go b/node/derivation/config.go index d918d7168..fb010bab3 100644 --- a/node/derivation/config.go +++ b/node/derivation/config.go @@ -108,8 +108,11 @@ func DefaultConfig() *Config { // tag: 10 blocks (~2 min on mainnet) keeps lag low, and the always-on // L1 reorg detector (SPEC-005 §4.7.6 in reorg.go) rewinds the // derivation cursor on hash mismatch so a deeper reorg is recoverable. - // Operators wanting strict no-reorg-possible reads can still set - // --derivation.confirmations=-3 (rpc.FinalizedBlockNumber). + // Applies to every verify mode. Layer1 validators derive their whole + // chain from L1, so a deeper default translates directly into head + // lag and into delayed batch-divergence alerts; deployments that + // want a consensus-backed read instead set + // --derivation.confirmations=-4 (safe) or -3 (finalized) explicitly. Confirmations: DefaultConfirmations, }, PollInterval: DefaultPollInterval, @@ -194,14 +197,6 @@ func (c *Config) SetCliContext(ctx *cli.Context) error { c.MetricsPort = ctx.GlobalUint64(flags.MetricsPort.Name) } - // Layer1-verify validators historically derived only from finalized L1 data - // (the pre-centralized-sequencer default). Preserve that: unless the operator - // explicitly set --derivation.confirmations, a layer1 node reads finalized - // rather than the fixed-depth latest-N default that consensus fullnodes use. - if c.VerifyMode == VerifyModeLayer1 && !ctx.GlobalIsSet(flags.DerivationConfirmations.Name) { - c.L1.Confirmations = rpc.FinalizedBlockNumber - } - if ctx.GlobalIsSet(flags.DerivationReorgCheckDepth.Name) { c.ReorgCheckDepth = ctx.GlobalUint64(flags.DerivationReorgCheckDepth.Name) } diff --git a/node/derivation/reorg.go b/node/derivation/reorg.go index ed457ec0e..1090cf908 100644 --- a/node/derivation/reorg.go +++ b/node/derivation/reorg.go @@ -20,11 +20,10 @@ import ( // derivation cursor + clears stale records. // // This is always-on regardless of the --derivation.confirmations setting. -// When confirmations=finalized (default), L1 finalized doesn't reorg by -// Ethereum consensus assumption, so detectReorg's fast path always returns -// (no reorg) at one L1 RPC per poll. When confirmations is configured below -// finalized (e.g. safe), detection becomes load-bearing without any code -// path divergence. +// At the fixed-depth default it is load-bearing; when an operator sets +// confirmations=finalized, L1 finalized doesn't reorg by Ethereum consensus +// assumption, so detectReorg's fast path always returns (no reorg) at one L1 +// RPC per poll. Neither case diverges in code path. // // L1 reorg does NOT directly trigger an L2 chain rollback in this PR. The // L2 rollback executor (verifyBlockContext + halted state machine + diff --git a/node/flags/flags.go b/node/flags/flags.go index 9fed41e17..b4235eaa6 100644 --- a/node/flags/flags.go +++ b/node/flags/flags.go @@ -292,7 +292,7 @@ var ( DerivationConfirmations = cli.Int64Flag{ Name: "derivation.confirmations", - Usage: "The number of confirmations needed on L1 for finalization. If not set, the default value is l1.confirmations", + Usage: "How deep derivation reads L1: a positive number is a fixed depth below latest, -1 latest, -3 finalized, -4 safe. Applies to every verify mode; defaults to 10 blocks paired with the L1 reorg detector", EnvVar: prefixEnvVar("DERIVATION_CONFIRMATIONS"), } diff --git a/ops/l2-genesis/morph-chain-ops/genesis/devnet_tokens.go b/ops/l2-genesis/morph-chain-ops/genesis/devnet_tokens.go new file mode 100644 index 000000000..28016dc06 --- /dev/null +++ b/ops/l2-genesis/morph-chain-ops/genesis/devnet_tokens.go @@ -0,0 +1,209 @@ +package genesis + +import ( + "fmt" + "math/big" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/core/vm" + "github.com/morph-l2/go-ethereum/crypto" + "github.com/morph-l2/go-ethereum/log" + + "morph-l2/bindings/predeploys" +) + +// DevnetTestToken defines a test token to be pre-registered in TokenRegistry for devnet. +type DevnetTestToken struct { + TokenID uint16 + TokenAddress common.Address + BalanceSlot common.Hash + // NeedBalanceSlot mirrors the flag L2TokenRegistry.registerToken takes. It is + // required because a zero BalanceSlot is ambiguous on its own: slot 0 is where + // OpenZeppelin ERC-20 puts _balances, so it cannot double as "no slot". + NeedBalanceSlot bool + Decimals uint8 + // Scale must be 10^Decimals, matching how L2TokenRegistry's own tests register + // USDC (1e6) and DAI (1e18). Scale cancels out of calculateTokenAmount, so its + // only job is to keep priceRatio from losing significant digits: the oracle + // computes scale * (tokenPrice/ethPrice) * 10^(18-decimals) and truncates to an + // integer, and 10^Decimals is what makes that product settle at 10^18 * + // tokenPrice/ethPrice, i.e. the wei value of one whole token. Scale is *not* the + // decimals adjustment; the oracle already applies 10^(18-decimals) separately. + Scale *big.Int +} + +// GetDevnetTestTokens returns the list of test tokens to pre-register in devnet. +// Token 1: BTC - for testing high-value asset price queries (all data sources support) +// Token 2: ETH - for testing gas token benchmark and relative price calculation +// Token 3: BGB - for testing platform token and CEX-specific data sources +func GetDevnetTestTokens() []DevnetTestToken { + return []DevnetTestToken{ + { + TokenID: 1, + // Placeholder address for a token with no deployed contract. It must stay + // clear of the 0x01-0x0a precompile range, where balanceOf and transfer + // would dispatch to ecRecover and friends instead of failing. + TokenAddress: common.HexToAddress("0x1111111111111111111111111111111111111111"), // Mock BTC address + NeedBalanceSlot: false, + Decimals: 8, + Scale: big.NewInt(1e8), + }, + { + TokenID: 2, + TokenAddress: common.HexToAddress("0x5300000000000000000000000000000000000011"), // L2WETH predeploy address + // WrappedEther keeps _balances at slot 0; slot 3 is _name. + BalanceSlot: common.Hash{}, + NeedBalanceSlot: true, + Decimals: 18, + Scale: big.NewInt(1e18), + }, + { + TokenID: 3, + TokenAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), // Mock BGB address + NeedBalanceSlot: false, + Decimals: 18, + Scale: big.NewInt(1e18), + }, + } +} + +// SetDevnetTestTokens pre-registers inactive test tokens in TokenRegistry storage for devnet. +// The contract owner must activate the tokens and allow the oracle signer before price updates. +func SetDevnetTestTokens(db vm.StateDB) error { + contractAddr := predeploys.L2TokenRegistryAddr + tokens := GetDevnetTestTokens() + + // Storage layout reference (from L2TokenRegistry.sol): + // slot 151: mapping(uint16 => TokenInfo) tokenRegistry + // slot 152: mapping(address => uint16) tokenRegistration + // slot 153: mapping(uint16 => uint256) priceRatio + // slot 156: EnumerableSet.UintSet supportedTokenSet + + tokenRegistrySlot := big.NewInt(151) + tokenRegistrationSlot := big.NewInt(152) + supportedTokenSetSlot := big.NewInt(156) + + log.Info("Pre-registering devnet test tokens in TokenRegistry", "count", len(tokens)) + + for _, token := range tokens { + // Set tokenRegistry[tokenID] = TokenInfo{...} + // Storage location: keccak256(abi.encode(tokenID, 151)) + if err := setTokenInfo(db, contractAddr, tokenRegistrySlot, token); err != nil { + return fmt.Errorf("failed to set tokenRegistry[%d]: %w", token.TokenID, err) + } + + // Set tokenRegistration[tokenAddress] = tokenID + // Storage location: keccak256(abi.encode(tokenAddress, 152)) + if err := setTokenRegistration(db, contractAddr, tokenRegistrationSlot, token.TokenAddress, token.TokenID); err != nil { + return fmt.Errorf("failed to set tokenRegistration[%s]: %w", token.TokenAddress.Hex(), err) + } + + log.Info("Pre-registered devnet token", + "tokenID", token.TokenID, + "address", token.TokenAddress.Hex(), + "decimals", token.Decimals, + "scale", token.Scale.String()) + } + + // Set supportedTokenSet (EnumerableSet.UintSet) + if err := setSupportedTokenSet(db, contractAddr, supportedTokenSetSlot, tokens); err != nil { + return fmt.Errorf("failed to set supportedTokenSet: %w", err) + } + + log.Info("Devnet test tokens pre-registered successfully", "tokenIDs", []uint16{1, 2, 3}) + return nil +} + +// setTokenInfo sets a TokenInfo struct in the tokenRegistry mapping. +func setTokenInfo(db vm.StateDB, contractAddr common.Address, registrySlot *big.Int, token DevnetTestToken) error { + // Calculate base slot: keccak256(abi.encode(tokenID, registrySlot)) + tokenIDBytes := common.LeftPadBytes(big.NewInt(int64(token.TokenID)).Bytes(), 32) + slotBytes := common.LeftPadBytes(registrySlot.Bytes(), 32) + baseSlot := crypto.Keccak256Hash(append(tokenIDBytes, slotBytes...)) + + // TokenInfo struct layout: + // slot+0: tokenAddress (address, 20 bytes) + // slot+1: stored balanceSlot (actual slot + 1 when non-zero) + // slot+2: isActive (bool, 1 byte) + decimals (uint8, 1 byte) in lowest 2 bytes + // slot+3: scale (uint256, 32 bytes) + + // Slot+0: pack tokenAddress (20 bytes) into lowest bytes + slot0Value := new(big.Int).SetBytes(token.TokenAddress.Bytes()) + db.SetState(contractAddr, baseSlot, common.BigToHash(slot0Value)) + + // Slot+1: mirror L2TokenRegistry._toStoredBalanceSlot, which stores the actual + // slot plus one when a slot is needed and zero when it is not. + storedBalanceSlot := common.Hash{} + if token.NeedBalanceSlot { + if token.BalanceSlot == common.HexToHash("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff") { + return fmt.Errorf("balance slot cannot be max uint256") + } + storedBalanceSlot = common.BigToHash(new(big.Int).Add(token.BalanceSlot.Big(), common.Big1)) + } + slot1Key := common.BigToHash(new(big.Int).Add(baseSlot.Big(), big.NewInt(1))) + db.SetState(contractAddr, slot1Key, storedBalanceSlot) + + // Slot+2: isActive=false (0x00) + decimals (1 byte) + // Pack as: [31 zeros][decimals][isActive=0] + slot2Key := common.BigToHash(new(big.Int).Add(baseSlot.Big(), big.NewInt(2))) + slot2Value := new(big.Int).SetUint64(uint64(token.Decimals) << 8) // decimals in second byte + db.SetState(contractAddr, slot2Key, common.BigToHash(slot2Value)) + + // Slot+3: scale (uint256) + slot3Key := common.BigToHash(new(big.Int).Add(baseSlot.Big(), big.NewInt(3))) + db.SetState(contractAddr, slot3Key, common.BigToHash(token.Scale)) + + return nil +} + +// setTokenRegistration sets the reverse mapping tokenRegistration[address] = tokenID. +func setTokenRegistration(db vm.StateDB, contractAddr common.Address, registrationSlot *big.Int, tokenAddress common.Address, tokenID uint16) error { + // Calculate storage location: keccak256(abi.encode(tokenAddress, registrationSlot)) + addrBytes := common.LeftPadBytes(tokenAddress.Bytes(), 32) + slotBytes := common.LeftPadBytes(registrationSlot.Bytes(), 32) + storageKey := crypto.Keccak256Hash(append(addrBytes, slotBytes...)) + + // Set tokenID as uint16 (2 bytes) in storage + tokenIDValue := new(big.Int).SetUint64(uint64(tokenID)) + db.SetState(contractAddr, storageKey, common.BigToHash(tokenIDValue)) + + return nil +} + +// setSupportedTokenSet sets EnumerableSet.UintSet for supported token IDs. +func setSupportedTokenSet(db vm.StateDB, contractAddr common.Address, setBaseSlot *big.Int, tokens []DevnetTestToken) error { + // EnumerableSet.UintSet layout: + // struct UintSet { + // Set _inner; // slot 156 + // } + // struct Set { + // bytes32[] _values; // slot 156+0: array length at base slot, elements at keccak256(baseSlot) + // mapping(bytes32 => uint256) _indexes; // slot 156+1: mapping base + // } + + // Set _values array length (number of tokens) + lengthSlot := common.BigToHash(setBaseSlot) + db.SetState(contractAddr, lengthSlot, common.BigToHash(big.NewInt(int64(len(tokens))))) + + // Calculate _values array storage location: keccak256(baseSlot) + valuesBaseSlot := crypto.Keccak256Hash(lengthSlot.Bytes()) + + // Set each token ID in _values array and _indexes mapping + for i, token := range tokens { + // Set _values[i] = tokenID (stored as bytes32/uint256) + elemSlot := common.BigToHash(new(big.Int).Add(valuesBaseSlot.Big(), big.NewInt(int64(i)))) + tokenIDValue := new(big.Int).SetUint64(uint64(token.TokenID)) + db.SetState(contractAddr, elemSlot, common.BigToHash(tokenIDValue)) + + // Set _indexes[tokenID] = i+1 (1-based index, 0 means not in set) + // Storage location: keccak256(abi.encode(tokenID, setBaseSlot+1)) + indexesBaseSlot := new(big.Int).Add(setBaseSlot, big.NewInt(1)) + tokenIDBytes := common.LeftPadBytes(big.NewInt(int64(token.TokenID)).Bytes(), 32) + indexSlotBytes := common.LeftPadBytes(indexesBaseSlot.Bytes(), 32) + indexKey := crypto.Keccak256Hash(append(tokenIDBytes, indexSlotBytes...)) + indexValue := new(big.Int).SetInt64(int64(i + 1)) // 1-based + db.SetState(contractAddr, indexKey, common.BigToHash(indexValue)) + } + + return nil +} diff --git a/ops/l2-genesis/morph-chain-ops/genesis/devnet_tokens_test.go b/ops/l2-genesis/morph-chain-ops/genesis/devnet_tokens_test.go new file mode 100644 index 000000000..dfe87e09f --- /dev/null +++ b/ops/l2-genesis/morph-chain-ops/genesis/devnet_tokens_test.go @@ -0,0 +1,116 @@ +package genesis + +import ( + "math/big" + "testing" + + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "morph-l2/bindings/predeploys" + "morph-l2/morph-deployer/morph-chain-ops/state" +) + +func TestSetDevnetTestTokensStorageLayout(t *testing.T) { + db := state.NewMemoryStateDB(nil) + contractAddr := predeploys.L2TokenRegistryAddr + db.CreateAccount(contractAddr) + + require.NoError(t, SetDevnetTestTokens(db)) + + tokens := GetDevnetTestTokens() + registrySlot := big.NewInt(151) + registrationSlot := big.NewInt(152) + supportedSetSlot := big.NewInt(156) + + require.Equal(t, common.BigToHash(big.NewInt(int64(len(tokens)))), db.GetState(contractAddr, common.BigToHash(supportedSetSlot))) + + valuesBaseSlot := crypto.Keccak256Hash(common.BigToHash(supportedSetSlot).Bytes()) + for i, token := range tokens { + baseSlot := mappingSlot(new(big.Int).SetUint64(uint64(token.TokenID)), registrySlot) + + // These assertions mirror getTokenInfo(), including balance-slot decoding. + require.Equal(t, common.BytesToHash(token.TokenAddress.Bytes()), db.GetState(contractAddr, baseSlot)) + storedBalanceSlot := db.GetState(contractAddr, offsetSlot(baseSlot, 1)) + hasBalanceSlot := storedBalanceSlot != (common.Hash{}) + require.Equal(t, token.NeedBalanceSlot, hasBalanceSlot) + if hasBalanceSlot { + actualBalanceSlot := common.BigToHash(new(big.Int).Sub(storedBalanceSlot.Big(), common.Big1)) + require.Equal(t, token.BalanceSlot, actualBalanceSlot) + } + + statusAndDecimals := db.GetState(contractAddr, offsetSlot(baseSlot, 2)).Big().Uint64() + require.Zero(t, statusAndDecimals&0xff, "tokens must start inactive") + require.Equal(t, uint64(token.Decimals), statusAndDecimals>>8) + require.Equal(t, common.BigToHash(token.Scale), db.GetState(contractAddr, offsetSlot(baseSlot, 3))) + + // This mirrors getTokenIdByAddress(). + reverseSlot := mappingSlot(new(big.Int).SetBytes(token.TokenAddress.Bytes()), registrationSlot) + require.Equal(t, common.BigToHash(new(big.Int).SetUint64(uint64(token.TokenID))), db.GetState(contractAddr, reverseSlot)) + + // These assertions mirror getSupportedIDList() and getSupportedTokenList(). + valueSlot := offsetSlot(valuesBaseSlot, int64(i)) + require.Equal(t, common.BigToHash(new(big.Int).SetUint64(uint64(token.TokenID))), db.GetState(contractAddr, valueSlot)) + indexSlot := mappingSlot(new(big.Int).SetUint64(uint64(token.TokenID)), new(big.Int).Add(supportedSetSlot, common.Big1)) + require.Equal(t, common.BigToHash(new(big.Int).SetInt64(int64(i+1))), db.GetState(contractAddr, indexSlot)) + } +} + +// TestDevnetTestTokenDefinitions pins the two properties that cannot be derived from +// the encoding itself: WETH's balance slot is a real slot 0 rather than "no slot", and +// the placeholder addresses stay out of the precompile range. +func TestDevnetTestTokenDefinitions(t *testing.T) { + byID := make(map[uint16]DevnetTestToken) + for _, token := range GetDevnetTestTokens() { + byID[token.TokenID] = token + } + + weth := byID[2] + require.Equal(t, predeploys.L2WETHAddr, weth.TokenAddress) + require.True(t, weth.NeedBalanceSlot, "WrappedEther keeps _balances at slot 0, which still needs to be registered") + require.Equal(t, common.Hash{}, weth.BalanceSlot) + + lowestNonPrecompile := big.NewInt(0xff) + for _, token := range byID { + require.Positive(t, new(big.Int).SetBytes(token.TokenAddress.Bytes()).Cmp(lowestNonPrecompile), + "token %d address %s falls in the precompile range", token.TokenID, token.TokenAddress) + + // A scale of 10^(18-decimals) reads plausibly but double-applies the decimals + // adjustment the oracle already makes, which collapses to 1 for an 18-decimal + // token and truncates every priceRatio below ETH to zero. + want := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(token.Decimals)), nil) + require.Zero(t, want.Cmp(token.Scale), + "token %d scale must be 10^decimals, got %s", token.TokenID, token.Scale) + } +} + +// TestDevnetTestTokenPriceRatioKeepsPrecision walks the oracle's priceRatio formula for +// the cheapest pre-registered token. The registry stores priceRatio as a uint256, so a +// scale that leaves the ratio below 1 makes the token permanently unpriceable. +func TestDevnetTestTokenPriceRatioKeepsPrecision(t *testing.T) { + // BGB near its spot price against ETH, the widest token/ETH gap in the set. + tokenPriceUSD := big.NewFloat(1.5954) + ethPriceUSD := big.NewFloat(1845.55) + + for _, token := range GetDevnetTestTokens() { + ratio := new(big.Float).SetInt(token.Scale) + ratio.Mul(ratio, tokenPriceUSD) + ratio.Mul(ratio, new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(18-token.Decimals)), nil))) + ratio.Quo(ratio, ethPriceUSD) + + truncated, _ := ratio.Int(nil) + require.Positive(t, truncated.Sign(), + "token %d priceRatio truncates to zero with scale %s", token.TokenID, token.Scale) + } +} + +func mappingSlot(key, slot *big.Int) common.Hash { + keyBytes := common.LeftPadBytes(key.Bytes(), 32) + slotBytes := common.LeftPadBytes(slot.Bytes(), 32) + return crypto.Keccak256Hash(append(keyBytes, slotBytes...)) +} + +func offsetSlot(base common.Hash, offset int64) common.Hash { + return common.BigToHash(new(big.Int).Add(base.Big(), big.NewInt(offset))) +} diff --git a/ops/l2-genesis/morph-chain-ops/genesis/layer_two.go b/ops/l2-genesis/morph-chain-ops/genesis/layer_two.go index 46a517f8f..b8871da4d 100644 --- a/ops/l2-genesis/morph-chain-ops/genesis/layer_two.go +++ b/ops/l2-genesis/morph-chain-ops/genesis/layer_two.go @@ -50,6 +50,15 @@ func BuildL2DeveloperGenesis(config *DeployConfig, l1StartBlock *types.Block, cu return nil, common.Hash{}, err } + // Pre-register test tokens in TokenRegistry for devnet. Gated on the same flag as + // the dev accounts: on mainnet and testnet these would occupy token IDs 1-3 and + // change the genesis state root. + if config.FundDevAccounts { + if err := SetDevnetTestTokens(db); err != nil { + return nil, common.Hash{}, fmt.Errorf("failed to pre-register devnet test tokens: %w", err) + } + } + withdrawRoot := withdrawtrie.ReadWTRSlot(rcfg.L2MessageQueueAddress, db) fmt.Println("get withdraw root:", withdrawRoot) diff --git a/token-price-oracle/DEVNET_SETUP.md b/token-price-oracle/DEVNET_SETUP.md new file mode 100644 index 000000000..412444780 --- /dev/null +++ b/token-price-oracle/DEVNET_SETUP.md @@ -0,0 +1,188 @@ +# Devnet TokenRegistry Setup + +Developer genesis pre-registers three tokens in `L2TokenRegistry`. Registration +alone does not make the tokens immediately updateable: the contract owner must +activate them and allow the oracle signer before starting `token-price-oracle`. + +Shell commands below run from the repository root unless stated otherwise. + +## Pre-registered tokens + +| Token ID | Symbol | Address | Decimals | Scale | Purpose | +|----------|--------|---------|----------|-------|---------| +| 1 | BTC | `0x1111111111111111111111111111111111111111` | 8 | 10^8 | High-value asset feed testing | +| 2 | ETH | `0x5300000000000000000000000000000000000011` | 18 | 10^18 | L2WETH benchmark | +| 3 | BGB | `0x3333333333333333333333333333333333333333` | 18 | 10^18 | CEX-specific feed testing | + +BTC and BGB use placeholder addresses and are not deployed ERC-20 contracts. +They are kept clear of the `0x01`-`0x0a` precompile range so that a call to +`balanceOf` cannot silently dispatch to a precompile. + +Scale is `10^decimals`, the same convention `L2TokenRegistry`'s tests use for +USDC and DAI. It is not the decimals adjustment: the oracle already multiplies by +`10^(18-decimals)` on its own, and scale cancels out of `calculateTokenAmount` +entirely. What it does control is how much of `priceRatio` survives truncation to +a uint256, and `10^decimals` is what makes the stored ratio equal +`10^18 * tokenPrice/ethPrice` for every token regardless of its decimals. Setting +it to `10^(18-decimals)` instead yields a scale of 1 for an 18-decimal token, +which truncates the ratio of anything cheaper than ETH to zero and leaves the +token permanently unpriceable. + +## Storage initialization + +`SetDevnetTestTokens` runs after the system contract implementations are +installed by `BuildL2DeveloperGenesis`, and only when `fundDevAccounts` is set, +so non-developer genesis is unaffected. It initializes: + +- `tokenRegistry[tokenID]` +- `tokenRegistration[tokenAddress]` +- `supportedTokenSet` + +Tokens are deliberately initialized with `isActive=false`. Balance slots follow +the `L2TokenRegistry` encoding: a token that needs one stores `balanceSlot + 1` +and a token that does not stores zero. The `NeedBalanceSlot` flag carries that +distinction, because slot 0 is a real balance slot (it is where `WrappedEther` +keeps `_balances`) and cannot be represented by a zero value alone. + +## Start and verify the devnet + +```bash +make devnet-down +rm -rf ops/docker/.devnet +make devnet-up +``` + +This is enough to pick up a new L2 genesis, which is regenerated on every run. +It does not reset L1: `make devnet-down` removes the containers and the network +but not the named volumes, so `layer1-el-data` survives and the L1 chain resumes +where it left off. The L1 contracts are then redeployed from the same account at +higher nonces, so every L1 address in `ops/docker/.env` shifts between runs. That +is expected — those are plain `CREATE` addresses derived from the deployer nonce, +unlike the L2 predeploys, which are fixed by genesis. To start L1 from scratch as +well: + +```bash +cd ops/docker && docker compose -f docker-compose-devnet.yml down --volumes +``` + +Verify the registered IDs with the `getSupportedIDList()` selector or a contract +binding. The expected decoded result is `[1, 2, 3]`. + +## Activate tokens and allow the oracle + +Perform both owner operations before starting the oracle: + +```bash +REGISTRY=0x5300000000000000000000000000000000000021 +RPC_URL=http://localhost:8545 +OWNER_PRIVATE_KEY="" +ORACLE_ADDRESS="" + +cast send "$REGISTRY" \ + "batchUpdateTokenStatus(uint16[],bool[])" \ + "[1,2,3]" "[true,true,true]" \ + --rpc-url "$RPC_URL" \ + --private-key "$OWNER_PRIVATE_KEY" + +cast send "$REGISTRY" \ + "setAllowList(address[],bool[])" \ + "[$ORACLE_ADDRESS]" "[true]" \ + --rpc-url "$RPC_URL" \ + --private-key "$OWNER_PRIVATE_KEY" +``` + +The private key must belong only to an isolated local devnet account. Never use +it on a public network or commit it to the repository. + +## Configure token-price-oracle + +```bash +export TOKEN_PRICE_ORACLE_L2_ETH_RPC=http://localhost:8545 +export TOKEN_PRICE_ORACLE_PRIVATE_KEY="" +export TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL=30s +export TOKEN_PRICE_ORACLE_PRICE_THRESHOLD=100 +export TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=chainlink,pyth,bitget,okx + +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET="1:BTCUSDT,2:ETHUSDT,3:BGBUSDT" +export TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL=https://api.bitget.com +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX="1:BTC-USDT,2:ETH-USDT" +export TOKEN_PRICE_ORACLE_OKX_API_BASE_URL=https://www.okx.com + +export TOKEN_PRICE_ORACLE_CHAINLINK_RPC=https://ethereum-rpc.publicnode.com +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK="1:0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c,2:0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" +export TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED=0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 +export TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS=1h + +export TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL=https://hermes.pyth.network +export TOKEN_PRICE_ORACLE_PYTH_API_KEY="" +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH="1:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43,2:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" +export TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID=0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace +export TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS=1m +export TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS=500 + +export TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE=true +export TOKEN_PRICE_ORACLE_METRICS_PORT=6060 +``` + +BGB is only listed on Bitget, so token 3 is deliberately absent from the Chainlink, +Pyth, and OKX mappings. Feeds omit tokens they cannot map rather than failing the +batch, so the higher-priority feeds resolve tokens 1 and 2 and Bitget resolves +token 3. + +Use an isolated devnet-only oracle private key. For production, use the external +signing mode described in [`README.md`](README.md). + +## Start the oracle + +Run the binary directly: + +```bash +cd token-price-oracle +./build/bin/token-price-oracle +``` + +Or run the container with an explicit name. `--env-file` takes `KEY=value` lines +rather than shell exports, so write the settings above to `devnet.env` first and +pass the path explicitly: + +```bash +cd token-price-oracle +env | grep '^TOKEN_PRICE_ORACLE_' > devnet.env + +docker run -d \ + --name token-price-oracle \ + --network docker_default \ + --env-file ./devnet.env \ + morph/token-price-oracle:latest + +docker logs -f token-price-oracle +``` + +`devnet.env` holds a private key, so keep it out of version control. + +## Verification checklist + +These checks require a freshly generated devnet and are not implied by unit +tests: + +- [ ] `getSupportedIDList()` returns `[1, 2, 3]`. +- [ ] `getTokenInfo()` returns the expected address, balance slot, decimals, + scale, and inactive initial status. +- [ ] The owner activates token IDs 1, 2, and 3. +- [ ] The owner adds the oracle signer to the allowlist. +- [ ] The oracle fetches all configured prices. +- [ ] `batchUpdatePrices` succeeds on-chain. +- [ ] `priceRatio()` is non-zero for all three token IDs, and the oracle logs no + `Skipping zero price`. A ratio that truncates to zero is dropped with only a + warning, so a scale mistake surfaces here rather than as a failed update. +- [ ] The metrics endpoint reports the successful update and `unresolved_tokens` + is 0. + +## Troubleshooting + +- `No tokens to update`: regenerate the devnet genesis and verify + `getSupportedIDList()`. +- `CallerNotAllowed`: add the oracle signer to the allowlist. +- Inactive-token errors: call `batchUpdateTokenStatus` before starting the + oracle. +- All feeds fail: verify network access, endpoints, mappings, and API keys. diff --git a/token-price-oracle/README.md b/token-price-oracle/README.md index 0fa987961..fd1557b51 100644 --- a/token-price-oracle/README.md +++ b/token-price-oracle/README.md @@ -4,7 +4,7 @@ Token Price Oracle service monitors token prices and updates the price ratio bet ## Features -- **Real-time Price Monitoring**: Fetches token USD prices from exchange APIs (Bitget) +- **Real-time Price Monitoring**: Fetches token USD prices from Chainlink, Pyth Hermes, Bitget, Binance, and OKX - **Price Ratio Calculation**: Computes price ratio between tokens and ETH - **Threshold-based Updates**: Only updates on-chain when price change exceeds threshold, saving Gas - **Batch Updates**: Updates multiple token prices in a single `batchUpdatePrices` transaction @@ -23,6 +23,20 @@ export TOKEN_PRICE_ORACLE_PRIVATE_KEY="0x..." # Required for local signing only export TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL="https://api.bitget.com" export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET="1:BTCUSDT,2:ETHUSDT" +# Optional: prefer oracle feeds first, fallback to CEX sources +export TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY="chainlink,pyth,bitget,binance,okx" +export TOKEN_PRICE_ORACLE_CHAINLINK_RPC="https://ethereum-rpc.publicnode.com" +export TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED="0x..." +export TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS="1h" +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK="1:0x...,2:0x..." +export TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL="https://hermes.pyth.network" +export TOKEN_PRICE_ORACLE_PYTH_API_KEY="..." +export TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID="0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" +export TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS="1h" +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH="1:0x...,2:0x..." +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE="1:BTCUSDT,2:ETHUSDT" +export TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX="1:BTC-USDT,2:ETH-USDT" + # Optional export TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL="1m" export TOKEN_PRICE_ORACLE_PRICE_THRESHOLD="100" # 1% (100 bps) @@ -59,8 +73,9 @@ docker run -d \ | Environment Variable | Description | |---------------------|-------------| | `TOKEN_PRICE_ORACLE_L2_ETH_RPC` | L2 node RPC endpoint | -| `TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL` | Bitget API base URL | -| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET` | TokenID to trading pair mapping | + +The optional price-feed priority defaults to `bitget`. Each enabled feed +requires its own mapping and configuration from the feed sections below. ### Required (Local Signing Mode Only) @@ -74,13 +89,58 @@ docker run -d \ |---------------------|---------|-------------| | `TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL` | `1m` | Price update interval | | `TOKEN_PRICE_ORACLE_PRICE_THRESHOLD` | `100` | Update threshold (basis points, 100=1%) | -| `TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY` | `bitget` | Price feed priority | +| `TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY` | `bitget` | Price feed priority (`chainlink`, `pyth`, `bitget`, `binance`, `okx`) | | `TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE` | `false` | Enable metrics server | | `TOKEN_PRICE_ORACLE_METRICS_HOSTNAME` | `0.0.0.0` | Metrics server hostname | | `TOKEN_PRICE_ORACLE_METRICS_PORT` | `6060` | Metrics server port | | `TOKEN_PRICE_ORACLE_LOG_LEVEL` | `info` | Log level | | `TOKEN_PRICE_ORACLE_LOG_FILENAME` | - | Log file path | +### Chainlink Feed + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `TOKEN_PRICE_ORACLE_CHAINLINK_RPC` | - | RPC endpoint used to read Chainlink AggregatorV3 feeds | +| `TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED` | - | Chainlink ETH/USD AggregatorV3 feed address | +| `TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS` | `1h` | Maximum accepted age of Chainlink rounds | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK` | - | TokenID to token/USD AggregatorV3 feed mapping | + +### Pyth Hermes Feed + +Pyth is consumed as an off-chain Hermes data source. The service reads parsed prices from Hermes and still writes the existing `priceRatio` to `L2TokenRegistry`; it does not submit Pyth updates on-chain. + +Hermes requires authentication from 2026-08-18 on both the current and the upgraded endpoint, so `PYTH_API_KEY` is mandatory whenever the pyth feed is enabled. Register for a key at [Pyth Terminal](https://terminal.pyth.network) and see the [upgrade guide](https://docs.pyth.network/price-feeds/core/upgrade/preparing) for the endpoint migration. + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL` | `https://hermes.pyth.network` | Pyth Hermes API base URL. Set `https://pyth.dourolabs.app/hermes` to move to the upgraded endpoint ahead of the cutover | +| `TOKEN_PRICE_ORACLE_PYTH_API_KEY` | - | Pyth Hermes API key, required whenever `pyth` is in the priority list | +| `TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID` | - | Pyth ETH/USD price ID | +| `TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS` | `1h` | Maximum accepted age of Pyth publish time | +| `TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS` | `0` | Maximum confidence interval relative to price in BPS; `0` disables the check | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH` | - | TokenID to token/USD Pyth price ID mapping | + +### CEX Feeds + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL` | - | Bitget API base URL, required when Bitget is enabled | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET` | - | TokenID to Bitget trading pair mapping, e.g. `1:BTCUSDT` | +| `TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL` | `https://api.binance.com` | Binance API base URL | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE` | - | TokenID to Binance trading pair mapping, e.g. `1:BTCUSDT` | +| `TOKEN_PRICE_ORACLE_OKX_API_BASE_URL` | `https://www.okx.com` | OKX API base URL | +| `TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX` | - | TokenID to OKX instrument mapping, e.g. `1:BTC-USDT` | + +Example priority with oracle feeds first and CEX fallback: + +```bash +TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=chainlink,pyth,bitget,binance,okx +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK=1:0x...,2:0x... +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH=1:0x...,2:0x... +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE=1:BTCUSDT,2:ETHUSDT +TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX=1:BTC-USDT,2:ETH-USDT +``` + ### External Signing (Recommended for Production) | Environment Variable | Description | @@ -154,11 +214,14 @@ token-price-oracle/ ├── cmd/ # Entry point ├── flags/ # CLI flags definition ├── config/ # Configuration loading -├── client/ # Client wrappers -│ ├── l2_client.go # L2 chain client -│ ├── price_feed.go # Price feed interface -│ ├── bitget_sdk.go # Bitget API client -│ └── sign.go # External signing +├── client/ # Client wrappers +│ ├── l2_client.go # L2 chain client +│ ├── price_feed.go # Price feed interface +│ ├── bitget_sdk.go # Bitget API client +│ ├── cex_feed.go # Binance and OKX API clients +│ ├── chainlink_feed.go # Chainlink AggregatorV3 client +│ ├── pyth_feed.go # Pyth Hermes client +│ └── sign.go # External signing ├── updater/ # Update logic │ ├── token_price.go # Price updater │ ├── tx_manager.go # Transaction manager @@ -185,6 +248,9 @@ cp env.example .env source .env && make run ``` +To run against a local devnet, where genesis pre-registers the test tokens the +oracle prices, see [`DEVNET_SETUP.md`](DEVNET_SETUP.md). + ## License MIT diff --git a/token-price-oracle/client/bitget_sdk.go b/token-price-oracle/client/bitget_sdk.go index 1f92f33ab..2cfb2b046 100644 --- a/token-price-oracle/client/bitget_sdk.go +++ b/token-price-oracle/client/bitget_sdk.go @@ -27,9 +27,9 @@ const ( // This type is safe for concurrent use by multiple goroutines type BitgetSDKPriceFeed struct { httpClient *http.Client - mu sync.RWMutex // protects tokenMap and ethPrice - tokenMap map[uint16]string // guarded by mu - ethPrice *big.Float // guarded by mu + mu sync.RWMutex // protects tokenMap and ethPrice + tokenMap map[uint16]string // guarded by mu + ethPrice *big.Float // guarded by mu log log.Logger baseURL string } @@ -66,8 +66,13 @@ func NewBitgetSDKPriceFeed(tokenMap map[uint16]string, baseURL string) *BitgetSD } } -// GetTokenPrice returns token price in USD -// Note: Caller should ensure ETH price is updated via GetBatchTokenPrices for batch operations +// GetTokenPrice returns token price in USD. +// +// The token price is always fetched fresh. The ETH leg is not: GetBatchTokenPrices +// samples it once per cycle and every token in that cycle divides by that one sample, +// which keeps a cycle at N+1 requests rather than 2N against a rate-limited endpoint. +// A standalone call fetches ETH only when it has never been fetched, so outside the +// batch path the ETH leg can be arbitrarily older than the token leg. // // Stablecoin handling: // - If the symbol starts with "$" (e.g., "$1.0"), it's treated as a stablecoin with fixed price @@ -82,9 +87,13 @@ func (b *BitgetSDKPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) return nil, fmt.Errorf("token ID %d not mapped to trading pair", tokenID) } - // Use cached ETH price (should be updated by GetBatchTokenPrices) if ethPrice.Cmp(big.NewFloat(0)) == 0 { - return nil, fmt.Errorf("ETH price not initialized, please call GetBatchTokenPrices first") + if err := b.updateETHPrice(ctx); err != nil { + return nil, fmt.Errorf("failed to initialize ETH price: %w", err) + } + b.mu.RLock() + ethPrice = new(big.Float).Copy(b.ethPrice) + b.mu.RUnlock() } var tokenPrice *big.Float @@ -96,10 +105,11 @@ func (b *BitgetSDKPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) if err != nil { return nil, fmt.Errorf("invalid stablecoin price format '%s': %w", symbol, err) } - if fixedPrice <= 0 { - return nil, fmt.Errorf("stablecoin price must be positive, got '%s'", symbol) + price, ok := newFinitePositiveFloat(fixedPrice) + if !ok { + return nil, fmt.Errorf("stablecoin price must be a positive finite number, got '%s'", symbol) } - tokenPrice = big.NewFloat(fixedPrice) + tokenPrice = price b.log.Info("Using fixed stablecoin price", "source", "stablecoin", @@ -261,12 +271,16 @@ func (b *BitgetSDKPriceFeed) fetchPriceOnce(ctx context.Context, symbol string) if err != nil { return nil, fmt.Errorf("failed to parse price '%s': %w", lastPriceStr, err) } + price, ok := newFinitePositiveFloat(lastPrice) + if !ok { + return nil, fmt.Errorf("price must be a positive finite number for symbol %s, got %s", symbol, lastPriceStr) + } b.log.Debug("Fetched price from Bitget API", "symbol", symbol, "price", lastPrice) - return big.NewFloat(lastPrice), nil + return price, nil } // UpdateTokenMap updates token mapping diff --git a/token-price-oracle/client/bitget_sdk_test.go b/token-price-oracle/client/bitget_sdk_test.go new file mode 100644 index 000000000..2c33a01d3 --- /dev/null +++ b/token-price-oracle/client/bitget_sdk_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "context" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "testing" +) + +// Mirrors TestCEXGetTokenPriceInitializesETHPrice: both CEX implementations must +// satisfy the same precondition, i.e. a standalone GetTokenPrice self-initializes +// the ETH leg rather than failing on an unprimed cache. +func TestBitgetGetTokenPriceInitializesETHPrice(t *testing.T) { + var ethRequests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + symbol := r.URL.Query().Get("symbol") + var price string + switch symbol { + case "ETHUSDT": + ethRequests++ + price = "3000" + case "BTCUSDT": + price = "60000" + default: + t.Errorf("unexpected symbol: %s", symbol) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"code":"00000","msg":"","data":[{"symbol":%q,"lastPr":%q}]}`, symbol, price) + })) + defer server.Close() + + feed := NewBitgetSDKPriceFeed(map[uint16]string{1: "BTCUSDT"}, server.URL) + + price, err := feed.GetTokenPrice(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if price.EthPriceUSD.Cmp(big.NewFloat(3000)) != 0 { + t.Fatalf("ETH price = %s, want 3000", price.EthPriceUSD.String()) + } + if price.TokenPriceUSD.Cmp(big.NewFloat(60000)) != 0 { + t.Fatalf("token price = %s, want 60000", price.TokenPriceUSD.String()) + } + if ethRequests != 1 { + t.Fatalf("ETH fetches = %d, want 1", ethRequests) + } + + // The cached ETH leg is reused, so a second call must not re-fetch it. + if _, err := feed.GetTokenPrice(context.Background(), 1); err != nil { + t.Fatal(err) + } + if ethRequests != 1 { + t.Fatalf("ETH fetches after second call = %d, want 1", ethRequests) + } +} + +// The batch path primes the ETH leg once and every token in the cycle reuses it, +// keeping a cycle at N+1 requests rather than 2N. +func TestBitgetBatchFetchesETHPriceOncePerCycle(t *testing.T) { + var ethRequests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + symbol := r.URL.Query().Get("symbol") + var price string + switch symbol { + case "ETHUSDT": + ethRequests++ + price = "3000" + case "BTCUSDT": + price = "60000" + case "SOLUSDT": + price = "150" + default: + t.Errorf("unexpected symbol: %s", symbol) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"code":"00000","msg":"","data":[{"symbol":%q,"lastPr":%q}]}`, symbol, price) + })) + defer server.Close() + + feed := NewBitgetSDKPriceFeed(map[uint16]string{1: "BTCUSDT", 2: "SOLUSDT"}, server.URL) + + prices, err := feed.GetBatchTokenPrices(context.Background(), []uint16{1, 2}) + if err != nil { + t.Fatal(err) + } + if len(prices) != 2 { + t.Fatalf("prices returned = %d, want 2", len(prices)) + } + if ethRequests != 1 { + t.Fatalf("ETH fetches for a 2-token cycle = %d, want 1", ethRequests) + } + for tokenID, price := range prices { + if price.EthPriceUSD.Cmp(big.NewFloat(3000)) != 0 { + t.Fatalf("token %d ETH price = %s, want 3000", tokenID, price.EthPriceUSD.String()) + } + } +} diff --git a/token-price-oracle/client/cex_feed.go b/token-price-oracle/client/cex_feed.go new file mode 100644 index 000000000..a2102d946 --- /dev/null +++ b/token-price-oracle/client/cex_feed.go @@ -0,0 +1,273 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/morph-l2/go-ethereum/log" +) + +const ( + binanceTickerPath = "/api/v3/ticker/price" + okxTickerPath = "/api/v5/market/ticker" +) + +type cexPriceFetcher func(ctx context.Context, httpClient *http.Client, baseURL string, symbol string) (*big.Float, error) + +// CEXPriceFeed fetches token prices from a centralized exchange REST API. +type CEXPriceFeed struct { + httpClient *http.Client + mu sync.RWMutex + tokenMap map[uint16]string + ethSymbol string + ethPrice *big.Float + source string + log log.Logger + baseURL string + fetcher cexPriceFetcher +} + +// NewBinancePriceFeed creates a Binance REST price feed. +func NewBinancePriceFeed(tokenMap map[uint16]string, baseURL string) *CEXPriceFeed { + return newCEXPriceFeed("binance", tokenMap, baseURL, "ETHUSDT", fetchBinancePrice) +} + +// NewOKXPriceFeed creates an OKX REST price feed. +func NewOKXPriceFeed(tokenMap map[uint16]string, baseURL string) *CEXPriceFeed { + return newCEXPriceFeed("okx", tokenMap, baseURL, "ETH-USDT", fetchOKXPrice) +} + +func newCEXPriceFeed(source string, tokenMap map[uint16]string, baseURL string, ethSymbol string, fetcher cexPriceFetcher) *CEXPriceFeed { + return &CEXPriceFeed{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + tokenMap: tokenMap, + ethSymbol: ethSymbol, + ethPrice: big.NewFloat(0), + source: source, + log: log.New("component", source+"_price_feed"), + baseURL: baseURL, + fetcher: fetcher, + } +} + +// GetTokenPrice returns token price in USD. +// +// The token price is always fetched fresh. The ETH leg is not: GetBatchTokenPrices +// samples it once per cycle and every token in that cycle divides by that one sample, +// which keeps a cycle at N+1 requests rather than 2N against a rate-limited endpoint. +// A standalone call fetches ETH only when it has never been fetched, so outside the +// batch path the ETH leg can be arbitrarily older than the token leg. +func (f *CEXPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) (*TokenPrice, error) { + f.mu.RLock() + symbol, exists := f.tokenMap[tokenID] + ethPrice := new(big.Float).Copy(f.ethPrice) + f.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("token ID %d not mapped to %s trading pair", tokenID, f.source) + } + if ethPrice.Cmp(big.NewFloat(0)) == 0 { + if err := f.updateETHPrice(ctx); err != nil { + return nil, fmt.Errorf("failed to initialize ETH price: %w", err) + } + f.mu.RLock() + ethPrice = new(big.Float).Copy(f.ethPrice) + f.mu.RUnlock() + } + + tokenPrice, err := f.fetchMappedPrice(ctx, symbol) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s price for %s: %w", f.source, symbol, err) + } + + f.log.Info("Fetched price from CEX", + "source", f.source, + "token_id", tokenID, + "symbol", symbol, + "token_price_usd", tokenPrice.String(), + "eth_price_usd", ethPrice.String()) + + return &TokenPrice{ + TokenID: tokenID, + Symbol: symbol, + TokenPriceUSD: tokenPrice, + EthPriceUSD: ethPrice, + }, nil +} + +// GetBatchTokenPrices returns batch token prices in USD. +func (f *CEXPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + if err := f.updateETHPrice(ctx); err != nil { + return nil, fmt.Errorf("failed to update ETH price: %w", err) + } + + prices := make(map[uint16]*TokenPrice, len(tokenIDs)) + for _, tokenID := range tokenIDs { + price, err := f.GetTokenPrice(ctx, tokenID) + if err != nil { + f.log.Warn("Failed to get price for token, skipping", + "source", f.source, + "token_id", tokenID, + "error", err) + continue + } + prices[tokenID] = price + } + return prices, nil +} + +func (f *CEXPriceFeed) updateETHPrice(ctx context.Context) error { + price, err := f.fetcher(ctx, f.httpClient, f.baseURL, f.ethSymbol) + if err != nil { + return fmt.Errorf("failed to fetch ETH price from %s: %w", f.source, err) + } + + f.mu.Lock() + f.ethPrice = price + f.mu.Unlock() + + f.log.Info("Fetched ETH price from CEX", + "source", f.source, + "symbol", f.ethSymbol, + "eth_price_usd", price.String()) + return nil +} + +func (f *CEXPriceFeed) fetchMappedPrice(ctx context.Context, symbol string) (*big.Float, error) { + if strings.HasPrefix(symbol, StablecoinPrefix) { + return parseFixedStablecoinPrice(symbol) + } + return f.fetcher(ctx, f.httpClient, f.baseURL, symbol) +} + +func parseFixedStablecoinPrice(symbol string) (*big.Float, error) { + priceStr := strings.TrimPrefix(symbol, StablecoinPrefix) + fixedPrice, err := strconv.ParseFloat(priceStr, 64) + if err != nil { + return nil, fmt.Errorf("invalid stablecoin price format '%s': %w", symbol, err) + } + price, ok := newFinitePositiveFloat(fixedPrice) + if !ok { + return nil, fmt.Errorf("stablecoin price must be a positive finite number, got '%s'", symbol) + } + return price, nil +} + +// newFinitePositiveFloat rejects values that survive a bare `<= 0` test but cannot be +// used as a price. strconv.ParseFloat accepts "NaN" and "Inf" without error; NaN then +// panics big.NewFloat, and an Inf propagates until big.Float.Int returns nil. +func newFinitePositiveFloat(value float64) (*big.Float, bool) { + if math.IsNaN(value) || math.IsInf(value, 0) || value <= 0 { + return nil, false + } + return big.NewFloat(value), true +} + +type binanceTickerResponse struct { + Symbol string `json:"symbol"` + Price string `json:"price"` +} + +func fetchBinancePrice(ctx context.Context, httpClient *http.Client, baseURL string, symbol string) (*big.Float, error) { + requestURL := fmt.Sprintf("%s%s?symbol=%s", strings.TrimRight(baseURL, "/"), binanceTickerPath, url.QueryEscape(symbol)) + body, err := getJSON(ctx, httpClient, requestURL) + if err != nil { + return nil, err + } + + var resp binanceTickerResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse Binance JSON response: %w", err) + } + return parsePositiveFloat(resp.Price, symbol) +} + +type okxTickerResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data []okxTickerRecord `json:"data"` +} + +type okxTickerRecord struct { + InstID string `json:"instId"` + Last string `json:"last"` +} + +func fetchOKXPrice(ctx context.Context, httpClient *http.Client, baseURL string, symbol string) (*big.Float, error) { + requestURL := fmt.Sprintf("%s%s?instId=%s", strings.TrimRight(baseURL, "/"), okxTickerPath, url.QueryEscape(symbol)) + body, err := getJSON(ctx, httpClient, requestURL) + if err != nil { + return nil, err + } + + var resp okxTickerResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse OKX JSON response: %w", err) + } + if resp.Code != "0" { + return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg) + } + if len(resp.Data) == 0 { + return nil, fmt.Errorf("no OKX ticker data returned for %s", symbol) + } + return parsePositiveFloat(resp.Data[0].Last, symbol) +} + +func getJSON(ctx context.Context, httpClient *http.Client, requestURL string) ([]byte, error) { + return getJSONWithHeaders(ctx, httpClient, requestURL, nil) +} + +func getJSONWithHeaders(ctx context.Context, httpClient *http.Client, requestURL string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + for name, value := range headers { + req.Header.Set(name, value) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP status %d: %s", resp.StatusCode, string(body)) + } + contentType := resp.Header.Get("Content-Type") + if contentType != "" && !strings.Contains(strings.ToLower(contentType), "json") { + return nil, fmt.Errorf("unexpected content type %q: %s", contentType, string(body)) + } + return body, nil +} + +func parsePositiveFloat(priceStr string, symbol string) (*big.Float, error) { + if priceStr == "" { + return nil, fmt.Errorf("no price data returned for symbol %s", symbol) + } + price, err := strconv.ParseFloat(priceStr, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse price '%s': %w", priceStr, err) + } + value, ok := newFinitePositiveFloat(price) + if !ok { + return nil, fmt.Errorf("price must be a positive finite number for symbol %s, got %s", symbol, priceStr) + } + return value, nil +} diff --git a/token-price-oracle/client/cex_feed_test.go b/token-price-oracle/client/cex_feed_test.go new file mode 100644 index 000000000..f70028d81 --- /dev/null +++ b/token-price-oracle/client/cex_feed_test.go @@ -0,0 +1,111 @@ +package client + +import ( + "context" + "math/big" + "net/http" + "net/http/httptest" + "testing" +) + +func TestParsePriceRejectsNonFiniteValues(t *testing.T) { + // strconv.ParseFloat accepts these without error, and a bare `<= 0` test lets them + // through: NaN then panics big.NewFloat, and an Inf survives until big.Float.Int + // returns nil. + for _, priceStr := range []string{"NaN", "nan", "Inf", "+Inf", "-Inf", "0", "-1"} { + if _, err := parsePositiveFloat(priceStr, "BTCUSDT"); err == nil { + t.Errorf("parsePositiveFloat(%q) succeeded, want error", priceStr) + } + if _, err := parseFixedStablecoinPrice(StablecoinPrefix + priceStr); err == nil { + t.Errorf("parseFixedStablecoinPrice(%q) succeeded, want error", priceStr) + } + } + + price, err := parsePositiveFloat("60000.5", "BTCUSDT") + if err != nil { + t.Fatal(err) + } + if price.Cmp(big.NewFloat(60000.5)) != 0 { + t.Fatalf("parsePositiveFloat() = %s, want 60000.5", price.String()) + } +} + +func TestCEXGetTokenPriceInitializesETHPrice(t *testing.T) { + fetcher := func(_ context.Context, _ *http.Client, _ string, symbol string) (*big.Float, error) { + switch symbol { + case "ETHUSDT": + return big.NewFloat(3000), nil + case "BTCUSDT": + return big.NewFloat(60000), nil + default: + t.Fatalf("unexpected symbol: %s", symbol) + return nil, nil + } + } + feed := newCEXPriceFeed("test", map[uint16]string{1: "BTCUSDT"}, "", "ETHUSDT", fetcher) + + price, err := feed.GetTokenPrice(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if price.EthPriceUSD.Cmp(big.NewFloat(3000)) != 0 { + t.Fatalf("ETH price = %s, want 3000", price.EthPriceUSD.String()) + } + if price.TokenPriceUSD.Cmp(big.NewFloat(60000)) != 0 { + t.Fatalf("token price = %s, want 60000", price.TokenPriceUSD.String()) + } +} + +func TestFetchBinancePrice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != binanceTickerPath { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("symbol") != "BTCUSDT" { + t.Fatalf("unexpected symbol: %s", r.URL.Query().Get("symbol")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"symbol":"BTCUSDT","price":"64385.12"}`)) + })) + defer server.Close() + + price, err := fetchBinancePrice(context.Background(), server.Client(), server.URL, "BTCUSDT") + if err != nil { + t.Fatal(err) + } + if price.Cmp(big.NewFloat(64385.12)) != 0 { + t.Fatalf("price = %s, want 64385.12", price.String()) + } +} + +func TestFetchOKXPrice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != okxTickerPath { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("instId") != "BTC-USDT" { + t.Fatalf("unexpected instId: %s", r.URL.Query().Get("instId")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":"0","msg":"","data":[{"instId":"BTC-USDT","last":"64386.45"}]}`)) + })) + defer server.Close() + + price, err := fetchOKXPrice(context.Background(), server.Client(), server.URL, "BTC-USDT") + if err != nil { + t.Fatal(err) + } + if price.Cmp(big.NewFloat(64386.45)) != 0 { + t.Fatalf("price = %s, want 64386.45", price.String()) + } +} + +func TestParseFixedStablecoinPrice(t *testing.T) { + price, err := parseFixedStablecoinPrice("$1.0") + if err != nil { + t.Fatal(err) + } + if price.Cmp(big.NewFloat(1.0)) != 0 { + t.Fatalf("price = %s, want 1", price.String()) + } +} diff --git a/token-price-oracle/client/chainlink_feed.go b/token-price-oracle/client/chainlink_feed.go new file mode 100644 index 000000000..fe960c6c9 --- /dev/null +++ b/token-price-oracle/client/chainlink_feed.go @@ -0,0 +1,292 @@ +package client + +import ( + "context" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "time" + + "github.com/morph-l2/go-ethereum/accounts/abi" + "github.com/morph-l2/go-ethereum/accounts/abi/bind" + "github.com/morph-l2/go-ethereum/common" + "github.com/morph-l2/go-ethereum/ethclient" + "github.com/morph-l2/go-ethereum/log" +) + +const chainlinkAggregatorV3ABI = `[ + {"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"latestRoundData","outputs":[{"internalType":"uint80","name":"roundId","type":"uint80"},{"internalType":"int256","name":"answer","type":"int256"},{"internalType":"uint256","name":"startedAt","type":"uint256"},{"internalType":"uint256","name":"updatedAt","type":"uint256"},{"internalType":"uint80","name":"answeredInRound","type":"uint80"}],"stateMutability":"view","type":"function"} +]` + +// chainlinkCallTimeout bounds a single eth_call against the configured RPC endpoint. +const chainlinkCallTimeout = 10 * time.Second + +var parsedChainlinkAggregatorABI = mustParseChainlinkAggregatorABI() + +// ChainlinkPriceFeed reads Chainlink AggregatorV3 feeds over RPC. +type ChainlinkPriceFeed struct { + caller bind.ContractCaller + mu sync.RWMutex + tokenFeeds map[uint16]common.Address + ethUSDFeed common.Address + maxStaleness time.Duration + log log.Logger +} + +// NewChainlinkPriceFeed creates a Chainlink price feed using an RPC endpoint. +func NewChainlinkPriceFeed(tokenFeedMap map[uint16]string, rpcURL string, ethUSDFeed common.Address, maxStaleness time.Duration) (*ChainlinkPriceFeed, error) { + if rpcURL == "" { + return nil, fmt.Errorf("chainlink price feed requires --chainlink-rpc") + } + + caller, err := ethclient.Dial(rpcURL) + if err != nil { + return nil, fmt.Errorf("failed to connect chainlink rpc: %w", err) + } + + feed, err := NewChainlinkPriceFeedWithCaller(tokenFeedMap, caller, ethUSDFeed, maxStaleness) + if err != nil { + caller.Close() + return nil, err + } + return feed, nil +} + +// NewChainlinkPriceFeedWithCaller creates a Chainlink price feed with a caller. +// It is primarily useful for tests. +func NewChainlinkPriceFeedWithCaller(tokenFeedMap map[uint16]string, caller bind.ContractCaller, ethUSDFeed common.Address, maxStaleness time.Duration) (*ChainlinkPriceFeed, error) { + if caller == nil { + return nil, fmt.Errorf("chainlink price feed requires rpc caller") + } + if ethUSDFeed == (common.Address{}) { + return nil, fmt.Errorf("chainlink price feed requires --chainlink-eth-usd-feed") + } + if maxStaleness <= 0 { + return nil, fmt.Errorf("chainlink max staleness must be positive") + } + + feeds := make(map[uint16]common.Address, len(tokenFeedMap)) + for tokenID, feedAddr := range tokenFeedMap { + feedAddr = strings.TrimSpace(feedAddr) + if !common.IsHexAddress(feedAddr) { + return nil, fmt.Errorf("invalid chainlink feed address for token %d: %s", tokenID, feedAddr) + } + feeds[tokenID] = common.HexToAddress(feedAddr) + } + if len(feeds) == 0 { + return nil, fmt.Errorf("chainlink price feed requires token mapping, please configure --token-mapping-chainlink") + } + + return &ChainlinkPriceFeed{ + caller: caller, + tokenFeeds: feeds, + ethUSDFeed: ethUSDFeed, + maxStaleness: maxStaleness, + log: log.New("component", "chainlink_price_feed"), + }, nil +} + +// GetTokenPrice returns token price in USD from Chainlink. +func (c *ChainlinkPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) (*TokenPrice, error) { + c.mu.RLock() + feedAddress, exists := c.tokenFeeds[tokenID] + ethUSDFeed := c.ethUSDFeed + c.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("token ID %d not mapped to Chainlink feed", tokenID) + } + + ethPrice, err := c.fetchFeedPrice(ctx, ethUSDFeed) + if err != nil { + return nil, fmt.Errorf("failed to fetch ETH/USD price from Chainlink: %w", err) + } + + tokenPrice, err := c.fetchFeedPrice(ctx, feedAddress) + if err != nil { + return nil, fmt.Errorf("failed to fetch token price from Chainlink for token %d: %w", tokenID, err) + } + + c.log.Info("Fetched price from Chainlink", + "source", "chainlink", + "token_id", tokenID, + "feed", feedAddress.Hex(), + "token_price_usd", tokenPrice.String(), + "eth_price_usd", ethPrice.String()) + + return &TokenPrice{ + TokenID: tokenID, + Symbol: feedAddress.Hex(), + TokenPriceUSD: tokenPrice, + EthPriceUSD: ethPrice, + }, nil +} + +// GetBatchTokenPrices returns token prices in USD for multiple tokens. Tokens with no +// configured feed are omitted rather than failing the batch, so that a mapping which +// covers only part of the requested set still contributes what it can and the caller +// can source the rest elsewhere. +func (c *ChainlinkPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + c.mu.RLock() + mapped := make(map[uint16]common.Address, len(tokenIDs)) + for _, tokenID := range tokenIDs { + if feedAddress, exists := c.tokenFeeds[tokenID]; exists { + mapped[tokenID] = feedAddress + } + } + c.mu.RUnlock() + + if len(mapped) == 0 { + return map[uint16]*TokenPrice{}, nil + } + + ethPrice, err := c.fetchFeedPrice(ctx, c.ethUSDFeed) + if err != nil { + return nil, fmt.Errorf("failed to fetch ETH/USD price from Chainlink: %w", err) + } + + prices := make(map[uint16]*TokenPrice, len(mapped)) + for _, tokenID := range tokenIDs { + feedAddress, exists := mapped[tokenID] + if !exists { + continue + } + + tokenPrice, err := c.fetchFeedPrice(ctx, feedAddress) + if err != nil { + return nil, fmt.Errorf("failed to fetch token price from Chainlink for token %d: %w", tokenID, err) + } + + prices[tokenID] = &TokenPrice{ + TokenID: tokenID, + Symbol: feedAddress.Hex(), + TokenPriceUSD: tokenPrice, + EthPriceUSD: new(big.Float).Copy(ethPrice), + } + } + + return prices, nil +} + +// callContract invokes a read-only method under its own deadline. The service-level +// context has no deadline of its own, and the RPC transport does not impose one, so +// without this a single unresponsive endpoint would stall the updater loop forever +// and the configured fallback feeds would never be reached. +func callContract(ctx context.Context, contract *bind.BoundContract, out *[]interface{}, method string) error { + callCtx, cancel := context.WithTimeout(ctx, chainlinkCallTimeout) + defer cancel() + return contract.Call(&bind.CallOpts{Context: callCtx}, out, method) +} + +func (c *ChainlinkPriceFeed) fetchFeedPrice(ctx context.Context, feedAddress common.Address) (*big.Float, error) { + contract := bind.NewBoundContract(feedAddress, parsedChainlinkAggregatorABI, c.caller, nil, nil) + + var roundData []interface{} + if err := callContract(ctx, contract, &roundData, "latestRoundData"); err != nil { + return nil, fmt.Errorf("latestRoundData call failed for feed %s: %w", feedAddress.Hex(), err) + } + + roundID, answer, updatedAt, answeredInRound, err := parseChainlinkRoundData(roundData) + if err != nil { + return nil, fmt.Errorf("invalid latestRoundData response for feed %s: %w", feedAddress.Hex(), err) + } + if err := validateChainlinkRound(answer, updatedAt, roundID, answeredInRound, c.maxStaleness, time.Now()); err != nil { + return nil, fmt.Errorf("invalid Chainlink round for feed %s: %w", feedAddress.Hex(), err) + } + + var decimalsOut []interface{} + if err := callContract(ctx, contract, &decimalsOut, "decimals"); err != nil { + return nil, fmt.Errorf("decimals call failed for feed %s: %w", feedAddress.Hex(), err) + } + decimals, err := parseChainlinkDecimals(decimalsOut) + if err != nil { + return nil, fmt.Errorf("invalid decimals response for feed %s: %w", feedAddress.Hex(), err) + } + + return chainlinkAnswerToFloat(answer, decimals), nil +} + +func parseChainlinkRoundData(values []interface{}) (roundID, answer, updatedAt, answeredInRound *big.Int, err error) { + if len(values) != 5 { + return nil, nil, nil, nil, fmt.Errorf("expected 5 values, got %d", len(values)) + } + + roundID, ok := values[0].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("roundId is not *big.Int") + } + answer, ok = values[1].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("answer is not *big.Int") + } + updatedAt, ok = values[3].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("updatedAt is not *big.Int") + } + answeredInRound, ok = values[4].(*big.Int) + if !ok { + return nil, nil, nil, nil, errors.New("answeredInRound is not *big.Int") + } + + return roundID, answer, updatedAt, answeredInRound, nil +} + +func parseChainlinkDecimals(values []interface{}) (uint8, error) { + if len(values) != 1 { + return 0, fmt.Errorf("expected 1 value, got %d", len(values)) + } + + switch decimals := values[0].(type) { + case uint8: + return decimals, nil + case *big.Int: + if !decimals.IsUint64() || decimals.Uint64() > 255 { + return 0, fmt.Errorf("decimals out of uint8 range: %s", decimals.String()) + } + return uint8(decimals.Uint64()), nil + default: + return 0, fmt.Errorf("decimals has unexpected type %T", values[0]) + } +} + +func validateChainlinkRound(answer, updatedAt, roundID, answeredInRound *big.Int, maxStaleness time.Duration, now time.Time) error { + if answer == nil || updatedAt == nil || roundID == nil || answeredInRound == nil { + return errors.New("round data contains nil value") + } + if answer.Sign() <= 0 { + return fmt.Errorf("answer must be positive, got %s", answer.String()) + } + if updatedAt.Sign() <= 0 { + return errors.New("updatedAt must be positive") + } + if answeredInRound.Cmp(roundID) < 0 { + return fmt.Errorf("answeredInRound %s is older than roundId %s", answeredInRound.String(), roundID.String()) + } + + updated := time.Unix(updatedAt.Int64(), 0) + if updated.After(now) { + return fmt.Errorf("updatedAt %s is in the future", updated.UTC().Format(time.RFC3339)) + } + if now.Sub(updated) > maxStaleness { + return fmt.Errorf("price is stale: updatedAt=%s maxStaleness=%s", updated.UTC().Format(time.RFC3339), maxStaleness) + } + + return nil +} + +func chainlinkAnswerToFloat(answer *big.Int, decimals uint8) *big.Float { + price := new(big.Float).SetPrec(256).SetInt(answer) + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + return price.Quo(price, new(big.Float).SetPrec(256).SetInt(scale)) +} + +func mustParseChainlinkAggregatorABI() abi.ABI { + parsed, err := abi.JSON(strings.NewReader(chainlinkAggregatorV3ABI)) + if err != nil { + panic(err) + } + return parsed +} diff --git a/token-price-oracle/client/chainlink_feed_test.go b/token-price-oracle/client/chainlink_feed_test.go new file mode 100644 index 000000000..d3209c7b7 --- /dev/null +++ b/token-price-oracle/client/chainlink_feed_test.go @@ -0,0 +1,112 @@ +package client + +import ( + "math/big" + "testing" + "time" +) + +func TestValidateChainlinkRound(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + + tests := []struct { + name string + answer *big.Int + updatedAt *big.Int + roundID *big.Int + answeredInRound *big.Int + wantErr bool + }{ + { + name: "valid", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + }, + { + name: "non-positive answer", + answer: big.NewInt(0), + updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "stale", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(-2 * time.Hour).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "answered in old round", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(-5 * time.Minute).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(9), + wantErr: true, + }, + { + name: "future timestamp", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(time.Second).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "nil answer", + updatedAt: big.NewInt(now.Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "nil updatedAt", + answer: big.NewInt(2000_00000000), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "nil round ID", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Unix()), + answeredInRound: big.NewInt(10), + wantErr: true, + }, + { + name: "nil answered in round", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Unix()), + roundID: big.NewInt(10), + wantErr: true, + }, + { + name: "at staleness boundary", + answer: big.NewInt(2000_00000000), + updatedAt: big.NewInt(now.Add(-time.Hour).Unix()), + roundID: big.NewInt(10), + answeredInRound: big.NewInt(10), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateChainlinkRound(tt.answer, tt.updatedAt, tt.roundID, tt.answeredInRound, time.Hour, now) + if (err != nil) != tt.wantErr { + t.Fatalf("validateChainlinkRound() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestChainlinkAnswerToFloat(t *testing.T) { + price := chainlinkAnswerToFloat(big.NewInt(123456789000), 8) + got, _ := price.Float64() + if got != 1234.56789 { + t.Fatalf("chainlinkAnswerToFloat() = %v, want 1234.56789", got) + } +} diff --git a/token-price-oracle/client/price_feed.go b/token-price-oracle/client/price_feed.go index b689f34e1..13ffb9411 100644 --- a/token-price-oracle/client/price_feed.go +++ b/token-price-oracle/client/price_feed.go @@ -86,52 +86,77 @@ func (f *FallbackPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) ( return nil, lastErr } -// GetBatchTokenPrices tries to get batch token prices from feeds in priority order +// GetBatchTokenPrices resolves prices across feeds in priority order, passing each +// feed only the tokens still unresolved. Discarding a whole response because one +// token was missing meant a provider that covers only part of the active set could +// never contribute: with any active token absent from the Chainlink or Pyth mapping, +// those feeds failed every cycle and every token silently came from a CEX instead. +// +// A token no feed can price is reported to the caller by its absence from the +// returned map. Only a cycle that resolves nothing at all is an error. func (f *FallbackPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + resolved := make(map[uint16]*TokenPrice, len(tokenIDs)) + pending := make([]uint16, len(tokenIDs)) + copy(pending, tokenIDs) var lastErr error for i, feed := range f.feeds { + if len(pending) == 0 { + break + } + feedName := "unknown" if i < len(f.names) { feedName = f.names[i] } - prices, err := feed.GetBatchTokenPrices(ctx, tokenIDs) - if err == nil { - // Validate all returned prices to prevent nil pointer panics - hasInvalidPrice := false - for tokenID, price := range prices { - if price == nil || price.TokenPriceUSD == nil || price.EthPriceUSD == nil { - f.log.Warn("Feed returned nil price or components for token, treating as failure", - "token_id", tokenID, - "feed", feedName, - "priority", i) - hasInvalidPrice = true - break - } - } + prices, err := feed.GetBatchTokenPrices(ctx, pending) + if err != nil { + f.log.Warn("Failed to fetch batch prices from feed, trying next", + "token_count", len(pending), + "feed", feedName, + "priority", i, + "error", err.Error()) + lastErr = err + continue + } - if hasInvalidPrice { - lastErr = fmt.Errorf("feed %s returned incomplete prices", feedName) + stillPending := make([]uint16, 0, len(pending)) + for _, tokenID := range pending { + price, exists := prices[tokenID] + if !exists || price == nil || price.TokenPriceUSD == nil || price.EthPriceUSD == nil { + stillPending = append(stillPending, tokenID) continue } + resolved[tokenID] = price + } - f.log.Info("Successfully fetched batch prices from feed", - "token_count", len(prices), - "requested_count", len(tokenIDs), + if resolvedHere := len(pending) - len(stillPending); resolvedHere > 0 { + f.log.Info("Fetched batch prices from feed", + "resolved_count", resolvedHere, + "requested_count", len(pending), "feed", feedName, "priority", i) - return prices, nil } + if len(stillPending) > 0 { + lastErr = fmt.Errorf("feed %s did not return prices for tokens %v", feedName, stillPending) + } + pending = stillPending + } - f.log.Warn("Failed to fetch batch prices from feed, trying next", - "token_count", len(tokenIDs), - "feed", feedName, - "priority", i, - "error", err.Error()) - lastErr = err + if len(resolved) == 0 { + if lastErr != nil { + return nil, fmt.Errorf("no price feed returned any of the %d requested tokens: %w", len(tokenIDs), lastErr) + } + return nil, fmt.Errorf("no price feed returned any of the %d requested tokens", len(tokenIDs)) } - return nil, lastErr -} + if len(pending) > 0 { + f.log.Warn("No price feed could resolve some tokens", + "unresolved_token_ids", pending, + "resolved_count", len(resolved), + "requested_count", len(tokenIDs)) + } + return resolved, nil +} diff --git a/token-price-oracle/client/price_feed_test.go b/token-price-oracle/client/price_feed_test.go new file mode 100644 index 000000000..ad9b3ecb8 --- /dev/null +++ b/token-price-oracle/client/price_feed_test.go @@ -0,0 +1,150 @@ +package client + +import ( + "context" + "errors" + "math/big" + "reflect" + "testing" +) + +// stubFeed serves only the tokens present in prices and records every batch it was +// asked for, so tests can assert which tokens reached each feed. +type stubFeed struct { + prices map[uint16]float64 + err error + requested [][]uint16 +} + +func (s *stubFeed) GetTokenPrice(_ context.Context, tokenID uint16) (*TokenPrice, error) { + prices, err := s.GetBatchTokenPrices(context.Background(), []uint16{tokenID}) + if err != nil { + return nil, err + } + price, exists := prices[tokenID] + if !exists { + return nil, errors.New("token not served by this feed") + } + return price, nil +} + +func (s *stubFeed) GetBatchTokenPrices(_ context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + s.requested = append(s.requested, append([]uint16(nil), tokenIDs...)) + if s.err != nil { + return nil, s.err + } + + out := make(map[uint16]*TokenPrice, len(tokenIDs)) + for _, tokenID := range tokenIDs { + price, exists := s.prices[tokenID] + if !exists { + continue + } + out[tokenID] = &TokenPrice{ + TokenID: tokenID, + TokenPriceUSD: big.NewFloat(price), + EthPriceUSD: big.NewFloat(3000), + } + } + return out, nil +} + +// TestFallbackMergesPartialCoverageAcrossFeeds covers the configuration this repo +// documents for devnet: Chainlink and Pyth map tokens 1 and 2 while the CEX feeds also +// cover token 3. Discarding a response for missing one token meant the oracle feeds +// failed every cycle and every token silently came from the CEX. +func TestFallbackMergesPartialCoverageAcrossFeeds(t *testing.T) { + oracle := &stubFeed{prices: map[uint16]float64{1: 100, 2: 200}} + cex := &stubFeed{prices: map[uint16]float64{1: 111, 2: 222, 3: 333}} + + feed := NewFallbackPriceFeed([]PriceFeed{oracle, cex}, []string{"oracle", "cex"}) + + prices, err := feed.GetBatchTokenPrices(context.Background(), []uint16{1, 2, 3}) + if err != nil { + t.Fatal(err) + } + if len(prices) != 3 { + t.Fatalf("resolved %d tokens, want 3", len(prices)) + } + + // Tokens 1 and 2 must come from the higher-priority feed, token 3 from the CEX. + for tokenID, want := range map[uint16]float64{1: 100, 2: 200, 3: 333} { + if got := prices[tokenID].TokenPriceUSD; got.Cmp(big.NewFloat(want)) != 0 { + t.Errorf("token %d price = %s, want %v", tokenID, got.String(), want) + } + } + + // The second feed must only be asked for what the first could not resolve. + if want := [][]uint16{{1, 2, 3}}; !reflect.DeepEqual(oracle.requested, want) { + t.Errorf("oracle feed received %v, want %v", oracle.requested, want) + } + if want := [][]uint16{{3}}; !reflect.DeepEqual(cex.requested, want) { + t.Errorf("cex feed received %v, want %v", cex.requested, want) + } +} + +func TestFallbackSkipsFeedsThatAlreadyResolvedEverything(t *testing.T) { + first := &stubFeed{prices: map[uint16]float64{1: 100, 2: 200}} + second := &stubFeed{prices: map[uint16]float64{1: 111, 2: 222}} + + feed := NewFallbackPriceFeed([]PriceFeed{first, second}, []string{"first", "second"}) + + if _, err := feed.GetBatchTokenPrices(context.Background(), []uint16{1, 2}); err != nil { + t.Fatal(err) + } + if len(second.requested) != 0 { + t.Fatalf("second feed was queried %v, want no queries", second.requested) + } +} + +// TestFallbackReturnsPartialResultForUnservableToken pins the agreed semantics: a token +// no feed can price does not block the others, and the caller sees it by its absence. +func TestFallbackReturnsPartialResultForUnservableToken(t *testing.T) { + first := &stubFeed{prices: map[uint16]float64{1: 100}} + second := &stubFeed{prices: map[uint16]float64{1: 111}} + + feed := NewFallbackPriceFeed([]PriceFeed{first, second}, []string{"first", "second"}) + + prices, err := feed.GetBatchTokenPrices(context.Background(), []uint16{1, 2}) + if err != nil { + t.Fatal(err) + } + if len(prices) != 1 { + t.Fatalf("resolved %d tokens, want 1", len(prices)) + } + if _, exists := prices[2]; exists { + t.Fatal("token 2 is served by no feed and must be absent from the result") + } +} + +// TestFallbackErrorsWhenNothingResolves is the single-feed regression: a CEX feed that +// returns an empty map with a nil error used to reach the updater as a successful +// cycle, which then advanced the last-successful-update timestamp. +func TestFallbackErrorsWhenNothingResolves(t *testing.T) { + only := &stubFeed{prices: map[uint16]float64{}} + + feed := NewFallbackPriceFeed([]PriceFeed{only}, []string{"only"}) + + if _, err := feed.GetBatchTokenPrices(context.Background(), []uint16{1, 2}); err == nil { + t.Fatal("GetBatchTokenPrices succeeded on an empty result, want error") + } +} + +func TestFallbackContinuesPastFailingFeed(t *testing.T) { + broken := &stubFeed{err: errors.New("rpc down")} + healthy := &stubFeed{prices: map[uint16]float64{1: 100, 2: 200}} + + feed := NewFallbackPriceFeed([]PriceFeed{broken, healthy}, []string{"broken", "healthy"}) + + prices, err := feed.GetBatchTokenPrices(context.Background(), []uint16{1, 2}) + if err != nil { + t.Fatal(err) + } + if len(prices) != 2 { + t.Fatalf("resolved %d tokens, want 2", len(prices)) + } + // A failing feed must not shrink what the next feed is asked for. + if want := [][]uint16{{1, 2}}; !reflect.DeepEqual(healthy.requested, want) { + t.Errorf("healthy feed received %v, want %v", healthy.requested, want) + } +} diff --git a/token-price-oracle/client/pyth_feed.go b/token-price-oracle/client/pyth_feed.go new file mode 100644 index 000000000..5c080427a --- /dev/null +++ b/token-price-oracle/client/pyth_feed.go @@ -0,0 +1,317 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/morph-l2/go-ethereum/log" +) + +const pythLatestPricePath = "/v2/updates/price/latest" + +// pythMaxExponentMagnitude caps |expo| from a Hermes response. Real feeds sit well +// inside this range (typically -12..0). +const pythMaxExponentMagnitude = 32 + +// pythMaxClockSkew is how far ahead of the local clock a publish time may sit before +// it is rejected. Unlike the Chainlink path, which reads an L1 block timestamp and so +// is always in the past, publish_time is the publisher's near-real-time wall clock at +// second granularity. Rejecting anything ahead of the local clock would turn a small +// amount of host clock drift into a total Pyth outage, so allow a bounded skew while +// still refusing prices dated far into the future. +const pythMaxClockSkew = 30 * time.Second + +// PythHermesPriceFeed reads Pyth prices from Hermes as an off-chain data source. +type PythHermesPriceFeed struct { + httpClient *http.Client + mu sync.RWMutex + tokenPriceIDs map[uint16]string + ethUSDPriceID string + maxStaleness time.Duration + maxConfidenceBPS uint64 + baseURL string + apiKey string + log log.Logger +} + +// NewPythHermesPriceFeed creates a Pyth Hermes price feed. +func NewPythHermesPriceFeed(tokenPriceIDs map[uint16]string, baseURL string, apiKey string, ethUSDPriceID string, maxStaleness time.Duration, maxConfidenceBPS uint64) (*PythHermesPriceFeed, error) { + ethUSDPriceID = normalizePythPriceID(ethUSDPriceID) + if ethUSDPriceID == "" { + return nil, fmt.Errorf("pyth price feed requires --pyth-eth-usd-price-id") + } + if maxStaleness <= 0 { + return nil, fmt.Errorf("pyth max staleness must be positive") + } + // Hermes requires authentication from 2026-08-18 onwards, on both the current + // and the upgraded endpoint. Unauthenticated requests still succeed until then, + // but an updater configured today keeps running past the cutover, at which point + // every Hermes request starts failing with nothing having changed on our side. + // Refusing to start is the earlier and louder failure, so the key is required + // rather than merely recommended. + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return nil, fmt.Errorf("pyth price feed requires --pyth-api-key") + } + + normalized := make(map[uint16]string, len(tokenPriceIDs)) + for tokenID, priceID := range tokenPriceIDs { + priceID = normalizePythPriceID(priceID) + if priceID == "" { + return nil, fmt.Errorf("invalid pyth price ID for token %d", tokenID) + } + normalized[tokenID] = priceID + } + if len(normalized) == 0 { + return nil, fmt.Errorf("pyth price feed requires token mapping, please configure --token-mapping-pyth") + } + + return &PythHermesPriceFeed{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + tokenPriceIDs: normalized, + ethUSDPriceID: ethUSDPriceID, + maxStaleness: maxStaleness, + maxConfidenceBPS: maxConfidenceBPS, + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + log: log.New("component", "pyth_price_feed"), + }, nil +} + +// GetTokenPrice returns token price in USD from Pyth Hermes. +func (p *PythHermesPriceFeed) GetTokenPrice(ctx context.Context, tokenID uint16) (*TokenPrice, error) { + p.mu.RLock() + priceID, exists := p.tokenPriceIDs[tokenID] + ethUSDPriceID := p.ethUSDPriceID + p.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("token ID %d not mapped to Pyth price ID", tokenID) + } + + priceMap, err := p.fetchPrices(ctx, []string{ethUSDPriceID, priceID}) + if err != nil { + return nil, err + } + + ethPrice, err := pythPriceToFloat(priceMap[ethUSDPriceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert ETH/USD Pyth price: %w", err) + } + tokenPrice, err := pythPriceToFloat(priceMap[priceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert token Pyth price for token %d: %w", tokenID, err) + } + + p.log.Info("Fetched price from Pyth", + "source", "pyth", + "token_id", tokenID, + "price_id", priceID, + "token_price_usd", tokenPrice.String(), + "eth_price_usd", ethPrice.String()) + + return &TokenPrice{ + TokenID: tokenID, + Symbol: priceID, + TokenPriceUSD: tokenPrice, + EthPriceUSD: ethPrice, + }, nil +} + +// GetBatchTokenPrices returns token prices in USD for multiple tokens. Tokens with no +// configured price ID are omitted rather than failing the batch, so that a mapping +// which covers only part of the requested set still contributes what it can and the +// caller can source the rest elsewhere. +func (p *PythHermesPriceFeed) GetBatchTokenPrices(ctx context.Context, tokenIDs []uint16) (map[uint16]*TokenPrice, error) { + p.mu.RLock() + priceIDs := make([]string, 0, len(tokenIDs)+1) + priceIDs = append(priceIDs, p.ethUSDPriceID) + tokenPriceIDs := make(map[uint16]string, len(tokenIDs)) + for _, tokenID := range tokenIDs { + priceID, exists := p.tokenPriceIDs[tokenID] + if !exists { + continue + } + tokenPriceIDs[tokenID] = priceID + priceIDs = append(priceIDs, priceID) + } + p.mu.RUnlock() + + if len(tokenPriceIDs) == 0 { + return map[uint16]*TokenPrice{}, nil + } + + priceMap, err := p.fetchPrices(ctx, priceIDs) + if err != nil { + return nil, err + } + + ethPrice, err := pythPriceToFloat(priceMap[p.ethUSDPriceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert ETH/USD Pyth price: %w", err) + } + + prices := make(map[uint16]*TokenPrice, len(tokenPriceIDs)) + for _, tokenID := range tokenIDs { + priceID, exists := tokenPriceIDs[tokenID] + if !exists { + continue + } + tokenPrice, err := pythPriceToFloat(priceMap[priceID]) + if err != nil { + return nil, fmt.Errorf("failed to convert token Pyth price for token %d: %w", tokenID, err) + } + prices[tokenID] = &TokenPrice{ + TokenID: tokenID, + Symbol: priceID, + TokenPriceUSD: tokenPrice, + EthPriceUSD: new(big.Float).Copy(ethPrice), + } + } + + return prices, nil +} + +func (p *PythHermesPriceFeed) fetchPrices(ctx context.Context, priceIDs []string) (map[string]pythPrice, error) { + values := url.Values{} + values.Set("parsed", "true") + values.Set("encoding", "hex") + seen := make(map[string]struct{}, len(priceIDs)) + for _, priceID := range priceIDs { + priceID = normalizePythPriceID(priceID) + if _, exists := seen[priceID]; exists { + continue + } + seen[priceID] = struct{}{} + values.Add("ids[]", priceID) + } + + requestURL := fmt.Sprintf("%s%s?%s", p.baseURL, pythLatestPricePath, values.Encode()) + headers := map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + p.apiKey, + } + body, err := getJSONWithHeaders(ctx, p.httpClient, requestURL, headers) + if err != nil { + return nil, err + } + + var resp pythLatestPriceResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("failed to parse Pyth Hermes JSON response: %w", err) + } + + priceMap := make(map[string]pythPrice, len(resp.Parsed)) + now := time.Now() + for _, parsed := range resp.Parsed { + priceID := normalizePythPriceID(parsed.ID) + if err := validatePythPrice(parsed.Price, p.maxStaleness, p.maxConfidenceBPS, now); err != nil { + return nil, fmt.Errorf("invalid Pyth price for %s: %w", priceID, err) + } + priceMap[priceID] = parsed.Price + } + + for priceID := range seen { + if _, exists := priceMap[priceID]; !exists { + return nil, fmt.Errorf("Pyth response missing price ID %s", priceID) + } + } + + return priceMap, nil +} + +type pythLatestPriceResponse struct { + Parsed []pythParsedPrice `json:"parsed"` +} + +type pythParsedPrice struct { + ID string `json:"id"` + Price pythPrice `json:"price"` +} + +type pythPrice struct { + Price string `json:"price"` + Confidence string `json:"conf"` + Exponent int32 `json:"expo"` + PublishTime int64 `json:"publish_time"` +} + +func validatePythPrice(price pythPrice, maxStaleness time.Duration, maxConfidenceBPS uint64, now time.Time) error { + priceInt, ok := new(big.Int).SetString(price.Price, 10) + if !ok { + return fmt.Errorf("invalid price integer %q", price.Price) + } + if priceInt.Sign() <= 0 { + return fmt.Errorf("price must be positive, got %s", price.Price) + } + + confInt, ok := new(big.Int).SetString(price.Confidence, 10) + if !ok { + return fmt.Errorf("invalid confidence integer %q", price.Confidence) + } + if confInt.Sign() < 0 { + return fmt.Errorf("confidence must be non-negative, got %s", price.Confidence) + } + + published := time.Unix(price.PublishTime, 0) + if price.PublishTime <= 0 { + return fmt.Errorf("publish_time must be positive") + } + if published.After(now.Add(pythMaxClockSkew)) { + return fmt.Errorf("publish_time %s is more than %s in the future", published.UTC().Format(time.RFC3339), pythMaxClockSkew) + } + if now.Sub(published) > maxStaleness { + return fmt.Errorf("price is stale: publish_time=%s maxStaleness=%s", published.UTC().Format(time.RFC3339), maxStaleness) + } + + if maxConfidenceBPS > 0 { + confBPS := new(big.Int).Mul(confInt, big.NewInt(10000)) + maxAllowed := new(big.Int).Mul(priceInt, new(big.Int).SetUint64(maxConfidenceBPS)) + if confBPS.Cmp(maxAllowed) > 0 { + return fmt.Errorf("confidence too wide: conf=%s price=%s max_bps=%d", price.Confidence, price.Price, maxConfidenceBPS) + } + } + + return nil +} + +func pythPriceToFloat(price pythPrice) (*big.Float, error) { + priceInt, ok := new(big.Int).SetString(price.Price, 10) + if !ok { + return nil, fmt.Errorf("invalid price integer %q", price.Price) + } + + value := new(big.Float).SetPrec(256).SetInt(priceInt) + if price.Exponent == 0 { + return value, nil + } + + // Bound the magnitude before exponentiating. Exponent is an int32, so the + // previous MaxInt32 check could never fire, and a hostile or malformed expo + // of -2147483648 would ask big.Int to materialize 10^2147483648. + exponent := int64(price.Exponent) + magnitude := exponent + if magnitude < 0 { + magnitude = -magnitude + } + if magnitude > pythMaxExponentMagnitude { + return nil, fmt.Errorf("pyth exponent out of range: %d", exponent) + } + + scale := new(big.Float).SetPrec(256).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(magnitude), nil)) + if exponent > 0 { + return value.Mul(value, scale), nil + } + return value.Quo(value, scale), nil +} + +func normalizePythPriceID(priceID string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(priceID), "0x")) +} diff --git a/token-price-oracle/client/pyth_feed_test.go b/token-price-oracle/client/pyth_feed_test.go new file mode 100644 index 000000000..80adece72 --- /dev/null +++ b/token-price-oracle/client/pyth_feed_test.go @@ -0,0 +1,134 @@ +package client + +import ( + "math/big" + "testing" + "time" +) + +func TestNewPythHermesPriceFeedRequiresAPIKey(t *testing.T) { + const ethUSDPriceID = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" + mapping := map[uint16]string{1: "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"} + + for _, apiKey := range []string{"", " "} { + if _, err := NewPythHermesPriceFeed(mapping, "https://hermes.pyth.network", apiKey, ethUSDPriceID, time.Hour, 0); err == nil { + t.Fatalf("NewPythHermesPriceFeed(apiKey=%q) succeeded, want error", apiKey) + } + } + + if _, err := NewPythHermesPriceFeed(mapping, "https://hermes.pyth.network", "key", ethUSDPriceID, time.Hour, 0); err != nil { + t.Fatalf("NewPythHermesPriceFeed with an API key failed: %v", err) + } +} + +func TestValidatePythPrice(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + + tests := []struct { + name string + price pythPrice + maxConfidenceBPS uint64 + wantErr bool + }{ + { + name: "valid", + price: pythPrice{ + Price: "175500000000", + Confidence: "100000000", + Exponent: -8, + PublishTime: now.Add(-5 * time.Minute).Unix(), + }, + maxConfidenceBPS: 100, + }, + { + name: "stale", + price: pythPrice{ + Price: "175500000000", + Confidence: "100000000", + Exponent: -8, + PublishTime: now.Add(-2 * time.Hour).Unix(), + }, + maxConfidenceBPS: 100, + wantErr: true, + }, + { + name: "too wide confidence", + price: pythPrice{ + Price: "100000000", + Confidence: "2000000", + Exponent: -8, + PublishTime: now.Add(-5 * time.Minute).Unix(), + }, + maxConfidenceBPS: 100, + wantErr: true, + }, + { + // A publish time ahead of the local clock used to be accepted anywhere + // inside the staleness window, which with the default meant nearly an hour. + name: "future publish time beyond skew", + price: pythPrice{ + Price: "175500000000", + Confidence: "100000000", + Exponent: -8, + PublishTime: now.Add(30 * time.Minute).Unix(), + }, + maxConfidenceBPS: 100, + wantErr: true, + }, + { + // Host clock drift against the publisher must not reject a fresh price. + name: "future publish time within skew", + price: pythPrice{ + Price: "175500000000", + Confidence: "100000000", + Exponent: -8, + PublishTime: now.Add(pythMaxClockSkew / 2).Unix(), + }, + maxConfidenceBPS: 100, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePythPrice(tt.price, time.Hour, tt.maxConfidenceBPS, now) + if (err != nil) != tt.wantErr { + t.Fatalf("validatePythPrice() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPythPriceToFloat(t *testing.T) { + price, err := pythPriceToFloat(pythPrice{ + Price: "175500000000", + Exponent: -8, + }) + if err != nil { + t.Fatal(err) + } + + want := big.NewFloat(1755) + if price.Cmp(want) != 0 { + t.Fatalf("pythPriceToFloat() = %s, want %s", price.String(), want.String()) + } +} + +func TestPythPriceToFloatRejectsOutOfRangeExponent(t *testing.T) { + // math.MinInt32 previously reached big.Int.Exp as 10^2147483648. + for _, exponent := range []int32{-2147483648, 2147483647, pythMaxExponentMagnitude + 1, -(pythMaxExponentMagnitude + 1)} { + if _, err := pythPriceToFloat(pythPrice{Price: "1", Exponent: exponent}); err == nil { + t.Fatalf("pythPriceToFloat(expo=%d) succeeded, want error", exponent) + } + } + + if _, err := pythPriceToFloat(pythPrice{Price: "1", Exponent: -pythMaxExponentMagnitude}); err != nil { + t.Fatalf("pythPriceToFloat at the exponent bound failed: %v", err) + } +} + +func TestNormalizePythPriceID(t *testing.T) { + got := normalizePythPriceID(" 0xAbC123 ") + if got != "abc123" { + t.Fatalf("normalizePythPriceID() = %q, want abc123", got) + } +} diff --git a/token-price-oracle/config/config.go b/token-price-oracle/config/config.go index ef0923326..9e05c65eb 100644 --- a/token-price-oracle/config/config.go +++ b/token-price-oracle/config/config.go @@ -21,15 +21,21 @@ const ( type PriceFeedType string const ( - PriceFeedTypeBitget PriceFeedType = "bitget" - PriceFeedTypeBinance PriceFeedType = "binance" + PriceFeedTypeBitget PriceFeedType = "bitget" + PriceFeedTypeBinance PriceFeedType = "binance" + PriceFeedTypeChainlink PriceFeedType = "chainlink" + PriceFeedTypeOKX PriceFeedType = "okx" + PriceFeedTypePyth PriceFeedType = "pyth" ) // ValidPriceFeedTypes returns all valid price feed types func ValidPriceFeedTypes() []PriceFeedType { return []PriceFeedType{ + PriceFeedTypeChainlink, + PriceFeedTypePyth, PriceFeedTypeBitget, - // PriceFeedTypeBinance, // TODO: Add back when Binance price feed is implemented + PriceFeedTypeBinance, + PriceFeedTypeOKX, } } @@ -58,12 +64,21 @@ type Config struct { // Private key PrivateKey string // Price update parameters - PriceUpdateInterval time.Duration // Price update interval - PriceThreshold uint64 // Price change threshold percentage to trigger update - PriceFeedPriority []PriceFeedType // Price feed types in priority order (fallback mechanism) - TokenMappings map[PriceFeedType]map[uint16]string // Token ID to trading pair mappings for each price feed type - BitgetAPIBaseURL string // Bitget API base URL - BinanceAPIBaseURL string // Binance API base URL + PriceUpdateInterval time.Duration // Price update interval + PriceThreshold uint64 // Price change threshold percentage to trigger update + PriceFeedPriority []PriceFeedType // Price feed types in priority order (fallback mechanism) + TokenMappings map[PriceFeedType]map[uint16]string // Token ID to trading pair mappings for each price feed type + BitgetAPIBaseURL string // Bitget API base URL + BinanceAPIBaseURL string // Binance API base URL + OKXAPIBaseURL string // OKX API base URL + ChainlinkRPC string // RPC URL used for Chainlink feeds + ChainlinkETHUSDFeed common.Address // ETH/USD AggregatorV3 feed address + ChainlinkMaxStaleness time.Duration // Maximum accepted age for Chainlink feed rounds + PythHermesBaseURL string // Pyth Hermes API base URL + PythAPIKey string // Pyth Hermes API key, required when the Pyth feed is enabled + PythETHUSDPriceID string // Pyth ETH/USD price ID + PythMaxStaleness time.Duration // Maximum accepted age for Pyth prices + PythMaxConfidenceBPS uint64 // Maximum accepted Pyth confidence interval in BPS (0 disables) // External sign ExternalSign bool @@ -141,7 +156,7 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { // Validate price threshold is reasonable (basis points should be 0-MaxPriceThresholdBPS) if cfg.PriceThreshold > MaxPriceThresholdBPS { - return nil, fmt.Errorf("price threshold %d is too large (should be 0-%d basis points, where %d bps = 100%%)", + return nil, fmt.Errorf("price threshold %d is too large (should be 0-%d basis points, where %d bps = 100%%)", cfg.PriceThreshold, MaxPriceThresholdBPS, MaxPriceThresholdBPS) } @@ -224,13 +239,83 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { cfg.TokenMappings[PriceFeedTypeBinance] = binanceMapping } + okxMapping, err := parseTokenMapping(ctx.String(flags.TokenMappingOKXFlag.Name)) + if err != nil { + return nil, fmt.Errorf("failed to parse okx token mapping: %w", err) + } + if len(okxMapping) > 0 { + cfg.TokenMappings[PriceFeedTypeOKX] = okxMapping + } + + chainlinkMapping, err := parseTokenMapping(ctx.String(flags.TokenMappingChainlinkFlag.Name)) + if err != nil { + return nil, fmt.Errorf("failed to parse chainlink token mapping: %w", err) + } + if len(chainlinkMapping) > 0 { + cfg.TokenMappings[PriceFeedTypeChainlink] = chainlinkMapping + } + + pythMapping, err := parseTokenMapping(ctx.String(flags.TokenMappingPythFlag.Name)) + if err != nil { + return nil, fmt.Errorf("failed to parse pyth token mapping: %w", err) + } + if len(pythMapping) > 0 { + cfg.TokenMappings[PriceFeedTypePyth] = pythMapping + } + // Parse API base URLs cfg.BitgetAPIBaseURL = ctx.String(flags.BitgetAPIBaseURLFlag.Name) cfg.BinanceAPIBaseURL = ctx.String(flags.BinanceAPIBaseURLFlag.Name) + cfg.OKXAPIBaseURL = ctx.String(flags.OKXAPIBaseURLFlag.Name) + cfg.ChainlinkRPC = ctx.String(flags.ChainlinkRPCFlag.Name) + cfg.ChainlinkMaxStaleness = ctx.Duration(flags.ChainlinkMaxStalenessFlag.Name) + cfg.PythHermesBaseURL = ctx.String(flags.PythHermesBaseURLFlag.Name) + cfg.PythAPIKey = strings.TrimSpace(ctx.String(flags.PythAPIKeyFlag.Name)) + cfg.PythETHUSDPriceID = strings.TrimSpace(ctx.String(flags.PythETHUSDPriceIDFlag.Name)) + cfg.PythMaxStaleness = ctx.Duration(flags.PythMaxStalenessFlag.Name) + cfg.PythMaxConfidenceBPS = ctx.Uint64(flags.PythMaxConfidenceBPSFlag.Name) + chainlinkETHUSDFeed := strings.TrimSpace(ctx.String(flags.ChainlinkETHUSDFeedFlag.Name)) + if chainlinkETHUSDFeed != "" { + if !common.IsHexAddress(chainlinkETHUSDFeed) { + return nil, fmt.Errorf("invalid chainlink ETH/USD feed address: %s", chainlinkETHUSDFeed) + } + cfg.ChainlinkETHUSDFeed = common.HexToAddress(chainlinkETHUSDFeed) + } // Validate API URLs for configured feeds (non-empty check only) for _, feedType := range cfg.PriceFeedPriority { switch feedType { + case PriceFeedTypeChainlink: + if cfg.ChainlinkRPC == "" { + return nil, fmt.Errorf("chainlink feed is configured but --chainlink-rpc is not set") + } + if cfg.ChainlinkETHUSDFeed == (common.Address{}) { + return nil, fmt.Errorf("chainlink feed is configured but --chainlink-eth-usd-feed is not set") + } + if cfg.ChainlinkMaxStaleness <= 0 { + return nil, fmt.Errorf("chainlink max staleness must be positive") + } + if len(cfg.TokenMappings[PriceFeedTypeChainlink]) == 0 { + return nil, fmt.Errorf("chainlink feed is configured but --token-mapping-chainlink is not set") + } + + case PriceFeedTypePyth: + if cfg.PythHermesBaseURL == "" { + return nil, fmt.Errorf("pyth feed is configured but --pyth-hermes-base-url is not set") + } + if cfg.PythETHUSDPriceID == "" { + return nil, fmt.Errorf("pyth feed is configured but --pyth-eth-usd-price-id is not set") + } + if cfg.PythAPIKey == "" { + return nil, fmt.Errorf("pyth feed is configured but --pyth-api-key is not set") + } + if cfg.PythMaxStaleness <= 0 { + return nil, fmt.Errorf("pyth max staleness must be positive") + } + if len(cfg.TokenMappings[PriceFeedTypePyth]) == 0 { + return nil, fmt.Errorf("pyth feed is configured but --token-mapping-pyth is not set") + } + case PriceFeedTypeBitget: if cfg.BitgetAPIBaseURL == "" { return nil, fmt.Errorf("bitget feed is configured but --bitget-api-base-url is not set") @@ -240,6 +325,17 @@ func LoadConfig(ctx *cli.Context) (*Config, error) { if cfg.BinanceAPIBaseURL == "" { return nil, fmt.Errorf("binance feed is configured but --binance-api-base-url is not set") } + if len(cfg.TokenMappings[PriceFeedTypeBinance]) == 0 { + return nil, fmt.Errorf("binance feed is configured but --token-mapping-binance is not set") + } + + case PriceFeedTypeOKX: + if cfg.OKXAPIBaseURL == "" { + return nil, fmt.Errorf("okx feed is configured but --okx-api-base-url is not set") + } + if len(cfg.TokenMappings[PriceFeedTypeOKX]) == 0 { + return nil, fmt.Errorf("okx feed is configured but --token-mapping-okx is not set") + } } } diff --git a/token-price-oracle/docker-compose.yml b/token-price-oracle/docker-compose.yml index 389f0945e..e832f172b 100644 --- a/token-price-oracle/docker-compose.yml +++ b/token-price-oracle/docker-compose.yml @@ -16,15 +16,26 @@ services: # Price update configuration TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL: ${TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL:-30s} - TOKEN_PRICE_ORACLE_PRICE_THRESHOLD: ${TOKEN_PRICE_ORACLE_PRICE_THRESHOLD:-5} # percentage (%) + TOKEN_PRICE_ORACLE_PRICE_THRESHOLD: ${TOKEN_PRICE_ORACLE_PRICE_THRESHOLD:-5} # basis points (bps) # Price feed configuration TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY: ${TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY:-bitget} + TOKEN_PRICE_ORACLE_CHAINLINK_RPC: ${TOKEN_PRICE_ORACLE_CHAINLINK_RPC} + TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED: ${TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED} + TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS: ${TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS:-1h} + TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK} + TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL: ${TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL:-https://hermes.pyth.network} + TOKEN_PRICE_ORACLE_PYTH_API_KEY: ${TOKEN_PRICE_ORACLE_PYTH_API_KEY} + TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID: ${TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID} + TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS: ${TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS:-1h} + TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS: ${TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS:-0} + TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH} TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET} + TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL: ${TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL} TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE} - - # Token IDs to monitor (optional, will fetch from contract if not set) - TOKEN_PRICE_ORACLE_TOKEN_IDS: ${TOKEN_PRICE_ORACLE_TOKEN_IDS} + TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL: ${TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL:-https://api.binance.com} + TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX: ${TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX} + TOKEN_PRICE_ORACLE_OKX_API_BASE_URL: ${TOKEN_PRICE_ORACLE_OKX_API_BASE_URL:-https://www.okx.com} # Metrics server TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE: ${TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE:-true} diff --git a/token-price-oracle/env.example b/token-price-oracle/env.example index aadec144d..45eab6720 100644 --- a/token-price-oracle/env.example +++ b/token-price-oracle/env.example @@ -4,8 +4,8 @@ # L2 RPC endpoint TOKEN_PRICE_ORACLE_L2_ETH_RPC=http://localhost:8545 -# L2 Token Registry contract address -TOKEN_PRICE_ORACLE_L2_TOKEN_REGISTRY_ADDRESS=0x5300000000000000000000000000000000000021 +# The L2TokenRegistry address is fixed at its predeploy address and is not configurable. +# Token IDs are always read from that contract; there is no override. # Private key for signing transactions (without 0x prefix in env var) TOKEN_PRICE_ORACLE_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 @@ -14,9 +14,28 @@ TOKEN_PRICE_ORACLE_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efca TOKEN_PRICE_ORACLE_PRICE_UPDATE_INTERVAL=30s TOKEN_PRICE_ORACLE_PRICE_THRESHOLD=100 # basis points (bps), e.g. 100 means 1% (100 bps), 10 means 0.1%, 1 means 0.01% -# Price feed priority (comma-separated: bitget,binance) +# Price feed priority (comma-separated: chainlink,pyth,bitget,binance,okx) TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=bitget +# Chainlink feed configuration (optional) +# Feed addresses are AggregatorV3-compatible token/USD feeds. +# TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=chainlink,pyth,bitget,binance,okx +# TOKEN_PRICE_ORACLE_CHAINLINK_RPC=https://ethereum-rpc.publicnode.com +# TOKEN_PRICE_ORACLE_CHAINLINK_ETH_USD_FEED=0x... +# TOKEN_PRICE_ORACLE_CHAINLINK_MAX_STALENESS=1h +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_CHAINLINK=1:0x...,2:0x... + +# Pyth Hermes feed configuration (optional) +# Price IDs are token/USD feeds. 0x prefix is optional. +# The API key is required whenever pyth is in the priority list: Hermes rejects +# unauthenticated requests from 2026-08-18. +# TOKEN_PRICE_ORACLE_PYTH_HERMES_BASE_URL=https://hermes.pyth.network +# TOKEN_PRICE_ORACLE_PYTH_API_KEY= +# TOKEN_PRICE_ORACLE_PYTH_ETH_USD_PRICE_ID=0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace +# TOKEN_PRICE_ORACLE_PYTH_MAX_STALENESS=1h +# TOKEN_PRICE_ORACLE_PYTH_MAX_CONFIDENCE_BPS=0 +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_PYTH=1:0x...,2:0x... + # Token mapping for Bitget (tokenID:tradingPair,tokenID:tradingPair) # Format: # - Regular tokens: tokenID:SYMBOL (e.g., 1:BGBUSDT, 2:BTCUSDT) @@ -25,14 +44,15 @@ TOKEN_PRICE_ORACLE_PRICE_FEED_PRIORITY=bitget TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BITGET=1:BGBUSDT,2:BTCUSDT,3:$1.0 # Token mapping for Binance (optional, same format as Bitget) -# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE=1:BGBUSDT,2:BTCUSDT,3:$1.0 +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_BINANCE=1:BNBUSDT,2:BTCUSDT,3:$1.0 + +# Token mapping for OKX (optional, OKX uses dash-separated instrument IDs) +# TOKEN_PRICE_ORACLE_TOKEN_MAPPING_OKX=1:BTC-USDT,2:ETH-USDT,3:$1.0 # API base URLs (optional, defaults provided) TOKEN_PRICE_ORACLE_BITGET_API_BASE_URL=https://api.bitget.com # TOKEN_PRICE_ORACLE_BINANCE_API_BASE_URL=https://api.binance.com - -# Token IDs to monitor (optional, will fetch from contract if not set) -TOKEN_PRICE_ORACLE_TOKEN_IDS=1,2 +# TOKEN_PRICE_ORACLE_OKX_API_BASE_URL=https://www.okx.com # Metrics server configuration TOKEN_PRICE_ORACLE_METRICS_SERVER_ENABLE=true diff --git a/token-price-oracle/flags/flags.go b/token-price-oracle/flags/flags.go index 1692806b7..7603bcb74 100644 --- a/token-price-oracle/flags/flags.go +++ b/token-price-oracle/flags/flags.go @@ -52,7 +52,7 @@ var ( PriceFeedPriorityFlag = cli.StringFlag{ Name: "price-feed-priority", - Usage: "Comma-separated list of price feed types in priority order (e.g. \"bitget,binance\")", + Usage: "Comma-separated list of price feed types in priority order (e.g. \"chainlink,pyth,bitget,binance,okx\")", Value: "bitget", EnvVar: prefixEnvVar("PRICE_FEED_PRIORITY"), } @@ -71,6 +71,27 @@ var ( EnvVar: prefixEnvVar("TOKEN_MAPPING_BINANCE"), } + TokenMappingOKXFlag = cli.StringFlag{ + Name: "token-mapping-okx", + Usage: "Token ID to OKX instrument mapping (e.g. \"1:BTC-USDT,2:ETH-USDT\")", + Value: "", + EnvVar: prefixEnvVar("TOKEN_MAPPING_OKX"), + } + + TokenMappingChainlinkFlag = cli.StringFlag{ + Name: "token-mapping-chainlink", + Usage: "Token ID to Chainlink AggregatorV3 feed address mapping (e.g. \"1:0x...,2:0x...\")", + Value: "", + EnvVar: prefixEnvVar("TOKEN_MAPPING_CHAINLINK"), + } + + TokenMappingPythFlag = cli.StringFlag{ + Name: "token-mapping-pyth", + Usage: "Token ID to Pyth price ID mapping (e.g. \"1:0x...,2:0x...\")", + Value: "", + EnvVar: prefixEnvVar("TOKEN_MAPPING_PYTH"), + } + BitgetAPIBaseURLFlag = cli.StringFlag{ Name: "bitget-api-base-url", Usage: "Bitget API base URL (required if bitget feed is enabled)", @@ -81,10 +102,73 @@ var ( BinanceAPIBaseURLFlag = cli.StringFlag{ Name: "binance-api-base-url", Usage: "Binance API base URL (required if binance feed is enabled)", - Value: "", + Value: "https://api.binance.com", EnvVar: prefixEnvVar("BINANCE_API_BASE_URL"), } + OKXAPIBaseURLFlag = cli.StringFlag{ + Name: "okx-api-base-url", + Usage: "OKX API base URL (required if okx feed is enabled)", + Value: "https://www.okx.com", + EnvVar: prefixEnvVar("OKX_API_BASE_URL"), + } + + ChainlinkRPCFlag = cli.StringFlag{ + Name: "chainlink-rpc", + Usage: "RPC endpoint used to read Chainlink AggregatorV3 feeds", + Value: "", + EnvVar: prefixEnvVar("CHAINLINK_RPC"), + } + + ChainlinkETHUSDFeedFlag = cli.StringFlag{ + Name: "chainlink-eth-usd-feed", + Usage: "Chainlink AggregatorV3 ETH/USD feed address", + Value: "", + EnvVar: prefixEnvVar("CHAINLINK_ETH_USD_FEED"), + } + + ChainlinkMaxStalenessFlag = cli.DurationFlag{ + Name: "chainlink-max-staleness", + Usage: "Maximum allowed age for Chainlink feed rounds", + Value: 1 * time.Hour, + EnvVar: prefixEnvVar("CHAINLINK_MAX_STALENESS"), + } + + PythHermesBaseURLFlag = cli.StringFlag{ + Name: "pyth-hermes-base-url", + Usage: "Pyth Hermes API base URL (required if pyth feed is enabled). Set https://pyth.dourolabs.app/hermes to move to the upgraded endpoint ahead of the 2026-08-18 cutover", + Value: "https://hermes.pyth.network", + EnvVar: prefixEnvVar("PYTH_HERMES_BASE_URL"), + } + + PythAPIKeyFlag = cli.StringFlag{ + Name: "pyth-api-key", + Usage: "Pyth Hermes API key (required if pyth feed is enabled; Hermes rejects unauthenticated requests from 2026-08-18)", + Value: "", + EnvVar: prefixEnvVar("PYTH_API_KEY"), + } + + PythETHUSDPriceIDFlag = cli.StringFlag{ + Name: "pyth-eth-usd-price-id", + Usage: "Pyth ETH/USD price ID", + Value: "", + EnvVar: prefixEnvVar("PYTH_ETH_USD_PRICE_ID"), + } + + PythMaxStalenessFlag = cli.DurationFlag{ + Name: "pyth-max-staleness", + Usage: "Maximum allowed age for Pyth price publish time", + Value: 1 * time.Hour, + EnvVar: prefixEnvVar("PYTH_MAX_STALENESS"), + } + + PythMaxConfidenceBPSFlag = cli.Uint64Flag{ + Name: "pyth-max-confidence-bps", + Usage: "Maximum allowed Pyth confidence interval in basis points relative to price (0 disables confidence check)", + Value: 0, + EnvVar: prefixEnvVar("PYTH_MAX_CONFIDENCE_BPS"), + } + // Logging flags LogLevelFlag = cli.StringFlag{ Name: "log-level", @@ -203,8 +287,20 @@ var optionalFlags = []cli.Flag{ PriceFeedPriorityFlag, TokenMappingBitgetFlag, TokenMappingBinanceFlag, + TokenMappingOKXFlag, + TokenMappingChainlinkFlag, + TokenMappingPythFlag, BitgetAPIBaseURLFlag, BinanceAPIBaseURLFlag, + OKXAPIBaseURLFlag, + ChainlinkRPCFlag, + ChainlinkETHUSDFeedFlag, + ChainlinkMaxStalenessFlag, + PythHermesBaseURLFlag, + PythAPIKeyFlag, + PythETHUSDPriceIDFlag, + PythMaxStalenessFlag, + PythMaxConfidenceBPSFlag, LogLevelFlag, LogFilenameFlag, diff --git a/token-price-oracle/local.sh b/token-price-oracle/local.sh index 609390ce0..7b48a34f8 100644 --- a/token-price-oracle/local.sh +++ b/token-price-oracle/local.sh @@ -8,7 +8,7 @@ --price-update-interval 30s \ --price-threshold 100 \ --price-feed-priority bitget \ - --token-mapping-bitget "1:BGBUSDT,2:BTCUSDT,3:\$1.0" \ + --token-mapping-bitget "1:BTCUSDT,2:ETHUSDT,3:BGBUSDT" \ --bitget-api-base-url https://api.bitget.com \ --log-level info \ --metrics-server-enable @@ -16,8 +16,26 @@ # Price threshold examples (in basis points): # 1 bps = 0.01%, 10 bps = 0.1%, 100 bps = 1%, 500 bps = 5%, 1000 bps = 10% +# The mapping above matches the devnet genesis, which pre-registers ID 1 as BTC, +# ID 2 as ETH and ID 3 as BGB. See DEVNET_SETUP.md. + # Token mapping format: -# - Regular tokens: tokenID:SYMBOL (e.g., 1:BGBUSDT, 2:BTCUSDT) -# - Stablecoins: tokenID:$PRICE (e.g., 3:$1.0 for USDT pegged to $1 USD) +# - Regular tokens: tokenID:SYMBOL (e.g., 1:BTCUSDT, 2:ETHUSDT) +# - Stablecoins: tokenID:$PRICE (e.g., 4:$1.0 for USDT pegged to $1 USD) # Note: Use \$ in bash to escape the dollar sign +# Chainlink example: +# --price-feed-priority chainlink,pyth,bitget,binance,okx \ +# --chainlink-rpc https://ethereum-rpc.publicnode.com \ +# --chainlink-eth-usd-feed 0x... \ +# --chainlink-max-staleness 1h \ +# --token-mapping-chainlink "1:0x...,2:0x..." \ +# --pyth-hermes-base-url https://hermes.pyth.network \ +# --pyth-api-key "$TOKEN_PRICE_ORACLE_PYTH_API_KEY" \ +# --pyth-eth-usd-price-id 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace \ +# --pyth-max-staleness 1h \ +# --pyth-max-confidence-bps 0 \ +# --token-mapping-pyth "1:0x...,2:0x..." \ +# --token-mapping-binance "1:BTCUSDT,2:ETHUSDT" \ +# --token-mapping-okx "1:BTC-USDT,2:ETH-USDT" \ + diff --git a/token-price-oracle/metrics/metrics.go b/token-price-oracle/metrics/metrics.go index e03b6be39..f1d4c501e 100644 --- a/token-price-oracle/metrics/metrics.go +++ b/token-price-oracle/metrics/metrics.go @@ -45,6 +45,16 @@ var ( }, []string{"type"}, // type: "updated" or "skipped" ) + + // UnresolvedTokens tracks how many active tokens no configured feed could price in + // the last cycle. A cycle that resolves some tokens still counts as successful, so + // this is the signal that a token is going stale while the oracle looks healthy. + UnresolvedTokens = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "unresolved_tokens", + Help: "Number of active tokens no price feed could resolve in the last update cycle", + }, + ) ) // init registers all metrics @@ -53,6 +63,7 @@ func init() { prometheus.MustRegister(AccountBalance) prometheus.MustRegister(LastSuccessfulUpdateTimestamp) prometheus.MustRegister(UpdatesTotal) + prometheus.MustRegister(UnresolvedTokens) // Initialize metrics with default values to avoid nil pointer issues in alerting systems // Set initial timestamp to current time (program start time) @@ -63,6 +74,8 @@ func init() { UpdatesTotal.WithLabelValues("skipped").Add(0) // Initialize error counter labels UpdateErrors.WithLabelValues("price").Add(0) + UpdateErrors.WithLabelValues("unresolved_token").Add(0) + UnresolvedTokens.Set(0) // Note: AccountBalance is NOT initialized here to avoid triggering low balance alerts // It will be set with the real value on the first update cycle } diff --git a/token-price-oracle/updater/factory.go b/token-price-oracle/updater/factory.go index 18a54c205..3e9d86566 100644 --- a/token-price-oracle/updater/factory.go +++ b/token-price-oracle/updater/factory.go @@ -2,9 +2,11 @@ package updater import ( "fmt" + "net/url" "github.com/morph-l2/go-ethereum/common" "github.com/morph-l2/go-ethereum/log" + "morph-l2/bindings/bindings" "morph-l2/token-price-oracle/client" "morph-l2/token-price-oracle/config" @@ -91,21 +93,59 @@ func createFallbackPriceFeed(cfg *config.Config) (client.PriceFeed, error) { return nil, fmt.Errorf("no valid price feeds could be created") } + // Wrap even a single feed: FallbackPriceFeed is what enforces that a batch came + // back complete and non-nil. Returning feeds[0] directly let a CEX feed's partial + // or empty map through as a success, which the updater then recorded as a + // successful cycle. if len(feeds) == 1 { log.Info("Single price feed configured (no fallback)", "feed", feedNames[0]) - return feeds[0], nil + } else { + log.Info("Fallback price feed configured with multiple sources", + "feeds", feedNames, + "priority", "first to last") } - log.Info("Fallback price feed configured with multiple sources", - "feeds", feedNames, - "priority", "first to last") - return client.NewFallbackPriceFeed(feeds, feedNames), nil } // createSinglePriceFeed creates a single price feed instance func createSinglePriceFeed(feedType config.PriceFeedType, cfg *config.Config) (client.PriceFeed, string, error) { switch feedType { + case config.PriceFeedTypeChainlink: + mapping, exists := cfg.TokenMappings[config.PriceFeedTypeChainlink] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("chainlink price feed requires token mapping, please configure --token-mapping-chainlink") + } + feed, err := client.NewChainlinkPriceFeed(mapping, cfg.ChainlinkRPC, cfg.ChainlinkETHUSDFeed, cfg.ChainlinkMaxStaleness) + if err != nil { + return nil, "", err + } + log.Info("Chainlink price feed created", + "type", "chainlink", + "rpc", redactRPCForLog(cfg.ChainlinkRPC), + "eth_usd_feed", cfg.ChainlinkETHUSDFeed.Hex(), + "max_staleness", cfg.ChainlinkMaxStaleness, + "mapping", mapping) + return feed, "chainlink", nil + + case config.PriceFeedTypePyth: + mapping, exists := cfg.TokenMappings[config.PriceFeedTypePyth] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("pyth price feed requires token mapping, please configure --token-mapping-pyth") + } + feed, err := client.NewPythHermesPriceFeed(mapping, cfg.PythHermesBaseURL, cfg.PythAPIKey, cfg.PythETHUSDPriceID, cfg.PythMaxStaleness, cfg.PythMaxConfidenceBPS) + if err != nil { + return nil, "", err + } + log.Info("Pyth price feed created", + "type", "pyth", + "base_url", cfg.PythHermesBaseURL, + "eth_usd_price_id", cfg.PythETHUSDPriceID, + "max_staleness", cfg.PythMaxStaleness, + "max_confidence_bps", cfg.PythMaxConfidenceBPS, + "mapping", mapping) + return feed, "pyth", nil + case config.PriceFeedTypeBitget: mapping, exists := cfg.TokenMappings[config.PriceFeedTypeBitget] if !exists || len(mapping) == 0 { @@ -119,15 +159,42 @@ func createSinglePriceFeed(feedType config.PriceFeedType, cfg *config.Config) (c return feed, "bitget", nil case config.PriceFeedTypeBinance: - // Binance price feed is not yet implemented - // This case should not be reached since Binance is not in ValidPriceFeedTypes - return nil, "", fmt.Errorf("binance price feed is not supported yet") + mapping, exists := cfg.TokenMappings[config.PriceFeedTypeBinance] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("binance price feed requires token mapping, please configure --token-mapping-binance") + } + feed := client.NewBinancePriceFeed(mapping, cfg.BinanceAPIBaseURL) + log.Info("Binance price feed created", + "type", "binance", + "base_url", cfg.BinanceAPIBaseURL, + "mapping", mapping) + return feed, "binance", nil + + case config.PriceFeedTypeOKX: + mapping, exists := cfg.TokenMappings[config.PriceFeedTypeOKX] + if !exists || len(mapping) == 0 { + return nil, "", fmt.Errorf("okx price feed requires token mapping, please configure --token-mapping-okx") + } + feed := client.NewOKXPriceFeed(mapping, cfg.OKXAPIBaseURL) + log.Info("OKX price feed created", + "type", "okx", + "base_url", cfg.OKXAPIBaseURL, + "mapping", mapping) + return feed, "okx", nil default: return nil, "", fmt.Errorf("unsupported price feed type: %s", feedType) } } +func redactRPCForLog(raw string) string { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host) +} + // CreateTxManager creates transaction manager func CreateTxManager(l2Client *client.L2Client) *TxManager { return NewTxManager(l2Client) diff --git a/token-price-oracle/updater/factory_test.go b/token-price-oracle/updater/factory_test.go new file mode 100644 index 000000000..eca35dbed --- /dev/null +++ b/token-price-oracle/updater/factory_test.go @@ -0,0 +1,35 @@ +package updater + +import "testing" + +func TestRedactRPCForLog(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "path API key", + raw: "https://rpc.example.com/v3/secret?token=also-secret", + want: "https://rpc.example.com", + }, + { + name: "credentials", + raw: "https://user:password@rpc.example.com/path", + want: "https://rpc.example.com", + }, + { + name: "invalid", + raw: "not-a-url", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := redactRPCForLog(tt.raw); got != tt.want { + t.Fatalf("redactRPCForLog() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/token-price-oracle/updater/token_price.go b/token-price-oracle/updater/token_price.go index ea7f473a7..a4fce38e0 100644 --- a/token-price-oracle/updater/token_price.go +++ b/token-price-oracle/updater/token_price.go @@ -125,6 +125,23 @@ func (u *PriceUpdater) update(ctx context.Context) error { return fmt.Errorf("failed to fetch token prices: %w", err) } + // Feeds resolve what they can, so a cycle can succeed while leaving some tokens + // unpriced. Those tokens go stale silently unless they are surfaced here. + var unresolvedTokenIDs []uint16 + for _, tokenID := range activeTokenIDs { + if _, exists := tokenPrices[tokenID]; !exists { + unresolvedTokenIDs = append(unresolvedTokenIDs, tokenID) + } + } + metrics.UnresolvedTokens.Set(float64(len(unresolvedTokenIDs))) + if len(unresolvedTokenIDs) > 0 { + log.Warn("No price feed could resolve some active tokens", + "token_ids", unresolvedTokenIDs, + "resolved", len(tokenPrices), + "active", len(activeTokenIDs)) + metrics.UpdateErrors.WithLabelValues("unresolved_token").Add(float64(len(unresolvedTokenIDs))) + } + // Step 2: Calculate price ratios using pre-fetched tokenInfo (no extra contract calls) newPriceRatios := make(map[uint16]*big.Int) for tokenID, tokenPrice := range tokenPrices {