diff --git a/.continue/config.yaml b/.continue/config.yaml new file mode 100644 index 0000000..f313691 --- /dev/null +++ b/.continue/config.yaml @@ -0,0 +1,17 @@ +models: + - name: DeepSeek Chat + provider: ollama + model: deepseek-coder:33b + roles: + - chat + - edit + + - name: DeepSeek Fast + provider: ollama + model: deepseek-coder:6.7b + roles: + - autocomplete + +embeddings: + provider: ollama + model: nomic-embed-text \ No newline at end of file diff --git a/.env.example b/.env.example index 8aad6d5..ed0a2da 100644 --- a/.env.example +++ b/.env.example @@ -30,21 +30,41 @@ ANVIL2_CHAIN_ID=31338 # EDEN_PK - Account key for deployments and solving on Eden testnet. EDEN_PK= +# Oracle operator signer — choose one: +# Option A: local key ORACLE_OPERATOR_PK=redacted +# Option B: AWS KMS (comment out ORACLE_OPERATOR_PK above) +#ORACLE_SIGNER_TYPE=aws_kms +#ORACLE_KMS_KEY_ID= +#ORACLE_KMS_REGION=us-east-1 +# Rebalancer signer — choose one: +# Option A: local key (or per-chain REBALANCER__PK) REBALANCER_PRIVATE_KEY=redacted +# Option B: AWS KMS (comment out REBALANCER_PRIVATE_KEY above) +#REBALANCER_SIGNER_TYPE=aws_kms +#REBALANCER_KMS_KEY_ID= +#REBALANCER_KMS_REGION=us-east-1 +# Solver signer — choose one: +# Option A: local key SOLVER_PRIVATE_KEY=redacted +# Option B: AWS KMS (comment out SOLVER_PRIVATE_KEY above) +#SOLVER_SIGNER_TYPE=aws_kms +#SOLVER_KMS_KEY_ID= +#SOLVER_KMS_REGION=us-east-1 # ANVIL1_PK - Deployer for local Anvil chain 1 (Anvil account #0) -# The Anvil default key is fine here (local chain only) +# This account has 10,000 ETH and is minted USDC during `make deploy`. +# The Anvil default key is fine here (local chain only). ANVIL1_PK=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # ANVIL2_PK - Deployer for local Anvil chain 2 (Anvil account #0 - same as ANVIL1_PK) # Using the same key since both are local Anvil instances ANVIL2_PK=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -# USER_PK - Account that creates intents +# USER_PK - Account that creates intents (Anvil account #3) +# On anvil1 this account starts with 10,000 ETH and is minted USDC during `make deploy`. USER_PK=3b100e493e845f3a316158400e765b3a75d92d9b922cf8e1d38b25e228a4d0a8 # ============================================================================= diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..a77e39f --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,39 @@ +name: Rust CI + +on: + pull_request: + push: + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: make fmt-check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run clippy + run: make lint + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run workspace tests + run: make test-rust diff --git a/.rust-toolchain.toml b/.rust-toolchain.toml new file mode 100644 index 0000000..03199c4 --- /dev/null +++ b/.rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.91.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/Cargo.lock b/Cargo.lock index f96a7f8..bca0fb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,9 +59,9 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f374d3c6d729268bbe2d0e0ff992bb97898b2df756691a62ee1d5f0506bc39" +checksum = "6d9d22005bf31b018f31ef9ecadb5d2c39cf4f6acc8db0456f72c815f3d7f757" dependencies = [ "alloy-primitives", "num_enum", @@ -828,13 +828,12 @@ dependencies = [ [[package]] name = "alloy-trie" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d7fd448ab0a017de542de1dcca7a58e7019fe0e7a34ed3f9543ebddf6aceffa" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" dependencies = [ "alloy-primitives", "alloy-rlp", - "arrayvec", "derive_more", "nybbles", "serde", @@ -1123,15 +1122,12 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "serde", -] [[package]] name = "assert_cmd" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" dependencies = [ "anstyle", "bstr", @@ -1894,9 +1890,9 @@ dependencies = [ [[package]] name = "c-kzg" -version = "2.1.6" +version = "2.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a0f582957c24870b7bfd12bf562c40b4734b533cafbaf8ded31d6d85f462c01" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" dependencies = [ "blst", "cc", @@ -3115,7 +3111,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -3493,9 +3489,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" @@ -3773,9 +3769,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ "bitflags", "cfg-if", @@ -3805,9 +3801,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", @@ -4239,7 +4235,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4248,9 +4244,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4276,7 +4272,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -4432,7 +4428,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tokio-util", "url", @@ -4819,9 +4815,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -5253,25 +5249,28 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "solver-account" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-consensus", "alloy-network", "alloy-primitives", "alloy-signer", + "alloy-signer-aws", "alloy-signer-local", "async-trait", + "aws-config", + "aws-sdk-kms", "hex", "solver-types", "thiserror 2.0.18", @@ -5291,6 +5290,8 @@ dependencies = [ "anyhow", "assert_cmd", "async-trait", + "aws-config", + "aws-sdk-kms", "chrono", "clap", "colored", @@ -5333,7 +5334,7 @@ dependencies = [ [[package]] name = "solver-config" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "dotenvy", "regex", @@ -5348,7 +5349,7 @@ dependencies = [ [[package]] name = "solver-core" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-primitives", "chrono", @@ -5363,7 +5364,7 @@ dependencies = [ "solver-discovery", "solver-order", "solver-pricing", - "solver-settlement 0.1.0 (git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f)", + "solver-settlement 0.1.0 (git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc)", "solver-storage", "solver-types", "thiserror 2.0.18", @@ -5375,7 +5376,7 @@ dependencies = [ [[package]] name = "solver-delivery" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-consensus", "alloy-network", @@ -5404,7 +5405,7 @@ dependencies = [ [[package]] name = "solver-discovery" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-contract", "alloy-primitives", @@ -5434,7 +5435,7 @@ dependencies = [ [[package]] name = "solver-order" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -5454,7 +5455,7 @@ dependencies = [ [[package]] name = "solver-pricing" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-primitives", "async-trait", @@ -5472,7 +5473,7 @@ dependencies = [ [[package]] name = "solver-service" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-primitives", "alloy-signer", @@ -5502,7 +5503,7 @@ dependencies = [ "solver-discovery", "solver-order", "solver-pricing", - "solver-settlement 0.1.0 (git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f)", + "solver-settlement 0.1.0 (git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc)", "solver-storage", "solver-types", "subtle", @@ -5527,7 +5528,7 @@ dependencies = [ "async-trait", "serde_json", "sha3", - "solver-settlement 0.1.0 (git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f)", + "solver-settlement 0.1.0 (git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc)", "solver-storage", "solver-types", "tokio", @@ -5538,7 +5539,7 @@ dependencies = [ [[package]] name = "solver-settlement" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-primitives", "alloy-provider", @@ -5563,7 +5564,7 @@ dependencies = [ [[package]] name = "solver-storage" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "async-trait", "chrono", @@ -5583,7 +5584,7 @@ dependencies = [ [[package]] name = "solver-types" version = "0.1.0" -source = "git+https://github.com/celestiaorg/oif-solver?rev=2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f#2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" +source = "git+https://github.com/celestiaorg/oif-solver?rev=a06231ca236ee9ce156900cd2cc9915fbe847cdc#a06231ca236ee9ce156900cd2cc9915fbe847cdc" dependencies = [ "alloy-consensus", "alloy-contract", @@ -5746,9 +5747,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -5889,7 +5890,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -6356,9 +6357,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6908,9 +6909,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -7068,18 +7069,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 67c266e..db1494e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [workspace] members = [ - "solver-cli", - "solver-settlement", "oracle-operator", "rebalancer", + "solver-cli", + "solver-settlement", ] resolver = "2" diff --git a/Makefile b/Makefile index f97a592..657e411 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,36 @@ build: @cd solver-cli && cargo build --release --features solver-runtime .PHONY: build +## build-all: Build all service binaries (solver-cli, oracle-operator, oif-aggregator) +build-all: build + @cd oracle-operator && cargo build --release + @cd oif/oif-aggregator && cargo build --release +.PHONY: build-all + +## fmt: Format all Rust workspace crates with rustfmt +fmt: + @cargo fmt --all +.PHONY: fmt + +## fmt-check: Check Rust formatting across the workspace +fmt-check: + @cargo fmt --all --check +.PHONY: fmt-check + +## lint: Run clippy across the Rust workspace +lint: + @cargo clippy --workspace --all-targets --all-features -- -D warnings +.PHONY: lint + +## test-rust: Run Rust workspace tests +test-rust: + @cargo test --workspace --all-targets +.PHONY: test-rust + +## ci-rust: Run the full Rust quality suite locally +ci-rust: fmt-check lint test-rust +.PHONY: ci-rust + # ============================================================================ # Docker Image Builds # ============================================================================ @@ -183,20 +213,35 @@ fund-user: cast send --rpc-url $$ANVIL1_RPC --private-key $$ANVIL1_PK --value 10ether $$USER_ADDR 2>/dev/null && \ if [ ! -z "$$ANVIL2_RPC" ]; then \ echo " Funding on Anvil2 (10 ETH)..." && \ - cast send --rpc-url $$ANVIL2_RPC --private-key $$ANVIL2_PK --value 10ether $$USER_ADDR 2>/dev/null; \ + cast send --rpc-url $$ANVIL2_RPC --private-key $$ANVIL2_PK --value 10ether $$USER_ADDR; \ fi && \ echo "User funded" .PHONY: fund-user ## mint: Mint tokens on a chain (SYMBOL=USDC, CHAIN=anvil1, TO=user, AMOUNT=10000000) mint: build - @$(SOLVER_CLI) token mint \ + @unset CHAIN; $(SOLVER_CLI) token mint \ --chain $(or $(CHAIN),anvil1) \ --symbol $(or $(SYMBOL),USDC) \ --to $(or $(TO),user) \ --amount $(or $(AMOUNT),10000000) .PHONY: mint +## fund-address: Fund any address with ETH and USDC on anvil1 +## Usage: make fund-address ADDR=0x... ETH=10 USDC=100000000 +fund-address: + @[ -n "$(ADDR)" ] || (echo "Error: ADDR is required. Usage: make fund-address ADDR=0x..."; exit 1) + @. ./.env && \ + echo "Funding $(ADDR) on anvil1..." && \ + cast send --rpc-url $$ANVIL1_RPC --private-key $$ANVIL1_PK --value $(or $(ETH),10)ether $(ADDR) && \ + echo " Sent $(or $(ETH),10) ETH" && \ + cast send --rpc-url $$ANVIL1_RPC --private-key $$ANVIL1_PK \ + $$(cat .config/state.json | python3 -c "import sys,json; chains=json.load(sys.stdin)['chains']; print(next(c['tokens']['USDC']['address'] for c in chains.values() if c['name']=='anvil1'))") \ + "mint(address,uint256)" $(ADDR) $(or $(USDC),100000000) && \ + echo " Minted $(or $(USDC),100000000) USDC (raw units)" && \ + echo "Done." +.PHONY: fund-address + # ============================================================================ # Services # ============================================================================ @@ -215,7 +260,7 @@ solver: solver-start ## operator-start: Start the oracle operator service operator-start: - @cd oracle-operator && ORACLE_CONFIG=../.config/oracle.toml RUST_LOG=info cargo run --release + @set -a && . ./.env && set +a && cd oracle-operator && ORACLE_CONFIG=../.config/oracle.toml RUST_LOG=info cargo run --release .PHONY: operator-start # Alias for convenience @@ -267,166 +312,6 @@ balances: build @$(SOLVER_CLI) balances $(if $(CHAIN),--chain $(CHAIN),) .PHONY: balances -# ============================================================================ -# Bridge Test (Hyperlane warp route e2e) -# ============================================================================ - -## rebalance: Move solver USDC from anvil1 -> Celestia -> anvil2 via Hyperlane + forwarding -FORWARDING_BACKEND ?= http://127.0.0.1:8080 -REBALANCE_AMOUNT ?= 10000000 - -rebalance: - @echo "" - @echo "═══════════════════════════════════════════════════════════════" - @echo " Rebalance: anvil1 -> Celestia -> anvil2" - @echo "═══════════════════════════════════════════════════════════════" - @echo "" - @. ./.env && \ - ADDRESSES=$$(cat .config/hyperlane-addresses.json) && \ - MOCK_USDC=$$(echo $$ADDRESSES | jq -r '.anvil1.mock_usdc') && \ - ANVIL1_WARP=$$(echo $$ADDRESSES | jq -r '.anvil1.warp_token') && \ - ANVIL2_WARP=$$(echo $$ADDRESSES | jq -r '.anvil2.warp_token') && \ - SOLVER_ADDR=$$(cast wallet address --private-key $$SOLVER_PRIVATE_KEY) && \ - SOLVER_ADDR_PADDED=$$(printf '0x000000000000000000000000%s' $${SOLVER_ADDR#0x}) && \ - echo " Solver address: $$SOLVER_ADDR" && \ - echo " MockERC20 (anvil1): $$MOCK_USDC" && \ - echo " HypCollateral (anvil1): $$ANVIL1_WARP" && \ - echo " HypSynthetic (anvil2): $$ANVIL2_WARP" && \ - echo " Amount: $(REBALANCE_AMOUNT) (raw)" && \ - echo "" && \ - echo "Step 1: Check initial balances..." && \ - ANVIL1_BAL=$$(cast call $$MOCK_USDC "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL1_RPC 2>/dev/null | cast to-dec 2>/dev/null || echo "0") && \ - ANVIL2_BAL=$$(cast call $$ANVIL2_WARP "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL2_RPC 2>/dev/null | cast to-dec 2>/dev/null || echo "0") && \ - echo " Anvil1 USDC: $$ANVIL1_BAL" && \ - echo " Anvil2 USDC: $$ANVIL2_BAL" && \ - echo "" && \ - echo "Step 2: Derive Celestia forwarding address (dest: anvil2)..." && \ - FORWARD_ADDR=$$(docker exec forwarding-relayer forwarding-relayer derive-address \ - --dest-domain 31338 \ - --dest-recipient $$SOLVER_ADDR_PADDED) && \ - echo " Forwarding address: $$FORWARD_ADDR" && \ - echo "" && \ - echo "Step 3: Register forwarding request with backend..." && \ - REGISTER_RESP=$$(curl -sf -X POST $(FORWARDING_BACKEND)/forwarding-requests \ - -H "Content-Type: application/json" \ - -d "{\"forward_addr\": \"$$FORWARD_ADDR\", \"dest_domain\": 31338, \"dest_recipient\": \"$$SOLVER_ADDR_PADDED\"}") && \ - echo " Response: $$REGISTER_RESP" && \ - echo "" && \ - echo "Step 4: Approve HypCollateral to spend solver USDC..." && \ - cast send $$MOCK_USDC "approve(address,uint256)" $$ANVIL1_WARP $(REBALANCE_AMOUNT) \ - --rpc-url $$ANVIL1_RPC --private-key $$SOLVER_PRIVATE_KEY > /dev/null && \ - echo " Done" && \ - echo "" && \ - echo "Step 5: Send USDC to Celestia via HypCollateral.transferRemote(69420, ...)..." && \ - FORWARD_ADDR_HEX=$$(python3 scripts/bech32_to_bytes32.py "$$FORWARD_ADDR") && \ - echo " Forwarding addr (bytes32): $$FORWARD_ADDR_HEX" && \ - cast send $$ANVIL1_WARP "transferRemote(uint32,bytes32,uint256)" \ - 69420 $$FORWARD_ADDR_HEX $(REBALANCE_AMOUNT) \ - --rpc-url $$ANVIL1_RPC --private-key $$SOLVER_PRIVATE_KEY --value 0 > /dev/null && \ - echo " Sent! anvil1 -> Celestia -> anvil2" && \ - echo "" && \ - echo "Step 6: Waiting for Hyperlane relayer + Celestia forwarding (60s)..." && \ - for i in $$(seq 1 12); do \ - sleep 5; \ - BAL=$$(cast call $$ANVIL2_WARP "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL2_RPC 2>/dev/null | cast to-dec 2>/dev/null || echo "0"); \ - printf " [%2ds] Anvil2 USDC balance: %s\n" $$((i * 5)) "$$BAL"; \ - if [ "$$BAL" != "$$ANVIL2_BAL" ] && [ "$$BAL" != "0" ]; then \ - echo ""; \ - echo " Tokens arrived!"; \ - break; \ - fi; \ - done && \ - echo "" && \ - echo "Step 7: Final balances..." && \ - ANVIL1_BAL_AFTER=$$(cast call $$MOCK_USDC "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL1_RPC | cast to-dec) && \ - ANVIL2_BAL_AFTER=$$(cast call $$ANVIL2_WARP "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL2_RPC | cast to-dec) && \ - echo " Anvil1 USDC: $$ANVIL1_BAL -> $$ANVIL1_BAL_AFTER" && \ - echo " Anvil2 USDC: $$ANVIL2_BAL -> $$ANVIL2_BAL_AFTER" && \ - echo "" && \ - if [ "$$ANVIL2_BAL_AFTER" != "$$ANVIL2_BAL" ]; then \ - echo " Rebalance complete — tokens moved anvil1 -> Celestia -> anvil2"; \ - else \ - echo " Rebalance failed — tokens did not arrive on anvil2"; \ - echo " Check logs: make logs SVC=relayer"; \ - echo " make logs SVC=forwarding-relayer"; \ - fi && \ - echo "" -.PHONY: rebalance - -## rebalance-back: Move solver USDC from anvil2 -> Celestia -> anvil1 via Hyperlane + forwarding -rebalance-back: - @echo "" - @echo "═══════════════════════════════════════════════════════════════" - @echo " Rebalance Back: anvil2 -> Celestia -> anvil1" - @echo "═══════════════════════════════════════════════════════════════" - @echo "" - @. ./.env && \ - ADDRESSES=$$(cat .config/hyperlane-addresses.json) && \ - MOCK_USDC=$$(echo $$ADDRESSES | jq -r '.anvil1.mock_usdc') && \ - ANVIL1_WARP=$$(echo $$ADDRESSES | jq -r '.anvil1.warp_token') && \ - ANVIL2_WARP=$$(echo $$ADDRESSES | jq -r '.anvil2.warp_token') && \ - SOLVER_ADDR=$$(cast wallet address --private-key $$SOLVER_PRIVATE_KEY) && \ - SOLVER_ADDR_PADDED=$$(printf '0x000000000000000000000000%s' $${SOLVER_ADDR#0x}) && \ - echo " Solver address: $$SOLVER_ADDR" && \ - echo " MockERC20 (anvil1): $$MOCK_USDC" && \ - echo " HypCollateral (anvil1): $$ANVIL1_WARP" && \ - echo " HypSynthetic (anvil2): $$ANVIL2_WARP" && \ - echo " Amount: $(REBALANCE_AMOUNT) (raw)" && \ - echo "" && \ - echo "Step 1: Check initial balances..." && \ - ANVIL1_BAL=$$(cast call $$MOCK_USDC "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL1_RPC 2>/dev/null | cast to-dec 2>/dev/null || echo "0") && \ - ANVIL2_BAL=$$(cast call $$ANVIL2_WARP "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL2_RPC 2>/dev/null | cast to-dec 2>/dev/null || echo "0") && \ - echo " Anvil1 USDC: $$ANVIL1_BAL" && \ - echo " Anvil2 USDC: $$ANVIL2_BAL" && \ - echo "" && \ - echo "Step 2: Derive Celestia forwarding address (dest: anvil1)..." && \ - FORWARD_ADDR=$$(docker exec forwarding-relayer forwarding-relayer derive-address \ - --dest-domain 131337 \ - --dest-recipient $$SOLVER_ADDR_PADDED) && \ - echo " Forwarding address: $$FORWARD_ADDR" && \ - echo "" && \ - echo "Step 3: Register forwarding request with backend..." && \ - REGISTER_RESP=$$(curl -sf -X POST $(FORWARDING_BACKEND)/forwarding-requests \ - -H "Content-Type: application/json" \ - -d "{\"forward_addr\": \"$$FORWARD_ADDR\", \"dest_domain\": 131337, \"dest_recipient\": \"$$SOLVER_ADDR_PADDED\"}") && \ - echo " Response: $$REGISTER_RESP" && \ - echo "" && \ - echo "Step 4: Send USDC to Celestia via HypSynthetic.transferRemote(69420, ...)..." && \ - FORWARD_ADDR_HEX=$$(python3 scripts/bech32_to_bytes32.py "$$FORWARD_ADDR") && \ - echo " Forwarding addr (bytes32): $$FORWARD_ADDR_HEX" && \ - cast send $$ANVIL2_WARP "transferRemote(uint32,bytes32,uint256)" \ - 69420 $$FORWARD_ADDR_HEX $(REBALANCE_AMOUNT) \ - --rpc-url $$ANVIL2_RPC --private-key $$SOLVER_PRIVATE_KEY --value 0 > /dev/null && \ - echo " Sent! anvil2 -> Celestia -> anvil1" && \ - echo "" && \ - echo "Step 5: Waiting for Hyperlane relayer + Celestia forwarding (60s)..." && \ - for i in $$(seq 1 12); do \ - sleep 5; \ - BAL=$$(cast call $$MOCK_USDC "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL1_RPC 2>/dev/null | cast to-dec 2>/dev/null || echo "0"); \ - printf " [%2ds] Anvil1 USDC balance: %s\n" $$((i * 5)) "$$BAL"; \ - if [ "$$BAL" != "$$ANVIL1_BAL" ] && [ "$$BAL" != "0" ]; then \ - echo ""; \ - echo " Tokens arrived!"; \ - break; \ - fi; \ - done && \ - echo "" && \ - echo "Step 6: Final balances..." && \ - ANVIL1_BAL_AFTER=$$(cast call $$MOCK_USDC "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL1_RPC | cast to-dec) && \ - ANVIL2_BAL_AFTER=$$(cast call $$ANVIL2_WARP "balanceOf(address)" $$SOLVER_ADDR --rpc-url $$ANVIL2_RPC | cast to-dec) && \ - echo " Anvil1 USDC: $$ANVIL1_BAL -> $$ANVIL1_BAL_AFTER" && \ - echo " Anvil2 USDC: $$ANVIL2_BAL -> $$ANVIL2_BAL_AFTER" && \ - echo "" && \ - if [ "$$ANVIL1_BAL_AFTER" != "$$ANVIL1_BAL" ]; then \ - echo " Rebalance complete — tokens moved anvil2 -> Celestia -> anvil1"; \ - else \ - echo " Rebalance failed — tokens did not arrive on anvil1"; \ - echo " Check logs: make logs SVC=relayer"; \ - echo " make logs SVC=forwarding-relayer"; \ - fi && \ - echo "" -.PHONY: rebalance-back - # ============================================================================ # Full Setup & Lifecycle # ============================================================================ @@ -464,6 +349,46 @@ mvp: @./mvp.sh .PHONY: mvp +OIF_SERVICES=oif-aggregator oif-solver oif-oracle oif-rebalancer oif-frontend-api oif-frontend + +## service-install: Install all OIF systemd units (requires root). Re-run after repo moves. +service-install: + @REPO=$(shell pwd); \ + for f in scripts/systemd/oif*.service scripts/systemd/oif.target; do \ + dest=/etc/systemd/system/$$(basename $$f); \ + sed "s|/root/solver-cli|$$REPO|g" $$f > $$dest; \ + echo " Installed $$dest"; \ + done + @systemctl daemon-reload + @systemctl enable $(OIF_SERVICES) oif.target + @echo "All units installed. Run: make service-start" +.PHONY: service-install + +## service-start: Start all OIF services +service-start: + @systemctl start oif.target +.PHONY: service-start + +## service-stop: Stop all OIF services +service-stop: + @systemctl stop oif.target +.PHONY: service-stop + +## service-restart: Restart all or one service (SVC=oif-solver to target one) +service-restart: + @systemctl restart $(or $(SVC),oif.target) +.PHONY: service-restart + +## service-status: Show status of all OIF services +service-status: + @systemctl status $(OIF_SERVICES) --no-pager || true +.PHONY: service-status + +## service-logs: Follow logs (SVC=oif-solver for one service, default shows all) +service-logs: + @journalctl -u $(or $(SVC),"oif-*") -f +.PHONY: service-logs + ## clean: Remove generated files and Docker volumes clean: @rm -rf .config diff --git a/README.md b/README.md index d0aced5..6537cff 100644 --- a/README.md +++ b/README.md @@ -288,11 +288,23 @@ Ensure your solver address has native tokens on all chains for gas. ## Development ```bash -# Build CLI -cd solver-cli && cargo build --release +# Rustup will honor .rust-toolchain.toml automatically in this repo. +# To preinstall it explicitly: +rustup toolchain install 1.91.1 --component rustfmt --component clippy -# Run tests -cd solver-cli && cargo test +# Format all workspace crates +make fmt + +# Run the same Rust quality checks as CI +make ci-rust + +# Build the main CLI +make build + +# Run individual checks +make fmt-check +make lint +make test-rust # Build contracts cd oif/oif-contracts && forge build diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..8d29173 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.91.1" diff --git a/docs/add-chain-sepolia.md b/docs/add-chain-sepolia.md index d5df243..ca26dd0 100644 --- a/docs/add-chain-sepolia.md +++ b/docs/add-chain-sepolia.md @@ -162,7 +162,6 @@ export CEL_TOKEN=0x... # paste from above **Enroll Celestia on Sepolia** — tells Sepolia's bridge contract that Celestia (domain 69420) is a valid route: ```bash -. ./.env cast send $HYP_SYNTHETIC_SEPOLIA \ "enrollRemoteRouter(uint32,bytes32)" \ 69420 $CEL_TOKEN \ @@ -202,8 +201,6 @@ anvil1 (HypCollateral) ↔ Celestia (native synthetic) ↔ anvil2 (HypSynthetic) The relayer passes messages between chains. Add Sepolia to `hyperlane/relayer-config.json`: ```bash -. ./.env - # Read the Sepolia mailbox address from the registry file downloaded in Step 3 SEPOLIA_MAILBOX=$(grep "^mailbox:" hyperlane/registry/chains/sepolia/addresses.yaml | awk '{print $2}' | tr -d '"') echo "Sepolia mailbox: $SEPOLIA_MAILBOX" @@ -261,7 +258,6 @@ make token-list CHAIN=sepolia **Solver** — needs ETH on Sepolia to pay gas when filling orders there: ```bash -. ./.env SOLVER_ADDR=$(cast wallet address --private-key $SOLVER_PRIVATE_KEY) echo "Funding solver: $SOLVER_ADDR" diff --git a/docs/deploy-testnet.md b/docs/deploy-testnet.md new file mode 100644 index 0000000..7a0caf6 --- /dev/null +++ b/docs/deploy-testnet.md @@ -0,0 +1,265 @@ +# Connect to Existing Testnet Deployments + +This guide covers connecting solver-cli to chains where OIF contracts, Hyperlane warp routes, and the Celestia forwarding layer are **already deployed and running**. No contract deployment is needed — just register your existing addresses and start the services. + +**Assumed already in place:** +- OIF contracts (InputSettlerEscrow, OutputSettlerSimple, CentralizedOracle) deployed on each EVM chain +- Hyperlane warp routes deployed and enrolled between EVM chains and Celestia +- Celestia synthetic tokens deployed and enrolled +- Hyperlane relayer running and connected to all chains +- Forwarding relayer running and accessible at a known URL + +--- + +## Step 1: Configure .env + +Copy `.env.example` to `.env` and fill in the values for your setup. + +### Signing keys + +Choose one approach per service. All three services (solver, oracle, rebalancer) can use different keys independently. + +**Option A — Local private keys:** +```bash +SOLVER_PRIVATE_KEY=0x +ORACLE_OPERATOR_PK=0x +REBALANCER_PRIVATE_KEY=0x +``` + +**Option B — AWS KMS (recommended for production):** +```bash +# Solver +SOLVER_SIGNER_TYPE=aws_kms +SOLVER_KMS_KEY_ID= +SOLVER_KMS_REGION=us-east-1 + +# Oracle operator +ORACLE_SIGNER_TYPE=aws_kms +ORACLE_KMS_KEY_ID= +ORACLE_KMS_REGION=us-east-1 + +# Rebalancer +REBALANCER_SIGNER_TYPE=aws_kms +REBALANCER_KMS_KEY_ID= +REBALANCER_KMS_REGION=us-east-1 +``` + +All three can share the same KMS key or use separate keys — just set the same UUID for each. + +> KMS keys must be asymmetric secp256k1 signing keys. See [aws-kms-key-import.md](aws-kms-key-import.md) for setup instructions. + +**Frontend bridge signer (always a local key — Node.js cannot use KMS):** +```bash +BRIDGE_SIGNER_PK=0x +``` + +### Celestia / forwarding + +```bash +CELESTIA_DOMAIN= +FORWARDING_BACKEND=http://: +``` + +### Aggregator integrity secret + +```bash +INTEGRITY_SECRET= +``` + +--- + +## Step 2: Initialize state + +```bash +solver-cli init +``` + +This creates `.config/state.json` to track chain registrations and generated config paths. + +--- + +## Step 3: Register chains + +Register each EVM chain with its deployed contract addresses. + +### HypCollateral chains + +On chains where the warp token is a **HypERC20Collateral** (wraps an existing ERC20), pass both the underlying ERC20 address via `--token` and the collateral router via `--warp-token`: + +```bash +solver-cli chain add \ + --name chain-a \ + --rpc https://rpc.chain-a.example \ + --chain-id 12345 \ + --input-settler 0x \ + --output-settler 0x \ + --oracle 0x \ + --token USDC=0x:6 \ + --token USDT=0x:6 \ + --warp-token 0x +``` + +> `--warp-token` is the address the rebalancer calls `transferRemote` on. It must be the collateral router, not the underlying ERC20. The rebalancer will automatically `approve` the router to spend the ERC20 before each transfer. + +> If different tokens have different collateral routers, register them separately using `solver-cli token add` after chain registration (see below). + +### HypSynthetic chains + +On chains where the warp token is a **HypERC20Synthetic** (mints/burns bridged tokens), the synthetic contract IS the token — pass it as the `--token` address and omit `--warp-token`: + +```bash +solver-cli chain add \ + --name chain-b \ + --rpc https://rpc.chain-b.example \ + --chain-id 67890 \ + --input-settler 0x \ + --output-settler 0x \ + --oracle 0x \ + --token USDC=0x:6 \ + --token USDT=0x:6 +``` + +### Adding more tokens to an already-registered chain + +```bash +solver-cli token add --chain chain-a --symbol DAI --address 0x --decimals 18 +``` + +### Verify registrations + +```bash +solver-cli chain list +solver-cli token list +``` + +--- + +## Step 4: Generate configs + +```bash +solver-cli configure +``` + +This reads your registered chains, derives the solver address from your signing key (local or KMS), and writes: + +| File | Purpose | +|---|---| +| `.config/solver.toml` | Solver engine config with all-to-all routes | +| `.config/oracle.toml` | Oracle operator config for all chains | +| `.config/rebalancer.toml` | Rebalancer config with equal-weight distribution | +| `.config/hyperlane-relayer.json` | Hyperlane relayer signer config | +| `oif/oif-aggregator/config/config.json` | Aggregator config | + +Forwarding section in `rebalancer.toml` is populated from `CELESTIA_DOMAIN` and `FORWARDING_BACKEND`. + +--- + +## Step 5: Verify solver and oracle addresses + +Confirm the addresses that will be used on-chain before funding them. + +```bash +solver-cli account address +``` + +For the oracle operator address, check the generated config: + +```bash +grep operator_address .config/oracle.toml +``` + +--- + +## Step 6: Fund accounts + +The solver needs gas (native token) on every chain where it will fill orders. The oracle operator needs gas on every chain where it will submit attestations (i.e. every origin chain). The rebalancer needs gas on every source chain it will initiate transfers from. + +```bash +# Example using cast — repeat for each chain +cast send \ + --rpc-url https://rpc.chain-a.example \ + --private-key \ + --value 0.1ether + +cast send \ + --rpc-url https://rpc.chain-a.example \ + --private-key \ + --value 0.05ether +``` + +The solver also needs token inventory on destination chains to fill orders. Fund it with the relevant tokens on each chain. + +--- + +## Step 7: Start services + +Each service reads from the generated configs in `.config/`. Start them in separate terminals or as background processes: + +```bash +# Aggregator (quote aggregation API on port 4000) +make aggregator + +# Solver (fills orders, claims on oracle confirmation) +make solver + +# Oracle operator (signs and submits fill attestations) +make operator + +# Rebalancer (maintains token distribution across chains) +make rebalancer + +# Frontend (bridge UI + API on ports 3001 / 5173) +./scripts/start-frontend.sh +``` + +Or use the convenience script that starts all backend services together: + +```bash +./scripts/start-services.sh +``` + +--- + +## Rebalancer behavior + +The rebalancer polls each chain's solver token balance every 30 seconds and transfers from over-weight chains to under-weight chains when any chain falls below its `min_weight` threshold. + +By default, `solver-cli configure` sets equal weights across all chains (50/50 for two chains, 33/33/33 for three, etc.) with a ±20% tolerance before triggering a rebalance. + +Transfers route through Celestia: the rebalancer calls `transferRemote` on the source chain's warp router toward the Celestia forwarding domain, and the forwarding relayer delivers the tokens to the destination chain. + +**For HypCollateral chains**, the rebalancer sends an `approve` transaction to the underlying ERC20 before each `transferRemote`. This is handled automatically — no manual approval is needed. + +To run a single rebalance cycle manually: + +```bash +solver-cli rebalancer start --once +``` + +--- + +## Updating a registered chain + +If you need to update contract addresses or add a warp token to an existing chain registration, re-run `chain add` with the same `--name` — it will overwrite the existing entry: + +```bash +solver-cli chain add --name chain-a --rpc ... --input-settler ... --output-settler ... --oracle ... --warp-token 0x +``` + +Then regenerate configs: + +```bash +solver-cli configure +``` + +And restart the affected services. + +--- + +## Removing a chain + +```bash +solver-cli chain remove --chain chain-b +solver-cli configure +# Restart services +``` diff --git a/frontend/index.html b/frontend/index.html index 3722fe3..ea94a2d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - OIF Solver + Celestia Bridge diff --git a/frontend/public/celestia-logo.svg b/frontend/public/celestia-logo.svg new file mode 100644 index 0000000..f2b823f --- /dev/null +++ b/frontend/public/celestia-logo.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/server.js b/frontend/server.js index 920cebe..df3eb9a 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -21,12 +21,6 @@ function readState() { return JSON.parse(readFileSync(resolve(ROOT, '.config/state.json'), 'utf8')); } -function getUserAccount() { - const pk = process.env.USER_PK; - if (!pk) throw new Error('USER_PK not set in .env'); - return privateKeyToAccount(`0x${pk.replace('0x', '')}`); -} - function makeViemChain(chainId, name, rpc) { return defineChain({ id: chainId, @@ -62,11 +56,6 @@ const erc20Abi = parseAbi([ 'function approve(address, uint256) returns (bool)', ]); -const hypTokenAbi = parseAbi([ - 'function transferRemote(uint32, bytes32, uint256) payable returns (bytes32)', - 'function balanceOf(address) view returns (uint256)', -]); - // ── Bech32 → bytes32 (replaces Python script) ─────────────────────────────── const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; @@ -132,7 +121,7 @@ function parseWarpRouteYaml(yamlContent) { function getWarpRouteConfig(token) { const state = readState(); const hypAddresses = readHyperlaneAddresses(); - if (!hypAddresses) throw new Error('Hyperlane not deployed — hyperlane-addresses.json not found'); + if (!hypAddresses) return null; const celestiaDomainId = hypAddresses.celestiadev?.domain_id || 69420; @@ -221,6 +210,24 @@ function isOriginChain(chainName, hypAddresses) { return data && data.mock_usdc; } +// ── Error helpers ──────────────────────────────────────────────────────────── + +/** + * Map raw aggregator error messages to user-friendly descriptions. + * The aggregator returns generic strings; we enrich them with actionable context. + */ +function mapAggregatorQuoteError(msg) { + if (typeof msg !== 'string') return JSON.stringify(msg); + const lower = msg.toLowerCase(); + if (lower.includes('all solvers failed') || lower.includes('no quotes')) { + return `SOLVER_REJECTED: ${msg}`; + } + if (lower.includes('no solvers available') || lower.includes('solver offline')) { + return `SOLVER_OFFLINE: ${msg}`; + } + return msg; +} + // ── Express App ────────────────────────────────────────────────────────────── const app = express(); @@ -244,15 +251,30 @@ app.get('/api/health', async (_req, res) => { app.get('/api/config', (_req, res) => { try { const state = readState(); - const user = getUserAccount(); const chains = {}; + // Build warp route fallback tokens (same logic as /api/balances) + const warpTokenByChainName = {}; + try { + for (const symbol of ['USDC']) { + const warp = getWarpRouteConfig(symbol); + if (!warp) continue; + for (const [chainName, info] of Object.entries(warp.chains)) { + if (!warpTokenByChainName[chainName]) warpTokenByChainName[chainName] = {}; + const queryAddr = info.warpType === 'collateral' ? info.underlying : info.warpToken; + if (queryAddr) warpTokenByChainName[chainName][symbol] = { address: queryAddr, symbol, decimals: 6, token_type: 'erc20' }; + } + } + } catch {} + for (const [chainId, chain] of Object.entries(state.chains)) { + const warpFallback = warpTokenByChainName[chain.name] ?? {}; + const tokens = Object.keys(chain.tokens).length > 0 ? chain.tokens : warpFallback; chains[chainId] = { name: chain.name, chainId: chain.chain_id, rpc: chain.rpc, - tokens: chain.tokens, + tokens, contracts: chain.contracts || {}, }; } @@ -266,7 +288,6 @@ app.get('/api/config', (_req, res) => { res.json({ chains, - userAddress: user.address, solverAddress: state.solver?.address ?? null, faucetChains, }); @@ -279,23 +300,40 @@ app.get('/api/config', (_req, res) => { app.get('/api/balances', async (req, res) => { try { const state = readState(); - // Use connected wallet address if provided, otherwise fall back to USER_PK const addressParam = req.query.address; - let userAddress; - if (addressParam && /^0x[0-9a-fA-F]{40}$/.test(addressParam)) { - userAddress = addressParam; - } else { - userAddress = getUserAccount().address; + if (!addressParam || !/^0x[0-9a-fA-F]{40}$/.test(addressParam)) { + return res.status(400).json({ error: 'Missing or invalid "address" query parameter. Connect a wallet.' }); } + const userAddress = addressParam; console.log(`[balances] user=${userAddress} solver=${state.solver?.address}`); const result = {}; + // Build warp route fallback: chainName -> symbol -> { address, decimals } + // Used when state.json tokens are missing (e.g. race condition or testnet chains) + const warpTokenByChainName = {}; + try { + for (const symbol of ['USDC']) { + const warp = getWarpRouteConfig(symbol); + if (!warp) continue; + for (const [chainName, info] of Object.entries(warp.chains)) { + if (!warpTokenByChainName[chainName]) warpTokenByChainName[chainName] = {}; + // For balance queries: collateral chain → underlying ERC20; synthetic → warpToken + const queryAddr = info.warpType === 'collateral' ? info.underlying : info.warpToken; + if (queryAddr) warpTokenByChainName[chainName][symbol] = { address: queryAddr, decimals: 6 }; + } + } + } catch {} + const promises = Object.entries(state.chains).map(async ([chainId, chain]) => { const client = createPublicClient({ transport: http(chain.rpc) }); const chainBal = { user: {}, solver: {} }; + // Merge state tokens with warp route fallback (state takes precedence) + const warpFallback = warpTokenByChainName[chain.name] ?? {}; + const tokensToQuery = { ...warpFallback, ...chain.tokens }; + // Token balances - for (const [symbol, token] of Object.entries(chain.tokens)) { + for (const [symbol, token] of Object.entries(tokensToQuery)) { try { const userBal = await client.readContract({ address: token.address, abi: erc20Abi, @@ -346,16 +384,10 @@ app.post('/api/faucet', async (req, res) => { const chain = Object.values(state.chains).find(c => c.name === chainName); if (!chain) throw new Error(`Chain "${chainName}" not found`); - // Use provided address (from connected wallet) or fall back to USER_PK - let recipient; - if (address) { - if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { - throw new Error('Invalid Ethereum address'); - } - recipient = address; - } else { - recipient = getUserAccount().address; + if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address)) { + throw new Error('Missing or invalid "address". Connect a wallet first.'); } + const recipient = address; // Resolve deployer key const envKey = `${chainName.toUpperCase()}_PK`; @@ -424,122 +456,6 @@ app.post('/api/faucet', async (req, res) => { } }); -// Rebalance: bridge tokens between any two chains via Celestia forwarding -app.post('/api/rebalance', async (req, res) => { - const { from, to, amount = '10000000', token = 'USDC' } = req.body; - if (!from || !to) return res.status(400).json({ error: '"from" and "to" chain names are required' }); - try { - // 1. Load warp route config for this token - const warp = getWarpRouteConfig(token); - const src = warp.chains[from]; - const dst = warp.chains[to]; - if (!src) throw new Error(`Chain "${from}" not found in ${token} warp route. Have you enrolled the routers?`); - if (!dst) throw new Error(`Chain "${to}" not found in ${token} warp route. Have you enrolled the routers?`); - - // Only the collateral side needs an ERC20 approve before transferRemote - const underlyingToApprove = src.warpType === 'collateral' ? src.underlying : null; - // On the collateral side, received tokens land as the underlying ERC20 (not the warp token) - const dstBalanceToken = dst.warpType === 'collateral' ? dst.underlying : dst.warpToken; - - // 2. Setup viem clients - const solverPk = process.env.SOLVER_PRIVATE_KEY; - if (!solverPk) throw new Error('SOLVER_PRIVATE_KEY not set in .env'); - const solver = privateKeyToAccount(`0x${solverPk.replace('0x', '')}`); - - const srcChain = makeViemChain(src.chainId, from, src.rpc); - const dstChain = makeViemChain(dst.chainId, to, dst.rpc); - - const srcWallet = createWalletClient({ account: solver, chain: srcChain, transport: http(src.rpc) }); - const srcPublic = createPublicClient({ chain: srcChain, transport: http(src.rpc) }); - const dstPublic = createPublicClient({ chain: dstChain, transport: http(dst.rpc) }); - - const solverPadded = '0x000000000000000000000000' + solver.address.slice(2); - const amountBigInt = BigInt(amount); - - console.log(`[rebalance] ${token} ${from}→${to} amount=${amount}`); - console.log(`[rebalance] solver=${solver.address} srcWarp=${src.warpToken} dstBalance=${dstBalanceToken}`); - - // 3. Check initial balance on destination - const initialDstBal = await dstPublic.readContract({ - address: dstBalanceToken, abi: hypTokenAbi, - functionName: 'balanceOf', args: [solver.address], - }); - console.log(`[rebalance] Initial dst balance: ${initialDstBal}`); - - // 4. Derive Celestia forwarding address - const forwardingBackend = process.env.FORWARDING_BACKEND || 'http://127.0.0.1:8080'; - const addrResp = await fetch(`${forwardingBackend}/forwarding-address?dest_domain=${dst.domainId}&dest_recipient=${solverPadded}`); - if (!addrResp.ok) { - const body = await addrResp.text(); - throw new Error(`Failed to derive forwarding address: ${body}`); - } - const { address: forwardAddr } = await addrResp.json(); - console.log(`[rebalance] Forwarding address: ${forwardAddr}`); - - // 5. Register forwarding request with backend - const registerResp = await fetch(`${forwardingBackend}/forwarding-requests`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - forward_addr: forwardAddr, - dest_domain: dst.domainId, - dest_recipient: solverPadded, - }), - }); - if (!registerResp.ok) { - const body = await registerResp.text(); - throw new Error(`Forwarding registration failed: ${body}`); - } - console.log(`[rebalance] Forwarding registered`); - - // 6. Approve underlying ERC20 → HypCollateral (only for collateral source) - if (underlyingToApprove) { - const approveHash = await srcWallet.writeContract({ - address: underlyingToApprove, abi: erc20Abi, - functionName: 'approve', args: [src.warpToken, amountBigInt], - }); - await srcPublic.waitForTransactionReceipt({ hash: approveHash }); - console.log(`[rebalance] Approved ${amount} of ${underlyingToApprove} → ${src.warpToken}`); - } - - // 7. Call transferRemote on the warp token - const forwardAddrBytes32 = bech32ToBytes32(forwardAddr); - const txHash = await srcWallet.writeContract({ - address: src.warpToken, abi: hypTokenAbi, - functionName: 'transferRemote', - args: [warp.celestiaDomainId, forwardAddrBytes32, amountBigInt], - value: 0n, - }); - await srcPublic.waitForTransactionReceipt({ hash: txHash }); - console.log(`[rebalance] transferRemote sent: ${txHash}`); - - // 8. Poll destination balance (up to 60s) - let arrived = false; - for (let i = 1; i <= 12; i++) { - await new Promise(r => setTimeout(r, 5000)); - const bal = await dstPublic.readContract({ - address: dstBalanceToken, abi: hypTokenAbi, - functionName: 'balanceOf', args: [solver.address], - }); - console.log(`[rebalance] [${i * 5}s] dst balance: ${bal}`); - if (bal !== initialDstBal && bal > 0n) { arrived = true; break; } - } - - if (arrived) { - res.json({ success: true, message: `Bridged ${token} from ${from} to ${to}`, txHash }); - } else { - res.json({ - success: false, - message: `Bridge tx sent (${txHash}) but tokens haven't arrived on ${to} yet. Check Hyperlane relayer logs.`, - txHash, - }); - } - } catch (err) { - console.error(`[rebalance] Failed:`, err.message); - res.status(500).json({ error: `Rebalance failed: ${err.message}` }); - } -}); - // Bridge prepare: derive forwarding address + register, return contract info for wallet-side execution app.post('/api/bridge/prepare', async (req, res) => { const { from, to, token = 'USDC', amount = '10000000', address } = req.body; @@ -547,6 +463,7 @@ app.post('/api/bridge/prepare', async (req, res) => { if (!address) return res.status(400).json({ error: '"address" (user wallet) is required' }); try { const warp = getWarpRouteConfig(token); + if (!warp) return res.status(503).json({ error: 'Slow route unavailable: Hyperlane not deployed on this network' }); const src = warp.chains[from]; const dst = warp.chains[to]; if (!src) throw new Error(`Chain "${from}" not found in ${token} warp route`); @@ -581,86 +498,9 @@ app.post('/api/bridge/prepare', async (req, res) => { } }); -// Bridge: server-side user bridge via Celestia (uses USER_PK) -app.post('/api/bridge', async (req, res) => { - const { from, to, amount = '10000000', token = 'USDC' } = req.body; - if (!from || !to) return res.status(400).json({ error: '"from" and "to" are required' }); - try { - const warp = getWarpRouteConfig(token); - const src = warp.chains[from]; - const dst = warp.chains[to]; - if (!src) throw new Error(`Chain "${from}" not found in ${token} warp route`); - if (!dst) throw new Error(`Chain "${to}" not found in ${token} warp route`); - - const underlyingToApprove = src.warpType === 'collateral' ? src.underlying : null; - const dstBalanceToken = dst.warpType === 'collateral' ? dst.underlying : dst.warpToken; - - const userPk = process.env.USER_PK; - if (!userPk) throw new Error('USER_PK not set in .env'); - const user = privateKeyToAccount(`0x${userPk.replace('0x', '')}`); - - const srcChain = makeViemChain(src.chainId, from, src.rpc); - const dstChain = makeViemChain(dst.chainId, to, dst.rpc); - const srcWallet = createWalletClient({ account: user, chain: srcChain, transport: http(src.rpc) }); - const srcPublic = createPublicClient({ chain: srcChain, transport: http(src.rpc) }); - const dstPublic = createPublicClient({ chain: dstChain, transport: http(dst.rpc) }); - - const userPadded = '0x000000000000000000000000' + user.address.slice(2); - const amountBigInt = BigInt(amount); - - const forwardingBackend = process.env.FORWARDING_BACKEND || 'http://127.0.0.1:8080'; - const addrResp = await fetch( - `${forwardingBackend}/forwarding-address?dest_domain=${dst.domainId}&dest_recipient=${userPadded}` - ); - if (!addrResp.ok) throw new Error(`Failed to derive forwarding address: ${await addrResp.text()}`); - const { address: forwardAddr } = await addrResp.json(); - - const registerResp = await fetch(`${forwardingBackend}/forwarding-requests`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ forward_addr: forwardAddr, dest_domain: dst.domainId, dest_recipient: userPadded }), - }); - if (!registerResp.ok) throw new Error(`Forwarding registration failed: ${await registerResp.text()}`); - - const initialDstBal = await dstPublic.readContract({ - address: dstBalanceToken, abi: hypTokenAbi, functionName: 'balanceOf', args: [user.address], - }); - - if (underlyingToApprove) { - const approveHash = await srcWallet.writeContract({ - address: underlyingToApprove, abi: erc20Abi, - functionName: 'approve', args: [src.warpToken, amountBigInt], - }); - await srcPublic.waitForTransactionReceipt({ hash: approveHash }); - } - - const txHash = await srcWallet.writeContract({ - address: src.warpToken, abi: hypTokenAbi, - functionName: 'transferRemote', - args: [warp.celestiaDomainId, bech32ToBytes32(forwardAddr), amountBigInt], - value: 0n, - }); - await srcPublic.waitForTransactionReceipt({ hash: txHash }); - - let arrived = false; - for (let i = 1; i <= 12; i++) { - await new Promise(r => setTimeout(r, 5000)); - const bal = await dstPublic.readContract({ - address: dstBalanceToken, abi: hypTokenAbi, functionName: 'balanceOf', args: [user.address], - }); - if (bal !== initialDstBal && bal > 0n) { arrived = true; break; } - } - - res.json({ - success: arrived, - message: arrived - ? `Bridged ${token} from ${from} to ${to}` - : `Bridge tx sent but tokens haven't arrived yet. Check Hyperlane relayer.`, - txHash, - }); - } catch (err) { - res.status(500).json({ error: `Bridge failed: ${err.message}` }); - } +// Bridge: server-side signing removed — wallet handles tx submission via /api/bridge/prepare +app.post('/api/bridge', (_req, res) => { + res.status(400).json({ error: 'Server-side bridge removed. Connect a wallet and use the wallet-based bridge flow.' }); }); // Quote: request quote from aggregator @@ -668,13 +508,10 @@ app.post('/api/quote', async (req, res) => { const { fromChainId, toChainId, amount, asset = 'USDC', address } = req.body; try { const state = readState(); - const user = getUserAccount(); - - // Use connected wallet address if provided, otherwise fall back to USER_PK - let userAddress = user.address; - if (address && /^0x[0-9a-fA-F]{40}$/.test(address)) { - userAddress = address; + if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address)) { + throw new Error('Missing or invalid "address". Connect a wallet first.'); } + const userAddress = address; const fromChain = state.chains[fromChainId.toString()]; const toChain = state.chains[toChainId.toString()]; @@ -715,7 +552,10 @@ app.post('/api/quote', async (req, res) => { }); const data = await response.json(); - if (!response.ok) throw new Error(data.message || data.error || JSON.stringify(data)); + if (!response.ok) { + const rawMsg = data.message || data.error || JSON.stringify(data); + throw new Error(mapAggregatorQuoteError(rawMsg)); + } console.log(`[quote] Got ${data.quotes?.length ?? 0} quotes`); res.json(data); } catch (err) { @@ -723,87 +563,9 @@ app.post('/api/quote', async (req, res) => { } }); -// Order: approve tokens, sign EIP-712, prepend type byte, submit to aggregator -app.post('/api/order', async (req, res) => { - const { quote, fromChainId, asset = 'USDC' } = req.body; - try { - const user = getUserAccount(); - const state = readState(); - - // Resolve source chain for approval + signing - const srcChainId = fromChainId?.toString() || Object.keys(state.chains)[0]; - const srcChain = state.chains[srcChainId]; - if (!srcChain) throw new Error(`Source chain ${srcChainId} not found`); - - const viemChain = makeViemChain(srcChain.chain_id, srcChain.name, srcChain.rpc); - - const walletClient = createWalletClient({ - account: user, - chain: viemChain, - transport: http(srcChain.rpc), - }); - const publicClient = createPublicClient({ - chain: viemChain, - transport: http(srcChain.rpc), - }); - - // Step 1: Approve token spending - // For Permit2: approve the Permit2 contract; for direct: approve the escrow - const token = srcChain.tokens[asset]; - const payload = quote.order.payload; - const isPermit2Approval = payload.primaryType?.includes('Permit'); - const spender = isPermit2Approval - ? payload.domain?.verifyingContract // Permit2 contract - : srcChain.contracts?.input_settler_escrow; - if (token && spender) { - const approveHash = await walletClient.writeContract({ - address: token.address, - abi: parseAbi(['function approve(address, uint256) returns (bool)']), - functionName: 'approve', - args: [spender, 100000000n], // 100 USDC allowance - }); - await publicClient.waitForTransactionReceipt({ hash: approveHash }); - } - - // Step 2: Sign EIP-712 typed data with viem - // (payload already extracted above for approval target) - - // Remove EIP712Domain from types (viem adds it automatically) - const types = { ...payload.types }; - delete types.EIP712Domain; - - // Coerce domain.chainId to number — the aggregator returns it as a string - // which causes viem to produce a different EIP-712 hash - const domain = { ...payload.domain }; - if (typeof domain.chainId === 'string') { - domain.chainId = Number(domain.chainId); - } - - const rawSignature = await user.signTypedData({ - domain, - types, - primaryType: payload.primaryType, - message: payload.message, - }); - - // Prepend signature type byte: 0x00=Permit2, 0x01=EIP-3009, 0xff=self - const isPermit2 = payload.primaryType?.includes('Permit'); - const prefix = isPermit2 ? '0x00' : '0x01'; - const signature = prefix + rawSignature.slice(2); - - // Step 4: Submit signed order to aggregator - const response = await fetch(`${AGGREGATOR_URL}/api/v1/orders`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ quoteResponse: quote, signature }), - }); - - const data = await response.json(); - if (!response.ok) throw new Error(data.message || data.error || JSON.stringify(data)); - res.json(data); - } catch (err) { - res.status(500).json({ error: err.message }); - } +// Order: deprecated server-side signing — use /api/order/submit with wallet signature +app.post('/api/order', (_req, res) => { + res.status(400).json({ error: 'Server-side signing removed. Connect a wallet and use the client-side signing flow.' }); }); // Order submit: forward a pre-signed order (for MetaMask / client-side signing flow) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed0d884..1a66fcc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useAppKit, AppKitNetworkButton } from '@reown/appkit/react' import { useAccount, useDisconnect, useWriteContract, useSignTypedData, useSwitchChain } from 'wagmi' import { parseAbi } from 'viem' import { api, Config, AllBalances, Quote, OrderStatus } from './api' +import { Docs } from './Docs' // ── Utilities ───────────────────────────────────────────────────────────────── @@ -35,6 +36,46 @@ function normalizeStatus(status: unknown): string { // ── Primitives ──────────────────────────────────────────────────────────────── +function fallbackCopy(text: string) { + const el = document.createElement('textarea') + el.value = text + el.style.position = 'fixed' + el.style.opacity = '0' + document.body.appendChild(el) + el.select() + document.execCommand('copy') + document.body.removeChild(el) +} + +function CopyableAddress({ address, className }: { address: string; className?: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1500) } + try { + if (navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(address).then(done).catch(() => { fallbackCopy(address); done() }) + } else { + fallbackCopy(address); done() + } + } catch { fallbackCopy(address); done() } + } + return ( + + {truncAddr(address)} + + + {address} + + + ) +} + function Spinner({ size = 16 }: { size?: number }) { return ( @@ -49,6 +90,7 @@ const CHAIN_GRADIENTS: Record = { anvil2: 'from-cyan-400 to-blue-500', sepolia: 'from-amber-400 to-orange-500', arbitrum: 'from-sky-400 to-blue-500', + eden: 'from-purple-500 to-violet-700', } function ChainBadge({ name }: { name: string }) { @@ -90,15 +132,10 @@ export default function App() { const [slowLoading, setSlowLoading] = useState(false) const [slowMsg, setSlowMsg] = useState('') + const [timedOut, setTimedOut] = useState(false) - const [faucetLoading, setFaucetLoading] = useState(null) - const [faucetMsg, setFaucetMsg] = useState('') - const [rebalanceLoading, setRebalanceLoading] = useState(null) - const [rebalanceMsg, setRebalanceMsg] = useState('') - const [rebalanceToken, setRebalanceToken] = useState('USDC') - const [rebalanceFrom, setRebalanceFrom] = useState('anvil1') - const [rebalanceTo, setRebalanceTo] = useState('anvil2') const [rightTab, setRightTab] = useState<'balances' | 'tools'>('balances') + const [showDocs, setShowDocs] = useState(false) const pollRef = useRef>() @@ -132,9 +169,9 @@ export default function App() { }, []) const loadBalances = useCallback(async () => { + if (!isConnected || !connectedAddress) { setBalances(null); return } try { - const addr = isConnected && connectedAddress ? connectedAddress : undefined - setBalances(await api.balances(addr)) + setBalances(await api.balances(connectedAddress)) } catch {} }, [isConnected, connectedAddress]) @@ -155,96 +192,96 @@ export default function App() { const handleGetQuote = async () => { if (!fromChain || !toChain || !amount) return + if (!isConnected || !connectedAddress) { setError('Connect a wallet first.'); setStep('error'); return } setStep('quoting'); setError(''); setQuote(null); setOrderId(''); setOrderStatus(null) try { const fromId = config!.chains[fromChain].chainId const toId = config!.chains[toChain].chainId const raw = Math.round(parseFloat(amount) * 10 ** selectedTokenDecimals).toString() - const resp = await api.quote(fromId, toId, raw, asset, - isConnected && connectedAddress ? connectedAddress : undefined) - if (!resp.quotes?.length) throw new Error('No quotes returned. Is the solver running?') + let resp: any + try { + resp = await api.quote(fromId, toId, raw, asset, connectedAddress) + } catch (fetchErr: any) { + const msg: string = fetchErr?.message ?? '' + // Only treat as network error if msg hasn't already been categorized by server.js + const alreadyCategorized = msg.startsWith('SOLVER_REJECTED:') || msg.startsWith('SOLVER_OFFLINE:') + const isNetworkErr = !alreadyCategorized && (fetchErr?.name === 'TypeError' || msg.toLowerCase().includes('econnrefused') || msg.toLowerCase().includes('fetch failed')) + throw new Error(isNetworkErr + ? 'SOLVER_OFFLINE: Cannot reach the aggregator. Make sure the solver and aggregator are running (make solver && make aggregator).' + : fetchErr.message) + } + if (!resp.quotes?.length) { + const meta = (resp as any).metadata + const detail: string = (resp as any).error || (resp as any).message || '' + const allFailed = meta && meta.solvers_queried > 0 && meta.solvers_responded_success === 0 + throw new Error(allFailed + ? `SOLVER_REJECTED: ${detail || 'No solver could fill this transfer.'}` + : 'SOLVER_OFFLINE: No quotes returned. Is the solver running? (make solver)') + } setQuote(resp.quotes[0]); setStep('quoted') } catch (err: any) { setError(err.message); setStep('error') } } const handleAcceptQuote = async () => { if (!quote) return + if (!isConnected || !connectedAddress) { setError('Connect a wallet first.'); setStep('error'); return } setStep('signing'); setError('') try { const fromId = config!.chains[fromChain].chainId - if (isConnected && connectedAddress) { - const chainInfo = config!.chains[fromChain] - const token = chainInfo.tokens[asset] - await switchChainAsync({ chainId: fromId }) - const payload = quote.order.payload as any - const isPermit2 = payload.primaryType?.includes('Permit') - const spender = isPermit2 - ? (payload.domain?.verifyingContract as `0x${string}`) - : (chainInfo.contracts?.input_settler_escrow as `0x${string}`) - if (token && spender) { - await writeContractAsync({ - address: token.address as `0x${string}`, - abi: parseAbi(['function approve(address, uint256) returns (bool)']), - functionName: 'approve', args: [spender, 100000000n], chainId: fromId, - }) - } - const types = { ...payload.types } - delete types.EIP712Domain - const domain = { ...payload.domain } - if (typeof domain.chainId === 'string') domain.chainId = Number(domain.chainId) - const rawSig = await signTypedDataAsync({ domain, types, primaryType: payload.primaryType, message: payload.message }) - const sig = (isPermit2 ? '0x00' : '0x01') + rawSig.slice(2) - const resp = await api.submitSignedOrder(quote, sig) - setOrderId(resp.orderId); setStep('polling'); startPolling(resp.orderId) - } else { - const resp = await api.submitOrder(quote, config!.chains[fromChain].chainId, asset) - setOrderId(resp.orderId); setStep('polling'); startPolling(resp.orderId) + const chainInfo = config!.chains[fromChain] + const token = chainInfo.tokens[asset] + await switchChainAsync({ chainId: fromId }) + const payload = quote.order.payload as any + const isPermit2 = payload.primaryType?.includes('Permit') + const spender = isPermit2 + ? (payload.domain?.verifyingContract as `0x${string}`) + : (chainInfo.contracts?.input_settler_escrow as `0x${string}`) + if (token && spender) { + await writeContractAsync({ + address: token.address as `0x${string}`, + abi: parseAbi(['function approve(address, uint256) returns (bool)']), + functionName: 'approve', args: [spender, BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')], chainId: fromId, + }) } + const types = { ...payload.types } + delete types.EIP712Domain + const domain = { ...payload.domain } + if (typeof domain.chainId === 'string') domain.chainId = Number(domain.chainId) + const rawSig = await signTypedDataAsync({ domain, types, primaryType: payload.primaryType, message: payload.message }) + const sig = (isPermit2 ? '0x00' : '0x01') + rawSig.slice(2) + const resp = await api.submitSignedOrder(quote, sig) + setOrderId(resp.orderId); setStep('polling'); startPolling(resp.orderId) } catch (err: any) { setError(err.message); setStep('error') } } const startPolling = (id: string) => { if (pollRef.current) clearInterval(pollRef.current) + const deadline = Date.now() + 120_000 pollRef.current = setInterval(async () => { + const expired = Date.now() > deadline + if (expired) { + setTimedOut(true) + clearInterval(pollRef.current) + setStep('done') + return + } try { const s = await api.orderStatus(id) setOrderStatus(s) + loadBalances() const n = normalizeStatus(s.status) - if (n === 'finalized' || n === 'failed') { - clearInterval(pollRef.current); setStep('done'); loadBalances() - } + const isDone = n === 'finalized' || n === 'executed' || n === 'settling' || n === 'settled' || n === 'failed' || n === 'refunded' + if (isDone) { clearInterval(pollRef.current); setStep('done') } } catch {} - }, 3000) + }, 1000) } useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) - // ── Faucet ──────────────────────────────────────────────────────────────── - - const handleFaucet = async (chainName: string, type: 'gas' | 'token', symbol?: string) => { - const key = `${chainName}-${type}${symbol ? `-${symbol}` : ''}` - setFaucetLoading(key); setFaucetMsg('') - try { - const r = await api.faucet(chainName, type, - isConnected && connectedAddress ? connectedAddress : undefined, symbol) - setFaucetMsg(`Sent ${r.amount} on ${chainName}`); loadBalances() - } catch (err: any) { setFaucetMsg(`Error: ${err.message}`) } - finally { setFaucetLoading(null) } - } - // ── Rebalance ───────────────────────────────────────────────────────────── - - const handleRebalance = async () => { - setRebalanceLoading('bridge'); setRebalanceMsg('') - try { - const r = await api.rebalance(rebalanceFrom, rebalanceTo, undefined, rebalanceToken) - setRebalanceMsg(r.message); loadBalances() - } catch (err: any) { setRebalanceMsg(`Error: ${err.message}`) } - finally { setRebalanceLoading(null) } - } const resetFlowState = () => { - setStep('idle'); setQuote(null); setOrderId(''); setOrderStatus(null); setError(''); setSlowMsg('') + setStep('idle'); setQuote(null); setOrderId(''); setOrderStatus(null); setError(''); setSlowMsg(''); setTimedOut(false) } // ── Slow route (Celestia bridge, user → user) ───────────────────────────── @@ -258,42 +295,40 @@ export default function App() { const fromId = config!.chains[fromChain].chainId const rawAmount = Math.round(parseFloat(amount) * 10 ** selectedTokenDecimals).toString() - if (isConnected && connectedAddress) { - // Wallet-connected: server prepares the forwarding, wallet executes the txs - setSlowMsg('Preparing Celestia route…') - const prep = await api.bridgePrepare(fromName, toName, asset, connectedAddress, rawAmount) - - await switchChainAsync({ chainId: fromId }) - - if (prep.needsApproval && prep.underlyingToken) { - setSlowMsg('Approving token…') - await writeContractAsync({ - address: prep.underlyingToken as `0x${string}`, - abi: parseAbi(['function approve(address, uint256) returns (bool)']), - functionName: 'approve', - args: [prep.warpToken as `0x${string}`, BigInt(rawAmount)], - chainId: fromId, - }) - } + if (!isConnected || !connectedAddress) { + throw new Error('Connect a wallet to use the bridge.') + } - setSlowMsg('Submitting bridge transaction…') - const txHash = await writeContractAsync({ - address: prep.warpToken as `0x${string}`, - abi: parseAbi(['function transferRemote(uint32, bytes32, uint256) payable returns (bytes32)']), - functionName: 'transferRemote', - args: [prep.celestiaDomainId, prep.forwardingAddressBytes32 as `0x${string}`, BigInt(rawAmount)], + // Wallet-connected: server prepares the forwarding, wallet executes the txs + setSlowMsg('Preparing Celestia route…') + const prep = await api.bridgePrepare(fromName, toName, asset, connectedAddress, rawAmount) + + await switchChainAsync({ chainId: fromId }) + + if (prep.needsApproval && prep.underlyingToken) { + setSlowMsg('Approving token…') + await writeContractAsync({ + address: prep.underlyingToken as `0x${string}`, + abi: parseAbi(['function approve(address, uint256) returns (bool)']), + functionName: 'approve', + args: [prep.warpToken as `0x${string}`, BigInt(rawAmount)], chainId: fromId, - value: 0n, }) - - setSlowMsg(`Submitted (${txHash.slice(0, 10)}…). Tokens arrive in ~2 min via Celestia.`) - loadBalances() - } else { - // No wallet: server executes using USER_PK - const resp = await api.bridge(fromName, toName, rawAmount, asset) - setSlowMsg(resp.message) - loadBalances() } + + setSlowMsg('Submitting bridge transaction…') + const txHash = await writeContractAsync({ + address: prep.warpToken as `0x${string}`, + abi: parseAbi(['function transferRemote(uint32, bytes32, uint256) payable returns (bytes32)']), + functionName: 'transferRemote', + args: [prep.celestiaDomainId, prep.forwardingAddressBytes32 as `0x${string}`, BigInt(rawAmount)], + chainId: fromId, + value: 0n, + gas: 3_000_000n, + }) + + setSlowMsg(`Submitted (${txHash.slice(0, 10)}…). Tokens arrive in ~2 min via Celestia.`) + loadBalances() } catch (err: any) { setSlowMsg(`Error: ${err.message}`) } finally { @@ -311,11 +346,7 @@ export default function App() { return (
-
- - - -
+ Celestia
Connecting to solver…
@@ -345,47 +376,89 @@ export default function App() {
{/* ── Header ─────────────────────────────────────────────────────────── */} -
-
- +
+ + {/* ── Centered pill navbar ── */} + + + {/* ── Wallet — pinned right ── */} +
{isConnected ? ( -
+ <> -
+
- {truncAddr(connectedAddress!)} +
-
+ ) : ( @@ -463,14 +536,14 @@ export default function App() { {/* You Send */}
- You send + You send {balances && fromChain && balances[fromChain] && asset && ( ) : step === 'done' ? ( - + <> + {timedOut && ( +
+ + + + +

+ Settlement is taking longer than expected — check your balances to see if the transfer completed. +

+
+ )} + + ) : null ) : ( // ── Slow route: direct Celestia bridge ───────────────────── <> - {slowMsg && ( + {/* Step progress (while loading) */} + {slowLoading && (() => { + const steps = [ + { label: 'Preparing route', match: 'Preparing' }, + { label: 'Approving token', match: 'Approving' }, + { label: 'Submitting transaction', match: 'Submitting' }, + ] + const activeIdx = steps.findIndex(s => slowMsg.includes(s.match)) + return ( +
+ {steps.map((s, i) => { + const isDone = activeIdx > i + const isCurrent = activeIdx === i + return ( +
+ {isCurrent + ? + : isDone + ? + : + } + {s.label} +
+ ) + })} +
+ ) + })()} + + {/* Result message (after loading) */} + {slowMsg && !slowLoading && (
{slowMsg}
)} - {slowMsg && !slowMsg.startsWith('Error') ? ( + + {slowMsg && !slowLoading && !slowMsg.startsWith('Error') ? ( )} @@ -762,33 +891,39 @@ export default function App() {
- {config && #{config.chains[chainId]?.chainId}} + {config && #{config.chains[chainId]?.chainId}}
-
+
- You +
+ You + {connectedAddress && } +
{asset && cb.balances.user[asset] && ( {formatToken(cb.balances.user[asset]?.formatted ?? '0')} - {asset} + {asset} )} {cb.balances.user['ETH'] && ( - + {formatETH(cb.balances.user['ETH']?.formatted ?? '0')} - ETH + ETH )}
- Solver +
+ Solver + {config?.solverAddress && } +
{asset && cb.balances.solver[asset] && ( {formatToken(cb.balances.solver[asset]?.formatted ?? '0')} - {asset} + {asset} )} {cb.balances.solver['ETH'] && ( @@ -804,151 +939,66 @@ export default function App() { ))}
) : ( -
+
Loading…
)} + + {/* Faucet links */} +
+ {[ + { label: 'Eden USDC', sub: 'Testnet USDC faucet', href: 'http://51.159.182.223:8080/' }, + { label: 'Eden Gas', sub: 'Testnet ETH faucet', href: 'https://faucet-eden-testnet.binarybuilders.services/' }, + ].map(({ label, sub, href }) => ( + +
+
{label}
+
{sub}
+
+ + + +
+ ))} +
)} - {/* ── Tools tab (Faucet + Bridge + System) ────────────────────── */} + {/* ── Tools tab (System) ─────────────────────────────────────── */} {rightTab === 'tools' && (
- {/* Faucet */} -
-
- Faucet - - {isConnected - ? {truncAddr(connectedAddress!)} - : config ? {truncAddr(config.userAddress)} : '—' - } - -
-

Claim testnet gas and tokens.

- {config && chainEntries.map(([chainId, chain]) => { - if (!config.faucetChains?.includes(chain.name)) return null - return ( -
-
-
- - {Object.keys(chain.tokens).map(sym => ( - - ))} -
-
- ) - })} - {faucetMsg && ( -
{faucetMsg}
- )} -
- - {/* Bridge */} -
- Bridge -

Move solver tokens via Hyperlane.

-
- -
- - - - - -
-
- - {rebalanceMsg && ( -
{rebalanceMsg}
- )} -
- {/* System */}
System
+ {connectedAddress && ( +
+ Wallet + +
+ )} + {config?.solverAddress && ( +
+ Solver + +
+ )} {[ - { label: 'User', value: config ? truncAddr(config.userAddress) : '—', mono: true }, - { label: 'Solver', value: config?.solverAddress ? truncAddr(config.solverAddress) : '—', mono: true }, - { label: 'Chains', value: String(chainEntries.length), mono: false }, + { label: 'Chains', value: String(chainEntries.length), status: undefined }, { label: 'Backend', value: health.backend, status: health.backend }, { label: 'Aggregator', value: health.aggregator, status: health.aggregator }, - ].map(({ label, value, mono, status }) => ( + ].map(({ label, value, status }) => (
- {label} - {label} + {value} @@ -967,10 +1017,12 @@ export default function App() { {/* ── Footer ─────────────────────────────────────────────────────────── */}
- OIF Solver + Celestia Bridge Open Intents Framework
+ + {showDocs && setShowDocs(false)} />}
) } diff --git a/frontend/src/Docs.tsx b/frontend/src/Docs.tsx new file mode 100644 index 0000000..123b2cf --- /dev/null +++ b/frontend/src/Docs.tsx @@ -0,0 +1,495 @@ +import { useEffect, useRef, useState } from 'react' + +// Icons + + +function IconLightning() { + return ( + + + + ) +} + +function IconWave() { + return ( + + + + + ) +} + +function IconBook() { + return ( + + + + + ) +} + +function IconScales() { + return ( + + + + + + + + ) +} + +// Sub-components + +interface FlowStepProps { + number: number + title: string + description: string + tag?: string + last?: boolean +} + +function FlowStep({ number, title, description, tag, last = false }: FlowStepProps) { + return ( +
+
+
+ {number} +
+ {!last &&
} +
+
+
+ {title} + {tag && ( + + {tag} + + )} +
+

{description}

+
+
+ ) +} + +interface CalloutProps { + type: 'info' | 'note' | 'tip' + title: string + children: React.ReactNode +} + +function Callout({ type, title, children }: CalloutProps) { + const s = { + info: { border: 'border-brand/40', bg: 'bg-brand/5', label: 'text-brand-light' }, + note: { border: 'border-amber-500/40', bg: 'bg-amber-500/5', label: 'text-amber-400' }, + tip: { border: 'border-emerald-500/40', bg: 'bg-emerald-500/5', label: 'text-emerald-400' }, + }[type] + return ( +
+
{title}
+
{children}
+
+ ) +} + +function SectionDivider() { + return
+} + +// Nav sections + +const NAV = [ + { id: 'overview', label: 'Overview', icon: }, + { id: 'fast-route', label: 'Fast Route', icon: }, + { id: 'slow-route', label: 'Slow Route', icon: }, + { id: 'comparison', label: 'Comparison', icon: }, +] + +// Main component + +export function Docs({ onClose }: { onClose: () => void }) { + const [active, setActive] = useState('overview') + const contentRef = useRef(null) + + // Escape closes + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', h) + return () => window.removeEventListener('keydown', h) + }, [onClose]) + + // Scrollspy + useEffect(() => { + const root = contentRef.current + if (!root) return + const update = () => { + const trigger = root.scrollTop + root.clientHeight * 0.2 + let current = NAV[0].id + for (const { id } of NAV) { + const el = root.querySelector(`#${id}`) as HTMLElement | null + if (el && el.offsetTop <= trigger) current = id + } + setActive(current) + } + root.addEventListener('scroll', update, { passive: true }) + update() + return () => root.removeEventListener('scroll', update) + }, []) + + const scrollTo = (id: string) => { + const el = contentRef.current?.querySelector(`#${id}`) + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + return ( +
+ + {/* Body */} +
+ + {/* Sidebar */} + + + {/* Mobile tabs */} +
+ {NAV.map(s => ( + + ))} +
+ + {/* Scrollable content */} +
+
+ +
+
Introduction
+

What is Celestia Bridge?

+

+ Celestia Bridge is a cross-chain token transfer interface built on the{' '} + Open Intents Framework, an open protocol for + expressing and fulfilling cross-chain user intents across EVM networks. Move tokens between chains + without managing bridges, liquidity pools, or multi-step transaction sequences yourself. +

+

+ Two routing mechanisms let you choose between speed and trustlessness depending on your needs. +

+ +
+ {[ + { + icon: ( + + + + ), + title: 'Intent-based', + desc: 'Declare what you want. Solvers compete to fill your order at the best available rate.', + }, + { + icon: ( + + + + + + ), + title: 'DA-secured', + desc: 'Backed by Celestia data availability for verifiable, trust-minimized cross-chain messaging.', + }, + { + icon: ( + + + + ), + title: 'Multi-chain', + desc: 'Any EVM chain to any EVM chain. Routes are configured, not hardcoded to specific pairs.', + }, + ].map(f => ( +
+
{f.icon}
+
{f.title}
+
{f.desc}
+
+ ))} +
+ + {/* Architecture overview */} +
+
System overview
+
+ {[ + { label: 'You', sub: 'sign intent', color: 'bg-surface-3 border-border text-gray-300' }, + null, + { label: 'Aggregator', sub: 'routes & quotes', color: 'bg-brand/10 border-brand/25 text-brand-light' }, + null, + { label: 'Solver', sub: 'fills on dest', color: 'bg-surface-3 border-border text-gray-300' }, + null, + { label: 'Oracle', sub: 'attests fill', color: 'bg-surface-3 border-border text-gray-300' }, + ].map((item, i) => + item === null ? ( +
+ + + +
+ ) : ( +
+
{item.label}
+
{item.sub}
+
+ ) + )} +
+
+
+ + + +
+
+ + + Fast Route + + ~15–30 seconds +
+

Intent Protocol

+

+ The fast route uses an intent-based protocol where + specialized solvers compete to fill your transfer. Instead of executing a bridge transaction yourself, + you sign a declaration of what you want; a solver fronts the capital to make it happen + immediately, then settles with the escrow contract after an oracle verifies the fill. +

+

+ You never pay gas. The solver earns a small spread between the input and output amounts as compensation + for the capital risk they take. +

+ +
Step by step
+ + + + + + + + EIP-712 signing separates authorization from execution. You declare intent; solvers execute. + This lets solvers batch, optimize, and compete without requiring you to manage gas or bridge contracts. + Your signature is only valid for the exact parameters you agreed to: amount, destination, expiry. + + + + The oracle operator and solver use different keys and are designed to be separate entities. + A solver attesting its own fills would be "trust me, I did the work" with no independent verification. + The oracle independently confirms fills happened on-chain before any settlement is possible. + +
+ + + +
+
+ + + Slow Route + + ~2 minutes +
+

Celestia Bridge

+

+ The slow route bridges tokens through{' '} + Hyperlane warp routes with cross-chain + messages secured by{' '} + Celestia data availability. + No solvers are involved. The transfer is fully permissionless: no third party can + decline or censor it. +

+

+ The "slow" is Celestia's data availability finality time, not any manual step you have to take. + You send one (or two) transactions and wait ~2 minutes. +

+ +
Step by step
+ + + + + + + + Whether you see an Approve step depends on the{' '} + warpType{' '} + of the source chain. A collateral chain (like Sepolia) + wraps a real ERC-20 and needs allowance. A synthetic chain + (like Eden) owns its token supply and burns from your balance directly, no approval needed. + + + + Celestia provides data availability as a separate layer from execution. By posting cross-chain + messages to Celestia, Hyperlane warp routes inherit Celestia's security for message verification, + independent of Ethereum's or any destination chain's own consensus. + +
+ + + +
+
Routes
+

Choosing the Right Route

+

+ Both routes move the same tokens to the same destination. The difference is the trust model, + speed, and who executes the transfer on your behalf. +

+ + {/* Comparison table */} +
+
+
Feature
+
⚡ Fast
+
〜 Slow
+
+ {[ + ['Speed', '~15–30 seconds', '~2 minutes'], + ['Gas you pay', 'None', 'Source chain tx fee'], + ['Steps', 'Sign once', 'Approve + send (or just send)'], + ['Requires solver', 'Yes', 'No'], + ['Trust model', 'Oracle + solver network', 'Celestia DA + Hyperlane'], + ['Censorship risk', 'Solver can decline', 'Fully permissionless'], + ['Best for', 'Speed, best rate', 'Trustlessness, sovereignty'], + ].map(([feat, fast, slow], i) => ( +
+
{feat}
+
{fast}
+
{slow}
+
+ ))} +
+ + {/* When to use each */} +
+
+
+ + Use Fast when… +
+
    + {[ + 'You want tokens to arrive in seconds', + 'You prefer not to pay gas yourself', + 'Solvers are online and competing', + 'Rate optimization matters', + ].map(item => ( +
  • + + {item} +
  • + ))} +
+
+
+
+ + Use Slow when… +
+
    + {[ + 'You want no solver dependency', + 'Solvers are offline or at capacity', + 'Censorship resistance matters', + 'You prefer Celestia-backed security', + ].map(item => ( +
  • + + {item} +
  • + ))} +
+
+
+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5ee1dee..02cdbcf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -16,9 +16,7 @@ export interface ChainInfo { export interface Config { chains: Record - userAddress: string solverAddress: string - faucetChains?: string[] } export interface BalanceEntry { @@ -71,14 +69,16 @@ export interface OrderResponse { } export interface OrderStatus { - id: string - status: 'pending' | 'accepted' | 'finalized' | 'failed' - createdAt: number - updatedAt: number + orderId: string + status: 'created' | 'pending' | 'executing' | 'executed' | 'settling' | 'settled' | 'finalized' | 'failed' | 'refunded' | string + createdAt: string + updatedAt: string + fillTransaction?: { hash: string; chainId: number } | Record settlement?: { - status: string - fillTransaction?: { hash: string; chainId: number } - claimTransaction?: { hash: string; chainId: number } + settlementType?: string + sourceChainId?: string + destinationChainId?: string + recipient?: string } } @@ -113,13 +113,6 @@ export const api = { body: JSON.stringify({ fromChainId, toChainId, amount, asset, ...(address ? { address } : {}) }), }), - submitOrder: (quote: Quote, fromChainId: number, asset: string, address?: string) => - json(`${BASE}/order`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ quote, fromChainId, asset, ...(address ? { address } : {}) }), - }), - submitSignedOrder: (quote: Quote, signature: string) => json(`${BASE}/order/submit`, { method: 'POST', @@ -129,20 +122,6 @@ export const api = { orderStatus: (id: string) => json(`${BASE}/order/${id}`), - faucet: (chainName: string, type: 'gas' | 'token', address?: string, symbol?: string) => - json<{ success: boolean; hash: string; amount: string }>(`${BASE}/faucet`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chainName, type, ...(address ? { address } : {}), ...(symbol ? { symbol } : {}) }), - }), - - rebalance: (from: string, to: string, amount?: string, token?: string) => - json<{ success: boolean; message: string; txHash?: string }>(`${BASE}/rebalance`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from, to, amount, ...(token ? { token } : {}) }), - }), - bridgePrepare: (from: string, to: string, token: string, address: string, amount: string) => json<{ warpToken: string @@ -156,10 +135,4 @@ export const api = { body: JSON.stringify({ from, to, token, address, amount }), }), - bridge: (from: string, to: string, amount: string, token?: string) => - json<{ success: boolean; message: string; txHash?: string }>(`${BASE}/bridge`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from, to, amount, ...(token ? { token } : {}) }), - }), } diff --git a/frontend/src/wallet.ts b/frontend/src/wallet.ts index 106fe44..9fc0376 100644 --- a/frontend/src/wallet.ts +++ b/frontend/src/wallet.ts @@ -61,7 +61,7 @@ export async function initWallet() { networks, projectId, metadata: { - name: 'OIF Solver', + name: 'Celestia Bridge', description: 'Cross-chain solver UI', url: 'http://localhost:5173', icons: [], diff --git a/hyperlane/configs/warp-config-sepolia.yaml b/hyperlane/configs/warp-config-sepolia.yaml new file mode 100644 index 0000000..dcfee62 --- /dev/null +++ b/hyperlane/configs/warp-config-sepolia.yaml @@ -0,0 +1,6 @@ +sepolia: + type: synthetic + owner: "0x9f2CD91d150236BA9796124F3Dcda305C3a2086C" + name: "USDC" + symbol: "USDC" + decimals: 6 diff --git a/hyperlane/registry/chains/sepolia/addresses.yaml b/hyperlane/registry/chains/sepolia/addresses.yaml index 9bca1de..3ee8f78 100644 --- a/hyperlane/registry/chains/sepolia/addresses.yaml +++ b/hyperlane/registry/chains/sepolia/addresses.yaml @@ -1,13 +1,14 @@ -domainRoutingIsmFactory: "0x8eBee8e6596Ae0Bd8A301D2a4163B1387Fc2E5F4" -interchainAccountRouter: "0x168fda89d2059cC33a822C73b2e7386356209C21" -mailbox: "0xF12598dd5A9aec25AA9355382eC884117f3093Cb" -merkleTreeHook: "0x8D8dC02315B6fDe3A81F0D333d673D4E8383eaC9" -proxyAdmin: "0x3F037d70f5a4e5e3E1e88fB6354e50a5eA158a92" -staticAggregationHookFactory: "0x3710181b357350725a5208a5358d0c835600fd2b" -staticAggregationIsmFactory: "0xe1f66ee601DE5e39220744aA3Bda9c0343D29B1A" -staticMerkleRootMultisigIsmFactory: "0x1048693487Ea12Bd969FBFDC53416aFA57ba9C49" -staticMerkleRootWeightedMultisigIsmFactory: "0x76E04CFaBC773512c72Ef056d36eBB1971A6028B" -staticMessageIdMultisigIsmFactory: "0xA25C642828F051F53fb4d1E52d3E6cc563a1a6F7" -staticMessageIdWeightedMultisigIsmFactory: "0x781e51D1Aa29429d981840357faB1f9Df8Db6cBb" -testRecipient: "0x78daf39c666a0b9F006DaE0D3724371B59E8Cc88" -validatorAnnounce: "0x575CE87d8cb90CCEf0172bB6Ec644D4567247312" +domainRoutingIsmFactory: "0x6556Aae6eb48C0DDCf6537490eD693EA43F42ef0" +incrementalDomainRoutingIsmFactory: "0x934D19F13ec8D025060Dc6017f6A3CF74c05E8D9" +interchainAccountRouter: "0x3f12B3977A0000cE60EC5BfEEF47379Df47a4627" +mailbox: "0x409c1D7f3bDC4e30E4502DeD20D8Ad561D60606c" +merkleTreeHook: "0xDfeD4337E52346a1e7722610648A6eB54cAB86ca" +proxyAdmin: "0xEDADdd77530E5D26FEd82082967E7011fC304456" +staticAggregationHookFactory: "0xe95d66048B614709CCD4c4A36c9855c156ECa1Bf" +staticAggregationIsmFactory: "0x6b7f78bD7895d1773F8c34489821f5fd04d32B21" +staticMerkleRootMultisigIsmFactory: "0xb87DF876d2E2C2B1CE38Ca9e7f1854f370d8d7D3" +staticMerkleRootWeightedMultisigIsmFactory: "0x3Bc8F11585D5E265576fE7e9269E17C8CA765a0B" +staticMessageIdMultisigIsmFactory: "0x86417252951a2E8e200f036C82EA79719a08d85e" +staticMessageIdWeightedMultisigIsmFactory: "0x97ab3Bd14dD519f3EAB52BEE11fb0A20A3939699" +testRecipient: "0xC250C9F3C03c20C72264631D4437ba14Edd7d3F7" +validatorAnnounce: "0x7286d8dA68Cdbd7c0fFC3E4E4AAC16b904eB6c45" diff --git a/hyperlane/registry/chains/sepolia/metadata.yaml b/hyperlane/registry/chains/sepolia/metadata.yaml index ffdeeb0..adb4a5c 100644 --- a/hyperlane/registry/chains/sepolia/metadata.yaml +++ b/hyperlane/registry/chains/sepolia/metadata.yaml @@ -9,5 +9,5 @@ nativeToken: symbol: ETH protocol: ethereum rpcUrls: - - http: https://rpc.ankr.com/eth_sepolia/3afc3fea39333420bb3a7b29067d4291b79d761cd18faafec716442cd0be0be2 + - http: https://ethereum-sepolia-rpc.publicnode.com technicalStack: other diff --git a/hyperlane/relayer-config.json b/hyperlane/relayer-config.json index 372abbf..661ef1c 100644 --- a/hyperlane/relayer-config.json +++ b/hyperlane/relayer-config.json @@ -141,14 +141,14 @@ "protocol": "ethereum", "rpcUrls": [ { - "http": "https://rpc.ankr.com/eth_sepolia/3afc3fea39333420bb3a7b29067d4291b79d761cd18faafec716442cd0be0be2" + "http": "https://ethereum-sepolia-rpc.publicnode.com" } ], "signer": { "type": "hexKey", "key": "0x52d441beb407f47811a09ed9d330320b2d336482512f26e9a5c5d3dacddc7b1e" }, - "mailbox": "0xF12598dd5A9aec25AA9355382eC884117f3093Cb" + "mailbox": "0x409c1D7f3bDC4e30E4502DeD20D8Ad561D60606c" } }, "defaultRpcConsensusType": "fallback", diff --git a/hyperlane/scripts/docker-entrypoint.sh b/hyperlane/scripts/docker-entrypoint.sh index 1ecdab7..c36d363 100755 --- a/hyperlane/scripts/docker-entrypoint.sh +++ b/hyperlane/scripts/docker-entrypoint.sh @@ -41,15 +41,15 @@ echo "$MOCK_USDC_OUTPUT" MOCK_USDC_ADDR=$(echo "$MOCK_USDC_OUTPUT" | grep "Deployed to:" | awk '{print $3}') echo "MockERC20 USDC deployed on anvil1: $MOCK_USDC_ADDR" -# Mint initial supply to deployer (100M USDC = 100000000 * 10^6) +# Mint initial supply to deployer (100 USDC = 100 * 10^6) cast send $MOCK_USDC_ADDR \ "mint(address,uint256)" \ 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \ - 100000000000000 \ + 100000000 \ --rpc-url $ANVIL1_RPC_URL \ --private-key $HYP_KEY -echo "Minted 100M USDC to deployer on anvil1" +echo "Minted 100 USDC to deployer on anvil1" # ============================================================================ # Step 2: Deploy Hyperlane core contracts to both EVM chains diff --git a/mvp.sh b/mvp.sh index 9c645b3..c832528 100755 --- a/mvp.sh +++ b/mvp.sh @@ -93,6 +93,7 @@ echo " │ Frontend: http://localhost:5173 │" echo " │ Backend API: http://localhost:3001/api │" echo " │ Aggregator: http://localhost:4000 │" echo " │ Solver: http://localhost:3000 │" +echo " │ Rebalancer: running (see logs/rebalancer.log) │" echo " │ │" echo " │ Docker Stack: │" echo " │ Anvil1: http://localhost:8545 │" diff --git a/oracle-operator/Cargo.toml b/oracle-operator/Cargo.toml index e87c30c..f1b19b3 100644 --- a/oracle-operator/Cargo.toml +++ b/oracle-operator/Cargo.toml @@ -21,6 +21,10 @@ alloy-sol-types = "1.0" # Error handling anyhow = "1.0" +# AWS KMS signing +aws-config = "1" +aws-sdk-kms = "1" + # Utils hex = "0.4" @@ -31,10 +35,6 @@ reqwest = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# AWS KMS signing -aws-config = "1" -aws-sdk-kms = "1" - # Crypto sha3 = "0.10" thiserror = "2.0" diff --git a/oracle-operator/src/operator.rs b/oracle-operator/src/operator.rs index 61cc390..b24759a 100644 --- a/oracle-operator/src/operator.rs +++ b/oracle-operator/src/operator.rs @@ -234,7 +234,7 @@ impl OracleOperator { // Periodically save state to disk poll_count += 1; - if poll_count % save_interval == 0 { + if poll_count.is_multiple_of(save_interval) { let mut state = self.state.lock().await; if let Err(e) = state.save_if_dirty() { error!("Failed to save state: {}", e); @@ -446,15 +446,14 @@ impl OracleOperator { })?, }; - let decoded = - IOutputSettlerSimple::OutputFilled::decode_log(&prim_log).map_err(|e| { - anyhow::anyhow!( - "Failed to decode OutputFilled for order {} on chain {}: {}", - hex::encode(order_id), - source_chain_id, - e - ) - })?; + let decoded = IOutputSettlerSimple::OutputFilled::decode_log(&prim_log).map_err(|e| { + anyhow::anyhow!( + "Failed to decode OutputFilled for order {} on chain {}: {}", + hex::encode(order_id), + source_chain_id, + e + ) + })?; let output = &decoded.output; let application_id = output.settler.0; diff --git a/rebalancer/src/client.rs b/rebalancer/src/client.rs index b371efe..bd58331 100644 --- a/rebalancer/src/client.rs +++ b/rebalancer/src/client.rs @@ -41,6 +41,8 @@ sol! { #[sol(rpc)] interface IERC20 { function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 amount) external returns (bool); } #[sol(rpc)] @@ -79,6 +81,7 @@ pub struct SubmittedTransfer { pub struct ChainClient { provider: DefaultProvider, + account: Address, } impl ChainClient { @@ -104,11 +107,12 @@ impl ChainClient { ); } + let account = signer.address; let provider = ProviderBuilder::new() .wallet(signer.wallet) .connect_http(rpc_url); - Ok(Self { provider }) + Ok(Self { provider, account }) } pub async fn token_balance(&self, token: Address, account: Address) -> Result { @@ -151,6 +155,81 @@ impl ChainClient { .context("Failed to query pending account nonce") } + async fn pending_nonce(&self) -> Result { + self.provider + .get_transaction_count(self.account) + .block_id(BlockId::Number(BlockNumberOrTag::Pending)) + .await + .context("Failed to fetch pending nonce") + } + + pub async fn approve_erc20( + &self, + token: Address, + spender: Address, + amount: U256, + ) -> Result { + let nonce = self.pending_nonce().await?; + let call = IERC20::approveCall { spender, amount }; + let call_data = Bytes::from(call.abi_encode()); + + let tx = TransactionRequest::default() + .to(token) + .input(call_data.into()) + .nonce(nonce); + + let pending = self.provider.send_transaction(tx).await.with_context(|| { + format!( + "Failed to send ERC20 approve tx: token={} spender={} amount={}", + token, spender, amount + ) + })?; + + Ok(*pending.tx_hash()) + } + + /// Approve spender for MaxUint256 only if current allowance is below `required`. + /// Returns Some(tx_hash) if an approval was submitted, None if already sufficient. + pub async fn ensure_allowance( + &self, + token: Address, + spender: Address, + required: U256, + ) -> Result> { + let call = IERC20::allowanceCall { + owner: self.account, + spender, + }; + let tx = TransactionRequest::default() + .to(token) + .input(Bytes::from(call.abi_encode()).into()); + let raw = self + .provider + .call(tx) + .await + .context("Failed to call ERC20 allowance")?; + let current = U256::from_be_slice(&raw); + + if current >= required { + return Ok(None); + } + + let max = U256::MAX; + let tx_hash = self.approve_erc20(token, spender, max).await?; + Ok(Some(tx_hash)) + } + + pub async fn wait_for_tx(&self, tx_hash: TxHash) -> Result<()> { + use alloy::providers::PendingTransactionConfig; + self.provider + .watch_pending_transaction(PendingTransactionConfig::new(tx_hash)) + .await + .context("Failed to watch pending transaction")? + .await + .with_context(|| format!("Transaction {} was not mined", tx_hash))?; + Ok(()) + } + pub async fn quote_transfer_remote( &self, source_router: Address, @@ -207,6 +286,7 @@ impl ChainClient { amount: U256, msg_value: U256, ) -> Result { + let nonce = self.pending_nonce().await?; let recipient = address_to_bytes32(destination_recipient); let call = ITokenRouter::transferRemoteCall { _destination: destination_domain_id, @@ -218,7 +298,8 @@ impl ChainClient { let tx = TransactionRequest::default() .to(source_router) .input(call_data.into()) - .value(msg_value); + .value(msg_value) + .nonce(nonce); let (message_id, preview_error) = match self.provider.call(tx.clone()).await { Ok(raw) => { diff --git a/rebalancer/src/main.rs b/rebalancer/src/main.rs index 62c4ae3..8cf0bde 100644 --- a/rebalancer/src/main.rs +++ b/rebalancer/src/main.rs @@ -1,3 +1,5 @@ +#![recursion_limit = "256"] + use anyhow::Result; use clap::Parser; use rebalancer::run_from_config; diff --git a/rebalancer/src/service.rs b/rebalancer/src/service.rs index e9b8f79..4b3aeba 100644 --- a/rebalancer/src/service.rs +++ b/rebalancer/src/service.rs @@ -499,6 +499,48 @@ impl RebalancerService { ); } + if source_token_config.asset_type == AssetType::Erc20 { + if let Some(erc20_address) = source_token_config.address { + match source_client + .ensure_allowance(erc20_address, source_collateral_token, transfer_amount) + .await + { + Ok(Some(tx_hash)) => { + info!( + "Asset {} ERC20 approve submitted: route {} -> {} token={} spender={} tx_hash={}", + asset.symbol, + source_chain.name, + destination_chain.name, + erc20_address, + source_collateral_token, + tx_hash, + ); + // Wait for approval to be mined before submitting transferRemote + if let Err(err) = source_client.wait_for_tx(tx_hash).await { + warn!( + "Asset {} route {} -> {} ERC20 approve tx not confirmed; skipping transfer:\n{:#}", + asset.symbol, source_chain.name, destination_chain.name, err + ); + continue; + } + } + Ok(None) => { + debug!( + "Asset {} route {} -> {}: allowance already sufficient, skipping approve", + asset.symbol, source_chain.name, destination_chain.name, + ); + } + Err(err) => { + warn!( + "Asset {} route {} -> {} ERC20 approve failed; skipping transfer:\n{:#}", + asset.symbol, source_chain.name, destination_chain.name, err + ); + continue; + } + } + } + } + match source_client .submit_transfer_remote( source_collateral_token, diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 9d9c0ff..edf7778 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -59,3 +59,19 @@ else tail -5 logs/operator.log exit 1 fi + +# ── Rebalancer ──────────────────────────────────────────────────────────────── + +step "Starting Rebalancer..." +make rebalancer > logs/rebalancer.log 2>&1 & +REBALANCER_PID=$! +echo "$REBALANCER_PID" > logs/rebalancer.pid +sleep 3 + +if kill -0 $REBALANCER_PID 2>/dev/null; then + success "Rebalancer running (PID: $REBALANCER_PID)" +else + error "Rebalancer failed to start. Check logs/rebalancer.log" + tail -5 logs/rebalancer.log + exit 1 +fi diff --git a/scripts/systemd/oif-aggregator.service b/scripts/systemd/oif-aggregator.service new file mode 100644 index 0000000..185bf2b --- /dev/null +++ b/scripts/systemd/oif-aggregator.service @@ -0,0 +1,19 @@ +[Unit] +Description=OIF Aggregator +After=network.target +PartOf=oif.target + +[Service] +Type=simple +WorkingDirectory=/root/solver-cli/oif/oif-aggregator +EnvironmentFile=/root/solver-cli/.env +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=RUST_LOG=info +ExecStart=/root/solver-cli/oif/oif-aggregator/target/release/oif-aggregator +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=oif.target diff --git a/scripts/systemd/oif-frontend-api.service b/scripts/systemd/oif-frontend-api.service new file mode 100644 index 0000000..fb97ffb --- /dev/null +++ b/scripts/systemd/oif-frontend-api.service @@ -0,0 +1,18 @@ +[Unit] +Description=OIF Frontend API +After=network.target +PartOf=oif.target + +[Service] +Type=simple +WorkingDirectory=/root/solver-cli/frontend +EnvironmentFile=/root/solver-cli/.env +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=node /root/solver-cli/frontend/server.js +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=oif.target diff --git a/scripts/systemd/oif-frontend.service b/scripts/systemd/oif-frontend.service new file mode 100644 index 0000000..be55411 --- /dev/null +++ b/scripts/systemd/oif-frontend.service @@ -0,0 +1,18 @@ +[Unit] +Description=OIF Frontend (Vite) +After=oif-frontend-api.service +PartOf=oif.target + +[Service] +Type=simple +WorkingDirectory=/root/solver-cli/frontend +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStartPre=npm install --silent +ExecStart=npx vite --host +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=oif.target diff --git a/scripts/systemd/oif-oracle.service b/scripts/systemd/oif-oracle.service new file mode 100644 index 0000000..8582fdc --- /dev/null +++ b/scripts/systemd/oif-oracle.service @@ -0,0 +1,20 @@ +[Unit] +Description=OIF Oracle Operator +After=network.target +PartOf=oif.target + +[Service] +Type=simple +WorkingDirectory=/root/solver-cli/oracle-operator +EnvironmentFile=/root/solver-cli/.env +Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=ORACLE_CONFIG=/root/solver-cli/.config/oracle.toml +Environment=RUST_LOG=info +ExecStart=/root/solver-cli/target/release/oracle-operator +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=oif.target diff --git a/scripts/systemd/oif-rebalancer.service b/scripts/systemd/oif-rebalancer.service new file mode 100644 index 0000000..cb4ed5e --- /dev/null +++ b/scripts/systemd/oif-rebalancer.service @@ -0,0 +1,17 @@ +[Unit] +Description=OIF Rebalancer +After=network.target +PartOf=oif.target + +[Service] +Type=simple +WorkingDirectory=/root/solver-cli +EnvironmentFile=/root/solver-cli/.env +ExecStart=/root/solver-cli/target/release/solver-cli rebalancer start +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=oif.target diff --git a/scripts/systemd/oif-solver.service b/scripts/systemd/oif-solver.service new file mode 100644 index 0000000..e7ae58e --- /dev/null +++ b/scripts/systemd/oif-solver.service @@ -0,0 +1,17 @@ +[Unit] +Description=OIF Solver +After=network.target oif-aggregator.service +PartOf=oif.target + +[Service] +Type=simple +WorkingDirectory=/root/solver-cli +EnvironmentFile=/root/solver-cli/.env +ExecStart=/root/solver-cli/target/release/solver-cli solver start +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=oif.target diff --git a/scripts/systemd/oif.target b/scripts/systemd/oif.target new file mode 100644 index 0000000..99212c1 --- /dev/null +++ b/scripts/systemd/oif.target @@ -0,0 +1,6 @@ +[Unit] +Description=OIF Services +Wants=oif-aggregator.service oif-solver.service oif-oracle.service oif-rebalancer.service oif-frontend-api.service oif-frontend.service + +[Install] +WantedBy=multi-user.target diff --git a/solver-cli/Cargo.toml b/solver-cli/Cargo.toml index 75b91e6..316a77b 100644 --- a/solver-cli/Cargo.toml +++ b/solver-cli/Cargo.toml @@ -21,6 +21,7 @@ solver-runtime = [ "sha3", "futures", "solver-account", + "solver-account?/kms", "solver-core", "solver-delivery", "solver-discovery", @@ -39,6 +40,7 @@ alloy = { version = "1.0", features = [ "full", "provider-http", "signer-local", + "signer-aws", "contract", "sol-types", "json-rpc", @@ -47,6 +49,8 @@ alloy = { version = "1.0", features = [ ] } anyhow = "1.0" async-trait = "0.1" +aws-config = "1" +aws-sdk-kms = "1" # Utilities chrono = { version = "0.4", features = ["serde"] } @@ -81,30 +85,30 @@ uuid = { version = "1.6", features = ["v4", "serde"] } # Unix process management [target.'cfg(unix)'.dependencies] alloy-primitives = { version = "1.0.37", features = ["std", "serde"], optional = true } -alloy-sol-types = { version = "1.0.37", optional = true } alloy-provider = { version = "1.0", optional = true } alloy-rpc-types = { version = "1.0", optional = true } +alloy-sol-types = { version = "1.0.37", optional = true } futures = { version = "0.3", optional = true } nix = { version = "0.28", features = ["signal", "process"] } rebalancer = { path = "../rebalancer" } sha3 = { version = "0.10", optional = true } -solver-account = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-config = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-core = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-delivery = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-discovery = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-order = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-pricing = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-service = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } +solver-account = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-config = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-core = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-delivery = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-discovery = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-order = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-pricing = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-service = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } solver-settlement = { path = "../solver-settlement", optional = true } -solver-storage = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", optional = true } -solver-types = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", features = ["oif-interfaces"], optional = true } +solver-storage = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", optional = true } +solver-types = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", features = ["oif-interfaces"], optional = true } [dev-dependencies] assert_cmd = "2.0" mockall = "0.13" predicates = "3.0" -tempfile = "3.8" solver-settlement = { path = "../solver-settlement", features = ["testing"] } -solver-storage = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", features = ["testing"] } +solver-storage = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", features = ["testing"] } +tempfile = "3.8" diff --git a/solver-cli/src/chain/contracts.rs b/solver-cli/src/chain/contracts.rs index 6d69efb..f0419ad 100644 --- a/solver-cli/src/chain/contracts.rs +++ b/solver-cli/src/chain/contracts.rs @@ -13,6 +13,7 @@ sol! { function allowance(address owner, address spender) external view returns (uint256); function approve(address spender, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); + } } diff --git a/solver-cli/src/commands/account.rs b/solver-cli/src/commands/account.rs new file mode 100644 index 0000000..da0daec --- /dev/null +++ b/solver-cli/src/commands/account.rs @@ -0,0 +1,73 @@ +use anyhow::{Context, Result}; +use clap::Subcommand; +use std::env; +use std::path::PathBuf; + +use crate::utils::{load_dotenv, SolverSignerConfig}; + +#[derive(Subcommand)] +pub enum AccountCommand { + /// Print the EVM address of the configured solver signing key. + /// + /// Set SOLVER_SIGNER_TYPE in .env to select the backend: + /// - "env" (default): derives address from SOLVER_PRIVATE_KEY + /// - "aws_kms": fetches address from AWS KMS public key + /// (also set SOLVER_KMS_KEY_ID and SOLVER_KMS_REGION) + Address { + /// Project directory (for .env loading) + #[arg(long)] + dir: Option, + }, +} + +impl AccountCommand { + pub async fn run(self) -> Result<()> { + match self { + AccountCommand::Address { dir } => { + let project_dir = dir.unwrap_or_else(|| env::current_dir().unwrap()); + load_dotenv(&project_dir)?; + print_solver_address().await + } + } + } +} + +async fn print_solver_address() -> Result<()> { + match SolverSignerConfig::from_env()? { + SolverSignerConfig::AwsKms { + key_id, + region, + endpoint, + } => { + use alloy::signers::aws::AwsSigner; + use alloy::signers::Signer; + use aws_sdk_kms::config::Region; + + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region)); + if let Some(ep) = endpoint { + loader = loader.endpoint_url(ep); + } + let sdk_config = loader.load().await; + let client = aws_sdk_kms::Client::new(&sdk_config); + let signer = AwsSigner::new(client, key_id, None) + .await + .map_err(|e| anyhow::anyhow!("KMS initialization failed: {e}"))?; + println!("{:?}", Signer::address(&signer)); + } + SolverSignerConfig::Env => { + use crate::chain::ChainClient; + + let raw = env::var("SOLVER_PRIVATE_KEY") + .context("Missing required environment variable: SOLVER_PRIVATE_KEY")?; + let pk = if raw.starts_with("0x") { + raw + } else { + format!("0x{raw}") + }; + let addr = ChainClient::address_from_pk(&pk)?; + println!("{addr:?}"); + } + } + Ok(()) +} diff --git a/solver-cli/src/commands/chain.rs b/solver-cli/src/commands/chain.rs index f71c3c4..d213755 100644 --- a/solver-cli/src/commands/chain.rs +++ b/solver-cli/src/commands/chain.rs @@ -45,6 +45,12 @@ 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). + #[arg(long)] + warp_token: Option, + /// Project directory #[arg(long)] dir: Option, @@ -118,6 +124,7 @@ struct ChainAddParams { oracle: String, tokens: Vec, default_decimals: u8, + warp_token: Option, dir: Option, } @@ -133,6 +140,7 @@ impl ChainCommand { oracle, token, decimals, + warp_token, dir, } => { Self::add( @@ -145,6 +153,7 @@ impl ChainCommand { oracle, tokens: token, default_decimals: decimals, + warp_token, dir, }, output, @@ -166,6 +175,7 @@ impl ChainCommand { oracle, tokens, default_decimals, + warp_token, dir, } = params; let out = OutputFormatter::new(output); @@ -200,17 +210,31 @@ impl ChainCommand { } // Build contracts struct + let hyperlane = warp_token + .as_ref() + .map(|addr| crate::state::HyperlaneAddresses { + domain_id: None, + mailbox: None, + merkle_tree_hook: None, + validator_announce: None, + igp: None, + warp_token: Some(addr.clone()), + warp_token_type: Some("collateral".to_string()), + }); + let contracts = ContractAddresses { input_settler_escrow: Some(input_settler.clone()), output_settler_simple: Some(output_settler.clone()), oracle: Some(oracle.clone()), - permit2: None, // TODO: Add permit2 parameter to chain add command - hyperlane: None, + hyperlane, }; 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); + } // Build tokens map let mut token_map: HashMap = HashMap::new(); diff --git a/solver-cli/src/commands/configure.rs b/solver-cli/src/commands/configure.rs index eb20698..007d4a2 100644 --- a/solver-cli/src/commands/configure.rs +++ b/solver-cli/src/commands/configure.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Args; use std::env; use std::path::PathBuf; @@ -48,11 +48,69 @@ impl ConfigureCommand { print_kv("Chains configured", state.chains.len()); - // Derive solver address from solver private key - let solver_pk = env_config.get_solver_pk()?; - let solver_address = ChainClient::address_from_pk(&solver_pk)?; + // Derive solver address based on SOLVER_SIGNER_TYPE (mirrors oracle-operator pattern). + let solver_address = match crate::utils::SolverSignerConfig::from_env()? { + crate::utils::SolverSignerConfig::AwsKms { + key_id, + region, + endpoint, + } => { + use alloy::signers::aws::AwsSigner; + use alloy::signers::Signer; + use aws_sdk_kms::config::Region; + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region)); + if let Some(ep) = endpoint { + loader = loader.endpoint_url(ep); + } + let sdk_config = loader.load().await; + let client = aws_sdk_kms::Client::new(&sdk_config); + let signer = AwsSigner::new(client, key_id, None) + .await + .map_err(|e| anyhow::anyhow!("KMS initialization failed: {e}"))?; + Signer::address(&signer) + } + crate::utils::SolverSignerConfig::Env => { + let solver_pk = env_config.get_solver_pk()?; + ChainClient::address_from_pk(&solver_pk)? + } + }; + print_address("Solver address", &format!("{:?}", solver_address)); + // Derive operator address from env if not already set in state + if state.solver.operator_address.is_none() { + let operator_address = match crate::utils::OracleSignerConfig::from_env()? { + crate::utils::OracleSignerConfig::AwsKms { + key_id, + region, + endpoint, + } => { + use alloy::signers::aws::AwsSigner; + use alloy::signers::Signer; + use aws_sdk_kms::config::Region; + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region)); + if let Some(ep) = endpoint { + loader = loader.endpoint_url(ep); + } + let sdk_config = loader.load().await; + let client = aws_sdk_kms::Client::new(&sdk_config); + let signer = AwsSigner::new(client, key_id, None) + .await + .map_err(|e| anyhow::anyhow!("Oracle KMS initialization failed: {e}"))?; + format!("{:?}", Signer::address(&signer)) + } + crate::utils::OracleSignerConfig::Env => { + let operator_pk = std::env::var("ORACLE_OPERATOR_PK") + .context("Missing ORACLE_OPERATOR_PK — set it in .env or state")?; + format!("{:?}", ChainClient::address_from_pk(&operator_pk)?) + } + }; + print_address("Operator address (from env)", &operator_address); + state.solver.operator_address = Some(operator_address); + } + // Update solver config in state state.solver.address = Some(format!("{:?}", solver_address)); state.solver.solver_id = Some(self.solver_id.clone()); @@ -87,13 +145,15 @@ impl ConfigureCommand { aggregator_config_path )); - // Generate rebalancer config + // Generate rebalancer config (optional — skipped if token coverage is insufficient) let rebalancer_config_path = project_dir.join(".config/rebalancer.toml"); - RebalancerConfigGenerator::write_config(&state, &rebalancer_config_path).await?; - print_success(&format!( - "Rebalancer config written to {:?}", - rebalancer_config_path - )); + match RebalancerConfigGenerator::write_config(&state, &rebalancer_config_path).await { + Ok(()) => print_success(&format!( + "Rebalancer config written to {:?}", + rebalancer_config_path + )), + Err(e) => print_info(&format!("Skipping rebalancer config: {e}")), + } // Generate Hyperlane relayer config let hyperlane_config_path = project_dir.join(".config/hyperlane-relayer.json"); diff --git a/solver-cli/src/commands/fund.rs b/solver-cli/src/commands/fund.rs index 70aafea..97f863d 100644 --- a/solver-cli/src/commands/fund.rs +++ b/solver-cli/src/commands/fund.rs @@ -48,9 +48,33 @@ impl FundCommand { let amount: U256 = self.amount.parse()?; - // Get the solver private key - let solver_key = env_config.get_solver_pk()?; - let solver_address = ChainClient::address_from_pk(&solver_key)?; + // Derive solver address from whichever signer is configured (env key or AWS KMS). + let solver_address = match crate::utils::SolverSignerConfig::from_env()? { + crate::utils::SolverSignerConfig::AwsKms { + key_id, + region, + endpoint, + } => { + use alloy::signers::aws::AwsSigner; + use alloy::signers::Signer; + use aws_sdk_kms::config::Region; + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region)); + if let Some(ep) = endpoint { + loader = loader.endpoint_url(ep); + } + let sdk_config = loader.load().await; + let client = aws_sdk_kms::Client::new(&sdk_config); + AwsSigner::new(client, key_id, None) + .await + .map_err(|e| anyhow::anyhow!("KMS initialization failed: {e}"))? + .address() + } + crate::utils::SolverSignerConfig::Env => { + let solver_key = env_config.get_solver_pk()?; + ChainClient::address_from_pk(&solver_key)? + } + }; // Determine which chains to fund let chain_ids: Vec = if let Some(ref chain_arg) = self.chain { diff --git a/solver-cli/src/commands/mod.rs b/solver-cli/src/commands/mod.rs index aded328..d3a425e 100644 --- a/solver-cli/src/commands/mod.rs +++ b/solver-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod balances; pub mod chain; pub mod configure; diff --git a/solver-cli/src/deployment/deployer.rs b/solver-cli/src/deployment/deployer.rs index f82ed19..de46dac 100644 --- a/solver-cli/src/deployment/deployer.rs +++ b/solver-cli/src/deployment/deployer.rs @@ -52,7 +52,6 @@ impl Deployer { input_settler_escrow: deployment.input_settler().cloned(), output_settler_simple: deployment.output_settler().cloned(), oracle: deployment.oracle().cloned(), - permit2: deployment.permit2().cloned(), hyperlane: None, }; @@ -90,14 +89,35 @@ impl Deployer { self.build().await?; } - // Derive operator address from ORACLE_OPERATOR_PK - let operator_pk = std::env::var("ORACLE_OPERATOR_PK") - .context("Missing required environment variable: ORACLE_OPERATOR_PK")?; - let operator_address = format!("{:?}", ChainClient::address_from_pk(&operator_pk)?); - info!( - "Using operator address: {} (derived from ORACLE_OPERATOR_PK)", - operator_address - ); + // Derive operator address from ORACLE_SIGNER_TYPE (env key or AWS KMS). + let operator_address = match crate::utils::OracleSignerConfig::from_env()? { + crate::utils::OracleSignerConfig::AwsKms { + key_id, + region, + endpoint, + } => { + use alloy::signers::aws::AwsSigner; + use alloy::signers::Signer; + use aws_sdk_kms::config::Region; + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region)); + if let Some(ep) = endpoint { + loader = loader.endpoint_url(ep); + } + let sdk_config = loader.load().await; + let client = aws_sdk_kms::Client::new(&sdk_config); + let signer = AwsSigner::new(client, key_id, None) + .await + .map_err(|e| anyhow::anyhow!("Oracle KMS initialization failed: {e}"))?; + format!("{:?}", Signer::address(&signer)) + } + crate::utils::OracleSignerConfig::Env => { + let operator_pk = std::env::var("ORACLE_OPERATOR_PK") + .context("Missing required environment variable: ORACLE_OPERATOR_PK")?; + format!("{:?}", ChainClient::address_from_pk(&operator_pk)?) + } + }; + info!("Using operator address: {}", operator_address); // Try to load Hyperlane deployment artifacts let hyperlane_addresses = Self::load_hyperlane_addresses().ok(); @@ -199,6 +219,7 @@ impl Deployer { // Store Hyperlane contract addresses let hyperlane = HyperlaneAddresses { + domain_id: chain_data.get("domain_id").and_then(|v| v.as_u64()), mailbox: chain_data .get("mailbox") .and_then(|v| v.as_str()) diff --git a/solver-cli/src/deployment/forge.rs b/solver-cli/src/deployment/forge.rs index d1ba8f1..d5bd3a4 100644 --- a/solver-cli/src/deployment/forge.rs +++ b/solver-cli/src/deployment/forge.rs @@ -224,8 +224,4 @@ impl DeploymentOutput { .get("OutputSettlerSimple") .or_else(|| self.addresses.get("OutputSettler")) } - - pub fn permit2(&self) -> Option<&String> { - self.addresses.get("Permit2") - } } diff --git a/solver-cli/src/main.rs b/solver-cli/src/main.rs index 86b4205..adc0042 100644 --- a/solver-cli/src/main.rs +++ b/solver-cli/src/main.rs @@ -11,9 +11,10 @@ use tracing::Level; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use commands::{ - balances::BalancesCommand, chain::ChainCommand, configure::ConfigureCommand, - deploy::DeployCommand, fund::FundCommand, init::InitCommand, intent::IntentCommand, - order::OrderCommand, rebalancer::RebalancerCommand, solver::SolverCommand, token::TokenCommand, + account::AccountCommand, balances::BalancesCommand, chain::ChainCommand, + configure::ConfigureCommand, deploy::DeployCommand, fund::FundCommand, init::InitCommand, + intent::IntentCommand, order::OrderCommand, rebalancer::RebalancerCommand, + solver::SolverCommand, token::TokenCommand, }; #[derive(Parser)] @@ -79,6 +80,10 @@ enum Commands { /// Rebalancer service management #[command(subcommand)] Rebalancer(RebalancerCommand), + + /// Account utilities (print signing key address) + #[command(subcommand)] + Account(AccountCommand), } fn setup_logging(level: &str) -> anyhow::Result<()> { @@ -114,6 +119,7 @@ async fn main() -> anyhow::Result<()> { Commands::Order(cmd) => cmd.run(cli.output).await, Commands::Balances(cmd) => cmd.run(cli.output).await, Commands::Rebalancer(cmd) => cmd.run(cli.output).await, + Commands::Account(cmd) => cmd.run().await, }; if let Err(e) = result { diff --git a/solver-cli/src/rebalancer/config_gen.rs b/solver-cli/src/rebalancer/config_gen.rs index c8136ed..f16f068 100644 --- a/solver-cli/src/rebalancer/config_gen.rs +++ b/solver-cli/src/rebalancer/config_gen.rs @@ -4,7 +4,6 @@ use std::collections::BTreeMap; use std::path::Path; use tokio::fs; -use crate::chain::ChainClient; use crate::state::SolverState; /// Generates rebalancer configuration files @@ -42,6 +41,13 @@ impl RebalancerConfigGenerator { Err(_) => 69420u64, }; + let signer_inline = match crate::utils::RebalancerSignerConfig::from_env()? { + crate::utils::RebalancerSignerConfig::AwsKms { key_id, region } => { + format!("type = \"aws_kms\"\n key_id = \"{key_id}\"\n region = \"{region}\"") + } + crate::utils::RebalancerSignerConfig::Env => "type = \"env\"".to_string(), + }; + let mut chains_section = String::new(); for chain in &chains { chains_section.push_str(&format!( @@ -49,16 +55,23 @@ impl RebalancerConfigGenerator { [[chains]] name = "{name}" chain_id = {chain_id} -domain_id = {chain_id} +domain_id = {domain_id} rpc_url = "{rpc_url}" account = "{account}" [chains.signer] - type = "env" + {signer_inline} "#, name = chain.name, chain_id = chain.chain_id, + domain_id = chain + .contracts + .hyperlane + .as_ref() + .and_then(|h| h.domain_id) + .unwrap_or_else(|| hyperlane_domain_id(chain.chain_id)), rpc_url = chain.rpc, account = account, + signer_inline = signer_inline, )); } @@ -81,10 +94,11 @@ decimals = {decimals} chain_id = {chain_id} type = "erc20" address = "{address}" - collateral_token = "{address}" + collateral_token = "{collateral_token}" "#, chain_id = token.chain_id, - address = token.address + address = token.address, + collateral_token = token.collateral_token, )); } @@ -149,6 +163,9 @@ max_transfer_bps = 5000 struct RebalancerTokenEntry { chain_id: u64, address: String, + /// Hyperlane warp token router address for `transferRemote` / `quoteTransferRemote`. + /// Falls back to `address` if no warp token is deployed. + collateral_token: String, } #[derive(Debug, Clone)] @@ -161,15 +178,23 @@ struct RebalancerAsset { } fn collect_assets(state: &SolverState) -> Result> { - let mut by_symbol: BTreeMap> = BTreeMap::new(); + let mut by_symbol: BTreeMap> = 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(); + // Use warp token address as collateral_token if available; otherwise fall back to ERC20 + let collateral = warp_token.clone().unwrap_or_else(|| token.address.clone()); by_symbol.entry(normalized).or_default().push(( *chain_id, token.address.clone(), token.decimals, + collateral, )); } } @@ -180,11 +205,11 @@ fn collect_assets(state: &SolverState) -> Result> { continue; } - entries.sort_by_key(|(chain_id, _, _)| *chain_id); + entries.sort_by_key(|(chain_id, _, _, _)| *chain_id); let expected_decimals = entries[0].2; if entries .iter() - .any(|(_, _, decimals)| *decimals != expected_decimals) + .any(|(_, _, decimals, _)| *decimals != expected_decimals) { anyhow::bail!( "Token {} has inconsistent decimals across chains, cannot generate rebalancer config", @@ -192,7 +217,10 @@ fn collect_assets(state: &SolverState) -> Result> { ); } - let chain_ids: Vec = entries.iter().map(|(chain_id, _, _)| *chain_id).collect(); + let chain_ids: Vec = entries + .iter() + .map(|(chain_id, _, _, _)| *chain_id) + .collect(); let weights = equal_weight_distribution(&chain_ids, 1_000_000); let min_weights: Vec<(u64, f64)> = weights .iter() @@ -209,7 +237,13 @@ fn collect_assets(state: &SolverState) -> Result> { decimals: expected_decimals, tokens: entries .into_iter() - .map(|(chain_id, address, _)| RebalancerTokenEntry { chain_id, address }) + .map( + |(chain_id, address, _, collateral_token)| RebalancerTokenEntry { + chain_id, + address, + collateral_token, + }, + ) .collect(), weights, min_weights, @@ -243,16 +277,19 @@ fn derive_rebalancer_account(state: &SolverState) -> Result { return normalize_address(address).context("Invalid solver.address in state"); } - let fallback_pk = std::env::var("SOLVER_PRIVATE_KEY") - .or_else(|_| std::env::var("SEPOLIA_PK")) - .map_err(|_| { - anyhow::anyhow!( - "Missing solver address in state and no fallback key found (SOLVER_PRIVATE_KEY / SEPOLIA_PK)" - ) - })?; - let address = ChainClient::address_from_pk(&fallback_pk) - .context("Invalid SOLVER_PRIVATE_KEY/SEPOLIA_PK for rebalancer account derivation")?; - Ok(format!("{:?}", address)) + anyhow::bail!( + "Missing solver address in state. Run 'solver-cli configure' first to populate the solver address." + ) +} + +/// Map EVM chain ID to Hyperlane domain ID. +/// Domain IDs can differ from chain IDs to avoid conflicts with Hyperlane's +/// hardcoded KnownHyperlaneDomain enum (e.g. 31337 is hardcoded as "test4"). +fn hyperlane_domain_id(chain_id: u64) -> u64 { + match chain_id { + 31337 => 131337, + _ => chain_id, + } } fn normalize_address(value: &str) -> Result { @@ -290,8 +327,8 @@ mod tests { "0x0000000000000000000000000000000000000102".to_string(), ), oracle: Some("0x0000000000000000000000000000000000000103".to_string()), - permit2: Some("0x0000000000000000000000000000000000000104".to_string()), hyperlane: Some(HyperlaneAddresses { + domain_id: None, mailbox: None, merkle_tree_hook: None, validator_announce: None, @@ -324,8 +361,8 @@ mod tests { "0x0000000000000000000000000000000000000202".to_string(), ), oracle: Some("0x0000000000000000000000000000000000000203".to_string()), - permit2: Some("0x0000000000000000000000000000000000000204".to_string()), hyperlane: Some(HyperlaneAddresses { + domain_id: None, mailbox: None, merkle_tree_hook: None, validator_announce: None, diff --git a/solver-cli/src/solver/config_gen.rs b/solver-cli/src/solver/config_gen.rs index 36bc0e9..69f41ee 100644 --- a/solver-cli/src/solver/config_gen.rs +++ b/solver-cli/src/solver/config_gen.rs @@ -15,15 +15,32 @@ impl ConfigGenerator { anyhow::bail!("No chains configured"); } - // Read private key from environment at generation time - let solver_private_key = std::env::var("SOLVER_PRIVATE_KEY") - .context("Missing required environment variable: SOLVER_PRIVATE_KEY")?; - - // Ensure the key has 0x prefix - let solver_private_key = if solver_private_key.starts_with("0x") { - solver_private_key - } else { - format!("0x{}", solver_private_key) + // Build account section based on SOLVER_SIGNER_TYPE. + let account_section = match crate::utils::SolverSignerConfig::from_env()? { + crate::utils::SolverSignerConfig::AwsKms { + key_id, + region, + endpoint, + } => { + let endpoint_line = endpoint + .map(|ep| format!("endpoint = \"{ep}\"\n")) + .unwrap_or_default(); + format!( + "[account]\nprimary = \"kms\"\n\n[account.implementations.kms]\nkey_id = \"{key_id}\"\nregion = \"{region}\"\n{endpoint_line}" + ) + } + crate::utils::SolverSignerConfig::Env => { + let raw = std::env::var("SOLVER_PRIVATE_KEY") + .context("Missing required environment variable: SOLVER_PRIVATE_KEY")?; + let key = if raw.starts_with("0x") { + raw + } else { + format!("0x{raw}") + }; + format!( + "[account]\nprimary = \"local\"\n\n[account.implementations.local]\nprivate_key = \"{key}\"" + ) + } }; // Collect all chain IDs @@ -42,7 +59,6 @@ impl ConfigGenerator { [networks.{}] input_settler_address = "{}" output_settler_address = "{}" -permit2_address = "{}" [[networks.{}.rpc_urls]] http = "{}" @@ -58,7 +74,6 @@ http = "{}" .output_settler_simple .as_deref() .unwrap_or(""), - chain.contracts.permit2.as_deref().unwrap_or(""), chain.chain_id, chain.rpc, )); @@ -86,21 +101,6 @@ decimals = {} output_oracles.push(format!("{} = [\"{}\"]", chain.chain_id, oracle)); } - // Build mock price pairs from all configured tokens - let mut price_symbols: Vec = state - .chains - .values() - .flat_map(|c| c.tokens.keys().cloned()) - .collect::>() - .into_iter() - .collect(); - price_symbols.sort(); - let mock_prices = price_symbols - .iter() - .map(|sym| format!("\"{}/USD\" = \"1.0\"", sym)) - .collect::>() - .join("\n"); - // Build routes (all-to-all) let mut routes = Vec::new(); for &from_id in &chain_ids { @@ -121,7 +121,9 @@ decimals = {} [solver] id = "{solver_id}" -min_profitability_pct = -1000.0 # Allow massive losses for testing +min_profitability_pct = 0.0 +commission_bps = 20 +rate_buffer_bps = 15 monitoring_timeout_seconds = 28800 # ============================================================================ @@ -148,11 +150,7 @@ cleanup_interval_seconds = 60 # ============================================================================ # ACCOUNT # ============================================================================ -[account] -primary = "local" - -[account.implementations.local] -private_key = "{solver_private_key}" +{account_section} # ============================================================================ # NETWORKS @@ -183,6 +181,26 @@ api_host = "127.0.0.1" api_port = 5002 network_ids = [{chain_ids_str}] +# ============================================================================ +# GAS ESTIMATES (per flow, in gas units) +# ============================================================================ +[gas] + +[gas.flows.resource_lock] +open = 0 +fill = 77298 +claim = 122793 + +[gas.flows.permit2_escrow] +open = 146306 +fill = 77298 +claim = 60084 + +[gas.flows.eip3009_escrow] +open = 130254 +fill = 77298 +claim = 60084 + # ============================================================================ # ORDER # ============================================================================ @@ -201,12 +219,17 @@ max_gas_price_gwei = 100 # PRICING # ============================================================================ [pricing] -primary = "mock" +primary = "defillama" +fallbacks = ["coingecko"] + +[pricing.implementations.defillama] +# base_url = "https://coins.llama.fi" # default +# cache_duration_seconds = 60 # default -[pricing.implementations.mock] -# Mock prices for testing (auto-generated from configured tokens) -[pricing.implementations.mock.pair_prices] -{mock_prices} +[pricing.implementations.coingecko] +# api_key = "" # optional, omit for free tier +# cache_duration_seconds = 60 +# rate_limit_delay_ms = 1200 # free tier default # ============================================================================ # SETTLEMENT @@ -231,7 +254,7 @@ output = {{ {output_oracles} }} chain_ids.len(), chain_ids_str, solver_id = state.solver.solver_id.as_deref().unwrap_or("solver-001"), - solver_private_key = solver_private_key, + account_section = account_section, networks_section = networks_section.trim(), chain_ids_str = chain_ids_str, input_oracles = input_oracles.join(", "), @@ -304,6 +327,15 @@ input_settler_address = "{}" )); } + let signer_section = match crate::utils::OracleSignerConfig::from_env()? { + crate::utils::OracleSignerConfig::AwsKms { key_id, region, .. } => { + format!( + "[signer]\ntype = \"aws_kms\"\nkey_id = \"{key_id}\"\nregion = \"{region}\"" + ) + } + crate::utils::OracleSignerConfig::Env => "[signer]\ntype = \"env\"".to_string(), + }; + let config = format!( r#"# Auto-generated oracle operator configuration # DO NOT EDIT MANUALLY - regenerate with 'solver-cli configure' @@ -312,17 +344,17 @@ input_settler_address = "{}" # Operator address (must match CentralizedOracle operator) operator_address = "{operator_address}" -# Operator signer configuration (single signer used across all chains) -[signer] -type = "env" - # Polling interval in seconds poll_interval_seconds = 3 +# Operator signer configuration (single signer used across all chains) +{signer_section} + # Chain configurations {chains_section}"#, state.chains.len(), operator_address = operator_address, + signer_section = signer_section, chains_section = chains_section.trim(), ); @@ -496,13 +528,21 @@ poll_interval_seconds = 3 anyhow::bail!("No chains configured"); } - // Read signer keys from environment - let evm_signer_key = std::env::var("SOLVER_PRIVATE_KEY") - .context("Missing required environment variable: SOLVER_PRIVATE_KEY")?; - let evm_signer_key = if evm_signer_key.starts_with("0x") { - evm_signer_key - } else { - format!("0x{}", evm_signer_key) + // Build EVM signer config based on SOLVER_SIGNER_TYPE. + let evm_signer = match crate::utils::SolverSignerConfig::from_env()? { + crate::utils::SolverSignerConfig::AwsKms { key_id, region, .. } => { + serde_json::json!({ "type": "aws", "id": key_id, "region": region }) + } + crate::utils::SolverSignerConfig::Env => { + let raw = std::env::var("SOLVER_PRIVATE_KEY") + .context("Missing required environment variable: SOLVER_PRIVATE_KEY")?; + let key = if raw.starts_with("0x") { + raw + } else { + format!("0x{raw}") + }; + serde_json::json!({ "type": "hexKey", "key": key }) + } }; let cosmos_signer_key = std::env::var("CELESTIA_SIGNER_KEY").unwrap_or_else(|_| { @@ -558,10 +598,7 @@ poll_interval_seconds = 3 }, "protocol": "ethereum", "rpcUrls": [{ "http": chain.rpc }], - "signer": { - "type": "hexKey", - "key": evm_signer_key - }, + "signer": evm_signer.clone(), "mailbox": mailbox, "merkleTreeHook": merkle_tree_hook, "validatorAnnounce": validator_announce, diff --git a/solver-cli/src/state/types.rs b/solver-cli/src/state/types.rs index dd2d81f..b758456 100644 --- a/solver-cli/src/state/types.rs +++ b/solver-cli/src/state/types.rs @@ -96,9 +96,6 @@ pub struct ContractAddresses { /// Oracle contract pub oracle: Option, - /// Permit2 contract - pub permit2: Option, - /// Hyperlane addresses (if deployed via Hyperlane warp route) #[serde(default, skip_serializing_if = "Option::is_none")] pub hyperlane: Option, @@ -106,6 +103,10 @@ pub struct ContractAddresses { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HyperlaneAddresses { + /// Hyperlane domain ID (may differ from EVM chain ID) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain_id: Option, + /// Hyperlane mailbox address pub mailbox: Option, @@ -130,7 +131,6 @@ impl ContractAddresses { self.input_settler_escrow.is_some() && self.output_settler_simple.is_some() && self.oracle.is_some() - && self.permit2.is_some() } } diff --git a/solver-cli/src/utils/env.rs b/solver-cli/src/utils/env.rs index 9f82d2b..ba821de 100644 --- a/solver-cli/src/utils/env.rs +++ b/solver-cli/src/utils/env.rs @@ -123,3 +123,106 @@ impl EnvConfig { .context("Missing required environment variable: SOLVER_PRIVATE_KEY") } } + +/// Solver signer configuration — mirrors the oracle-operator's SignerConfig. +/// +/// Select the backend via SOLVER_SIGNER_TYPE in .env: +/// - "env" (default): reads SOLVER_PRIVATE_KEY +/// - "aws_kms": reads SOLVER_KMS_KEY_ID + SOLVER_KMS_REGION (+ optional SOLVER_KMS_ENDPOINT) +#[derive(Debug, Clone)] +pub enum SolverSignerConfig { + /// Local private key read from SOLVER_PRIVATE_KEY env var (default) + Env, + /// AWS KMS signing — private key never leaves the HSM + AwsKms { + key_id: String, + region: String, + /// Optional custom endpoint, e.g. for LocalStack (SOLVER_KMS_ENDPOINT) + endpoint: Option, + }, +} + +impl SolverSignerConfig { + /// Load from SOLVER_SIGNER_TYPE (defaults to "env" if unset). + pub fn from_env() -> Result { + let signer_type = env::var("SOLVER_SIGNER_TYPE").unwrap_or_else(|_| "env".to_string()); + match signer_type.as_str() { + "aws_kms" => { + let key_id = env::var("SOLVER_KMS_KEY_ID") + .context("Missing required environment variable: SOLVER_KMS_KEY_ID")?; + let region = env::var("SOLVER_KMS_REGION") + .context("Missing required environment variable: SOLVER_KMS_REGION")?; + let endpoint = env::var("SOLVER_KMS_ENDPOINT").ok(); + Ok(Self::AwsKms { + key_id, + region, + endpoint, + }) + } + _ => Ok(Self::Env), + } + } +} + +/// Rebalancer signer configuration. +/// +/// Select the backend via REBALANCER_SIGNER_TYPE in .env: +/// - "env" (default): reads REBALANCER_PRIVATE_KEY (or per-chain REBALANCER__PK) +/// - "aws_kms": reads REBALANCER_KMS_KEY_ID + REBALANCER_KMS_REGION +#[derive(Debug, Clone)] +pub enum RebalancerSignerConfig { + Env, + AwsKms { key_id: String, region: String }, +} + +impl RebalancerSignerConfig { + pub fn from_env() -> Result { + let signer_type = env::var("REBALANCER_SIGNER_TYPE").unwrap_or_else(|_| "env".to_string()); + match signer_type.as_str() { + "aws_kms" => { + let key_id = env::var("REBALANCER_KMS_KEY_ID") + .context("Missing required environment variable: REBALANCER_KMS_KEY_ID")?; + let region = env::var("REBALANCER_KMS_REGION") + .context("Missing required environment variable: REBALANCER_KMS_REGION")?; + Ok(Self::AwsKms { key_id, region }) + } + _ => Ok(Self::Env), + } + } +} + +/// Oracle operator signer configuration. +/// +/// Select the backend via ORACLE_SIGNER_TYPE in .env: +/// - "env" (default): reads ORACLE_OPERATOR_PK +/// - "aws_kms": reads ORACLE_KMS_KEY_ID + ORACLE_KMS_REGION (+ optional ORACLE_KMS_ENDPOINT) +#[derive(Debug, Clone)] +pub enum OracleSignerConfig { + Env, + AwsKms { + key_id: String, + region: String, + endpoint: Option, + }, +} + +impl OracleSignerConfig { + pub fn from_env() -> Result { + let signer_type = env::var("ORACLE_SIGNER_TYPE").unwrap_or_else(|_| "env".to_string()); + match signer_type.as_str() { + "aws_kms" => { + let key_id = env::var("ORACLE_KMS_KEY_ID") + .context("Missing required environment variable: ORACLE_KMS_KEY_ID")?; + let region = env::var("ORACLE_KMS_REGION") + .context("Missing required environment variable: ORACLE_KMS_REGION")?; + let endpoint = env::var("ORACLE_KMS_ENDPOINT").ok(); + Ok(Self::AwsKms { + key_id, + region, + endpoint, + }) + } + _ => Ok(Self::Env), + } + } +} diff --git a/solver-settlement/Cargo.toml b/solver-settlement/Cargo.toml index 313310c..3931b4c 100644 --- a/solver-settlement/Cargo.toml +++ b/solver-settlement/Cargo.toml @@ -7,16 +7,16 @@ edition = "2024" testing = ["upstream-solver-settlement/testing"] [dependencies] -upstream-solver-settlement = { package = "solver-settlement", git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" } -solver-storage = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f" } -solver-types = { git = "https://github.com/celestiaorg/oif-solver", rev = "2364b4d5bd6f2b77299fbcf2d1a921ce46923a6f", features = ["oif-interfaces"] } alloy-primitives = { version = "1.0.37", features = ["std", "serde"] } alloy-provider = { version = "1.0" } alloy-rpc-types = { version = "1.0" } alloy-sol-types = { version = "1.0.37" } async-trait = "0.1" -sha3 = "0.10" serde_json = "1.0" +sha3 = "0.10" +solver-storage = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc" } +solver-types = { git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc", features = ["oif-interfaces"] } tokio = { version = "1", features = ["rt"] } toml = "0.9" tracing = "0.1" +upstream-solver-settlement = { package = "solver-settlement", git = "https://github.com/celestiaorg/oif-solver", rev = "a06231ca236ee9ce156900cd2cc9915fbe847cdc" } diff --git a/solver-settlement/src/centralized.rs b/solver-settlement/src/centralized.rs index bacf09f..a8c06c7 100644 --- a/solver-settlement/src/centralized.rs +++ b/solver-settlement/src/centralized.rs @@ -1,19 +1,19 @@ -use alloy_primitives::{hex, Address as AlloyAddress, FixedBytes, U256}; +use alloy_primitives::{Address as AlloyAddress, FixedBytes, U256, hex}; use alloy_provider::{DynProvider, Provider}; -use alloy_sol_types::{sol, SolCall}; +use alloy_sol_types::{SolCall, sol}; use async_trait::async_trait; use sha3::{Digest, Keccak256}; -use upstream_solver_settlement::{ - utils::parse_oracle_config, OracleConfig, SettlementError, SettlementInterface, -}; use solver_types::{ - create_http_provider, standards::eip7683::Eip7683OrderData, with_0x_prefix, Address, - ConfigSchema, Field, FieldType, FillProof, NetworksConfig, Order, ProviderError, Schema, - Transaction, TransactionHash, TransactionReceipt, TransactionType, + Address, ConfigSchema, Field, FieldType, FillProof, NetworksConfig, Order, ProviderError, + Schema, Transaction, TransactionHash, TransactionReceipt, TransactionType, + create_http_provider, standards::eip7683::Eip7683OrderData, with_0x_prefix, }; use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info, warn}; +use upstream_solver_settlement::{ + OracleConfig, SettlementError, SettlementInterface, utils::parse_oracle_config, +}; sol! { interface ICentralizedOracle { @@ -376,7 +376,7 @@ impl SettlementInterface for CentralizedSettlement { .logs() .iter() .map(|log| solver_types::Log { - address: solver_types::Address(log.address().0 .0.to_vec()), + address: solver_types::Address(log.address().0.0.to_vec()), topics: log .topics() .iter() @@ -462,13 +462,13 @@ impl SettlementInterface for CentralizedSettlement { }; debug!( - "Checking isProven: chain={}, remote_chain={}, remote_oracle=0x{}, settler=0x{}, hash=0x{}", - origin_chain_id, - destination_chain_id, - hex::encode(remote_oracle), - hex::encode(output_settler), - hex::encode(payload_hash) - ); + "Checking isProven: chain={}, remote_chain={}, remote_oracle=0x{}, settler=0x{}, hash=0x{}", + origin_chain_id, + destination_chain_id, + hex::encode(remote_oracle), + hex::encode(output_settler), + hex::encode(payload_hash) + ); match self .check_is_proven( @@ -504,10 +504,10 @@ impl SettlementInterface for CentralizedSettlement { let payload_hash = self.compute_payload_hash(order, &solver_array, timestamp)?; info!( - "Fill confirmed for order {}, waiting for oracle operator attestation (payload_hash=0x{})", - order.id, - hex::encode(payload_hash) - ); + "Fill confirmed for order {}, waiting for oracle operator attestation (payload_hash=0x{})", + order.id, + hex::encode(payload_hash) + ); Ok(None) }