diff --git a/.env.example b/.env.example index a2b2be12..ff3f525e 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,17 @@ EDEN_CHAIN_ID=3735928814 ANVIL1_CHAIN_ID=31337 ANVIL2_CHAIN_ID=31338 +# ============================================================================= +# HYPERLANE DOMAIN IDs (optional, only set when domain ≠ chain ID) +# ============================================================================= +# A chain's Hyperlane domain ID defaults to its EVM chain ID. Override here +# when the chain uses a distinct domain ID (e.g. Eden, or any chain whose ID +# collides with Hyperlane's hardcoded KnownHyperlaneDomain enum). +# Pattern: {NAME}_DOMAIN_ID — picked up by `solver-cli deploy`. +# Equivalent CLI flag for `chain add`: --domain-id . +# +#EDEN_DOMAIN_ID= + # ============================================================================= # PRIVATE KEYS (without 0x prefix) # ============================================================================= diff --git a/Cargo.lock b/Cargo.lock index 9e927931..6c98c861 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4456,6 +4456,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "solver-shared", "tempfile", "tokio", "toml 0.9.12+spec-1.1.0", @@ -5451,6 +5452,7 @@ dependencies = [ "solver-pricing", "solver-service", "solver-settlement-impls", + "solver-shared", "solver-storage", "solver-types", "tempfile", @@ -5689,6 +5691,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "solver-shared" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "solver-storage" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index db1494e5..fa62171f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,6 @@ members = [ "rebalancer", "solver-cli", "solver-settlement", + "solver-shared", ] resolver = "2" diff --git a/Makefile b/Makefile index 4e1950d4..a9ccdbab 100644 --- a/Makefile +++ b/Makefile @@ -153,7 +153,8 @@ fund: build @$(SOLVER_CLI) fund --amount 100000000 --chain anvil1 .PHONY: fund -## chain-add: Add a chain with existing contracts (use make chain-add NAME=arbitrum RPC=... INPUT_SETTLER=... OUTPUT_SETTLER=... ORACLE=...) +## chain-add: Register a chain with existing contracts. Required: NAME, RPC, INPUT_SETTLER, OUTPUT_SETTLER, ORACLE. +## Optional: CHAIN_ID, DOMAIN_ID, MAILBOX, IGP, WARP_TOKEN, WARP_TOKEN_TYPE, TOKEN_SYMBOL+TOKEN_ADDR. chain-add: build @$(SOLVER_CLI) chain add \ --name $(NAME) \ @@ -162,6 +163,11 @@ chain-add: build --input-settler $(INPUT_SETTLER) \ --output-settler $(OUTPUT_SETTLER) \ --oracle $(ORACLE) \ + $(if $(DOMAIN_ID),--domain-id $(DOMAIN_ID),) \ + $(if $(MAILBOX),--mailbox $(MAILBOX),) \ + $(if $(IGP),--igp $(IGP),) \ + $(if $(WARP_TOKEN),--warp-token $(WARP_TOKEN),) \ + $(if $(WARP_TOKEN_TYPE),--warp-token-type $(WARP_TOKEN_TYPE),) \ $(if $(TOKEN_ADDR),--token $(TOKEN_SYMBOL)=$(TOKEN_ADDR),) .PHONY: chain-add @@ -175,9 +181,17 @@ chain-remove: build @$(SOLVER_CLI) chain remove --chain $(CHAIN) .PHONY: chain-remove -## token-add: Add a token to a chain (use CHAIN=name SYMBOL=USDC ADDRESS=0x... DECIMALS=6) +## token-add: Add a token to a chain. Required: CHAIN, SYMBOL, ADDRESS. +## Optional: DECIMALS, TOKEN_TYPE (erc20|native), WARP_TOKEN, WARP_TOKEN_TYPE (collateral|synthetic|native). token-add: build - @$(SOLVER_CLI) token add --chain $(CHAIN) --symbol $(SYMBOL) --address $(ADDRESS) $(if $(DECIMALS),--decimals $(DECIMALS),) + @$(SOLVER_CLI) token add \ + --chain $(CHAIN) \ + --symbol $(SYMBOL) \ + --address $(ADDRESS) \ + $(if $(DECIMALS),--decimals $(DECIMALS),) \ + $(if $(TOKEN_TYPE),--token-type $(TOKEN_TYPE),) \ + $(if $(WARP_TOKEN),--warp-token $(WARP_TOKEN),) \ + $(if $(WARP_TOKEN_TYPE),--warp-token-type $(WARP_TOKEN_TYPE),) .PHONY: token-add ## token-list: List all tokens across chains (use CHAIN=name to filter) @@ -327,21 +341,32 @@ balances: build # Full Setup & Lifecycle # ============================================================================ -## setup: Full setup (init + deploy + configure + fund) -setup: init deploy-permit2 deploy configure fund fund-operator fund-user +## setup: Bring up local chains + deploy contracts ONLY. Does not configure or fund. +## Configuration and funding are explicit follow-up steps so you can see the wiring. +setup: init deploy-permit2 deploy @echo "" - @echo "Setup complete! Next steps:" - @echo " 1. make aggregator - Start OIF aggregator (in separate terminal)" - @echo " 2. make solver - Start solver service (in another terminal)" - @echo " 3. make operator - Start oracle operator service (in another terminal)" - @echo " 4. make rebalancer - Start rebalancer service (in another terminal)" - @echo " 5. make intent - Submit a test intent" - @echo " 6. make balances - Check balances" + @echo "Contracts deployed. State written to .config/state.json." + @echo "" + @echo "Next steps (run each one yourself — see README for what they do):" + @echo " 1. solver-cli chain list # inspect deployed chains" + @echo " 2. solver-cli token list # inspect tokens populated from Hyperlane artifacts" + @echo " 3. solver-cli configure # generate .config/{solver,oracle,rebalancer,aggregator}.* files" + @echo " 4. make fund fund-operator fund-user" + @echo " 5. make aggregator | make solver | make operator | make rebalancer (in separate terminals)" + @echo "" + @echo "Adding external chains/tokens? See README -> 'Adding external chains' for the CLI walkthrough." .PHONY: setup -## reset: Clean and reinitialize everything +## setup-demo: Convenience wrapper that runs setup + configure + fund. Use this for the +## one-button local demo flow; for production wiring, run the individual steps. +setup-demo: setup configure fund fund-operator fund-user + @echo "" + @echo "Demo setup complete! Start services with: make aggregator solver operator" +.PHONY: setup-demo + +## reset: Clean and reinitialize everything (demo path) reset: clean - @$(MAKE) setup FORCE=1 + @$(MAKE) setup-demo FORCE=1 .PHONY: reset ## frontend: Start the frontend (backend API + Next.js dev server) diff --git a/README.md b/README.md index 6537cff5..54b88267 100644 --- a/README.md +++ b/README.md @@ -20,120 +20,262 @@ This CLI deploys OIF contracts, runs a solver, and executes cross-chain token tr - [Rust](https://rustup.rs/) - Build the CLI - **Testnet ETH** - Get testnet ETH from a [faucet](https://sepoliafaucet.com) -## Quick Start: E2E Test +## Quick Start -### Option 1: Direct to Chain (Simpler) +Pick one path: + +- **Path A — Local Anvil walkthrough.** Two-chain Docker stack. Best for learning the flow end-to-end and for development. +- **Path B — Real EVM chains (Sepolia/Eden/Arbitrum/...).** Skip Docker; deploy or register contracts on existing chains. +- **Path C — One-button demo.** Hands-off local stack with everything started for you. + +### Prep (all paths) ```bash -# 1. Start local EVM chain -make start +# Build the CLI binary (target/release/solver-cli) +make build -# 2. Configure environment +# Put solver-cli on PATH for the rest of this session. +# Pick one: +export PATH="$PWD/target/release:$PATH" # quick session-only +# alias solver-cli="$PWD/target/release/solver-cli" # alternative +# cargo install --path solver-cli # ~/.cargo/bin (persistent) + +# Environment cp .env.example .env -# Edit .env with your SEPOLIA_PK (must have Sepolia ETH for gas!) +# Edit .env. Required: +# per-chain {NAME}_RPC + {NAME}_PK (e.g. SEPOLIA_RPC, SEPOLIA_PK) +# SOLVER_PRIVATE_KEY (== REBALANCER_PRIVATE_KEY) +# ORACLE_OPERATOR_PK (must differ from SOLVER_PRIVATE_KEY) +# USER_PK (test-user wallet) +# INTEGRITY_SECRET (32+ random chars; signs aggregator +# quotes/orders end-to-end so a forged +# quote can't be replayed) + +# Pre-funding (real-chain users only): +# Each chain's {NAME}_PK needs native gas to deploy contracts (~0.05 ETH on +# Sepolia is plenty; per-chain deploys are 4 txs). +# SOLVER_PRIVATE_KEY needs gas on every chain (filling + claiming). +# ORACLE_OPERATOR_PK needs gas on every chain (attestation submission). +# USER_PK needs gas + token inventory on the source chain. +``` -# 3. Full setup (build, deploy, configure, fund) -make clean && make setup +### Path A — Local Anvil walkthrough -# 4. Start solver (in separate terminal) -make solver +`make setup` stops after deploying contracts so you can see each follow-up step on its own. -# 5. Start oracle operator (in another separate terminal) -make operator +```bash +# 1. Start Docker stack (Anvil1, Anvil2, Hyperlane init, forwarding relayer) +make start -# 6. Submit intent and check balances (in original terminal) -make balances -make mint +# 2. Deploy OIF contracts on both Anvil chains +make setup +# ↳ runs: init + deploy-permit2 + solver-cli deploy +# ↳ writes .config/state.json with chain IDs, contract addresses, and +# Hyperlane warp router + token addresses pulled from +# .config/hyperlane-addresses.json (created by `make start`) + +# 3. Inspect state +solver-cli chain list +solver-cli token list + +# 4. Generate the four service configs from state.json +solver-cli configure +# ↳ writes .config/solver.toml, .config/oracle.toml, +# .config/rebalancer.toml, .config/aggregator.json + +# 5. Fund accounts +make fund # mint USDC to the solver on anvil1 (collateral chain) +make fund-operator # ETH to oracle operator on every chain +make fund-user # ETH to test user on every chain + +# 6. Start services in separate terminals +make aggregator # T1 +make solver # T2 +make operator # T3 +make rebalancer # T4 (optional — Celestia rebalance loop) + +# 7. Submit intent + watch balances make balances make intent make balances ``` -### Option 2: With Aggregator (Recommended for Multi-Solver) - -```bash -# 1-3. Same as above (start chain, configure, setup) +### Path B — Real EVM chains (no Docker) -# 4. Start aggregator (Terminal 1) -make aggregator +For a public testnet/mainnet, **do not run `make start`** — the Docker stack is local-only. Skip straight to `solver-cli deploy` (or `chain add` if contracts already exist), then add tokens, configure, and fund manually with `cast send` / your wallet. -# 5. Start solver (Terminal 2) -make solver +```bash +# 1. Add the chain(s) to .env (auto-detected via {NAME}_RPC + {NAME}_PK). +# Optional: {NAME}_DOMAIN_ID if Hyperlane domain ≠ chain ID. +echo 'EDEN_RPC=https://eden-rpc.example' >> .env +echo 'EDEN_PK=0x' >> .env +# echo 'EDEN_DOMAIN_ID=12345' >> .env # only if needed + +solver-cli init + +# 2a. Deploy fresh OIF contracts on each chain. +# Note: --token / --decimals only auto-register tokens when +# .config/hyperlane-addresses.json exists (local Anvil only). On real +# chains, deploy writes the contract addresses but NOT tokens — you'll +# register tokens explicitly in step 3 below. +solver-cli deploy --chains sepolia,eden + +# 2b. ...OR if contracts are already deployed, register them per chain: +solver-cli chain add \ + --name eden --rpc "$EDEN_RPC" \ + --input-settler 0x... --output-settler 0x... --oracle 0x... \ + --domain-id 12345 \ + --mailbox 0x \ + --igp 0x + +# 3. Add tokens. Pick the right warp_token_type for each chain: +# collateral = HypERC20Collateral wraps a vanilla ERC20 (two distinct addresses) +# synthetic = HypERC20Synthetic IS the ERC20 (same address in both fields) +# native = HypNative wraps the chain's gas token (no underlying ERC20) +solver-cli token add --chain sepolia --symbol ETH \ + --address 0x --decimals 18 --token-type erc20 \ + --warp-token 0x --warp-token-type synthetic +solver-cli token add --chain eden --symbol ETH \ + --address 0x --decimals 18 --token-type erc20 \ + --warp-token 0x --warp-token-type synthetic + +# 4. Generate configs +solver-cli configure + +# 5. Fund manually — `make fund` is anvil-only. +# Send native gas + token inventory to: +# - SOLVER_PRIVATE_KEY's address on every chain (gas + token inventory +# on the destination chain so the solver can fill). +# - ORACLE_OPERATOR_PK's address on every chain (gas only). +# - USER_PK's address on the source chain (gas + tokens for the test). +# +# Token inventory address: for synthetic warp tokens, deposit the warp +# token itself (token.address == warp_token in this case). For collateral, +# deposit the underlying ERC20 — the rebalancer/solver will approve the +# warp router as needed. +# +# With `cast` (gas only, repeat per chain/recipient): +cast send --rpc-url "$EDEN_RPC" --private-key "$EDEN_PK" \ + --value 0.05ether $(cast wallet address --private-key "$SOLVER_PRIVATE_KEY") +# ...or use any wallet UI. + +# 6. Start services (same as Path A step 6, separate terminals). +# Order matters: aggregator first, then solver/operator, then frontend +# (frontend talks to the aggregator + solver HTTP APIs). +make aggregator # T1 — port 4000 +make solver # T2 — solver HTTP on 5001 +make operator # T3 +make rebalancer # T4 (optional — only useful if Celestia warp legs exist) +make frontend # T5 (optional — http://localhost:3000; needs T1+T2 up) + +# 7. Submit + verify +solver-cli intent submit --asset ETH --from sepolia --to eden --amount 100000000000000000 # 0.1 ETH +solver-cli balances +``` -# 6. Start oracle operator (Terminal 3) -make operator +### Path C — One-button local demo -# 7. Use aggregator API or CLI -curl http://localhost:4000/api/v1/solvers -make intent +```bash +make mvp # full Docker stack + every service + frontend +# ...or just the wiring without the services / frontend: +make setup-demo # = setup + configure + fund + fund-operator + fund-user ``` -## Environment Setup +`setup-demo` is the old all-in-one `setup`; the new `setup` stops after deploy. -Chains are configured with the pattern `{CHAIN}_RPC` and `{CHAIN}_PK`: +## Reference -```bash -cp .env.example .env -# Edit with your keys -``` +### Hyperlane warp router types -See [Deploy New Token](docs/deploy-new-token.md) for detailed environment setup. +| Type | Underlying ERC20 (`token.address`) | Warp router (`warp_token`) | Set `token-type` to | Set `warp-token-type` to | +| --- | --- | --- | --- | --- | +| **Collateral** | vanilla ERC20 (e.g. real USDC) | separate `HypERC20Collateral` | `erc20` | `collateral` | +| **Synthetic** | router IS the ERC20 (same address) | same address | `erc20` | `synthetic` | +| **Native** | none — gas token (`0x0..0` placeholder) | `HypNative` | `native` | `native` | -Oracle operator signer defaults to env-backed config. Generated `.config/oracle.toml` now contains: +### Finding the addresses you need -```toml -operator_address = "0x..." +| Address | Source | +| --- | --- | +| Hyperlane mailbox / IGP / warp routers on real chains | Hyperlane registry: | +| Hyperlane domain ID | Same registry — `metadata.yaml` has `domainId`. Defaults to chain ID if you don't override. | +| Deployed OIF contracts on local stack | `solver-cli chain list` (reads `.config/state.json` written by `make setup`) | +| Local-stack Hyperlane addresses | `.config/hyperlane-addresses.json` (created by `make start`) | +| Token contract addresses | Token issuer docs, block explorer, or — for warp routes — `cast call "wrappedToken()(address)"` | +| Underlying ERC20 vs warp router | On a **collateral** chain they differ; `cast call "wrappedToken()(address)"` returns the underlying ERC20. On a **synthetic** chain they are the same contract. | -[signer] -type = "env" -``` +### Per-token vs chain-level warp router -When `type = "env"`, the operator loads `ORACLE_OPERATOR_PK` at runtime (for example via `.env`). +- The chain-level `--warp-token` on `chain add` is a **default** for every token on that chain. +- The per-token `--warp-token` on `token add` is **per-asset** and overrides the chain-level value. +- You need the per-token form when one chain has multiple tokens with different warp routers (e.g. USDC and USDT each on their own `HypERC20Collateral`). -## Make Commands +## Environment +Chains are auto-detected from `{NAME}_RPC` + `{NAME}_PK` pairs in `.env`. See [Deploy New Token](docs/deploy-new-token.md) for an end-to-end example. The oracle operator signer defaults to `type = "env"` (loads `ORACLE_OPERATOR_PK`); switch to AWS KMS by setting `ORACLE_SIGNER_TYPE=aws_kms` + `ORACLE_KMS_KEY_ID` + `ORACLE_KMS_REGION`. Same pattern for `SOLVER_SIGNER_TYPE` and `REBALANCER_SIGNER_TYPE`. -| Command | Description | -| ----------------- | -------------------------------------------------------- | -| `make start` | Start local EVM chain (Anvil) | -| `make stop` | Stop Anvil, solver, operator, and aggregator | -| `make setup` | Full setup: init + deploy + configure + fund | -| `make deploy` | Deploy contracts (use `CHAINS=a,b` to limit) | -| `make aggregator` | Start the OIF aggregator service (port 4000) | -| `make solver` | Start the solver service | -| `make operator` | Start the oracle operator service | -| `make mint` | Mint mock tokens (`CHAIN=`, `SYMBOL=`, `TO=`, `AMOUNT=`) | -| `make intent` | Submit intent (`FROM=`, `TO=`, `AMOUNT=`, `ASSET=`) | -| `make balances` | Check balances (use `CHAIN=name` to filter) | -| `make chain-list` | List configured chains | -| `make token-list` | List tokens across chains | -| `make clean` | Remove generated files | +## Make Commands -Use `FORCE=1` to reinitialize or redeploy: `make setup FORCE=1` +**Anvil-only** = depends on the local Docker stack (`make start`). + +| Command | Description | +| -------------------- | ----------------------------------------------------------------- | +| `make build` | Build the `solver-cli` binary (`target/release/solver-cli`) | +| `make start` | **Anvil-only.** Start Docker chains + Hyperlane | +| `make stop` | Stop Docker stack + every running service | +| `make setup` | **Deploys contracts only.** Run `solver-cli configure` and the fund targets yourself. | +| `make setup-demo` | **Anvil-only.** `setup` + `configure` + `fund` + `fund-operator` + `fund-user`. | +| `make mvp` | **Anvil-only.** Full Docker + every service + frontend. | +| `make deploy` | `solver-cli deploy` (use `CHAINS=a,b` to limit) | +| `make configure` | `solver-cli configure` | +| `make fund` | **Anvil-only.** Mint USDC to solver on anvil1 (hardcoded). For external chains, fund manually with `cast send` or your wallet. | +| `make fund-operator` | **Anvil-only.** Send ETH to oracle operator on anvil1/anvil2. | +| `make fund-user` | **Anvil-only.** Send ETH to user on anvil1/anvil2. | +| `make chain-add` | `solver-cli chain add` wrapper. Vars: `NAME`, `RPC`, `INPUT_SETTLER`, `OUTPUT_SETTLER`, `ORACLE`, optional `CHAIN_ID`, `DOMAIN_ID`, `MAILBOX`, `IGP`, `WARP_TOKEN`, `WARP_TOKEN_TYPE`. | +| `make token-add` | `solver-cli token add` wrapper. Vars: `CHAIN`, `SYMBOL`, `ADDRESS`, optional `DECIMALS`, `TOKEN_TYPE`, `WARP_TOKEN`, `WARP_TOKEN_TYPE`. | +| `make aggregator` | Start OIF aggregator (port 4000) | +| `make solver` | Start solver service | +| `make operator` | Start oracle operator | +| `make rebalancer` | Start rebalancer | +| `make mint` | **Anvil-only.** Mint mock tokens (`CHAIN=`, `SYMBOL=`, `TO=`, `AMOUNT=`) | +| `make intent` | Submit intent (`FROM=`, `TO=`, `AMOUNT=`, `ASSET=`) | +| `make balances` | Check balances (use `CHAIN=name` to filter) | +| `make chain-list` | List configured chains | +| `make token-list` | List tokens across chains | +| `make clean` | Remove generated files | + + +Use `FORCE=1` to reinitialize or redeploy: `make setup FORCE=1` or `make setup-demo FORCE=1`. Run `make help` to see all available commands. ## CLI Commands -| Command | Description | -| ------------------------------------------ | ------------------------------------- | -| `solver-cli init` | Initialize project state | -| `solver-cli deploy` | Deploy contracts to all chains | -| `solver-cli deploy --chains a,b` | Deploy to specific chains | -| `solver-cli configure` | Generate solver config | -| `solver-cli fund` | Fund solver with tokens on all chains | -| `solver-cli fund --chain X` | Fund solver on specific chain | -| `solver-cli chain add` | Add a chain with existing contracts | -| `solver-cli chain list` | List configured chains | -| `solver-cli token add` | Add a token to a chain | -| `solver-cli token list` | List all tokens | -| `solver-cli token mint` | Mint mock tokens (MockERC20 only) | -| `solver-cli solver start` | Start the solver | -| `solver-cli intent submit` | Submit a cross-chain intent | -| `solver-cli intent submit --from a --to b` | Specify direction | -| `solver-cli balances` | Check balances on all chains | +| Command | Description | +| ------------------------------------------ | ------------------------------------------------------------ | +| `solver-cli init` | Initialize project state | +| `solver-cli deploy` | Deploy contracts to all chains in `.env` | +| `solver-cli deploy --chains a,b` | Deploy to specific chains | +| `solver-cli configure` | Generate `solver.toml` / `oracle.toml` / `rebalancer.toml` / `aggregator.json` | +| `solver-cli fund` | Fund solver with tokens on all chains | +| `solver-cli fund --chain X` | Fund solver on a specific chain | +| `solver-cli chain add` | Register a chain. Flags: `--rpc`, `--chain-id`, `--input-settler`, `--output-settler`, `--oracle`, `--warp-token`, `--warp-token-type`, `--mailbox`, `--igp`, `--domain-id` | +| `solver-cli chain list` | List configured chains | +| `solver-cli token add` | Add a token. Flags: `--chain`, `--symbol`, `--address`, `--decimals`, `--token-type`, `--warp-token`, `--warp-token-type` | +| `solver-cli token list` | List all tokens | +| `solver-cli token mint` | Mint mock tokens (MockERC20 only) | +| `solver-cli solver start` | Start the solver | +| `solver-cli intent submit` | Submit a cross-chain intent | +| `solver-cli intent submit --from a --to b` | Specify direction | +| `solver-cli balances` | Check balances on all chains | + +### Hyperlane domain ID override + +A chain's Hyperlane domain ID defaults to its EVM chain ID. Override per chain via either: +- env var: `EDEN_DOMAIN_ID=12345` (used by `solver-cli deploy`) +- CLI flag: `solver-cli chain add --domain-id 12345` ## Submitting Intents diff --git a/rebalancer/Cargo.toml b/rebalancer/Cargo.toml index 0155b1d4..b91b7c86 100644 --- a/rebalancer/Cargo.toml +++ b/rebalancer/Cargo.toml @@ -28,6 +28,7 @@ reqwest = { version = "0.12", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" +solver-shared = { path = "../solver-shared" } tokio = { version = "1.34", features = ["full"] } toml = "0.9" tracing = "0.1" diff --git a/rebalancer/src/config.rs b/rebalancer/src/config.rs index 9ec15b91..fee54869 100644 --- a/rebalancer/src/config.rs +++ b/rebalancer/src/config.rs @@ -1,11 +1,12 @@ use alloy::primitives::Address; use anyhow::{bail, Context, Result}; use serde::Deserialize; +use solver_shared::TokenType; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; -/// (chain_id, token, optional warp_token_address) grouped by symbol. -type TokensBySymbol<'a> = BTreeMap)>>; +/// (chain_id, token) grouped by symbol. +type TokensBySymbol<'a> = BTreeMap>; const WEIGHT_TOLERANCE: f64 = 1e-6; const MIN_POLL_INTERVAL_SECONDS: u64 = 30; @@ -184,7 +185,6 @@ struct StateContracts { #[derive(Deserialize)] struct StateHyperlane { domain_id: Option, - warp_token: Option, } #[derive(Deserialize)] @@ -193,7 +193,9 @@ struct StateToken { symbol: String, decimals: u8, #[serde(default)] - token_type: Option, + token_type: Option, + #[serde(default)] + warp_token: Option, } // ── Config loading ───────────────────────────────────────────────────────── @@ -376,20 +378,17 @@ fn collect_assets( ) -> Result> { let chain_id_set: HashSet = chains.iter().map(|c| c.chain_id).collect(); - // Group tokens by symbol across chains + // Group tokens by symbol across chains. Each token carries its own Hyperlane + // warp router (set via `solver-cli token add --warp-token`); a chain may + // host multiple tokens with distinct routers. let mut by_symbol: TokensBySymbol<'_> = BTreeMap::new(); for (chain_id, chain) in &state.chains { - let warp_token = chain - .contracts - .hyperlane - .as_ref() - .and_then(|h| h.warp_token.clone()); for token in chain.tokens.values() { let normalized = token.symbol.to_ascii_uppercase(); by_symbol .entry(normalized) .or_default() - .push((*chain_id, token, warp_token.clone())); + .push((*chain_id, token)); } } @@ -400,20 +399,17 @@ fn collect_assets( continue; } - entries.sort_by_key(|(chain_id, _, _)| *chain_id); + entries.sort_by_key(|(chain_id, _)| *chain_id); // Validate consistent decimals let expected_decimals = entries[0].1.decimals; - if entries - .iter() - .any(|(_, t, _)| t.decimals != expected_decimals) - { + if entries.iter().any(|(_, t)| t.decimals != expected_decimals) { bail!("Token {} has inconsistent decimals across chains", symbol); } // Build token configs let mut token_configs: HashMap = HashMap::new(); - for (chain_id, token, warp_token) in &entries { + for (chain_id, token) in &entries { if !chain_id_set.contains(chain_id) { continue; } @@ -421,15 +417,28 @@ fn collect_assets( .address .parse() .with_context(|| format!("Invalid address for {} on chain {}", symbol, chain_id))?; - let collateral_token = match warp_token { + // The rebalancer bridges via Hyperlane's `transferRemote`, which only + // exists on the warp router (HypERC20Collateral / HypSynthetic / HypNative). + // Refuse to fall back to the underlying ERC20 silently — that combination + // produces "Native: amount exceeds msg.value" / missing-selector reverts + // at submit time, far away from the misconfiguration. + let collateral_token: Address = match &token.warp_token { Some(wt) => wt .parse() .with_context(|| format!("Invalid warp_token for chain {}", chain_id))?, - None => address, + None => bail!( + "Asset {} on chain {} has no Hyperlane warp router configured. \ + Set `chains.{}.tokens.{}.warp_token` in state.json \ + (or pass `--warp-token` to `solver-cli token add`).", + symbol, + chain_id, + chain_id, + symbol + ), }; - let asset_type = match token.token_type.as_deref() { - Some(t) if t.eq_ignore_ascii_case("native") => AssetType::Native, - _ => AssetType::Erc20, + let asset_type = match token.token_type { + Some(TokenType::Native) => AssetType::Native, + Some(TokenType::Erc20) | None => AssetType::Erc20, }; // Native warp routes (HypNative) have no separate ERC20 underlying; // the warp router itself is the collateral path and `address` field @@ -630,10 +639,14 @@ mod tests { "name": "anvil1", "chain_id": 31337, "rpc": "http://127.0.0.1:8545", "contracts": { - "hyperlane": { "domain_id": 131337, "warp_token": "0x0000000000000000000000000000000000000A01" } + "hyperlane": { "domain_id": 131337 } }, "tokens": { - "USDC": { "address": "0x0000000000000000000000000000000000001111", "symbol": "USDC", "decimals": 6 } + "USDC": { + "address": "0x0000000000000000000000000000000000001111", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000A01" + } }, "deployer": null }, @@ -641,10 +654,14 @@ mod tests { "name": "anvil2", "chain_id": 31338, "rpc": "http://127.0.0.1:8546", "contracts": { - "hyperlane": { "domain_id": 31338, "warp_token": "0x0000000000000000000000000000000000000B01" } + "hyperlane": { "domain_id": 31338 } }, "tokens": { - "USDC": { "address": "0x0000000000000000000000000000000000002222", "symbol": "USDC", "decimals": 6 } + "USDC": { + "address": "0x0000000000000000000000000000000000002222", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000B01" + } }, "deployer": null } @@ -770,6 +787,108 @@ service_url = "http://127.0.0.1:8080" assert!(err.to_string().contains("Missing [signer]")); } + #[test] + fn per_token_warp_token_resolves_router() { + // Each token carries its own Hyperlane warp router; the rebalancer + // surfaces it as `collateral_token` on the asset's per-chain config. + let state = serde_json::json!({ + "env": "local", + "chains": { + "31337": { + "name": "anvil1", "chain_id": 31337, + "rpc": "http://127.0.0.1:8545", + "contracts": {}, + "tokens": { + "USDC": { + "address": "0x0000000000000000000000000000000000001111", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000A01" + } + }, + "deployer": null + }, + "31338": { + "name": "anvil2", "chain_id": 31338, + "rpc": "http://127.0.0.1:8546", + "contracts": {}, + "tokens": { + "USDC": { + "address": "0x0000000000000000000000000000000000002222", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000B01" + } + }, + "deployer": null + } + }, + "solver": { + "address": "0x000000000000000000000000000000000000dEaD", + "operator_address": null, "private_key_ref": "env", "configured": true + }, + "users": {}, "last_updated": "2025-01-01T00:00:00Z" + }) + .to_string(); + let (_dir, path) = setup_config(&state, minimal_toml()); + let config = RebalancerConfig::load(&path).expect("valid config"); + let usdc = &config.assets[0]; + let collateral_31337 = usdc.tokens[&31337].collateral_token; + assert_eq!( + format!("{:?}", collateral_31337).to_lowercase(), + "0x0000000000000000000000000000000000000a01" + ); + } + + #[test] + fn rejects_missing_warp_token() { + // USDC on both chains but chain 31338's token lacks a warp_token — + // the rebalancer must reject this rather than fall back to the ERC20 + // (which would revert at `transferRemote` time). + let state = serde_json::json!({ + "env": "local", + "chains": { + "31337": { + "name": "anvil1", "chain_id": 31337, + "rpc": "http://127.0.0.1:8545", + "contracts": {}, + "tokens": { + "USDC": { + "address": "0x0000000000000000000000000000000000001111", + "symbol": "USDC", "decimals": 6, + "warp_token": "0x0000000000000000000000000000000000000A01" + } + }, + "deployer": null + }, + "31338": { + "name": "anvil2", "chain_id": 31338, + "rpc": "http://127.0.0.1:8546", + "contracts": {}, + "tokens": { + "USDC": { "address": "0x0000000000000000000000000000000000002222", "symbol": "USDC", "decimals": 6 } + }, + "deployer": null + } + }, + "solver": { + "address": "0x000000000000000000000000000000000000dEaD", + "operator_address": null, + "private_key_ref": "env", + "configured": true + }, + "users": {}, + "last_updated": "2025-01-01T00:00:00Z" + }) + .to_string(); + let (_dir, path) = setup_config(&state, minimal_toml()); + let err = RebalancerConfig::load(&path).expect_err("should fail"); + let msg = err.to_string(); + assert!( + msg.contains("no Hyperlane warp router configured"), + "unexpected error: {}", + msg + ); + } + #[test] fn rejects_invalid_transfer_bps_bounds() { let toml = r#" diff --git a/scripts/setup.sh b/scripts/setup.sh index 9da6802f..de5e4eff 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -11,8 +11,8 @@ cd "$PROJECT_ROOT" # ── Deploy + configure + fund ──────────────────────────────────────────────── -step "Running full setup (init + deploy OIF contracts + configure + fund)..." -make setup FORCE=1 +step "Running full demo setup (init + deploy + configure + fund)..." +make setup-demo FORCE=1 # Allow small losses on local dev (gas costs exceed spread for tiny orders) if grep -q 'min_profitability_pct = 0.0' .config/solver.toml 2>/dev/null; then diff --git a/solver-cli/Cargo.toml b/solver-cli/Cargo.toml index 88b25c26..edd7a548 100644 --- a/solver-cli/Cargo.toml +++ b/solver-cli/Cargo.toml @@ -83,6 +83,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } uuid = { version = "1.6", features = ["v4", "serde"] } +# Shared workspace types +solver-shared = { path = "../solver-shared" } + # Unix process management [target.'cfg(unix)'.dependencies] alloy-primitives = { version = "1.0.37", features = ["std", "serde"], optional = true } diff --git a/solver-cli/src/commands/chain.rs b/solver-cli/src/commands/chain.rs index d2137553..8a17c32e 100644 --- a/solver-cli/src/commands/chain.rs +++ b/solver-cli/src/commands/chain.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::Subcommand; +use solver_shared::TokenType; use std::collections::HashMap; use std::env; use std::path::PathBuf; @@ -9,6 +10,9 @@ use crate::state::{ChainConfig, ContractAddresses, StateManager, TokenInfo}; use crate::utils::*; use crate::OutputFormat; +// `Add` carries a lot of optional Hyperlane fields by design (see `solver-cli +// chain add --help`); boxing each one would just hurt ergonomics. +#[allow(clippy::large_enum_variant)] #[derive(Subcommand)] pub enum ChainCommand { /// Add a chain with existing contract deployments @@ -45,11 +49,19 @@ pub enum ChainCommand { #[arg(long, default_value = "6")] decimals: u8, - /// Hyperlane warp token router address (HypERC20Collateral or HypERC20Synthetic). - /// Required for HypCollateral chains where the warp router differs from the ERC20. - /// Optional for HypSynthetic chains (the token address is used as fallback). + /// Hyperlane mailbox address on this chain. #[arg(long)] - warp_token: Option, + mailbox: Option, + + /// Hyperlane interchain gas paymaster address on this chain. + #[arg(long)] + igp: Option, + + /// Hyperlane domain ID. Defaults to the EVM chain ID; set this when the + /// chain's domain ID differs (e.g. Eden testnet, or chains that collide + /// with Hyperlane's hardcoded KnownHyperlaneDomain enum). + #[arg(long)] + domain_id: Option, /// Project directory #[arg(long)] @@ -124,7 +136,9 @@ struct ChainAddParams { oracle: String, tokens: Vec, default_decimals: u8, - warp_token: Option, + mailbox: Option, + igp: Option, + domain_id: Option, dir: Option, } @@ -140,7 +154,9 @@ impl ChainCommand { oracle, token, decimals, - warp_token, + mailbox, + igp, + domain_id, dir, } => { Self::add( @@ -153,7 +169,9 @@ impl ChainCommand { oracle, tokens: token, default_decimals: decimals, - warp_token, + mailbox, + igp, + domain_id, dir, }, output, @@ -175,7 +193,9 @@ impl ChainCommand { oracle, tokens, default_decimals, - warp_token, + mailbox, + igp, + domain_id, dir, } = params; let out = OutputFormatter::new(output); @@ -209,18 +229,20 @@ impl ChainCommand { )); } - // Build contracts struct - let hyperlane = warp_token - .as_ref() - .map(|addr| crate::state::HyperlaneAddresses { - domain_id: None, - mailbox: None, + // Build contracts struct. + // Construct HyperlaneAddresses if any Hyperlane field was supplied. + let any_hyperlane = domain_id.is_some() || mailbox.is_some() || igp.is_some(); + let hyperlane = if any_hyperlane { + Some(crate::state::HyperlaneAddresses { + domain_id, + mailbox: mailbox.clone(), merkle_tree_hook: None, validator_announce: None, - igp: None, - warp_token: Some(addr.clone()), - warp_token_type: Some("collateral".to_string()), - }); + igp: igp.clone(), + }) + } else { + None + }; let contracts = ContractAddresses { input_settler_escrow: Some(input_settler.clone()), @@ -232,8 +254,14 @@ impl ChainCommand { print_address("InputSettlerEscrow", &input_settler); print_address("OutputSettlerSimple", &output_settler); print_address("CentralizedOracle", &oracle); - if let Some(ref addr) = warp_token { - print_address("Warp token router", addr); + if let Some(ref addr) = mailbox { + print_address("Mailbox", addr); + } + if let Some(ref addr) = igp { + print_address("IGP", addr); + } + if let Some(id) = domain_id { + print_kv("Hyperlane domain ID", id); } // Build tokens map @@ -250,7 +278,9 @@ impl ChainCommand { address: parsed.address, symbol: parsed.symbol, decimals, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, + warp_token: None, + warp_token_type: None, }, ); } diff --git a/solver-cli/src/commands/token.rs b/solver-cli/src/commands/token.rs index 686a98a6..0be5e9f1 100644 --- a/solver-cli/src/commands/token.rs +++ b/solver-cli/src/commands/token.rs @@ -1,6 +1,7 @@ use alloy::primitives::{Address, U256}; use anyhow::Result; use clap::Subcommand; +use solver_shared::{TokenType, WarpTokenType}; use std::env; use std::path::PathBuf; use std::str::FromStr; @@ -10,6 +11,17 @@ use crate::state::{StateManager, TokenInfo}; use crate::utils::*; use crate::OutputFormat; +struct AddTokenParams { + chain_ref: String, + symbol: String, + address: String, + decimals: u8, + token_type: TokenType, + warp_token: Option, + warp_token_type: Option, + dir: Option, +} + #[derive(Subcommand)] pub enum TokenCommand { /// Add a token to a chain @@ -22,7 +34,7 @@ pub enum TokenCommand { #[arg(long)] symbol: String, - /// Token contract address + /// Token contract address (use any placeholder for native tokens — value is unused) #[arg(long)] address: String, @@ -30,6 +42,20 @@ pub enum TokenCommand { #[arg(long, default_value = "18")] decimals: u8, + /// Token type + #[arg(long, value_enum, default_value_t = TokenType::Erc20)] + token_type: TokenType, + + /// Hyperlane warp router address for this token (takes precedence over the + /// chain-level warp_token). Required when a chain has multiple tokens with + /// distinct routers. + #[arg(long)] + warp_token: Option, + + /// Hyperlane warp router type + #[arg(long, value_enum)] + warp_token_type: Option, + /// Project directory #[arg(long)] dir: Option, @@ -94,8 +120,26 @@ impl TokenCommand { symbol, address, decimals, + token_type, + warp_token, + warp_token_type, dir, - } => Self::add(chain, symbol, address, decimals, dir, output).await, + } => { + Self::add( + AddTokenParams { + chain_ref: chain, + symbol, + address, + decimals, + token_type, + warp_token, + warp_token_type, + dir, + }, + output, + ) + .await + } TokenCommand::Remove { chain, symbol, dir } => { Self::remove(chain, symbol, dir, output).await } @@ -110,14 +154,17 @@ impl TokenCommand { } } - async fn add( - chain_ref: String, - symbol: String, - address: String, - decimals: u8, - dir: Option, - output: OutputFormat, - ) -> Result<()> { + async fn add(params: AddTokenParams, output: OutputFormat) -> Result<()> { + let AddTokenParams { + chain_ref, + symbol, + address, + decimals, + token_type, + warp_token, + warp_token_type, + dir, + } = params; let out = OutputFormatter::new(output); let project_dir = dir.unwrap_or_else(|| env::current_dir().unwrap()); let state_mgr = StateManager::new(&project_dir); @@ -156,6 +203,13 @@ impl TokenCommand { print_kv("Symbol", &symbol_upper); print_address("Address", &address); print_kv("Decimals", decimals); + print_kv("Token type", token_type.as_str()); + if let Some(ref wt) = warp_token { + print_address("Warp router", wt); + } + if let Some(wtt) = warp_token_type { + print_kv("Warp router type", wtt.as_str()); + } // Add token chain.tokens.insert( @@ -164,7 +218,9 @@ impl TokenCommand { address: address.clone(), symbol: symbol_upper.clone(), decimals, - token_type: "erc20".to_string(), + token_type, + warp_token: warp_token.clone(), + warp_token_type, }, ); } @@ -182,6 +238,9 @@ impl TokenCommand { "symbol": symbol_upper, "address": address, "decimals": decimals, + "token_type": token_type, + "warp_token": warp_token, + "warp_token_type": warp_token_type, }))?; } diff --git a/solver-cli/src/deployment/deployer.rs b/solver-cli/src/deployment/deployer.rs index de46dac9..03171acf 100644 --- a/solver-cli/src/deployment/deployer.rs +++ b/solver-cli/src/deployment/deployer.rs @@ -1,6 +1,7 @@ #![allow(clippy::too_many_arguments)] use anyhow::{Context, Result}; +use solver_shared::{TokenType, WarpTokenType}; use std::path::Path; use tracing::info; @@ -148,9 +149,13 @@ impl Deployer { hyp_addrs, token_symbol, token_decimals, - ); + )?; } + // Apply {NAME}_DOMAIN_ID env-var override (takes precedence over the + // value pulled from hyperlane-addresses.json). + Self::apply_domain_id_override(&mut chain_config); + // Insert into state by chain_id state.chains.insert(chain_config.chain_id, chain_config); } @@ -174,13 +179,35 @@ impl Deployer { Ok(value) } + /// Apply a `{NAME}_DOMAIN_ID` env-var override onto chain_config, if present. + /// Creates a HyperlaneAddresses entry if one doesn't yet exist on the chain. + fn apply_domain_id_override(chain_config: &mut ChainConfig) { + let var = format!("{}_DOMAIN_ID", chain_config.name.to_uppercase()); + let Ok(raw) = std::env::var(&var) else { + return; + }; + let Ok(domain_id) = raw.parse::() else { + info!("Ignoring {} (not a valid u64): {}", var, raw); + return; + }; + let hyperlane = chain_config + .contracts + .hyperlane + .get_or_insert_with(HyperlaneAddresses::default); + hyperlane.domain_id = Some(domain_id); + info!( + " Hyperlane domain ID for {} (from {}): {}", + chain_config.name, var, domain_id + ); + } + /// Populate token and Hyperlane addresses from the deployment artifacts fn populate_tokens_from_hyperlane( chain_config: &mut ChainConfig, hyp_addrs: &serde_json::Value, token_symbol: &str, token_decimals: u8, - ) { + ) -> Result<()> { let chain_name = chain_config.name.to_lowercase(); // Look up this chain in the Hyperlane addresses @@ -199,6 +226,25 @@ impl Deployer { .map(|s| s.to_string()) }; + // Warp router address for this token, read from the artifact. + // On a collateral chain (`mock_usdc` exists), `addr` is the + // underlying ERC20 and the warp router is a separate contract. + // On synthetic/native chains, `addr` is the warp router itself. + let warp_token = chain_data + .get("warp_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let warp_token_type = chain_data + .get("warp_token_type") + .and_then(|v| v.as_str()) + .map(|s| { + serde_json::from_value::(serde_json::Value::String( + s.to_string(), + )) + .with_context(|| format!("Invalid warp_token_type in deployment artifact: {s}")) + }) + .transpose()?; + if let Some(addr) = token_address { chain_config.tokens.insert( token_symbol.to_string(), @@ -206,7 +252,9 @@ impl Deployer { address: addr, symbol: token_symbol.to_string(), decimals: token_decimals, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, + warp_token, + warp_token_type, }, ); info!( @@ -217,7 +265,8 @@ impl Deployer { ); } - // Store Hyperlane contract addresses + // Store Hyperlane contract addresses (mailbox / IGP / etc). + // Warp router lives per-token, not here. let hyperlane = HyperlaneAddresses { domain_id: chain_data.get("domain_id").and_then(|v| v.as_u64()), mailbox: chain_data @@ -232,18 +281,14 @@ impl Deployer { .get("validator_announce") .and_then(|v| v.as_str()) .map(|s| s.to_string()), - igp: None, - warp_token: chain_data - .get("warp_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - warp_token_type: chain_data - .get("warp_token_type") + igp: chain_data + .get("igp") .and_then(|v| v.as_str()) .map(|s| s.to_string()), }; chain_config.contracts.hyperlane = Some(hyperlane); } + Ok(()) } } diff --git a/solver-cli/src/rebalancer/config_gen.rs b/solver-cli/src/rebalancer/config_gen.rs index e02a6ed5..49d01ab7 100644 --- a/solver-cli/src/rebalancer/config_gen.rs +++ b/solver-cli/src/rebalancer/config_gen.rs @@ -54,24 +54,51 @@ impl RebalancerConfigGenerator { anyhow::bail!("No chains configured"); } - // Verify at least one token exists on 2+ chains (so rebalancer has something to do) - let has_multi_chain_token = { - let mut by_symbol: BTreeMap = BTreeMap::new(); - for chain in state.chains.values() { - for token in chain.tokens.values() { - *by_symbol - .entry(token.symbol.to_ascii_uppercase()) - .or_default() += 1; - } + // Group tokens by symbol → list of (chain_id, chain_name) so we can detect + // both "no asset on 2+ chains" and "asset present without a warp router". + let mut by_symbol: BTreeMap> = BTreeMap::new(); + for chain in state.chains.values() { + for token in chain.tokens.values() { + by_symbol + .entry(token.symbol.to_ascii_uppercase()) + .or_default() + .push((chain.chain_id, chain.name.as_str())); } - by_symbol.values().any(|&count| count >= 2) - }; + } + let has_multi_chain_token = by_symbol.values().any(|chains| chains.len() >= 2); if !has_multi_chain_token { anyhow::bail!( "No asset found on at least two chains; cannot generate rebalancer config" ); } + // Every token participating in a multi-chain asset must have a Hyperlane + // warp router. Catching this here means a misconfig surfaces at + // `solver-cli configure` instead of at rebalancer startup (where the same + // check is enforced). + for (symbol, chains) in &by_symbol { + if chains.len() < 2 { + continue; + } + for (chain_id, chain_name) in chains { + let chain = &state.chains[chain_id]; + let token_warp = chain + .tokens + .values() + .find(|t| t.symbol.eq_ignore_ascii_case(symbol)) + .and_then(|t| t.warp_token.as_deref()); + if token_warp.is_none() { + anyhow::bail!( + "Asset {} on chain {} ({}) has no Hyperlane warp router configured. \ + Set it via `solver-cli token add --warp-token `.", + symbol, + chain_name, + chain_id, + ); + } + } + } + let forwarding_service_url = std::env::var("FORWARDING_BACKEND") .unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); let _: reqwest::Url = forwarding_service_url @@ -167,6 +194,7 @@ mod tests { use crate::state::{ ChainConfig, ContractAddresses, HyperlaneAddresses, SolverState, TokenInfo, }; + use solver_shared::{TokenType, WarpTokenType}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; @@ -197,8 +225,6 @@ mod tests { merkle_tree_hook: None, validator_announce: None, igp: None, - warp_token: None, - warp_token_type: None, }), }, tokens: HashMap::from([( @@ -207,7 +233,9 @@ mod tests { address: "0x0000000000000000000000000000000000001111".to_string(), symbol: "USDC".to_string(), decimals: 6, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, + warp_token: Some("0x0000000000000000000000000000000000009999".to_string()), + warp_token_type: Some(WarpTokenType::Synthetic), }, )]), deployer: None, @@ -231,8 +259,6 @@ mod tests { merkle_tree_hook: None, validator_announce: None, igp: None, - warp_token: None, - warp_token_type: None, }), }, tokens: HashMap::from([( @@ -241,7 +267,9 @@ mod tests { address: "0x0000000000000000000000000000000000002222".to_string(), symbol: "USDC".to_string(), decimals: 6, - token_type: "erc20".to_string(), + token_type: TokenType::Erc20, + warp_token: Some("0x0000000000000000000000000000000000009998".to_string()), + warp_token_type: Some(WarpTokenType::Synthetic), }, )]), deployer: None, diff --git a/solver-cli/src/solver/config_gen.rs b/solver-cli/src/solver/config_gen.rs index 0fac8804..82202b88 100644 --- a/solver-cli/src/solver/config_gen.rs +++ b/solver-cli/src/solver/config_gen.rs @@ -548,7 +548,9 @@ poll_interval_seconds = 3 }, "chainId": chain.chain_id, "displayName": display_name, - "domainId": Self::hyperlane_domain_id(chain.chain_id), + "domainId": hyp + .and_then(|h| h.domain_id) + .unwrap_or_else(|| Self::hyperlane_domain_id(chain.chain_id)), "isTestnet": true, "name": chain.name, "nativeToken": { @@ -647,10 +649,12 @@ poll_interval_seconds = 3 .context("Failed to serialize Hyperlane relayer config") } - /// Map EVM chain ID to Hyperlane domain ID. - /// Domain IDs can differ from chain IDs to avoid conflicts with the Hyperlane agent's - /// hardcoded KnownHyperlaneDomain enum (e.g. 31337 is hardcoded as "test4"). - /// Using domain 131337 for chain 31337 lets us keep the "anvil1" name. + /// Fallback EVM chain ID → Hyperlane domain ID mapping. + /// Used only when the chain has no explicit `domain_id` in state. To override + /// per chain, pass `--domain-id` to `solver-cli chain add`, or set + /// `{NAME}_DOMAIN_ID` in `.env` before `solver-cli deploy`. + /// The 31337 → 131337 entry exists because the Hyperlane agent has 31337 + /// hardcoded in its KnownHyperlaneDomain enum as "test4". fn hyperlane_domain_id(chain_id: u64) -> u64 { match chain_id { 31337 => 131337, diff --git a/solver-cli/src/state/types.rs b/solver-cli/src/state/types.rs index b7584561..6dfda4f7 100644 --- a/solver-cli/src/state/types.rs +++ b/solver-cli/src/state/types.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use solver_shared::{TokenType, WarpTokenType}; use std::collections::HashMap; /// The main state file structure @@ -118,12 +119,6 @@ pub struct HyperlaneAddresses { /// Interchain gas paymaster address pub igp: Option, - - /// Warp token address (HypCollateral or HypSynthetic) - pub warp_token: Option, - - /// Warp token type ("collateral" or "synthetic") - pub warp_token_type: Option, } impl ContractAddresses { @@ -136,7 +131,7 @@ impl ContractAddresses { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenInfo { - /// Token contract address + /// Token contract address (placeholder allowed for native tokens) pub address: String, /// Token symbol @@ -145,13 +140,20 @@ pub struct TokenInfo { /// Token decimals pub decimals: u8, - /// Token type (erc20, native) - #[serde(default = "default_token_type")] - pub token_type: String, -} + /// Token kind (defaults to ERC20 for backward compatibility with existing state files). + #[serde(default)] + pub token_type: TokenType, + + /// Hyperlane warp router address for this token (HypERC20Collateral / + /// HypSynthetic / HypNative). Required for assets that the rebalancer + /// bridges via `transferRemote`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warp_token: Option, -fn default_token_type() -> String { - "erc20".to_string() + /// Hyperlane warp router variant; mirrors the on-chain HypERC20Collateral / + /// HypSynthetic / HypNative contract kind. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warp_token_type: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -262,3 +264,56 @@ impl SolverState { !self.chains.is_empty() && self.chains.values().all(|c| c.contracts.is_complete()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Existing v0.2.x `state.json` files store `token_type` and `warp_token_type` + /// as plain lowercase strings. The shared enums use `serde(rename_all = "lowercase")`, + /// so loading those old files must keep working without migration. + #[test] + fn token_info_deserializes_legacy_string_values() { + let json = r#"{ + "address": "0x0000000000000000000000000000000000000001", + "symbol": "USDC", + "decimals": 6, + "token_type": "native", + "warp_token": "0x0000000000000000000000000000000000000002", + "warp_token_type": "collateral" + }"#; + + let info: TokenInfo = serde_json::from_str(json).expect("legacy state.json must parse"); + assert_eq!(info.token_type, TokenType::Native); + assert_eq!(info.warp_token_type, Some(WarpTokenType::Collateral)); + } + + #[test] + fn token_info_defaults_token_type_to_erc20_when_missing() { + let json = r#"{ + "address": "0x0000000000000000000000000000000000000001", + "symbol": "USDC", + "decimals": 6 + }"#; + + let info: TokenInfo = serde_json::from_str(json).expect("missing token_type must default"); + assert_eq!(info.token_type, TokenType::Erc20); + assert_eq!(info.warp_token_type, None); + } + + #[test] + fn token_info_serializes_to_lowercase_strings() { + let info = TokenInfo { + address: "0x0000000000000000000000000000000000000001".to_string(), + symbol: "USDC".to_string(), + decimals: 6, + token_type: TokenType::Native, + warp_token: None, + warp_token_type: Some(WarpTokenType::Synthetic), + }; + + let value = serde_json::to_value(&info).unwrap(); + assert_eq!(value["token_type"], "native"); + assert_eq!(value["warp_token_type"], "synthetic"); + } +} diff --git a/solver-shared/Cargo.toml b/solver-shared/Cargo.toml new file mode 100644 index 00000000..5b332524 --- /dev/null +++ b/solver-shared/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "solver-shared" +version = "0.1.0" +edition = "2021" +description = "Shared types reused across the solver-cli, rebalancer, and oracle-operator crates" +license = "MIT" + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0" +toml = "0.8" diff --git a/solver-shared/src/lib.rs b/solver-shared/src/lib.rs new file mode 100644 index 00000000..7eaf9b29 --- /dev/null +++ b/solver-shared/src/lib.rs @@ -0,0 +1,6 @@ +//! Shared types used across `solver-cli`, `rebalancer`, and any future workspace +//! crates that need to agree on token-related schema. + +pub mod token; + +pub use token::{TokenType, WarpTokenType}; diff --git a/solver-shared/src/token.rs b/solver-shared/src/token.rs new file mode 100644 index 00000000..81298af6 --- /dev/null +++ b/solver-shared/src/token.rs @@ -0,0 +1,119 @@ +//! Token kind enums shared between the CLI surface (`clap`) and the persisted +//! state.json / TOML schemas (`serde`). Both encodings use the same lowercase +//! string spellings, so existing config and state files keep parsing. + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Underlying asset kind for a registered token. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum TokenType { + #[default] + Erc20, + Native, +} + +impl TokenType { + /// Canonical lowercase identifier used in state.json and downstream configs. + pub const fn as_str(self) -> &'static str { + match self { + Self::Erc20 => "erc20", + Self::Native => "native", + } + } +} + +impl fmt::Display for TokenType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Hyperlane warp router variant for a token. Maps to the on-chain contract +/// kind: `HypERC20Collateral`, `HypSynthetic`, or `HypNative`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum WarpTokenType { + Collateral, + Synthetic, + Native, +} + +impl WarpTokenType { + /// Canonical lowercase identifier used in state.json and downstream configs. + pub const fn as_str(self) -> &'static str { + match self { + Self::Collateral => "collateral", + Self::Synthetic => "synthetic", + Self::Native => "native", + } + } +} + +impl fmt::Display for WarpTokenType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_type_serde_roundtrip() { + for (variant, encoded) in [ + (TokenType::Erc20, "\"erc20\""), + (TokenType::Native, "\"native\""), + ] { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, encoded); + let back: TokenType = serde_json::from_str(encoded).unwrap(); + assert_eq!(back, variant); + } + } + + #[test] + fn warp_token_type_serde_roundtrip() { + for (variant, encoded) in [ + (WarpTokenType::Collateral, "\"collateral\""), + (WarpTokenType::Synthetic, "\"synthetic\""), + (WarpTokenType::Native, "\"native\""), + ] { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, encoded); + let back: WarpTokenType = serde_json::from_str(encoded).unwrap(); + assert_eq!(back, variant); + } + } + + #[test] + fn token_type_rejects_unknown() { + let err = serde_json::from_str::("\"weird\"").unwrap_err(); + assert!(err.to_string().contains("unknown variant")); + } + + #[test] + fn warp_token_type_rejects_unknown() { + let err = serde_json::from_str::("\"weird\"").unwrap_err(); + assert!(err.to_string().contains("unknown variant")); + } + + #[test] + fn clap_value_enum_parses_lowercase() { + let parsed = TokenType::from_str("erc20", true).unwrap(); + assert_eq!(parsed, TokenType::Erc20); + let parsed = WarpTokenType::from_str("synthetic", true).unwrap(); + assert_eq!(parsed, WarpTokenType::Synthetic); + } + + #[test] + fn display_matches_as_str() { + assert_eq!(TokenType::Erc20.to_string(), "erc20"); + assert_eq!(WarpTokenType::Collateral.to_string(), "collateral"); + } +}