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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Comment on lines +112 to +117

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Create and locate devnet.env before the Docker command.

The guide defines shell exports but never writes them to devnet.env; running this container command as documented therefore fails when Docker cannot find the env file. Add an explicit env-file creation step and an unambiguous working directory/path.

Suggested documentation change
+cd token-price-oracle
+# Save the configuration above as devnet.env before running:
 docker run -d \
   --name token-price-oracle \
   --network docker_default \
-  --env-file devnet.env \
+  --env-file ./devnet.env \
   morph/token-price-oracle:latest
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DEVNET_TOKENREGISTRY_SETUP.md` around lines 112 - 117, Update the setup
instructions before the token-price-oracle docker run command to explicitly
create devnet.env from the documented environment values, and state the working
directory or use an unambiguous path so Docker can locate it. Keep the existing
docker run configuration unchanged aside from the env-file path if needed.


docker logs -f token-price-oracle
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

## 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.
15 changes: 5 additions & 10 deletions node/derivation/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
9 changes: 4 additions & 5 deletions node/derivation/reorg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
2 changes: 1 addition & 1 deletion node/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}

Expand Down
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
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
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