Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2e4be7d
feat(token-price-oracle): add Chainlink price feed
Jun 22, 2026
ef6d751
feat(token-price-oracle): add Pyth and CEX price feeds
Jun 22, 2026
04b5ea9
fix(token-price-oracle): request parsed Pyth prices
Jun 22, 2026
a5a77e7
Merge branch 'main' into feat/977-chainlink-token-price-oracle
curryxbo Jul 22, 2026
1596872
feat(genesis): pre-register test tokens in TokenRegistry for devnet
curryxbo Jul 25, 2026
d1b686a
docs: add TokenRegistry pre-registration documentation
curryxbo Jul 25, 2026
b1be127
docs: add today's work summary
curryxbo Jul 25, 2026
4174e3d
fix(token-price-oracle): address review findings
Jul 27, 2026
1dec6bd
Merge branch 'main' into feat/977-chainlink-token-price-oracle
curryxbo Jul 27, 2026
83a8214
Merge branch 'main' into feat/977-chainlink-token-price-oracle
curryxbo Jul 31, 2026
939d007
fix(node): drop the layer1-verify override of derivation confirmations
Jul 31, 2026
4b40bb2
fix(token-price-oracle): unify GetTokenPrice preconditions across CEX…
Aug 3, 2026
1bab5a9
fix(token-price-oracle): address review findings on feeds, genesis an…
Aug 3, 2026
7d18942
fix(token-price-oracle): resolve batch prices across feeds instead of…
Aug 3, 2026
e974620
fix(token-price-oracle): allow bounded clock skew on Pyth publish time
Aug 3, 2026
9c5e25b
fix(genesis): register devnet test tokens with a scale of 10^decimals
Aug 3, 2026
dbbf296
docs(devnet): note that devnet-down keeps the L1 data volume
Aug 3, 2026
ede8cca
docs(devnet): show how to produce the devnet.env the docker run expects
Aug 3, 2026
09c0a6c
fix(token-price-oracle): point local.sh at the devnet token IDs
Aug 3, 2026
5d60d5a
docs(token-price-oracle): spell out why the Pyth key is required befo…
Aug 3, 2026
276e87a
docs(token-price-oracle): move the devnet guide out of the repository…
Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions DEVNET_TOKENREGISTRY_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# 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`.

## Pre-registered tokens

| Token ID | Symbol | Address | Decimals | Scale | Purpose |
|----------|--------|---------|----------|-------|---------|
| 1 | BTC | `0x0000000000000000000000000000000000000001` | 8 | 10^10 | High-value asset feed testing |
| 2 | ETH | `0x5300000000000000000000000000000000000011` | 18 | 1 | L2WETH benchmark |
| 3 | BGB | `0x0000000000000000000000000000000000000003` | 18 | 1 | CEX-specific feed testing |

BTC and BGB use mock addresses and are not deployed ERC-20 contracts.

## Storage initialization

`SetDevnetTestTokens` runs after the system contract implementations are
installed by `BuildL2DeveloperGenesis`. It initializes:

- `tokenRegistry[tokenID]`
- `tokenRegistration[tokenAddress]`
- `supportedTokenSet`

Tokens are deliberately initialized with `isActive=false`. A non-zero balance
slot is stored as `balanceSlot + 1`, matching the encoding used by
`L2TokenRegistry`; zero remains zero.

## Start and verify the devnet

```bash
make devnet-down
rm -rf ops/docker/.devnet
make devnet-up
```

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="<DEVNET_OWNER_PRIVATE_KEY>"
ORACLE_ADDRESS="<ORACLE_SIGNER_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_L2_TOKEN_REGISTRY_ADDRESS=0x5300000000000000000000000000000000000021

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The documented registry-address and token-ID controls are ignored. There are no corresponding flags/config fields: the service always binds the predeploy address and always reads every supported token ID from the contract. This is especially misleading because it hides the full-batch coverage failure. Either implement both options end-to-end with tests, or remove these exports and document the actual fixed behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1bab5a9. Both exports are gone from the devnet guide.

They were not only in the new doc: env.example and docker-compose.yml carried the same two dead variables from before this PR, so those were cleaned up in the same commit rather than leaving two more copies of the same misdirection. env.example now states that the registry address is fixed at its predeploy and that token IDs always come from the contract.

export TOKEN_PRICE_ORACLE_PRIVATE_KEY="<DEVNET_ORACLE_PRIVATE_KEY>"
export TOKEN_PRICE_ORACLE_TOKEN_IDS=1,2,3
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,3:BGB-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_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
```

Use an isolated devnet-only oracle private key. For production, use the external
signing mode described in `token-price-oracle/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:

```bash
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
```

## 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.
- [ ] The metrics endpoint reports the successful update.

## 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.
192 changes: 192 additions & 0 deletions ops/l2-genesis/morph-chain-ops/genesis/devnet_tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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
Decimals uint8
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,
TokenAddress: common.HexToAddress("0x0000000000000000000000000000000000000001"), // Mock BTC address

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These are precompile addresses, not mock ERC-20 contracts. Address 0x01 executes ECRECOVER and 0x03 executes RIPEMD160. The setup guide later activates all three IDs, after which balanceOf/transfer calls for BTC and BGB return invalid or hash-derived data and cannot settle fee-token transactions. Deploy real mock ERC-20 bytecode at non-precompile addresses (or do not advertise/activate these IDs) and add one fee-payment integration test per advertised token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1bab5a9. The placeholder addresses moved to 0x1111...1111 and 0x3333...3333, clear of the 0x01-0x0a precompile range, and a test asserts the registered addresses stay above 0xff so this cannot regress. The setup guide was updated to match and now says the addresses are placeholders with no deployed contract.

BalanceSlot: common.Hash{}, // zero hash (no balance slot needed for mock)
Decimals: 8,
Scale: big.NewInt(1e10), // 10^(18-8) for ETH decimals adjustment
},
{
TokenID: 2,
TokenAddress: common.HexToAddress("0x5300000000000000000000000000000000000011"), // L2WETH predeploy address
BalanceSlot: common.BigToHash(big.NewInt(3)), // WETH standard balance slot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The L2WETH balance slot is incorrect. bindings/bindings/wrappedether_more.go places _balances at slot 0; slot 3 is _name. After token ID 2 is activated, fee accounting reads keccak256(user, 3), so deposited WETH at keccak256(user, 0) appears absent and WETH-paid transactions fail. The current model also uses a zero hash to mean 'no balance slot', so it cannot represent an actual slot of zero. Add a separate NeedBalanceSlot/HasBalanceSlot field and encode actual slot 0 as stored value 1, or intentionally use the EVM-call path, then cover a WETH fee-payment flow in a genesis-level test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1bab5a9. L2WETH now registers slot 0.

You were right that the encoding needed a separate flag: slot 0 is a real balance slot, so a zero hash could not distinguish it from "no slot". DevnetTestToken gained a NeedBalanceSlot field mirroring the parameter L2TokenRegistry.registerToken already takes, and setTokenInfo now follows _toStoredBalanceSlot exactly.

The existing storage test had the same ambiguity baked into its assertion, so it was corrected as well, and a new test pins the WETH entry to slot 0 with the flag set.

Decimals: 18,
Scale: big.NewInt(1), // 1:1 ratio
},
{
TokenID: 3,
TokenAddress: common.HexToAddress("0x0000000000000000000000000000000000000003"), // Mock BGB address
BalanceSlot: common.Hash{}, // zero hash
Decimals: 18,
Scale: big.NewInt(1),
},
}
}

// 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: encode a requested balance slot as actual slot + 1.
storedBalanceSlot := token.BalanceSlot
if token.BalanceSlot != (common.Hash{}) {
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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
}
Loading
Loading