diff --git a/.github/workflows/tentou.yml b/.github/workflows/tentou.yml new file mode 100644 index 0000000000..1aa4f3a82c --- /dev/null +++ b/.github/workflows/tentou.yml @@ -0,0 +1,69 @@ +name: "tentou deploy" + +on: + push: + branches: [ "hyper-evm" ] + +jobs: + build: + runs-on: ubuntu-latest + env: + MIGRATIONS_IMAGE: ghcr.io/tentou-tech/cow-migrations + ORDERBOOK_IMAGE: ghcr.io/tentou-tech/cow-orderbook + AUTOPILOT_IMAGE: ghcr.io/tentou-tech/cow-autopilot + DRIVER_IMAGE: ghcr.io/tentou-tech/cow-driver + BASELINE_IMAGE: ghcr.io/tentou-tech/cow-baseline + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set environment variable + run: | + SHORT_SHA_COMMIT=$(git rev-parse --short HEAD) + echo IMAGE_TAG=${GITHUB_REF_NAME}_${SHORT_SHA_COMMIT} >> $GITHUB_ENV + - name: Build & Push Images + run: | + docker buildx build -t $MIGRATIONS_IMAGE:${{ env.IMAGE_TAG }} --target migrations --push . + docker buildx build -t $ORDERBOOK_IMAGE:${{ env.IMAGE_TAG }} --target orderbook --push . + docker buildx build -t $AUTOPILOT_IMAGE:${{ env.IMAGE_TAG }} --target autopilot --push . + docker buildx build -t $DRIVER_IMAGE:${{ env.IMAGE_TAG }} --target driver --push . + docker buildx build -t $BASELINE_IMAGE:${{ env.IMAGE_TAG }} --target solvers --push . + updategitops: + runs-on: ubuntu-latest + needs: build + env: + MIGRATIONS_IMAGE: ghcr.io/tentou-tech/cow-migrations + ORDERBOOK_IMAGE: ghcr.io/tentou-tech/cow-orderbook + AUTOPILOT_IMAGE: ghcr.io/tentou-tech/cow-autopilot + DRIVER_IMAGE: ghcr.io/tentou-tech/cow-driver + BASELINE_IMAGE: ghcr.io/tentou-tech/cow-baseline + steps: + - uses: actions/checkout@v4 + - name: Set environment variable + run: | + SHORT_SHA_COMMIT=$(git rev-parse --short HEAD) + echo IMAGE_TAG=${GITHUB_REF_NAME}_${SHORT_SHA_COMMIT} >> $GITHUB_ENV + - name: Update GitOps repo + run: | + git clone https://${{ secrets.REGISTRY_PASSWORD }}@github.com/tentou-tech/gitops.git + cd gitops/clusters/k8s-dev/cow-protocol + + curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash + ./kustomize edit set image image_migrations=$MIGRATIONS_IMAGE:${{ env.IMAGE_TAG }} + ./kustomize edit set image image_orderbook=$ORDERBOOK_IMAGE:${{ env.IMAGE_TAG }} + ./kustomize edit set image image_autopilot=$AUTOPILOT_IMAGE:${{ env.IMAGE_TAG }} + ./kustomize edit set image image_driver=$DRIVER_IMAGE:${{ env.IMAGE_TAG }} + ./kustomize edit set image image_baseline=$BASELINE_IMAGE:${{ env.IMAGE_TAG }} + rm kustomize + + git config user.name "ci-bot" + git config user.email "ci@bot" + git commit -am "Update images to ${GITHUB_SHA::8}" + + git push diff --git a/Cargo.lock b/Cargo.lock index e16ebed404..45111ea797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2638,6 +2638,37 @@ dependencies = [ "web3", ] +[[package]] +name = "driver-hyperliquid-template" +version = "0.1.0" +dependencies = [ + "anyhow", + "app-data", + "axum", + "chrono", + "clap", + "derive_more 1.0.0", + "driver", + "futures", + "hyper 0.14.29", + "model", + "num", + "number", + "observe", + "reqwest 0.11.27", + "serde", + "serde_with", + "solvers-dto", + "thiserror 1.0.61", + "tokio", + "tower 0.4.13", + "tower-http", + "tracing", + "tracing-subscriber", + "vergen", + "web3", +] + [[package]] name = "dunce" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 9c71dbd5de..4e1ace7317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ contracts = { path = "crates/contracts" } cow-amm = { path = "crates/cow-amm" } database = { path = "crates/database" } driver = { path = "crates/driver" } +driver-hyperliquid-template = { path = "crates/driver-hyperliquid-template" } ethrpc = { path = "crates/ethrpc" } model = { path = "crates/model" } moka = "0.12.10" diff --git a/HYPER_EVM_TESTNET.md b/HYPER_EVM_TESTNET.md new file mode 100644 index 0000000000..30fd6c6ff3 --- /dev/null +++ b/HYPER_EVM_TESTNET.md @@ -0,0 +1,296 @@ +# Hyper EVM Testnet Integration + +This document describes the integration of Hyper EVM Testnet (Chain ID: 998) into the CoW Protocol services codebase. + +## Network Specifications + +- **Chain ID**: 998 +- **Network Name**: Hyper EVM Testnet +- **Native Token**: HYPE +- **Wrapped Native Token**: WHYPE +- **Block Time**: 1 second (1000ms) +- **Native Price Estimation Amount**: 0.1 HYPE (100000000000000000 wei) + +## DEX Ecosystem + +- **HyperSwap V2**: Uniswap V2 compatible DEX +- **HyperSwap V3**: Uniswap V3 compatible DEX +- **KyperSwap**: DEX aggregator (integration pending) + +## Changes Made + +### 1. Chain Definition (`crates/chain/src/lib.rs`) + +Added `HyperEvmTestnet = 998` to the `Chain` enum with full implementation: +- Chain name: "Hyper EVM Testnet" +- Block time: 1000ms (1 second) +- Native price estimation amount: 0.1 HYPE (10^17 wei) +- Added to `TryFrom` implementation for chain ID conversion + +### 2. Contract Build Configuration (`crates/contracts/build.rs`) + +Added `HYPER_EVM_TESTNET` constant and placeholder addresses for: +- `WETH9` (WHYPE - Wrapped HYPE token) +- `GPv2Settlement` (Core settlement contract) +- `GPv2AllowListAuthentication` (Solver authorization contract) +- `Balances` (Balance checking helper contract) + +**Important**: All contract addresses are currently set to `0x0000000000000000000000000000000000000000` and marked with `TODO` comments. These MUST be updated after contracts are deployed. + +### 3. Alloy Network Constants (`crates/contracts/src/alloy.rs`) + +Added `HYPER_EVM_TESTNET: u64 = 998` to the `networks` module for Alloy-based contract bindings. + +### 4. Default Liquidity Sources (`crates/shared/src/sources/mod.rs`) + +Configured default liquidity sources for Hyper EVM Testnet: +- `BaselineSource::UniswapV2` (for HyperSwap V2) +- `BaselineSource::UniswapV3` (for HyperSwap V3) + +### 5. Example Configuration (`configs/local/hyper-evm-testnet.toml`) + +Created example configuration file with: +- Chain ID setting +- Placeholder base token addresses +- Routing parameters (max-hops, max-partial-attempts) +- Native token price estimation amount + +## Required Contract Deployments + +The following CoW Protocol contracts need to be deployed to Hyper EVM Testnet: + +### Core CoW Protocol Contracts + +1. **GPv2Settlement** + - Main settlement contract for order execution + - Location in code: `crates/contracts/build.rs` line ~318 + +2. **GPv2AllowListAuthentication** + - Controls which solvers can submit settlements + - Location in code: `crates/contracts/build.rs` line ~218 + +3. **Balances** (Helper Contract) + - Used for balance checking and simulations + - Location in code: `crates/contracts/build.rs` line ~461 + +### Token Contracts + +4. **WHYPE (Wrapped HYPE)** + - ERC20 wrapper for native HYPE token + - Location in code: `crates/contracts/build.rs` line ~354 + +### DEX Integration Contracts (If Not Already Deployed) + +5. **HyperSwap V2 Router** + - Uniswap V2 compatible router + - May need configuration in liquidity sources + +6. **HyperSwap V3 Router** + - Uniswap V3 compatible router + - May need configuration in liquidity sources + +7. **HyperSwap Factories** + - V2 and V3 factory contracts for pool creation + +## Post-Deployment Steps + +### Step 1: Update Contract Addresses + +After deploying the CoW Protocol contracts, update the following files: + +#### `crates/contracts/build.rs` + +Search for `TODO: Update with actual` comments and replace `0x0000000000000000000000000000000000000000` with deployed addresses: + +```rust +// Line ~354: WHYPE address +.add_network_str(HYPER_EVM_TESTNET, "0xYOUR_WHYPE_ADDRESS_HERE") + +// Line ~218: GPv2AllowListAuthentication +.add_network( + HYPER_EVM_TESTNET, + Network { + address: addr("0xYOUR_AUTHENTICATION_ADDRESS_HERE"), + deployment_information: Some(DeploymentInformation::BlockNumber(YOUR_BLOCK_NUMBER)), + }, +) + +// Line ~318: GPv2Settlement +.add_network( + HYPER_EVM_TESTNET, + Network { + address: addr("0xYOUR_SETTLEMENT_ADDRESS_HERE"), + deployment_information: Some(DeploymentInformation::BlockNumber(YOUR_BLOCK_NUMBER)), + }, +) + +// Line ~461: Balances +.add_network_str(HYPER_EVM_TESTNET, "0xYOUR_BALANCES_ADDRESS_HERE") +``` + +### Step 2: Update Base Token Addresses + +Update `configs/local/hyper-evm-testnet.toml` with actual token addresses: + +```toml +base-tokens = [ + "0xACTUAL_WHYPE_ADDRESS", # WHYPE (Wrapped HYPE) + "0xACTUAL_USDC_ADDRESS", # USDC or equivalent + "0xACTUAL_USDT_ADDRESS", # USDT or equivalent + "0xACTUAL_DAI_ADDRESS", # DAI or equivalent +] +``` + +### Step 3: Configure DEX Router Addresses + +If HyperSwap routers are deployed, add their addresses to the alloy contract deployments. + +Create entries similar to existing DEX routers in `crates/contracts/src/alloy.rs`: + +```rust +crate::bindings!( + HyperSwapV2Router, + crate::deployments! { + HYPER_EVM_TESTNET => address!("0xYOUR_HYPERSWAP_V2_ROUTER"), + } +); + +crate::bindings!( + HyperSwapV3Router, + crate::deployments! { + HYPER_EVM_TESTNET => address!("0xYOUR_HYPERSWAP_V3_ROUTER"), + } +); +``` + +### Step 4: Rebuild the Project + +After updating all addresses: + +```bash +# Rebuild contracts crate to regenerate bindings +cd crates/contracts +cargo build + +# Or rebuild the entire project +cd ../.. +cargo build +``` + +### Step 5: Test the Integration + +1. Configure your driver to connect to Hyper EVM Testnet RPC +2. Set the chain ID to 998 in your driver configuration +3. Deploy test orders and verify settlement functionality + +## Configuration Example + +Example `driver.toml` configuration for Hyper EVM Testnet: + +```toml +# Hyper EVM Testnet Driver Configuration +chain-id = 998 + +tx-gas-limit = "45000000" + +[[solver]] +name = "hyperswap-solver" +endpoint = "http://localhost:8080" +absolute-slippage = "40000000000000000" +relative-slippage = "0.1" +account = "0xYOUR_SOLVER_PRIVATE_KEY" + +[submission] +gas-price-cap = "1000000000000" + +[[submission.mempool]] +mempool = "public" + +[contracts] +gp-v2-settlement = "0xYOUR_SETTLEMENT_ADDRESS" +weth = "0xYOUR_WHYPE_ADDRESS" +balances = "0xYOUR_BALANCES_ADDRESS" +signatures = "0xYOUR_SIGNATURES_ADDRESS" + +[liquidity] +base-tokens = [ + "0xYOUR_WHYPE_ADDRESS", + "0xYOUR_USDC_ADDRESS", + "0xYOUR_USDT_ADDRESS", +] + +[[liquidity.uniswap-v2]] +preset = "uniswap-v2" # Will use HyperSwap V2 + +[[liquidity.uniswap-v3]] +# Configure HyperSwap V3 +``` + +## Additional Notes + +### Liquidity Source Configuration + +The system will automatically use HyperSwap V2 and V3 as liquidity sources based on the default configuration in `defaults_for_network()`. Manual configuration can override these defaults via TOML config files. + +### Price Estimation + +Native price estimation uses 0.1 HYPE (10^17 wei) as the default amount for querying prices. This can be adjusted in the chain configuration if needed. + +### Block Time Considerations + +With a 1-second block time, the Hyper EVM Testnet is significantly faster than Ethereum mainnet (12s) but slower than Arbitrum (250ms). This affects: +- Settlement finality timing +- Block number calculations for historical queries +- Reorg protection strategies + +### Testing Checklist + +- [ ] All contract addresses updated with actual deployments +- [ ] Base token addresses configured +- [ ] DEX router addresses added if applicable +- [ ] Driver successfully connects to Hyper EVM Testnet RPC +- [ ] Orders can be created and signed +- [ ] Settlements execute successfully +- [ ] Liquidity sources (HyperSwap) accessible +- [ ] Native price estimation working +- [ ] Gas estimation functioning correctly + +## Support and Troubleshooting + +### Common Issues + +1. **"Chain ID not supported" error** + - Ensure you've rebuilt the project after adding the chain + - Verify chain ID is 998 in your configuration + +2. **"No deployment info for chain" error** + - Update placeholder contract addresses with actual deployments + - Rebuild the contracts crate + +3. **Liquidity source errors** + - Verify DEX router addresses are correct + - Check that pools exist for your token pairs + - Ensure sufficient liquidity in pools + +### Contract Deployment Resources + +- CoW Protocol contracts repository: https://github.com/cowprotocol/contracts +- Deployment scripts are available in the contracts repo +- Recommended deployment order: + 1. WHYPE (if not already deployed) + 2. GPv2AllowListAuthentication + 3. GPv2Settlement + 4. Balances helper contract + 5. Additional support contracts as needed + +## Version Information + +- **Integration Date**: 2025-10-21 +- **Target Chain**: Hyper EVM Testnet (Chain ID 998) +- **Status**: Placeholder addresses - requires contract deployment +- **Compatibility**: CoW Protocol services (current version) + +--- + +For questions or issues, refer to the main CoW Protocol documentation or the services repository. + diff --git a/Justfile b/Justfile index c5a33ac10c..9ab0558d7d 100644 --- a/Justfile +++ b/Justfile @@ -28,6 +28,9 @@ test-e2e *filters: test-driver: RUST_MIN_STACK=3145728 cargo nextest run -p driver --test-threads 1 --run-ignored ignored-only +test-driver-hyperliquid-template: + RUST_MIN_STACK=3145728 cargo nextest run -p driver-hyperliquid-template --test-threads 1 --run-ignored ignored-only + # Run clippy clippy: cargo clippy --locked --workspace --all-features --all-targets -- -D warnings diff --git a/configs/hyper/baseline.toml b/configs/hyper/baseline.toml new file mode 100644 index 0000000000..40c0026b22 --- /dev/null +++ b/configs/hyper/baseline.toml @@ -0,0 +1,8 @@ +chain-id = "998" # Hyper EVM Testnet +base-tokens = [ + "0xADcb2f358Eae6492F61A5F87eb8893d09391d160", # WETH + "0x24ac48bf01fd6CB1C3836D08b3EdC70a9C4380cA", # USDC +] +max-hops = 2 +max-partial-attempts = 5 +native-token-price-estimation-amount = "100000000000000000" # 0.1 HYPE diff --git a/configs/hyper/driver.toml b/configs/hyper/driver.toml new file mode 100644 index 0000000000..0bb01e7f21 --- /dev/null +++ b/configs/hyper/driver.toml @@ -0,0 +1,33 @@ +tx-gas-limit = "45000000" +disable-access-list-simulation = true + +[[solver]] +name = "baseline" # Arbitrary name given to this solver, must be unique +endpoint = "http://baseline" +absolute-slippage = "40000000000000000" # Denominated in wei, optional +relative-slippage = "0.1" # Percentage in the [0, 1] range +account = "" # solver 3 private key + +[submission] +gas-price-cap = "1000000000000" + +[[submission.mempool]] +mempool = "public" + +[liquidity] +base-tokens = [ + "0xADcb2f358Eae6492F61A5F87eb8893d09391d160", # WETH + "0x24ac48bf01fd6CB1C3836D08b3EdC70a9C4380cA", # USDC +] + +[[liquidity.uniswap-v2]] # Custom Uniswap V2 configuration +router = "0x6Ca2d020285a991930Db9336685F36530Dd62F90" +pool-code = "0x89e28f0672d230e32003ce2233112a4f044ab0238d476078999b58524fa386db" +missing-pool-cache-time = "1h" + +# [[liquidity.uniswap-v3]] # Uniswap V3 configuration +# router = "0xD81F56576B1FF2f3Ef18e9Cc71Adaa42516fD990" +# max-pools-to-initialize = 100 # how many of the deepest pools to initialise on startup +# max-pools-per-tick-query = 9223372036854775807 +# graph-url = "https://api.goldsky.com/api/public/project_cm97l77ib0cz601wlgi9wb0ec/subgraphs/v3-subgraph/6.0.0/gn" # which subgraph url to fetch the data from +# reinit-interval = "12h" \ No newline at end of file diff --git a/configs/local/driver.toml b/configs/local/driver.toml index 5309fd6c10..6bca37da72 100644 --- a/configs/local/driver.toml +++ b/configs/local/driver.toml @@ -1,8 +1,8 @@ -tx-gas-limit = 45000000 +tx-gas-limit = "45000000" [[solver]] name = "baseline" # Arbitrary name given to this solver, must be unique -endpoint = "http://baseline" +endpoint = "http://localhost:9001" absolute-slippage = "40000000000000000" # Denominated in wei, optional relative-slippage = "0.1" # Percentage in the [0, 1] range account = "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" # Known test private key diff --git a/crates/chain/src/lib.rs b/crates/chain/src/lib.rs index fd3beb0f3d..0e6e289844 100644 --- a/crates/chain/src/lib.rs +++ b/crates/chain/src/lib.rs @@ -24,6 +24,7 @@ pub enum Chain { Optimism = 10, Polygon = 137, Lens = 232, + HyperEvmTestnet = 998, } impl Chain { @@ -49,6 +50,7 @@ impl Chain { Self::Optimism => "Optimism", Self::Polygon => "Polygon", Self::Lens => "Lens", + Self::HyperEvmTestnet => "Hyper EVM Testnet", } } @@ -61,7 +63,8 @@ impl Chain { | Self::ArbitrumOne | Self::Base | Self::Bnb - | Self::Optimism => 10u128.pow(17).into(), + | Self::Optimism + | Self::HyperEvmTestnet => 10u128.pow(17).into(), // 0.1 native token Self::Gnosis | Self::Avalanche | Self::Lens => 10u128.pow(18).into(), Self::Polygon => 10u128.pow(20).into(), Self::Hardhat => { @@ -85,6 +88,7 @@ impl Chain { Self::Optimism => Duration::from_millis(2_000), Self::Polygon => Duration::from_millis(2_000), Self::Lens => Duration::from_millis(2_000), + Self::HyperEvmTestnet => Duration::from_millis(1_000), // 1 second block time } } @@ -114,6 +118,7 @@ impl TryFrom for Chain { x if x == Self::Optimism as u64 => Self::Optimism, x if x == Self::Polygon as u64 => Self::Polygon, x if x == Self::Lens as u64 => Self::Lens, + x if x == Self::HyperEvmTestnet as u64 => Self::HyperEvmTestnet, _ => Err(ChainIdNotSupported)?, }; Ok(network) diff --git a/crates/contracts/build.rs b/crates/contracts/build.rs index 06af3d62fe..8f3c8c2736 100644 --- a/crates/contracts/build.rs +++ b/crates/contracts/build.rs @@ -21,6 +21,8 @@ const AVALANCHE: &str = "43114"; const BNB: &str = "56"; const OPTIMISM: &str = "10"; const LENS: &str = "232"; +const HYPER_EVM: &str = "999"; +const HYPER_EVM_TESTNET: &str = "998"; fn main() { // NOTE: This is a workaround for `rerun-if-changed` directives for @@ -124,6 +126,20 @@ fn main() { deployment_information: Some(DeploymentInformation::BlockNumber(2612937)), }, ) + .add_network( + HYPER_EVM_TESTNET, + Network { + address: addr("0xF447565981Ad390a44de9764032F2E3991F12661"), + deployment_information: Some(DeploymentInformation::BlockNumber(35687405)), + }, + ) + .add_network( + HYPER_EVM, + Network { + address: addr("0x1e2972A48c4dA05055Ae4630bd203aA363aFcBa0"), + deployment_information: Some(DeploymentInformation::BlockNumber(18380860)), + }, + ) }); generate_contract_with_config("GPv2Settlement", |builder| { builder @@ -215,6 +231,48 @@ fn main() { deployment_information: Some(DeploymentInformation::BlockNumber(2621745)), }, ) + .add_network( + HYPER_EVM_TESTNET, + Network { + address: addr("0xDc746A7FF2DaAf182DA82560318F6c1b36d067b1"), + deployment_information: Some(DeploymentInformation::BlockNumber(35687405)), + }, + ) + .add_network( + HYPER_EVM, + Network { + address: addr("0xf633D8a63E9E26b4A9e77B8aEf4A1bf54C8fC4AD"), + deployment_information: Some(DeploymentInformation::BlockNumber(18380921)), + }, + ) + }); + // EIP-1271 contract - SignatureValidator + generate_contract("ERC1271SignatureValidator"); + generate_contract_with_config("UniswapV3SwapRouterV2", |builder| { + // + builder + .add_network_str(MAINNET, "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45") + .add_network_str(ARBITRUM_ONE, "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45") + .add_network_str(POLYGON, "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45") + .add_network_str(OPTIMISM, "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45") + .add_network_str(BASE, "0x2626664c2603336E57B271c5C0b26F421741e481") + .add_network_str(AVALANCHE, "0xbb00FF08d01D300023C629E8fFfFcb65A5a578cE") + .add_network_str(BNB, "0xB971eF87ede563556b2ED4b1C0b0019111Dd85d2") + .add_network_str(LENS, "0x6ddD32cd941041D8b61df213B9f515A7D288Dc13") + // Not available on Gnosis Chain + }); + generate_contract_with_config("UniswapV3QuoterV2", |builder| { + // + builder + .add_network_str(MAINNET, "0x61fFE014bA17989E743c5F6cB21bF9697530B21e") + .add_network_str(ARBITRUM_ONE, "0x61fFE014bA17989E743c5F6cB21bF9697530B21e") + .add_network_str(BASE, "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a") + .add_network_str(AVALANCHE, "0xbe0F5544EC67e9B3b2D979aaA43f18Fd87E6257F") + .add_network_str(BNB, "0x78D78E420Da98ad378D7799bE8f4AF69033EB077") + .add_network_str(OPTIMISM, "0x61fFE014bA17989E743c5F6cB21bF9697530B21e") + .add_network_str(POLYGON, "0x61fFE014bA17989E743c5F6cB21bF9697530B21e") + .add_network_str(LENS, "0x1eEA2B790Dc527c5a4cd3d4f3ae8A2DDB65B2af1") + // Not listed on Gnosis and Sepolia chains }); generate_contract_with_config("WETH9", |builder| { // Note: the WETH address must be consistent with the one used by the ETH-flow @@ -231,6 +289,40 @@ fn main() { .add_network_str(OPTIMISM, "0x4200000000000000000000000000000000000006") .add_network_str(POLYGON, "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270") .add_network_str(LENS, "0x6bDc36E20D267Ff0dd6097799f82e78907105e2F") + .add_network_str( + HYPER_EVM_TESTNET, + "0xADcb2f358Eae6492F61A5F87eb8893d09391d160", + ) + .add_network_str( + HYPER_EVM, + "0x5555555555555555555555555555555555555555", + ) + }); + generate_contract_with_config("IUniswapV3Factory", |builder| { + // + builder + .add_network_str(MAINNET, "0x1F98431c8aD98523631AE4a59f267346ea31F984") + .add_network_str(GOERLI, "0x1F98431c8aD98523631AE4a59f267346ea31F984") + .add_network_str(SEPOLIA, "0x1F98431c8aD98523631AE4a59f267346ea31F984") + .add_network_str(ARBITRUM_ONE, "0x1F98431c8aD98523631AE4a59f267346ea31F984") + .add_network_str(BASE, "0x33128a8fC17869897dcE68Ed026d694621f6FDfD") + .add_network_str(AVALANCHE, "0x740b1c1de25031C31FF4fC9A62f554A55cdC1baD") + .add_network_str(BNB, "0xdB1d10011AD0Ff90774D0C6Bb92e5C5c8b4461F7") + .add_network_str(OPTIMISM, "0x1F98431c8aD98523631AE4a59f267346ea31F984") + .add_network_str(POLYGON, "0x1F98431c8aD98523631AE4a59f267346ea31F984") + // not official + .add_network_str(LENS, "0xc3A5b857Ba82a2586A45a8B59ECc3AA50Bc3D0e3") + // Not available on Gnosis Chain + }); + generate_contract_with_config("CowProtocolToken", |builder| { + builder + .add_network_str(MAINNET, "0xDEf1CA1fb7FBcDC777520aa7f396b4E015F497aB") + .add_network_str(GOERLI, "0x91056D4A53E1faa1A84306D4deAEc71085394bC8") + .add_network_str(GNOSIS, "0x177127622c4A00F3d409B75571e12cB3c8973d3c") + .add_network_str(SEPOLIA, "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59") + .add_network_str(ARBITRUM_ONE, "0xcb8b5CD20BdCaea9a010aC1F8d835824F5C87A04") + .add_network_str(BASE, "0xc694a91e6b071bF030A18BD3053A7fE09B6DaE69") + // Not available on Lens }); generate_contract("CowAmm"); generate_contract_with_config("CowAmmConstantProductFactory", |builder| { @@ -280,6 +372,121 @@ fn main() { ) }); generate_contract("CowAmmUniswapV2PriceOracle"); + + // Support contracts used for various order simulations. + generate_contract_with_config("Balances", |builder| { + builder + .add_network_str(MAINNET, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(ARBITRUM_ONE, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(BASE, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(AVALANCHE, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(BNB, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(OPTIMISM, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(POLYGON, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(LENS, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(GNOSIS, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str(SEPOLIA, "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b") + .add_network_str( + HYPER_EVM_TESTNET, + "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b", + ) + .add_network_str( + HYPER_EVM, + "0x3e8c6de9510e7ecad902d005de3ab52f35cf4f1b", + ) + }); + + // Contract for Uniswap's Permit2 contract. + generate_contract_with_config("Permit2", |builder| { + builder + .add_network( + MAINNET, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(15986406)), + }, + ) + .add_network( + GNOSIS, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(27338672)), + }, + ) + .add_network( + SEPOLIA, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(2356287)), + }, + ) + .add_network( + ARBITRUM_ONE, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(38692735)), + }, + ) + .add_network( + BASE, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(1425180)), + }, + ) + .add_network( + AVALANCHE, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(28844415)), + }, + ) + .add_network( + BNB, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(25343783)), + }, + ) + .add_network( + OPTIMISM, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(38854427)), + }, + ) + .add_network( + POLYGON, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + // + deployment_information: Some(DeploymentInformation::BlockNumber(35701901)), + }, + ) + .add_network( + HYPER_EVM_TESTNET, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + deployment_information: Some(DeploymentInformation::BlockNumber(35687405)), + }, + ) + .add_network( + HYPER_EVM, + Network { + address: addr("0x000000000022D473030F116dDEE9F6B43aC78BA3"), + deployment_information: Some(DeploymentInformation::BlockNumber(18380921)), + }, + ) + // Not available on Lens + }); } fn generate_contract(name: &str) { diff --git a/crates/contracts/src/alloy.rs b/crates/contracts/src/alloy.rs index d1c5cd1617..ace08834a0 100644 --- a/crates/contracts/src/alloy.rs +++ b/crates/contracts/src/alloy.rs @@ -9,6 +9,8 @@ pub mod networks { pub const BNB: u64 = 56; pub const OPTIMISM: u64 = 10; pub const LENS: u64 = 232; + pub const HYPER_EVM: u64 = 999; + pub const HYPER_EVM_TESTNET: u64 = 998; } crate::bindings!( @@ -434,6 +436,9 @@ crate::bindings!( // POLYGON => address!("0x9e5A52f57b3038F1B8EeE45F28b3C1967e22799C"), // Not available on Lens + HYPER_EVM_TESTNET => address!("0xA028411927E2015A363014881a4404C636218fb1"), + // Hyper EVM use hyper-swap router + HYPER_EVM => address!("0x724412C00059bf7d6ee7d4a1d0D5cd4de3ea1C48"), } ); crate::bindings!( @@ -459,6 +464,8 @@ crate::bindings!( // POLYGON => address!("0xedf6066a2b290C185783862C7F4776A2C8077AD1"), // Not available on Lens + HYPER_EVM_TESTNET => address!("0x85aA63EB2ab9BaAA74eAd7e7f82A571d74901853"), + HYPER_EVM => address!("0xb4a9C4e6Ea8E2191d2FA5B380452a634Fb21240A"), } ); crate::bindings!(IUniswapLikeRouter); @@ -477,6 +484,8 @@ crate::bindings!( POLYGON => address!("0x61fFE014bA17989E743c5F6cB21bF9697530B21e"), LENS => address!("0x1eEA2B790Dc527c5a4cd3d4f3ae8A2DDB65B2af1"), // Not listed on Gnosis and Sepolia chains + HYPER_EVM_TESTNET => address!("0x7FEd8993828A61A5985F384Cee8bDD42177Aa263"), + HYPER_EVM => address!("0x03A918028f22D9E1473B7959C927AD7425A45C7C"), } ); crate::bindings!( @@ -492,6 +501,8 @@ crate::bindings!( BNB => address!("0xB971eF87ede563556b2ED4b1C0b0019111Dd85d2"), LENS => address!("0x6ddD32cd941041D8b61df213B9f515A7D288Dc13"), // Not available on Gnosis Chain + HYPER_EVM_TESTNET => address!("0xD81F56576B1FF2f3Ef18e9Cc71Adaa42516fD990"), + HYPER_EVM => address!("0x6D99e7f6747AF2cDbB5164b6DD50e40D4fDe1e77"), } ); crate::bindings!( @@ -509,6 +520,8 @@ crate::bindings!( // not official LENS => address!( "0xc3A5b857Ba82a2586A45a8B59ECc3AA50Bc3D0e3"), // Not available on Gnosis Chain + HYPER_EVM_TESTNET => address!("0x22B0768972bB7f1F5ea7a8740BB8f94b32483826"), + HYPER_EVM => address!("0xB1c0fa0B789320044A6F623cFe5eBda9562602E3"), } ); @@ -527,6 +540,8 @@ crate::bindings!( OPTIMISM => address!("0x60Bf78233f48eC42eE3F101b9a05eC7878728006"), POLYGON => address!("0x60Bf78233f48eC42eE3F101b9a05eC7878728006"), LENS => address!("0x60Bf78233f48eC42eE3F101b9a05eC7878728006"), + HYPER_EVM_TESTNET => address!("0xe7287fbeb5e11cA97Dc32422C572847C94413c4f"), + HYPER_EVM => address!("0x77FDB9625e8DD8Acca75F751b1c448CA9faae4e9"), } ); @@ -625,6 +640,8 @@ crate::bindings!( OPTIMISM => (address!("0x000000000022D473030F116dDEE9F6B43aC78BA3"), 38854427), // POLYGON => (address!("0x000000000022D473030F116dDEE9F6B43aC78BA3"), 35701901), + HYPER_EVM_TESTNET => (address!("0x000000000022D473030F116dDEE9F6B43aC78BA3"), 35687405), + HYPER_EVM => (address!("0x000000000022D473030F116dDEE9F6B43aC78BA3"), 22901), } ); @@ -653,6 +670,8 @@ pub mod support { LENS => address!("0x8262d639c38470F38d2eff15926F7071c28057Af"), GNOSIS => address!("0x8262d639c38470F38d2eff15926F7071c28057Af"), SEPOLIA => address!("0x8262d639c38470F38d2eff15926F7071c28057Af"), + HYPER_EVM_TESTNET => address!("0x8262d639c38470F38d2eff15926F7071c28057Af"), + HYPER_EVM => address!("0x8262d639c38470f38d2eff15926f7071c28057af"), } ); // Support contracts used for various order simulations. @@ -669,6 +688,8 @@ pub mod support { LENS => address!("0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b"), GNOSIS => address!("0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b"), SEPOLIA => address!("0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b"), + HYPER_EVM_TESTNET => address!("0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b"), + HYPER_EVM => address!("0x3e8c6de9510e7ecad902d005de3ab52f35cf4f1b"), } ); } diff --git a/crates/driver-hyperliquid-template/Cargo.toml b/crates/driver-hyperliquid-template/Cargo.toml new file mode 100644 index 0000000000..b1ca1b64ee --- /dev/null +++ b/crates/driver-hyperliquid-template/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "driver-hyperliquid-template" +version = "0.1.0" +authors = ["Cow Protocol Developers "] +edition = "2021" +license = "GPL-3.0-or-later" + +[[bin]] +name = "driver-hyperliquid-template" +path = "src/main.rs" + +[dependencies] +app-data = { workspace = true } +axum = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +chrono = { workspace = true, features = ["clock"], default-features = false } +derive_more = { workspace = true } +driver = { path = "../driver" } +futures = { workspace = true } +hyper = { workspace = true } +num = { workspace = true } +number = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_with = { workspace = true } +solvers-dto = { path = "../solvers-dto" } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "time"] } +tower = { workspace = true } +tower-http = { workspace = true, features = ["limit", "trace"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +web3 = { workspace = true, features = ["http"] } + +# TODO These either need to be removed or changed to be direct +# dependencies rather than workspace dependencies +anyhow = { workspace = true } +model = { workspace = true } +observe = { workspace = true, features = ["axum-tracing"] } + +[build-dependencies] +anyhow = { workspace = true } +vergen = { workspace = true, features = ["git", "gitcl"] } + +[lints] +workspace = true \ No newline at end of file diff --git a/crates/driver-hyperliquid-template/GUIDELINES.md b/crates/driver-hyperliquid-template/GUIDELINES.md new file mode 100644 index 0000000000..047dbf5254 --- /dev/null +++ b/crates/driver-hyperliquid-template/GUIDELINES.md @@ -0,0 +1,53 @@ +# Guidelines + +High-level guidelines for writing code in a domain-driven style. + +## Tests + +Prefer to test at the _highest level of abstraction_. This means that the best tests are those +that interact with the code at the public API layer. E.g., if the code exposes a REST API, +the tests should be hitting the REST API. Mocking should be brought to a minimum. The +only components that should be mocked are those components which are _external_ to our system. +Example: quoting or settlement scoring logic are not external to the system. However, a remote +solver or an external API provider are external to our system. + +There are a few reasons why this is a good approach: +- The only way to know that the boundary integrations work (e.g. the boundary between your API +layer and some other layer) is to include that boundary in the tests. +- Code needs to be refactorable. When tests touch implementation details, those implementation +details become harder to change. Furthermore, because tests need to change along with the +implementation details, tests are now useless because you may have made a mistake while changing +them. Developers are less inclined to refactor and code starts to rot. + +It's OK to test implementation details if it's an edge case that's impossible to test from the +highest layer, or if it's so difficult to test from the highest layer that testing it that way +becomes impractical. + +## Core Logic + +This is also referred to as "domain logic". The core logic sets your software +apart from all other software and brings value to your business. For example, almost +every software system today has some form of persistence (DB, writing to files, etc.), some +form of UI (web UI, CLI, REST API), some external components that it consumes, etc. What +sets these software systems apart are the _domain problems_ which they solve. For example, cowswap +is different than any other software system today not because it provides a REST API, but +because it provides a clever way to find the best exchange price for the user across all DEXes. +So when we remove all the externalities and the boilerplate, what we're left with is the _core logic_. +This is the most important part of the codebase. This code needs to be clean and well-documented. This +is the code which defines rules that people who want to understand the system will care about the most. + +To this end, when writing code in a domain-driven style, expose a module called `logic` which will +contain the core logic. Model the core logic around _concepts_, not [processes](https://www.martinfowler.com/eaaCatalog/transactionScript.html). +The `driver` crate can serve as a more practical illustration of what this domain-driven architecture +looks like. The `logic` module defines various types like `Auction`, `Solution`, `solution::Score`, +etc. These model the domain logic _concepts_. Other top-level modules such as `api` or `solver` +model boilerplate and external components. (Solvers are external servers which expose an HTTP +interface to the driver.) + +## [Data Transfer Objects (DTOs)](https://martinfowler.com/eaaCatalog/dataTransferObject.html) + +DTOs generally refer to "models" used to receive or make HTTP or RPC requests, or to store +data in the database, etc. These models should be used _only_ for that one purpose. E.g., +an API model should be used only for receiving HTTP requests. It should _NEVER_ be +reused in the core logic or in the database storage logic. Other than that, the only other +thing that DTOs should be used for is mapping to/from core logic types. diff --git a/crates/driver-hyperliquid-template/README.md b/crates/driver-hyperliquid-template/README.md new file mode 100644 index 0000000000..a20803a70d --- /dev/null +++ b/crates/driver-hyperliquid-template/README.md @@ -0,0 +1,47 @@ +# Driver + +The purpose of the driver is to make it easier to author, run, and maintain solvers. + +All solvers do the following: + +- collect some onchain liquidity (optional), +- pick the best solution from a set, +- encode the solution into a settlement and publish it when it wins the competition, +- generate quotes. + +The logic for these tasks is, in many (almost all) cases, completely reusable. The only piece of logic that is +unique to an individual solver is how to generate a set of solutions. Then, picking the best solution, +or converting the solutions into a quote, or encoding and publishing settlements, etc. can all be done +in the same way. + +The purpose of the driver is to execute all of the tasks above so that the solvers don't have to. Then, +the solvers need only write the code for generating solutions. + +Solvers which make use of the driver are sometimes referred to as _solver engines_, whereas the +$(solver \space engine, driver)$ pair is referred to as _the solver_. A solver which does not make +use of the driver is sometimes referred to as a _full solver_. But often the distinction between +a solver which uses the driver and one which does not use the driver is irrelevant, in which case +we often simply say _solver_. + +Note that all of the functionality which is normally provided by the driver has to be provided by +the full solver itself, in case it decides not to use the driver. + +## Sequence Diagram + +```mermaid +sequenceDiagram + box protocol + participant autopilot + end + box solver + participant driver + participant solver engine + end + autopilot->>driver: auction + driver->>solver engine: auction + solver engine->>driver: set of solutions + driver->>autopilot: the best solution or quote,
depending on the request + autopilot->>driver: request to publish a settlement,
in case this solver won + driver->>driver: encode and publish the settlement + autopilot->>autopilot: detect when the settlement is published
by monitoring the blockchain +``` diff --git a/crates/driver-hyperliquid-template/build.rs b/crates/driver-hyperliquid-template/build.rs new file mode 100644 index 0000000000..3243ed0b8c --- /dev/null +++ b/crates/driver-hyperliquid-template/build.rs @@ -0,0 +1,9 @@ +use { + anyhow::{Context, Result}, + vergen::EmitBuilder, +}; + +fn main() -> Result<()> { + // Set environment variable VERGEN_GIT_SHA for use to log version at startup + EmitBuilder::builder().git_sha(true).emit().context("emit") +} diff --git a/crates/driver-hyperliquid-template/example.toml b/crates/driver-hyperliquid-template/example.toml new file mode 100644 index 0000000000..bdc1e5a4b7 --- /dev/null +++ b/crates/driver-hyperliquid-template/example.toml @@ -0,0 +1,115 @@ +tx-gas-limit = "45000000" +[[solver]] +name = "mysolver" # Arbitrary name given to this solver, must be unique +endpoint = "http://0.0.0.0:7872" +absolute-slippage = "40000000000000000" # Denominated in wei, optional +relative-slippage = "0.1" # Percentage in the [0, 1] range +account = "0x0000000000000000000000000000000000000000000000000000000000000001" # The private key of the solver +merge-solutions = true # Multiple solutions proposed by the solver may be combined into one by the driver +response-size-limit-max-bytes = 30000000 + +[solver.request-headers] +fake-header-one = "FAKE-HEADER-VALUE" # For instance an authorization token which must be provided on each request + +# [[solver]] # And so on, specify as many solvers as needed +# name = "othersolver" +# endpoint = "http://localhost:1235" +# relative-slippage = "0.1" +# account = "0x0000000000000000000000000000000000000000000000000000000000000002" + +[submission] +gas-price-cap = "1000000000000" + +[[submission.mempool]] +mempool = "public" +max-additional-tip = "5000000000" +additional-tip-percentage = 0.05 + +[[submission.mempool]] +mempool = "mev-blocker" +url = "https://your.custom.rpc.endpoint" +max-additional-tip = "5000000000" +additional-tip-percentage = 0.05 +use-soft-cancellations = true + +[contracts] # Optionally override the contract addresses, necessary on less popular blockchains +gp-v2-settlement = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" +weth = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" +balances = "0x3e8C6De9510e7ECad902D005DE3Ab52f35cF4f1b" +signatures = "0x8262d639c38470F38d2eff15926F7071c28057Af" +flashloan-router = "0x0000000000000000000000000000000000000000" + +[[contracts.cow-amms]] +# address of factory creating new CoW AMMs +factory = "0x86f3df416979136cb4fdea2c0886301b911c163b" +# address of contract to help interfacing with the created CoW AMMs +helper = "0x86f3df416979136cb4fdea2c0886301b911c163b" + +[liquidity] +base-tokens = [ + "0xDEf1CA1fb7FBcDC777520aa7f396b4E015F497aB", + "0x6B175474E89094C44Da98b954EedeAC495271d0F", +] + +[[order-priority]] +strategy = "creation-timestamp" + +[[order-priority]] +strategy = "external-price" + +[[order-priority]] +strategy = "own-quotes" +max-order-age = "1m" + +# [[liquidity.uniswap-v2]] # Uniswap V2 configuration +# preset = "uniswap-v2" # or "sushi-swap", "honeyswap", "baoswap", "pancake-swap", etc. + +# [[liquidity.uniswap-v2]] # Custom Uniswap V2 configuration +# router = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D" +# pool-code = "0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f" + +# [[liquidity.swapr]] # Swapr configuration +# preset = "swapr" + +# [[liquidity.swapr]] # Custom Swapr configuration +# router = "0xb9960d9bca016e9748be75dd52f02188b9d0829f" +# pool-code = "0xd306a548755b9295ee49cc729e13ca4a45e00199bbd890fa146da43a50571776" + +# [[liquidity.uniswap-v2]] # Uniswap V2 configuration +# preset = "uniswap-v2" # or "sushi-swap", "honeyswap", "baoswap", "pancake-swap", etc. + +# [[liquidity.uniswap-v2]] # Custom Uniswap V2 configuration +# router = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D" +# pool-code = "0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f" + +# [[liquidity.balancer-v2]] # Balancer V2 configuration +# preset = "balancer-v2" +# graph-url = "http://localhost:1234" # which subgraph url to fetch the data from +# pool-deny-list = [] # optional + +# [[liquidity.balancer-v2]] # Custom Balancer V2 configuration +# vault = "0xBA12222222228d8Ba445958a75a0704d566BF2C8" +# weighted = [] # weighted pool factory addresses +# stable = [] # stable pool factory addresses +# liquidity-bootstrapping = [] # liquidity bootstrapping pool factory addresses +# pool-deny-list = [] # which pools to ignore + +# [[liquidity.uniswap-v3]] # Uniswap V3 configuration +# preset = "uniswap-v3" +# graph-url = "http://localhost:1234" # which subgraph url to fetch the data from +# max_pools_to_initialize = 100 # how many of the deepest pools to initialise on startup + +# [[liquidity.uniswap-v3]] # Custom Uniswap V3 configuration +# router = "0xE592427A0AEce92De3Edee1F18E0157C05861564" +# max_pools_to_initialize = 100 # how many of the deepest pools to initialise on startup + +# [enso] +# url = "http://localhost:8454" +# network-block-interval = "12s" + +# [liquidity-sources-notifier] # Sends settlement notifications to third party liquidity sources used by solvers +# [liquidity-sources-notifier.liquorice] +# base-url = "https://api.liquorice.tech/" +# api-key = "..." +# http_timeout = "10s" + diff --git a/crates/driver-hyperliquid-template/intergrate-trading-system.md b/crates/driver-hyperliquid-template/intergrate-trading-system.md new file mode 100644 index 0000000000..8ab57bc392 --- /dev/null +++ b/crates/driver-hyperliquid-template/intergrate-trading-system.md @@ -0,0 +1,154 @@ +# Integrating a Trading System with Driver-Hyperliquid-Template + +This guide explains how to integrate an external trading system, particularly one that utilizes a central `vault` contract for token transfers, with the `driver-hyperliquid-template`. + +## Core Concept: Vault-based Swaps + +In this integration model, the `vault` contract acts as a central intermediary for token swaps. The `driver-hyperliquid-template`, acting as a solver, is responsible for constructing the necessary on-chain `interactions` that utilize this `vault` for token transfers. + +When a user wants to swap Token A for Token B, the solver's proposed solution, executed via the settlement contract, will orchestrate two main types of interactions involving the `vault`: + +1. **User to Vault (Sell Token)**: The user first grants approval to the `vault` contract (or the settlement contract, which then calls the vault) to spend their `sellToken` (Token A). The `vault` then pulls the `sellAmount` of Token A from the user. +2. **Vault to User (Buy Token)**: The `vault` contract then transfers the `buyAmount` of `buyToken` (Token B) to the user. + +This design centralizes liquidity management and execution within the `vault`, simplifying the on-chain logic for individual trades and allowing the solver to focus on optimal order matching and interaction construction. These vault-based interactions are generated during the `/quote` phase and ultimately executed during the `/settle` phase. + +## API Integration Flow + +### 1. Getting a Quote (`GET /quote`) + +This endpoint is used to obtain a price estimation for a potential trade. + +When the `driver-hyperliquid-template` receives a `GET /quote` request, it expects the following query parameters: `sellToken`, `buyToken`, `kind` (buy/sell), `amount`, and `deadline`. + +- The driver will internally query the external trading system (e.g., Hyperliquid) to determine the optimal `buyAmount` for a given `sellAmount` (or vice-versa) based on the requested `kind` and `amount`. +- The response from the external trading system will be used to construct the `QuoteResponseKind`. +- Crucially, the `interactions` field within the `QuoteResponse` will describe the necessary on-chain steps for the swap. For a vault-based system, these interactions will typically look like this: + - **Interaction 1 (User to Vault)**: + - `target`: Address of the `sellToken` (Token A) contract. + - `value`: `0` (for ERC20 transfers). + - `callData`: Encoded `transferFrom` call to move `sellAmount` of Token A from the user to the `vault`. + - **Interaction 2 (Vault to User)**: + - `target`: Address of the `buyToken` (Token B) contract. + - `value`: `0` (for ERC20 transfers). + - `callData`: Encoded `transfer` call to move `buyAmount` of Token B from the `vault` to the user. + + The `solver` field in the `QuoteResponse` should be the address of the `vault` contract, as it is the entity performing the actual token transfer to the user. The `gas` field will provide an estimated gas cost for the trade. + +### 2. Solving an Auction (`POST /solve`) + +This endpoint is called by Autopilot to find the optimal settlement solution for a given auction. It does not execute the trade directly but proposes a solution. + +When the `driver-hyperliquid-template` receives a `POST /solve` request, the `SolveRequest` body will contain: + +- `id`: A unique identifier for the auction. +- `orders`: A list of solvable orders included in the auction. +- `tokens`: Information about the tokens involved in the auction. +- `deadline`: The time by which the solver is expected to respond. + +- The driver, acting as a solver, will process these orders and tokens, potentially querying the external trading system for the latest market data. +- It will then determine the best way to match and settle these orders, aiming to maximize the objective value (e.g., user surplus). +- The response will be a `SolveResponse`, which includes one or more `solutions`. Each solution will have a `solutionId`, a `score` (objective value), and details about how orders are executed (e.g., `executedSell`, `executedBuy`). Crucially, this response *does not* contain the calldata for on-chain execution; it only indicates the solver's proposed outcome. + +### Example `QuoteResponse` (Conceptual) + +```json +{ + "clearingPrices": {}, + "preInteractions": [], + "interactions": [ + { + "target": "0x...[Token A Address]...", + "value": "0", + "callData": "0x...[transferFrom(user, vault, amountA)]..." + }, + { + "target": "0x...[Token B Address]...", + "value": "0", + "callData": "0x...[transfer(user, amountB)]..." + } + ], + "solver": "0x...[Vault Contract Address]...", + "gas": 100000, + "txOrigin": "0x...[User Address]...", + "jitOrders": [] +} +``` + +### 3. Settling an Auction (`POST /settle`) + +This endpoint is called by Autopilot to instruct the solver to execute a previously found solution on-chain. + +When the `driver-hyperliquid-template` receives a `POST /settle` request, the `SettleRequest` body will contain: + +- `solutionId`: The unique identifier of the solution to be executed, which was previously returned by the `/solve` endpoint. +- `submissionDeadlineLatestBlock`: The last block number in which the solution transaction can be included. +- `auctionId`: The ID of the auction in which the specified solution is competing. + +- Upon receiving this request, the driver is expected to immediately submit the transaction to the blockchain that performs the on-chain `interactions` associated with the `solutionId`. +- This action finalizes the swap, transferring tokens via the `vault` contract as described in the solution. + +By following this pattern, the `driver-hyperliquid-template` can effectively integrate with external trading systems that rely on a `vault` for secure and efficient token swaps. + +## Sequence Diagram + +```mermaid +sequenceDiagram + participant User + participant CowSystem + participant Driver + participant Onchain + participant TradingSystem as External Trading System + + User->>CowSystem: Enter swap order (Token A -> Token B) + activate CowSystem + CowSystem-->>Driver: GET /quote (Token A -> Token B) + activate Driver + Note right of Driver: There are 2 ways:
1. Build quote on External Trading System
2. Build quote on Driver + alt Build quote on External Trading System + Driver->>TradingSystem: Get quote + activate TradingSystem + TradingSystem->>TradingSystem: Build quote on External Trading System + TradingSystem-->>Driver: Return quote details + deactivate TradingSystem + else Build quote on Driver + loop + Driver->>TradingSystem: Get latest limit orders + activate TradingSystem + TradingSystem-->>Driver: Return limit orders + deactivate TradingSystem + Driver->>Driver: Build quote from limit orders + end + end + Driver-->>CowSystem: 200 OK (QuoteResponse with interactions) + deactivate Driver + CowSystem-->>User: 200 OK (QuoteResponse with interactions) + deactivate CowSystem + + User->>CowSystem: Click "Swap" button + activate CowSystem + + CowSystem->>Driver: POST /solve (with interactions) + activate Driver + Driver->>Driver: Validate interactions, create auction solution + Note over Driver, TradingSystem: may be need query External Trading System for latest data + Driver-->>CowSystem: auction solution + deactivate Driver + CowSystem-->>CowSystem: Check if this solution wins + alt This solution wins + CowSystem->>Driver: POST /settle + activate Driver + Driver->>Onchain: Submit auction settlement transaction + activate Onchain + Onchain-->>Driver: Tx completion + deactivate Onchain + Driver-->>CowSystem: Notify swap completion + Driver->>TradingSystem: Notify swap completion + deactivate Driver + else this solution loses + CowSystem->>CowSystem: Call winner driver's /settle + end + + CowSystem-->>User: Notify swap completion + deactivate CowSystem +``` diff --git a/crates/driver-hyperliquid-template/openapi.yml b/crates/driver-hyperliquid-template/openapi.yml new file mode 100644 index 0000000000..50ab2176c6 --- /dev/null +++ b/crates/driver-hyperliquid-template/openapi.yml @@ -0,0 +1,758 @@ +openapi: 3.0.3 +info: + title: Solver API + description: | + The API implemented by Solvers to be queried by Autopilot. + version: 0.0.1 +paths: + /quote: + get: + operationId: getQuote + description: Get price estimation quote. + parameters: + - in: query + name: sellToken + description: The token to sell. + schema: + $ref: "#/components/schemas/Address" + required: true + - in: query + name: buyToken + description: The token to buy. + schema: + $ref: "#/components/schemas/Address" + required: true + - in: query + name: kind + description: >- + - `buy`: amount is in buy_token, out_amount is in sell_token - + `sell`: amount is in sell_token, out_amount is in buy_token + schema: + type: string + enum: + - buy + - sell + required: true + - in: query + name: amount + description: The amount to buy or sell. + schema: + $ref: "#/components/schemas/TokenAmount" + required: true + - in: query + name: deadline + description: The time until which the caller expects a response. + schema: + $ref: "#/components/schemas/DateTime" + required: true + responses: + "200": + description: Quote successfully created. + content: + application/json: + schema: + $ref: "#/components/schemas/QuoteResponseKind" + "400": + $ref: "#/components/responses/BadRequest" + "429": + description: The solver cannot keep up. It is too busy to handle more requests. + "500": + $ref: "#/components/responses/InternalServerError" + /solve: + post: + operationId: solve + description: |- + Solve the passed in auction. + + The response contains the objective value of the solution the Solver is + able to find but not the calldata. This facilitates solvers that work + with an RFQ system. When Autopilot decides the winner of the of the + auction it prompts the corresponding solver to execute its solution + through the execute endpoint. + + The Solver should respond quickly enough so that the caller of the + endpoint receives the response within the deadline indicated in the + request. This includes taking into account network delay. + + Autopilot will call this endpoint at most once for the same auction id + and the following call will have a larger id. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SolveRequest" + responses: + "200": + description: Auction successfully solved. + content: + application/json: + schema: + $ref: "#/components/schemas/SolveResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalServerError" + /reveal: + post: + operationId: revealCalldata + description: > + Reveal the calldata of the previously solved auction. + + This may be used by the autopilot for the shadow competition to verify + the solution before requesting its execution it on chain. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RevealRequest" + responses: + "200": + description: Execution accepted. + content: + application/json: + schema: + $ref: "#/components/schemas/RevealResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalServerError" + /settle: + post: + operationId: settle + description: |- + Execute the previously solved auction on chain. + + The auction that should be executed is identified through its id and was + recently returned by this Solver's solve endpoint. + + By accepting the execute request the Solver promises to execute the + solution on chain immediately. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SettleRequest" + responses: + "200": + description: Execution accepted. + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalServerError" + /notify: + post: + operationId: receiveNotification + description: | + Receive a notification with a specific reason. + requestBody: + required: true + content: + application/json: + schema: + type: string + enum: + - banned + description: |- + The reason for the notification with optional additional context. + responses: + "200": + description: notification successfully received. +components: + schemas: + Address: + description: 20 byte Ethereum address encoded as a hex with `0x` prefix. + type: string + example: "0x6810e776880c02933d47db1b9fc05908e5386b96" + TokenAmount: + description: Amount of an ERC20 token. 256 bit unsigned integer in decimal notation. + type: string + example: "1234567890" + Interaction: + type: object + properties: + target: + $ref: "#/components/schemas/Address" + value: + $ref: "#/components/schemas/TokenAmount" + callData: + description: Hex encoded bytes with `0x` prefix. + type: string + Token: + description: Token information. + type: object + properties: + address: + $ref: "#/components/schemas/Address" + price: + nullable: true + allOf: + - $ref: "#/components/schemas/TokenAmount" + description: |- + The reference price denominated in native token (i.e. 1e18 + represents a token that + + trades one to one with the native token). These prices are used for + solution competition + + for computing surplus and converting fees to native token. + trusted: + description: |- + Whether the protocol trusts the token to be used for internalizing + trades. + + Solvers are allowed to internalize trades, ie. omit the interaction + that executes the swap from token A to token B and instead use the + settlement contract balances, aka buffers, to fulfil the interaction + as long as the token the contract receives (A in the example) is + trusted. + type: boolean + Order: + description: | + Order information like what is returned by the Orderbook apis. + type: object + properties: + uid: + $ref: "#/components/schemas/OrderUID" + sellToken: + allOf: + - description: Token being sold + - $ref: "#/components/schemas/Address" + buyToken: + allOf: + - description: Token being bought + - $ref: "#/components/schemas/Address" + sellAmount: + allOf: + - description: Amount to be sold + - $ref: "#/components/schemas/TokenAmount" + buyAmount: + allOf: + - description: Amount to be bought + - $ref: "#/components/schemas/TokenAmount" + created: + description: Creation time of the order. Denominated in epoch seconds. + type: string + example: "123456" + validTo: + description: The time until which the order is valid. + type: integer + kind: + type: string + enum: + - buy + - sell + receiver: + allOf: + - description: The address that should receive the bought tokens. + - $ref: "#/components/schemas/Address" + owner: + allOf: + - description: The address that created the order. + - $ref: "#/components/schemas/Address" + partiallyFillable: + description: |- + Whether the order can be partially filled. + + If this is false then the solver must fill the entire order or not + at all. + type: boolean + executed: + allOf: + - description: The amount that has already been filled. + - $ref: "#/components/schemas/TokenAmount" + preInteractions: + description: Interactions that must be executed before the order can be filled. + type: array + items: + $ref: "#/components/schemas/Interaction" + postInteractions: + description: Interactions that must be executed after the order has been filled. + type: array + items: + $ref: "#/components/schemas/Interaction" + sellTokenBalance: + type: string + enum: + - erc20 + - internal + - external + buyTokenBalance: + type: string + enum: + - erc20 + - internal + class: + type: string + enum: + - market + - limit + appData: + description: 32 bytes encoded as hex with `0x` prefix. + type: string + signingScheme: + type: string + enum: + - eip712 + - ethsign + - presign + - eip1271 + signature: + description: Hex encoded bytes with `0x` prefix. + type: string + protocolFees: + description: |- + Any protocol fee policies that apply to the order. + + The solver should make sure the fee policy is applied when computing + their solution. + type: array + items: + $ref: "#/components/schemas/FeePolicy" + quote: + allOf: + - description: A winning quote. + - $ref: "#/components/schemas/Quote" + required: + - uid + - sellToken + - buyToken + - sellAmount + - buyAmount + - created + - validTo + - kind + - receiver + - owner + - partiallyFillable + - executed + - preInteractions + - postInteractions + - sellTokenBalance + - buyTokenBalance + - class + - appData + - signature + - protocolFees + BigUint: + description: A big unsigned integer encoded in decimal. + type: string + example: "1234567890" + OrderUID: + description: |- + Unique identifier for the order: 56 bytes encoded as hex with `0x` + prefix. + + Bytes 0 to 32 are the order digest, bytes 30 to 52 the owner address and + bytes 52..56 valid to, + type: string + example: >- + 0x30cff40d9f60caa68a37f0ee73253ad6ad72b45580c945fe3ab67596476937197854163b1b0d24e77dca702b97b5cc33e0f83dcb626122a6 + QuoteResponseKind: + oneOf: + - $ref: "#/components/schemas/LegacyQuoteResponse" + - $ref: "#/components/schemas/QuoteResponse" + - $ref: "#/components/schemas/Error" + LegacyQuoteResponse: + description: |- + Successful Quote. + + The Solver knows how to fill the request with these parameters. + + If the request was of type `buy` then the response's buy amount has the + same value as the request's amount and the sell amount was filled in by + the server. Vice versa for type `sell`. + type: object + properties: + amount: + $ref: "#/components/schemas/TokenAmount" + interactions: + type: array + items: + $ref: "#/components/schemas/Interaction" + solver: + allOf: + - description: The address of the solver that quoted this order. + - $ref: "#/components/schemas/Address" + gas: + type: integer + description: How many units of gas this trade is estimated to cost. + txOrigin: + allOf: + - $ref: "#/components/schemas/Address" + description: Which `tx.origin` is required to make a quote simulation pass. + required: + - amount + - interactions + - solver + QuoteResponse: + description: |- + Successful Quote with JIT orders support. + The Solver knows how to fill the request with these parameters. + type: object + properties: + clearingPrices: + description: | + Mapping of hex token address to the uniform clearing price. + type: object + additionalProperties: + $ref: "#/components/schemas/BigUint" + preInteractions: + type: array + items: + $ref: "#/components/schemas/Interaction" + interactions: + type: array + items: + $ref: "#/components/schemas/Interaction" + solver: + allOf: + - $ref: "#/components/schemas/Address" + description: The address of the solver that quoted this order. + gas: + type: integer + description: How many units of gas this trade is estimated to cost. + txOrigin: + allOf: + - $ref: "#/components/schemas/Address" + description: Which `tx.origin` is required to make a quote simulation pass. + jitOrders: + type: array + items: + $ref: "#/components/schemas/JitOrder" + required: + - clearingPrices + - solver + DateTime: + description: An ISO 8601 UTC date time string. + type: string + example: "2020-12-03T18:35:18.814523Z" + Calldata: + description: hex encoded calldata with `0x` prefix. + type: object + properties: + internalized: + description: |- + The calldata without any internalized interactions encoded. + This is the calldata that can be found on chain. + type: string + example: "0x1234567890" + uninternalized: + description: |- + The calldata with all internalized interactions encoded. + + This is the calldata that should be used for simulation/verification + purposes. + type: string + example: "0x1234567890" + SolveRequest: + description: Request to the solve endpoint. + type: object + properties: + id: + type: integer + description: | + The unique identifier of the auction. + orders: + type: array + items: + $ref: "#/components/schemas/Order" + description: | + The solvable orders included in the auction. + tokens: + type: array + items: + $ref: "#/components/schemas/Token" + description: | + Information about tokens used in the auction. + deadline: + $ref: "#/components/schemas/DateTime" + surplusCapturingJitOrderOwners: + type: array + items: + $ref: "#/components/schemas/Address" + description: > + List of addresses on whose surplus will count towards the objective + value of their solution (unlike other orders that were created by + the solver). + SolveResponse: + description: | + Response of the solve endpoint. + type: object + properties: + solutions: + type: array + items: + type: object + properties: + solutionId: + description: |- + The unique identifier of the solution. + This id is used to identify the solution when executing it. + type: integer + example: 1 + score: + description: | + The objective value of the solution. + type: string + example: "100" + submissionAddress: + allOf: + - description: The address that will be used to submit the solution. + - $ref: "#/components/schemas/Address" + orders: + description: > + Mapping of order uid to net executed amount (including all + fees). + additionalProperties: + type: object + properties: + side: + type: string + enum: + - buy + - sell + sellToken: + allOf: + - description: Token being sold + - $ref: "#/components/schemas/Address" + buyToken: + allOf: + - description: Token being bought + - $ref: "#/components/schemas/Address" + limitSell: + type: string + description: Maximum amount to be sold. + limitBuy: + type: string + description: Minimum amount to be bought. + executedSell: + type: string + description: >- + The effective amount that left the user's wallet + including all fees. + executedBuy: + type: string + description: The effective amount the user received after all fees. + clearingPrices: + description: > + Mapping of hex token address to price. + + The prices of tokens for settled user orders as passed to the + settlement contract. + type: object + additionalProperties: + $ref: "#/components/schemas/BigUint" + gas: + type: integer + SettleRequest: + description: Request to the `/settle` endpoint. + type: object + properties: + solutionId: + description: Id of the solution that should be executed. + type: integer + example: 123 + submissionDeadlineLatestBlock: + description: The last block number in which the solution TX can be included. + type: integer + example: 12345 + auctionId: + description: Auction ID in which the specified solution ID is competing. + type: integer + example: 123 + RevealRequest: + description: Request to the `/reveal` endpoint. + type: object + properties: + solutionId: + description: Id of the solution that should be executed. + type: integer + example: 123 + auctionId: + description: Auction ID in which the specified solution ID is competing. + type: integer + example: 123 + RevealResponse: + description: Response of the reveal endpoint. + type: object + properties: + calldata: + $ref: "#/components/schemas/Calldata" + FeePolicy: + description: > + A fee policy that applies to an order. + + The solver should make sure the fee policy is applied when computing + their solution. + type: object + oneOf: + - $ref: "#/components/schemas/SurplusFee" + - $ref: "#/components/schemas/PriceImprovement" + - $ref: "#/components/schemas/VolumeFee" + SurplusFee: + description: > + If the order receives more than limit price, pay the protocol a factor + of the difference. + type: object + properties: + kind: + type: string + enum: + - surplus + maxVolumeFactor: + description: Never charge more than that percentage of the order volume. + type: number + example: 0.1 + factor: + description: >- + The factor of the user surplus that the protocol will request from + the solver after settling the order + type: number + example: 0.5 + PriceImprovement: + description: > + A cut from the price improvement over the best quote is taken as a + protocol fee. + type: object + properties: + kind: + type: string + enum: + - priceImprovement + maxVolumeFactor: + description: Never charge more than that percentage of the order volume. + type: number + example: 0.01 + factor: + description: >- + The factor of the user surplus that the protocol will request from + the solver after settling the order + type: number + example: 0.5 + quote: + $ref: "#/components/schemas/Quote" + VolumeFee: + type: object + properties: + kind: + type: string + enum: + - volume + factor: + description: >- + The fraction of the order's volume that the protocol will request + from the solver after settling the order. + type: number + example: 0.5 + Quote: + type: object + properties: + sellAmount: + $ref: "#/components/schemas/TokenAmount" + buyAmount: + $ref: "#/components/schemas/TokenAmount" + fee: + $ref: "#/components/schemas/TokenAmount" + solver: + $ref: "#/components/schemas/Address" + JitOrder: + type: object + properties: + sellToken: + $ref: "#/components/schemas/Address" + buyToken: + $ref: "#/components/schemas/Address" + sellAmount: + $ref: "#/components/schemas/TokenAmount" + buyAmount: + $ref: "#/components/schemas/TokenAmount" + executedAmount: + $ref: "#/components/schemas/TokenAmount" + receiver: + $ref: "#/components/schemas/Address" + validTo: + type: integer + side: + type: string + enum: + - buy + - sell + partiallyFillable: + type: boolean + sellTokenSource: + type: string + enum: + - erc20 + - internal + - external + buyTokenSource: + type: string + enum: + - erc20 + - internal + appData: + type: string + signature: + description: >- + Hex encoded bytes with `0x` prefix. The content depends on the + `signingScheme`. + + For `presign`, this should contain the address of the owner. + + For `eip1271`, the signature should consist of + ``. + type: string + signingScheme: + type: string + enum: + - eip712 + - ethsign + - presign + - eip1271 + required: + - sellToken + - buyToken + - sellAmount + - buyAmount + - executedAmount + - receiver + - validTo + - side + - partiallyFillable + - sellTokenSource + - buyTokenSource + - appData + - signature + - signingScheme + Error: + description: Response on API errors. + type: object + properties: + kind: + description: The kind of error. + type: string + description: + description: Text describing the error. + type: string + responses: + "200": + description: The request was successful. + BadRequest: + description: |- + There is something wrong with the request. + Body potentially contains extra information. + content: + text/plain: + schema: + type: string + InternalServerError: + description: |- + Something went wrong when handling the request. + Body potentially contains extra information. + content: + text/plain: + schema: + type: string diff --git a/crates/driver-hyperliquid-template/src/api.rs b/crates/driver-hyperliquid-template/src/api.rs new file mode 100644 index 0000000000..39a00a6a88 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/api.rs @@ -0,0 +1,120 @@ +//! This module implements the solver API that is served by the driver. +use axum::Json; +use serde::{Deserialize, Serialize}; +use solvers_dto::solution::{JitOrder, Solutions as SolveResponse}; +use std::collections::HashMap; +use web3::types::H160; + +#[derive(Debug, Deserialize, Serialize)] +pub struct SolveRequest { + pub id: u64, + pub orders: Vec, + pub tokens: HashMap, + pub deadline: String, + #[serde(rename = "surplusCapturingJitOrderOwners")] + pub surplus_capturing_jit_order_owners: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RevealRequest { + pub solution_id: u64, + pub auction_id: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SettleRequest { + pub solution_id: u64, + pub submission_deadline_latest_block: u64, + pub auction_id: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Calldata { + pub internalized: String, + pub uninternalized: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct RevealResponse { + pub calldata: Calldata, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum QuoteResponseKind { + Legacy(LegacyQuoteResponse), + Modern(QuoteResponse), + Error(Error), +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct LegacyQuoteResponse { + pub amount: String, + pub interactions: Vec, + pub solver: String, + pub gas: u64, + pub tx_origin: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct QuoteResponse { + pub clearing_prices: HashMap, + pub pre_interactions: Vec, + pub interactions: Vec, + pub solver: String, + pub gas: u64, + pub tx_origin: String, + pub jit_orders: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Error { + pub kind: String, + pub description: String, +} + +/// Get price estimation quote. +pub(super) async fn get_quote( +) -> Result, String> { + let response = QuoteResponse { + clearing_prices: HashMap::new(), + pre_interactions: vec![], + interactions: vec![], + solver: "0x0000000000000000000000000000000000000000".to_string(), + gas: 50000, + tx_origin: "0x0000000000000000000000000000000000000000".to_string(), + jit_orders: vec![], + }; + Ok(Json(QuoteResponseKind::Modern(response))) +} +/// Solve the passed in auction. +pub(super) async fn solve( + Json(_body): Json, +) -> Result, String> { + Ok(Json(SolveResponse { + solutions: vec![], + })) +} +/// Reveal the calldata of the previously solved auction. +pub(super) async fn reveal_calldata( + Json(_body): Json, +) -> Result, String> { + Ok(Json(RevealResponse { + calldata: Calldata { + internalized: "0x1234".to_string(), + uninternalized: "0x1234".to_string(), + }, + })) +} +/// Execute the previously solved auction on chain. +pub(super) async fn settle( + Json(_body): Json, +) -> Result { + Ok("".to_string()) +} +/// Receive a notification with a specific reason. +pub(super) async fn receive_notification( + Json(_body): Json, +) -> Result { + Ok("".to_string()) +} \ No newline at end of file diff --git a/crates/driver-hyperliquid-template/src/infra/api/error.rs b/crates/driver-hyperliquid-template/src/infra/api/error.rs new file mode 100644 index 0000000000..f513cf8f3e --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/error.rs @@ -0,0 +1,104 @@ +use { + crate::{ + infra::{api}, + }, + serde::Serialize, +}; + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "PascalCase")] +enum Kind { + QuotingFailed, + SolverFailed, + TooManyPendingSettlements, + SolutionNotAvailable, + DeadlineExceeded, + Unknown, + InvalidAuctionId, + MissingSurplusFee, + InvalidTokens, + InvalidAmounts, + QuoteSameTokens, + FailedToSubmit, + NoValidOrders, + MalformedRequest, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Error { + kind: Kind, + description: &'static str, +} + +impl From for (hyper::StatusCode, axum::Json) { + fn from(value: Kind) -> Self { + let description = match value { + Kind::QuotingFailed => "No valid quote found", + Kind::SolverFailed => "Solver engine returned an invalid response", + Kind::SolutionNotAvailable => { + "no solution is available yet, this might mean that /settle was called before \ + /solve returned" + } + Kind::DeadlineExceeded => "Exceeded solution deadline", + Kind::Unknown => "An unknown error occurred", + Kind::InvalidAuctionId => "Invalid ID specified in the auction", + Kind::MissingSurplusFee => "Auction contains a limit order with no surplus fee", + Kind::QuoteSameTokens => "Invalid quote with same buy and sell tokens", + Kind::InvalidTokens => { + "Invalid tokens specified in the auction, the tokens for some orders are missing" + } + Kind::InvalidAmounts => { + "Invalid order specified in the auction, some orders have either a 0 remaining buy \ + or sell amount" + } + Kind::FailedToSubmit => "Could not submit the solution to the blockchain", + Kind::TooManyPendingSettlements => "Settlement queue is full", + Kind::NoValidOrders => "No valid orders found in the auction", + Kind::MalformedRequest => "Could not parse the request", + }; + ( + hyper::StatusCode::BAD_REQUEST, + axum::Json(Error { + kind: value, + description, + }), + ) + } +} + +// impl From for (hyper::StatusCode, axum::Json) { +// fn from(value: quote::Error) -> Self { +// let error = match value { +// quote::Error::QuotingFailed(_) => Kind::QuotingFailed, +// quote::Error::DeadlineExceeded(_) => Kind::DeadlineExceeded, +// quote::Error::Solver(_) => Kind::SolverFailed, +// quote::Error::Blockchain(_) => Kind::Unknown, +// quote::Error::Boundary(_) => Kind::Unknown, +// quote::Error::Encoding(_) => Kind::Unknown, +// }; +// error.into() +// } +// } + +impl From for (hyper::StatusCode, axum::Json) { + fn from(value: api::routes::AuctionError) -> Self { + let error = match value { + api::routes::AuctionError::InvalidAuctionId => Kind::InvalidAuctionId, + api::routes::AuctionError::MissingSurplusFee => Kind::MissingSurplusFee, + api::routes::AuctionError::InvalidTokens => Kind::InvalidTokens, + api::routes::AuctionError::InvalidAmounts => Kind::InvalidAmounts, + api::routes::AuctionError::Blockchain(_) => Kind::Unknown, + }; + error.into() + } +} + +impl From for (hyper::StatusCode, axum::Json) { + fn from(value: api::routes::OrderError) -> Self { + let error = match value { + api::routes::OrderError::SameTokens => Kind::QuoteSameTokens, + }; + error.into() + } +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/mod.rs new file mode 100644 index 0000000000..d5699942b8 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/mod.rs @@ -0,0 +1,95 @@ +use { + driver::{ + infra::{ + solver::Solver, + }, + }, + futures::Future, + std::{net::SocketAddr, sync::Arc}, + tokio::sync::oneshot, +}; + +mod error; +pub mod routes; + +const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024; + +pub struct Api { + pub solvers: Vec, + pub addr: SocketAddr, + /// If this channel is specified, the bound address will be sent to it. This + /// allows the driver to bind to 0.0.0.0:0 during testing. + pub addr_sender: Option>, +} + +#[derive(Clone)] +struct State(Arc); + +impl State { + fn solver(&self) -> &Solver { + // This will be replaced by a middleware + &self.0.solver + } +} + +struct Inner { + solver: Solver, +} + +impl Api { + pub async fn serve( + self, + shutdown: impl Future + Send + 'static + ) -> Result<(), hyper::Error> { + // Add middleware. + + let mut app = axum::Router::new() + .layer(tower::ServiceBuilder::new().layer( + tower_http::limit::RequestBodyLimitLayer::new(REQUEST_BODY_LIMIT), + )); + + // Add the metrics, healthz, and gasprice endpoints. + app = routes::metrics(app); + app = routes::healthz(app); + + + // Multiplex each solver as part of the API. Multiple solvers are multiplexed + // on the same driver so only one liquidity collector collects the liquidity + // for all of them. This is important because liquidity collection is + // computationally expensive for the Ethereum node. + for solver in self.solvers { + let name = solver.name().clone(); + let router = axum::Router::new(); + let router = routes::info(router); + let router = routes::quote(router); + let router = routes::solve(router); + let router = routes::reveal(router); + let router = routes::settle(router); + let router = routes::notify(router); + + let router = router.with_state(State(Arc::new(Inner{solver: solver.clone()}))); + let path = format!("/{name}"); + app = app.nest(&path, router); + } + + app = app + // axum's default body limit needs to be disabled to not have the default limit on top of our custom limit + .layer(axum::extract::DefaultBodyLimit::disable()) + .layer( + tower::ServiceBuilder::new() + .layer(tower_http::trace::TraceLayer::new_for_http()) + ); + + // Start the server. + let server = axum::Server::bind(&self.addr).serve(app.into_make_service()); + tracing::info!(port = server.local_addr().port(), "serving driver"); + if let Some(addr_sender) = self.addr_sender { + addr_sender.send(server.local_addr()).unwrap(); + } + server.with_graceful_shutdown(shutdown).await + } + + +} + + diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/healthz.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/healthz.rs new file mode 100644 index 0000000000..157f557a93 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/healthz.rs @@ -0,0 +1,9 @@ +use axum::{http::StatusCode, response::IntoResponse, routing::get}; + +pub(in crate::infra::api) fn healthz(app: axum::Router<()>) -> axum::Router<()> { + app.route("/healthz", get(route)) +} + +async fn route() -> impl IntoResponse { + StatusCode::OK +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/info.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/info.rs new file mode 100644 index 0000000000..5c9576f44d --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/info.rs @@ -0,0 +1,10 @@ +use {crate::infra::api::State, tracing::instrument}; + +pub(in crate::infra::api) fn info(app: axum::Router) -> axum::Router { + app.route("/", axum::routing::get(route)) +} + +#[instrument(skip(state))] +async fn route(state: axum::extract::State) -> String { + state.solver().name().to_string() +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/metrics.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/metrics.rs new file mode 100644 index 0000000000..ae81919f80 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/metrics.rs @@ -0,0 +1,8 @@ +pub(in crate::infra::api) fn metrics(app: axum::Router<()>) -> axum::Router<()> { + app.route("/metrics", axum::routing::get(route)) +} + +async fn route() -> String { + let registry = observe::metrics::get_registry(); + observe::metrics::encode(registry) +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/mod.rs new file mode 100644 index 0000000000..ecc20a48c9 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/mod.rs @@ -0,0 +1,19 @@ +mod healthz; +mod info; +mod metrics; +mod notify; +mod quote; +mod reveal; +mod settle; +pub mod solve; + +pub(super) use { + healthz::healthz, + info::info, + metrics::metrics, + notify::notify, + quote::{OrderError, quote}, + reveal::reveal, + settle::settle, + solve::{AuctionError, solve}, +}; diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/notify/dto/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/notify/dto/mod.rs new file mode 100644 index 0000000000..9a24eedbc1 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/notify/dto/mod.rs @@ -0,0 +1,3 @@ +mod notify_request; + +pub use notify_request::NotifyRequest; diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/notify/dto/notify_request.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/notify/dto/notify_request.rs new file mode 100644 index 0000000000..70a1a9c266 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/notify/dto/notify_request.rs @@ -0,0 +1,42 @@ +use { + driver::infra::notify, + chrono::{DateTime, Utc}, + serde::Deserialize, + serde_with::serde_as, +}; + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NotifyRequest { + Banned { + reason: BanReason, + until: DateTime, + }, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BanReason { + /// The driver won multiple consecutive auctions but never settled them. + UnsettledConsecutiveAuctions, + /// Driver's settle failure rate is above the threshold. + HighSettleFailureRate, +} + +impl From for notify::Kind { + fn from(value: NotifyRequest) -> Self { + match value { + NotifyRequest::Banned { reason, until } => notify::Kind::Banned { + reason: match reason { + BanReason::UnsettledConsecutiveAuctions => { + notify::BanReason::UnsettledConsecutiveAuctions + } + BanReason::HighSettleFailureRate => notify::BanReason::HighSettleFailureRate, + }, + until, + }, + } + } +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/notify/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/notify/mod.rs new file mode 100644 index 0000000000..50473ef479 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/notify/mod.rs @@ -0,0 +1,17 @@ +mod dto; + +use crate::infra::api::{error, State}; + +pub(in crate::infra::api) fn notify(router: axum::Router) -> axum::Router { + router.route("/notify", axum::routing::post(route)) +} + +async fn route( + state: axum::extract::State, + req: axum::Json, +) -> Result)> { + let solver = &state.solver().name().0; + tracing::debug!(?req, ?solver, "received a notification"); + state.solver().notify(None, None, req.0.into()); + Ok(hyper::StatusCode::OK) +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/mod.rs new file mode 100644 index 0000000000..47d8d9588e --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/mod.rs @@ -0,0 +1,7 @@ +mod order; +mod quote; + +pub use { + order::{Error as OrderError, Order}, + quote::Quote, +}; diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/order.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/order.rs new file mode 100644 index 0000000000..06bf276971 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/order.rs @@ -0,0 +1,46 @@ +use { + driver::util::serialize, + driver::domain::{competition, eth, quote}, + serde::Deserialize, + serde_with::serde_as, +}; + +impl Order { + pub fn into_domain(self) -> Result { + Ok(quote::Order { + tokens: quote::Tokens::try_new(self.sell_token.into(), self.buy_token.into()) + .map_err(|quote::SameTokens| Error::SameTokens)?, + amount: self.amount.into(), + side: match self.kind { + Kind::Sell => competition::order::Side::Sell, + Kind::Buy => competition::order::Side::Buy, + }, + deadline: self.deadline, + }) + } +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + sell_token: eth::H160, + buy_token: eth::H160, + #[serde_as(as = "serialize::U256")] + amount: eth::U256, + kind: Kind, + deadline: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Kind { + Sell, + Buy, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("received an order with identical buy and sell tokens")] + SameTokens, +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/quote.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/quote.rs new file mode 100644 index 0000000000..1e2ecdb1b0 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/dto/quote.rs @@ -0,0 +1,126 @@ +use { + driver::{ + domain::{self, competition::solution::encoding::codec, eth, quote}, + util::serialize, + }, + model::{ + order::{BuyTokenDestination, SellTokenSource}, + signature::SigningScheme, + }, + serde::Serialize, + serde_with::serde_as, + std::collections::HashMap, +}; + +impl Quote { + pub fn new(quote: quote::Quote) -> Self { + Self { + clearing_prices: quote.clearing_prices, + pre_interactions: quote.pre_interactions.into_iter().map(Into::into).collect(), + interactions: quote.interactions.into_iter().map(Into::into).collect(), + solver: quote.solver.0, + gas: quote.gas.map(|gas| gas.0.as_u64()), + tx_origin: quote.tx_origin.map(|addr| addr.0), + jit_orders: quote.jit_orders.into_iter().map(Into::into).collect(), + } + } +} + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Quote { + #[serde_as(as = "HashMap<_, serialize::U256>")] + clearing_prices: HashMap, + pre_interactions: Vec, + interactions: Vec, + solver: eth::H160, + #[serde(skip_serializing_if = "Option::is_none")] + gas: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tx_origin: Option, + jit_orders: Vec, +} + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Interaction { + target: eth::H160, + #[serde_as(as = "serialize::U256")] + value: eth::U256, + #[serde_as(as = "serialize::Hex")] + call_data: Vec, +} + +impl From for Interaction { + fn from(interaction: eth::Interaction) -> Self { + Self { + target: interaction.target.into(), + value: interaction.value.into(), + call_data: interaction.call_data.into(), + } + } +} + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct JitOrder { + buy_token: eth::H160, + sell_token: eth::H160, + #[serde_as(as = "serialize::U256")] + sell_amount: eth::U256, + #[serde_as(as = "serialize::U256")] + buy_amount: eth::U256, + #[serde_as(as = "serialize::U256")] + executed_amount: eth::U256, + receiver: eth::H160, + partially_fillable: bool, + valid_to: u32, + #[serde_as(as = "serialize::Hex")] + app_data: [u8; 32], + side: Side, + sell_token_source: SellTokenSource, + buy_token_destination: BuyTokenDestination, + #[serde_as(as = "serialize::Hex")] + signature: Vec, + signing_scheme: SigningScheme, +} + +impl From for JitOrder { + fn from(jit: domain::competition::solution::trade::Jit) -> Self { + Self { + sell_token: jit.order().sell.token.into(), + buy_token: jit.order().buy.token.into(), + sell_amount: jit.order().sell.amount.into(), + buy_amount: jit.order().buy.amount.into(), + executed_amount: jit.executed().into(), + receiver: jit.order().receiver.into(), + partially_fillable: jit.order().partially_fillable, + valid_to: jit.order().valid_to.into(), + app_data: jit.order().app_data.into(), + side: jit.order().side.into(), + sell_token_source: jit.order().sell_token_balance.into(), + buy_token_destination: jit.order().buy_token_balance.into(), + signature: codec::signature(&jit.order().signature).into(), + signing_scheme: jit.order().signature.scheme.to_boundary_scheme(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +enum Side { + Sell, + Buy, +} + +impl From for Side { + fn from(side: domain::competition::order::Side) -> Self { + match side { + domain::competition::order::Side::Sell => Side::Sell, + domain::competition::order::Side::Buy => Side::Buy, + } + } +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/quote/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/mod.rs new file mode 100644 index 0000000000..11ba51787a --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/quote/mod.rs @@ -0,0 +1,49 @@ +use { + crate::infra::api::State, + driver::infra::{ + api::{error::Error}, + observe, + }, + tracing::Instrument, +}; + +mod dto; + +pub use dto::OrderError; + +pub(in crate::infra::api) fn quote(router: axum::Router) -> axum::Router { + router.route("/quote", axum::routing::get(route)) +} + +async fn route( + state: axum::extract::State, + order: axum::extract::Query, +) -> Result, (hyper::StatusCode, axum::Json)> { + let handle_request = async { + // let order = order.0.into_domain().inspect_err(|err| { + // observe::invalid_dto(err, "order"); + // })?; + // // observe::quoting(&order); + // let quote = order + // .quote( + // state.solver(), + // ) + // .await; + // observe::quoted(state.solver().name(), &order, "e); + + let empty_quote = driver::domain::quote::Quote { + clearing_prices: std::collections::HashMap::new(), + pre_interactions: vec![], + interactions: vec![], + solver: driver::domain::eth::Address::default(), + gas: None, + tx_origin: None, + jit_orders: vec![], + }; + Ok(axum::response::Json(dto::Quote::new(empty_quote))) + }; + + handle_request + .instrument(tracing::info_span!("/quote", solver = %state.solver().name())) + .await +} \ No newline at end of file diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/mod.rs new file mode 100644 index 0000000000..75671089e4 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/mod.rs @@ -0,0 +1,4 @@ +mod reveal_request; +mod reveal_response; + +pub use {reveal_request::RevealRequest, reveal_response::RevealResponse}; diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/reveal_request.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/reveal_request.rs new file mode 100644 index 0000000000..e0e762dd47 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/reveal_request.rs @@ -0,0 +1,12 @@ +use {serde::Deserialize, serde_with::serde_as}; + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealRequest { + /// Unique ID of the solution (per driver competition), to reveal. + pub solution_id: u64, + /// Auction ID in which the specified solution ID is competing. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub auction_id: i64, +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/reveal_response.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/reveal_response.rs new file mode 100644 index 0000000000..5069db99ea --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/dto/reveal_response.rs @@ -0,0 +1,33 @@ +use { + driver::{domain::competition, util::serialize}, + serde::Serialize, + serde_with::serde_as, +}; + +impl RevealResponse { + pub fn new(reveal: competition::Revealed) -> Self { + Self { + calldata: Calldata { + internalized: reveal.internalized_calldata.into(), + uninternalized: reveal.uninternalized_calldata.into(), + }, + } + } +} + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealResponse { + calldata: Calldata, +} + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Calldata { + #[serde_as(as = "serialize::Hex")] + internalized: Vec, + #[serde_as(as = "serialize::Hex")] + uninternalized: Vec, +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/mod.rs new file mode 100644 index 0000000000..89748cb0b3 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/reveal/mod.rs @@ -0,0 +1,44 @@ +mod dto; + +use { + crate::infra::api::State, + driver::{ + domain::competition::auction, + infra::{ + api::{self, error::Error}, + observe, + }, + }, + tracing::Instrument, +}; + +pub(in crate::infra::api) fn reveal(router: axum::Router) -> axum::Router { + router.route("/reveal", axum::routing::post(route)) +} + +async fn route( + state: axum::extract::State, + req: axum::Json, +) -> Result, (hyper::StatusCode, axum::Json)> { + // let auction_id = + // auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; + let handle_request = async { + // observe::revealing(); + // let result = state + // .competition() + // .reveal(req.solution_id, auction_id) + // .await; + // observe::revealed(state.solver().name(), &result); + // let result = result?; + + let empty_revealed = driver::domain::competition::Revealed { + internalized_calldata: driver::util::Bytes(vec![]), + uninternalized_calldata: driver::util::Bytes(vec![]), + }; + Ok(axum::Json(dto::RevealResponse::new(empty_revealed))) + }; + + handle_request + .instrument(tracing::info_span!("/reveal", solver = %state.solver().name())) + .await +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/settle/dto/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/settle/dto/mod.rs new file mode 100644 index 0000000000..0986792238 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/settle/dto/mod.rs @@ -0,0 +1,3 @@ +mod settle_request; + +pub use settle_request::SettleRequest; diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/settle/dto/settle_request.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/settle/dto/settle_request.rs new file mode 100644 index 0000000000..6f5735fbce --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/settle/dto/settle_request.rs @@ -0,0 +1,14 @@ +use {serde::Deserialize, serde_with::serde_as}; + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettleRequest { + /// Unique ID of the solution (per driver competition), to settle. + pub solution_id: u64, + /// The last block number in which the solution TX can be included + pub submission_deadline_latest_block: u64, + /// Auction ID in which this solution is competing. + #[serde_as(as = "serde_with::DisplayFromStr")] + pub auction_id: i64, +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/settle/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/settle/mod.rs new file mode 100644 index 0000000000..f1a62518ee --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/settle/mod.rs @@ -0,0 +1,43 @@ +mod dto; + +use { + crate::infra::api::State, + driver::{ + domain::competition::auction, + infra::{ + api::{self, error::Error}, + observe, + }, + }, + tracing::Instrument, +}; + +pub(in crate::infra::api) fn settle(router: axum::Router) -> axum::Router { + router.route("/settle", axum::routing::post(route)) +} + +async fn route( + state: axum::extract::State, + req: axum::Json, +) -> Result<(), (hyper::StatusCode, axum::Json)> { + // let auction_id = + // auction::Id::try_from(req.auction_id).map_err(api::routes::AuctionError::from)?; + // let solver = state.solver().name().to_string(); + + // async move { + // observe::settling(); + // let result = state + // .competition() + // .settle( + // auction_id, + // req.solution_id, + // req.submission_deadline_latest_block, + // ) + // .await; + // observe::settled(state.solver().name(), &result); + // result.map(|_| ()).map_err(Into::into) + // } + // .instrument(tracing::info_span!("/settle", solver, %auction_id)) + // .await + Ok(()) +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/mod.rs new file mode 100644 index 0000000000..458d9c47a9 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/mod.rs @@ -0,0 +1,7 @@ +pub mod solve_request; +mod solve_response; + +pub use { + solve_request::{Error as AuctionError, SolveRequest}, + solve_response::SolveResponse, +}; diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/solve_request.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/solve_request.rs new file mode 100644 index 0000000000..96ee4f27ab --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/solve_request.rs @@ -0,0 +1,373 @@ +use { + driver::{ + domain::{ + competition::{ + self, + auction, + order::{ + self, + app_data::{AppData, AppDataHash}, + }, + }, + eth, + }, + infra::{Ethereum, tokens}, + util::serialize, + }, + serde::Deserialize, + serde_with::serde_as, + std::{ + collections::{HashMap, HashSet}, + sync::Arc, + }, + tracing::instrument, +}; + +impl SolveRequest { + #[instrument(skip_all)] + pub async fn into_domain( + self, + eth: &Ethereum, + tokens: &tokens::Fetcher, + app_data: HashMap, Arc>, + ) -> Result { + let token_addresses: Vec<_> = self + .tokens + .iter() + .map(|token| token.address.into()) + .collect(); + let token_infos = tokens.get(&token_addresses).await; + + competition::Auction::new( + Some(self.id.try_into()?), + self.orders + .into_iter() + .map(|order| competition::Order { + uid: order.uid.into(), + receiver: order.receiver.map(Into::into), + created: order.created.into(), + valid_to: order.valid_to.into(), + buy: eth::Asset { + amount: order.buy_amount.into(), + token: order.buy_token.into(), + }, + sell: eth::Asset { + amount: order.sell_amount.into(), + token: order.sell_token.into(), + }, + side: match order.kind { + Kind::Sell => competition::order::Side::Sell, + Kind::Buy => competition::order::Side::Buy, + }, + kind: match order.class { + Class::Market => competition::order::Kind::Market, + Class::Limit => competition::order::Kind::Limit, + }, + app_data: match app_data.get(&AppDataHash::from(order.app_data)) { + Some(data) => AppData::Full(data.clone()), + None => AppData::Hash(AppDataHash::from(order.app_data)), + }, + partial: if order.partially_fillable { + competition::order::Partial::Yes { + available: match order.kind { + Kind::Sell => { + order.sell_amount.saturating_sub(order.executed).into() + } + Kind::Buy => order.buy_amount.saturating_sub(order.executed).into(), + }, + } + } else { + competition::order::Partial::No + }, + pre_interactions: order + .pre_interactions + .into_iter() + .map(|interaction| eth::Interaction { + target: interaction.target.into(), + value: interaction.value.into(), + call_data: interaction.call_data.into(), + }) + .collect(), + post_interactions: order + .post_interactions + .into_iter() + .map(|interaction| eth::Interaction { + target: interaction.target.into(), + value: interaction.value.into(), + call_data: interaction.call_data.into(), + }) + .collect(), + sell_token_balance: match order.sell_token_balance { + SellTokenBalance::Erc20 => competition::order::SellTokenBalance::Erc20, + SellTokenBalance::Internal => { + competition::order::SellTokenBalance::Internal + } + SellTokenBalance::External => { + competition::order::SellTokenBalance::External + } + }, + buy_token_balance: match order.buy_token_balance { + BuyTokenBalance::Erc20 => competition::order::BuyTokenBalance::Erc20, + BuyTokenBalance::Internal => competition::order::BuyTokenBalance::Internal, + }, + signature: competition::order::Signature { + scheme: match order.signing_scheme { + SigningScheme::Eip712 => competition::order::signature::Scheme::Eip712, + SigningScheme::EthSign => { + competition::order::signature::Scheme::EthSign + } + SigningScheme::PreSign => { + competition::order::signature::Scheme::PreSign + } + SigningScheme::Eip1271 => { + competition::order::signature::Scheme::Eip1271 + } + }, + data: order.signature.into(), + signer: order.owner.into(), + }, + protocol_fees: order + .protocol_fees + .into_iter() + .map(|policy| match policy { + FeePolicy::Surplus { + factor, + max_volume_factor, + } => competition::order::FeePolicy::Surplus { + factor, + max_volume_factor, + }, + FeePolicy::PriceImprovement { + factor, + max_volume_factor, + quote, + } => competition::order::FeePolicy::PriceImprovement { + factor, + max_volume_factor, + quote: quote.into_domain(order.sell_token, order.buy_token), + }, + FeePolicy::Volume { factor } => { + competition::order::FeePolicy::Volume { factor } + } + }) + .collect(), + quote: order + .quote + .map(|q| q.into_domain(order.sell_token, order.buy_token)), + }) + .collect(), + self.tokens.into_iter().map(|token| { + let info = token_infos.get(&token.address.into()); + competition::auction::Token { + decimals: info.and_then(|i| i.decimals), + symbol: info.and_then(|i| i.symbol.clone()), + address: token.address.into(), + price: token.price.map(Into::into), + available_balance: info.map(|i| i.balance).unwrap_or(0.into()).into(), + trusted: token.trusted, + } + }), + self.deadline, + eth, + self.surplus_capturing_jit_order_owners + .into_iter() + .map(Into::into) + .collect::>(), + ) + .await + .map_err(Into::into) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid auction ID")] + InvalidAuctionId, + #[error("surplus fee is missing for limit order")] + MissingSurplusFee, + #[error("invalid tokens in auction")] + InvalidTokens, + #[error("invalid order amounts in auction")] + InvalidAmounts, + #[error("blockchain error: {0:?}")] + Blockchain(#[source] driver::infra::blockchain::Error), +} + +impl From for Error { + fn from(_value: auction::InvalidId) -> Self { + Self::InvalidAuctionId + } +} + +impl From for Error { + fn from(value: auction::Error) -> Self { + match value { + auction::Error::Blockchain(err) => Self::Blockchain(err), + } + } +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SolveRequest { + #[serde_as(as = "serde_with::DisplayFromStr")] + id: i64, + tokens: Vec, + orders: Vec, + deadline: chrono::DateTime, + #[serde(default)] + surplus_capturing_jit_order_owners: Vec, +} + +impl SolveRequest { + pub fn id(&self) -> i64 { + self.id + } +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Token { + pub address: eth::H160, + #[serde_as(as = "Option")] + pub price: Option, + pub trusted: bool, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Order { + #[serde_as(as = "serialize::Hex")] + uid: [u8; order::UID_LEN], + sell_token: eth::H160, + buy_token: eth::H160, + #[serde_as(as = "serialize::U256")] + sell_amount: eth::U256, + #[serde_as(as = "serialize::U256")] + buy_amount: eth::U256, + protocol_fees: Vec, + created: u32, + valid_to: u32, + kind: Kind, + receiver: Option, + owner: eth::H160, + partially_fillable: bool, + /// Always zero if the order is not partially fillable. + #[serde_as(as = "serialize::U256")] + executed: eth::U256, + pre_interactions: Vec, + post_interactions: Vec, + #[serde(default)] + sell_token_balance: SellTokenBalance, + #[serde(default)] + buy_token_balance: BuyTokenBalance, + class: Class, + #[serde_as(as = "serialize::Hex")] + app_data: [u8; order::app_data::APP_DATA_LEN], + signing_scheme: SigningScheme, + #[serde_as(as = "serialize::Hex")] + signature: Vec, + quote: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Kind { + Sell, + Buy, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Interaction { + target: eth::H160, + #[serde_as(as = "serialize::U256")] + value: eth::U256, + #[serde_as(as = "serialize::Hex")] + call_data: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +enum SellTokenBalance { + #[default] + Erc20, + Internal, + External, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +enum BuyTokenBalance { + #[default] + Erc20, + Internal, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SigningScheme { + Eip712, + EthSign, + PreSign, + Eip1271, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Class { + Market, + Limit, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +enum FeePolicy { + #[serde(rename_all = "camelCase")] + Surplus { factor: f64, max_volume_factor: f64 }, + #[serde(rename_all = "camelCase")] + PriceImprovement { + factor: f64, + max_volume_factor: f64, + quote: Quote, + }, + #[serde(rename_all = "camelCase")] + Volume { factor: f64 }, +} + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Quote { + #[serde_as(as = "serialize::U256")] + pub sell_amount: eth::U256, + #[serde_as(as = "serialize::U256")] + pub buy_amount: eth::U256, + #[serde_as(as = "serialize::U256")] + pub fee: eth::U256, + pub solver: eth::H160, +} + +impl Quote { + fn into_domain(self, sell_token: eth::H160, buy_token: eth::H160) -> competition::order::Quote { + competition::order::Quote { + sell: eth::Asset { + amount: self.sell_amount.into(), + token: sell_token.into(), + }, + buy: eth::Asset { + amount: self.buy_amount.into(), + token: buy_token.into(), + }, + fee: eth::Asset { + amount: self.fee.into(), + token: sell_token.into(), + }, + solver: self.solver.into(), + } + } +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/solve_response.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/solve_response.rs new file mode 100644 index 0000000000..0a80e643d3 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/dto/solve_response.rs @@ -0,0 +1,110 @@ +use { + driver::{ + domain::{competition, competition::order, eth}, + infra::Solver, + util::serialize, + }, + serde::Serialize, + serde_with::serde_as, + std::collections::HashMap, +}; + +impl SolveResponse { + pub fn new(solved: Option, solver: &Solver) -> Self { + let solutions = solved + .into_iter() + .map(|solved| Solution::new(solved.id.get(), solved, solver)) + .collect(); + Self { solutions } + } +} + +#[serde_as] +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SolveResponse { + solutions: Vec, +} + +impl Solution { + pub fn new(solution_id: u64, solved: competition::Solved, solver: &Solver) -> Self { + Self { + solution_id, + score: solved.score.0, + submission_address: solver.address().into(), + orders: solved + .trades + .into_iter() + .map(|(order_id, amounts)| { + ( + order_id.into(), + TradedOrder { + side: match amounts.side { + order::Side::Buy => Side::Buy, + order::Side::Sell => Side::Sell, + }, + sell_token: amounts.sell.token.into(), + limit_sell: amounts.sell.amount.into(), + buy_token: amounts.buy.token.into(), + limit_buy: amounts.buy.amount.into(), + executed_sell: amounts.executed_sell.into(), + executed_buy: amounts.executed_buy.into(), + }, + ) + }) + .collect(), + clearing_prices: solved + .prices + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + } + } +} + +type OrderId = [u8; order::UID_LEN]; + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Solution { + /// Unique ID of the solution (per driver competition), used to identify it + /// in subsequent requests (reveal, settle). + solution_id: u64, + #[serde_as(as = "serialize::U256")] + score: eth::U256, + submission_address: eth::H160, + #[serde_as(as = "HashMap")] + orders: HashMap, + #[serde_as(as = "HashMap<_, serialize::U256>")] + clearing_prices: HashMap, +} + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TradedOrder { + pub side: Side, + pub sell_token: eth::H160, + pub buy_token: eth::H160, + #[serde_as(as = "serialize::U256")] + /// Sell limit order amount. + pub limit_sell: eth::U256, + #[serde_as(as = "serialize::U256")] + /// Buy limit order amount. + pub limit_buy: eth::U256, + /// The effective amount that left the user's wallet including all fees. + #[serde_as(as = "serialize::U256")] + pub executed_sell: eth::U256, + /// The effective amount the user received after all fees. + #[serde_as(as = "serialize::U256")] + pub executed_buy: eth::U256, +} + +#[serde_as] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Side { + Buy, + Sell, +} diff --git a/crates/driver-hyperliquid-template/src/infra/api/routes/solve/mod.rs b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/mod.rs new file mode 100644 index 0000000000..38875a4af2 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/api/routes/solve/mod.rs @@ -0,0 +1,41 @@ +pub mod dto; + +pub use dto::AuctionError; +use { + crate::infra::api::State, + driver::infra::{ + api::{error::Error}, + observe, + }, + std::sync::Arc, + tracing::Instrument, +}; + +pub(in crate::infra::api) fn solve(router: axum::Router) -> axum::Router { + router.route("/solve", axum::routing::post(route)) +} + +async fn route( + state: axum::extract::State, + // take the request body as a raw string to delay parsing as much + // as possible because many requests don't have to be parsed at all + req: String, +) -> Result, (hyper::StatusCode, axum::Json)> { + // let handle_request = async { + // let competition = state.competition(); + // let result = competition.solve(Arc::new(req)).await; + // // Solving takes some time, so there is a chance for the settlement queue to + // // have capacity again. + // competition.ensure_settle_queue_capacity()?; + // observe::solved(state.solver().name(), &result); + // Ok(axum::Json(dto::SolveResponse::new( + // result?, + // &competition.solver, + // ))) + // }; + + // handle_request + // .instrument(tracing::info_span!("/solve", solver = %state.solver().name(), auction_id = tracing::field::Empty)) + // .await + Ok(axum::Json(dto::SolveResponse::default())) +} diff --git a/crates/driver-hyperliquid-template/src/infra/mod.rs b/crates/driver-hyperliquid-template/src/infra/mod.rs new file mode 100644 index 0000000000..425fcbfc50 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/mod.rs @@ -0,0 +1,6 @@ +pub mod api; +// pub mod solver; + +pub use { + api::Api, +}; \ No newline at end of file diff --git a/crates/driver-hyperliquid-template/src/infra/solver/dto/auction.rs b/crates/driver-hyperliquid-template/src/infra/solver/dto/auction.rs new file mode 100644 index 0000000000..3e6887f336 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/solver/dto/auction.rs @@ -0,0 +1,387 @@ +use { + crate::{ + domain::{ + competition::{ + self, + order::{self, Side, fees, signature::Scheme}, + }, + eth::{self}, + liquidity, + }, + infra::{config::file::FeeHandler, solver::ManageNativeToken}, + util::conv::{rational_to_big_decimal, u256::U256Ext}, + }, + app_data::AppDataHash, + ethrpc::alloy::conversions::IntoLegacy, + model::order::{BuyTokenDestination, SellTokenSource}, + std::collections::HashMap, +}; + +pub fn new( + auction: &competition::Auction, + liquidity: &[liquidity::Liquidity], + weth: eth::WethAddress, + fee_handler: FeeHandler, + solver_native_token: ManageNativeToken, + flashloan_hints: &HashMap, + deadline: chrono::DateTime, +) -> solvers_dto::auction::Auction { + let mut tokens: HashMap = auction + .tokens() + .iter() + .map(|token| { + ( + token.address.into(), + solvers_dto::auction::Token { + decimals: token.decimals, + symbol: token.symbol.clone(), + reference_price: token.price.map(Into::into), + available_balance: token.available_balance, + trusted: token.trusted, + }, + ) + }) + .collect(); + + // Make sure that we have at least empty entries for all tokens for + // which we are providing liquidity. + for token in liquidity + .iter() + .flat_map(|liquidity| match &liquidity.kind { + liquidity::Kind::UniswapV2(pool) => pool.reserves.iter().map(|r| r.token).collect(), + liquidity::Kind::UniswapV3(pool) => vec![pool.tokens.get().0, pool.tokens.get().1], + liquidity::Kind::BalancerV2Stable(pool) => pool.reserves.tokens().collect(), + liquidity::Kind::BalancerV2Weighted(pool) => pool.reserves.tokens().collect(), + liquidity::Kind::Swapr(pool) => pool.base.reserves.iter().map(|r| r.token).collect(), + liquidity::Kind::ZeroEx(limit_order) => { + vec![ + limit_order.order.maker_token.into(), + limit_order.order.taker_token.into(), + ] + } + }) + { + tokens.entry(token.into()).or_insert_with(Default::default); + } + + solvers_dto::auction::Auction { + id: auction.id().as_ref().map(|id| id.0), + orders: auction + .orders() + .iter() + .map(|order| { + let mut available = order.available(); + + if solver_native_token.wrap_address { + available.buy.token = available.buy.token.as_erc20(weth) + } + // In case of volume based fees, fee withheld by driver might be higher than the + // surplus of the solution. This would lead to violating limit prices when + // driver tries to withhold the volume based fee. To avoid this, we artificially + // adjust the order limit amounts (make then worse) before sending to solvers, + // to force solvers to only submit solutions with enough surplus to cover the + // fee. + // + // https://github.com/cowprotocol/services/issues/2440 + if fee_handler == FeeHandler::Driver { + order.protocol_fees.iter().for_each(|protocol_fee| { + if let fees::FeePolicy::Volume { factor } = protocol_fee { + match order.side { + Side::Buy => { + // reduce sell amount by factor + available.sell.amount = available + .sell + .amount + .apply_factor(1.0 / (1.0 + factor)) + .unwrap_or_default(); + } + Side::Sell => { + // increase buy amount by factor + available.buy.amount = available + .buy + .amount + .apply_factor(1.0 / (1.0 - factor)) + .unwrap_or_default(); + } + } + } + }) + } + solvers_dto::auction::Order { + uid: order.uid.into(), + sell_token: available.sell.token.into(), + buy_token: available.buy.token.into(), + sell_amount: available.sell.amount.into(), + buy_amount: available.buy.amount.into(), + full_sell_amount: order.sell.amount.into(), + full_buy_amount: order.buy.amount.into(), + kind: match order.side { + Side::Buy => solvers_dto::auction::Kind::Buy, + Side::Sell => solvers_dto::auction::Kind::Sell, + }, + receiver: order.receiver.map(Into::into), + owner: order.signature.signer.into(), + partially_fillable: order.is_partial(), + class: match order.kind { + order::Kind::Market => solvers_dto::auction::Class::Market, + order::Kind::Limit => solvers_dto::auction::Class::Limit, + }, + pre_interactions: order + .pre_interactions + .iter() + .cloned() + .map(interaction_from_domain) + .collect::>(), + post_interactions: order + .post_interactions + .iter() + .cloned() + .map(interaction_from_domain) + .collect::>(), + sell_token_source: sell_token_source_from_domain( + order.sell_token_balance.into(), + ), + buy_token_destination: buy_token_destination_from_domain( + order.buy_token_balance.into(), + ), + fee_policies: (fee_handler == FeeHandler::Solver).then_some( + order + .protocol_fees + .iter() + .cloned() + .map(fee_policy_from_domain) + .collect(), + ), + app_data: AppDataHash(order.app_data.hash().0.into()), + flashloan_hint: flashloan_hints.get(&order.uid).map(Into::into), + signature: order.signature.data.clone().into(), + signing_scheme: match order.signature.scheme { + Scheme::Eip712 => solvers_dto::auction::SigningScheme::Eip712, + Scheme::EthSign => solvers_dto::auction::SigningScheme::EthSign, + Scheme::Eip1271 => solvers_dto::auction::SigningScheme::Eip1271, + Scheme::PreSign => solvers_dto::auction::SigningScheme::PreSign, + }, + valid_to: order.valid_to.into(), + } + }) + .collect(), + liquidity: liquidity + .iter() + .map(|liquidity| match &liquidity.kind { + liquidity::Kind::UniswapV2(pool) => { + solvers_dto::auction::Liquidity::ConstantProduct( + solvers_dto::auction::ConstantProductPool { + id: liquidity.id.0.to_string(), + address: pool.address.into(), + router: pool.router.into(), + gas_estimate: liquidity.gas.into(), + tokens: pool + .reserves + .iter() + .map(|asset| { + ( + asset.token.into(), + solvers_dto::auction::ConstantProductReserve { + balance: asset.amount.into(), + }, + ) + }) + .collect(), + fee: bigdecimal::BigDecimal::new(3.into(), 3), + }, + ) + } + liquidity::Kind::UniswapV3(pool) => { + solvers_dto::auction::Liquidity::ConcentratedLiquidity( + solvers_dto::auction::ConcentratedLiquidityPool { + id: liquidity.id.0.to_string(), + address: pool.address.0, + router: pool.router.into(), + gas_estimate: liquidity.gas.0, + tokens: vec![pool.tokens.get().0.into(), pool.tokens.get().1.into()], + sqrt_price: pool.sqrt_price.0, + liquidity: pool.liquidity.0, + tick: pool.tick.0, + liquidity_net: pool + .liquidity_net + .iter() + .map(|(key, value)| (key.0, value.0)) + .collect(), + fee: rational_to_big_decimal(&pool.fee.0), + }, + ) + } + liquidity::Kind::BalancerV2Stable(pool) => { + solvers_dto::auction::Liquidity::Stable(solvers_dto::auction::StablePool { + id: liquidity.id.0.to_string(), + address: pool.id.address().into(), + balancer_pool_id: pool.id.into(), + gas_estimate: liquidity.gas.into(), + tokens: pool + .reserves + .iter() + .map(|r| { + ( + r.asset.token.into(), + solvers_dto::auction::StableReserve { + balance: r.asset.amount.into(), + scaling_factor: scaling_factor_to_decimal(r.scale), + }, + ) + }) + .collect(), + amplification_parameter: rational_to_big_decimal(&num::BigRational::new( + pool.amplification_parameter.factor().to_big_int(), + pool.amplification_parameter.precision().to_big_int(), + )), + fee: fee_to_decimal(pool.fee), + }) + } + liquidity::Kind::BalancerV2Weighted(pool) => { + solvers_dto::auction::Liquidity::WeightedProduct( + solvers_dto::auction::WeightedProductPool { + id: liquidity.id.0.to_string(), + address: pool.id.address().into(), + balancer_pool_id: pool.id.into(), + gas_estimate: liquidity.gas.into(), + tokens: pool + .reserves + .iter() + .map(|r| { + ( + r.asset.token.into(), + solvers_dto::auction::WeightedProductReserve { + balance: r.asset.amount.into(), + scaling_factor: scaling_factor_to_decimal(r.scale), + weight: weight_to_decimal(r.weight), + }, + ) + }) + .collect(), + fee: fee_to_decimal(pool.fee), + version: match pool.version { + liquidity::balancer::v2::weighted::Version::V0 => { + solvers_dto::auction::WeightedProductVersion::V0 + } + liquidity::balancer::v2::weighted::Version::V3Plus => { + solvers_dto::auction::WeightedProductVersion::V3Plus + } + }, + }, + ) + } + liquidity::Kind::Swapr(pool) => solvers_dto::auction::Liquidity::ConstantProduct( + solvers_dto::auction::ConstantProductPool { + id: liquidity.id.0.to_string(), + address: pool.base.address.into(), + router: pool.base.router.into(), + gas_estimate: liquidity.gas.into(), + tokens: pool + .base + .reserves + .iter() + .map(|asset| { + ( + asset.token.into(), + solvers_dto::auction::ConstantProductReserve { + balance: asset.amount.into(), + }, + ) + }) + .collect(), + fee: bigdecimal::BigDecimal::new(pool.fee.bps().into(), 4), + }, + ), + liquidity::Kind::ZeroEx(limit_order) => { + solvers_dto::auction::Liquidity::LimitOrder( + solvers_dto::auction::ForeignLimitOrder { + id: liquidity.id.0.to_string(), + address: limit_order.zeroex.address().into_legacy(), + gas_estimate: liquidity.gas.into(), + hash: Default::default(), + maker_token: limit_order.order.maker_token, + taker_token: limit_order.order.taker_token, + maker_amount: limit_order.fillable.maker.into(), + taker_amount: limit_order.fillable.taker.into(), + taker_token_fee_amount: limit_order.order.taker_token_fee_amount.into(), + }, + ) + } + }) + .collect(), + tokens, + effective_gas_price: auction.gas_price().effective().into(), + deadline, + surplus_capturing_jit_order_owners: auction + .surplus_capturing_jit_order_owners() + .iter() + .cloned() + .map(Into::into) + .collect::>(), + } +} + +fn fee_policy_from_domain(value: fees::FeePolicy) -> solvers_dto::auction::FeePolicy { + match value { + order::FeePolicy::Surplus { + factor, + max_volume_factor, + } => solvers_dto::auction::FeePolicy::Surplus { + factor, + max_volume_factor, + }, + order::FeePolicy::PriceImprovement { + factor, + max_volume_factor, + quote, + } => solvers_dto::auction::FeePolicy::PriceImprovement { + factor, + max_volume_factor, + quote: solvers_dto::auction::Quote { + sell_amount: quote.sell.amount.into(), + buy_amount: quote.buy.amount.into(), + fee: quote.fee.amount.into(), + }, + }, + order::FeePolicy::Volume { factor } => solvers_dto::auction::FeePolicy::Volume { factor }, + } +} + +fn interaction_from_domain(value: eth::Interaction) -> solvers_dto::auction::InteractionData { + solvers_dto::auction::InteractionData { + target: value.target.0, + value: value.value.0, + call_data: value.call_data.0, + } +} + +fn sell_token_source_from_domain(value: SellTokenSource) -> solvers_dto::auction::SellTokenSource { + match value { + SellTokenSource::Erc20 => solvers_dto::auction::SellTokenSource::Erc20, + SellTokenSource::External => solvers_dto::auction::SellTokenSource::External, + SellTokenSource::Internal => solvers_dto::auction::SellTokenSource::Internal, + } +} + +fn buy_token_destination_from_domain( + value: BuyTokenDestination, +) -> solvers_dto::auction::BuyTokenDestination { + match value { + BuyTokenDestination::Erc20 => solvers_dto::auction::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => solvers_dto::auction::BuyTokenDestination::Internal, + } +} + +fn fee_to_decimal(fee: liquidity::balancer::v2::Fee) -> bigdecimal::BigDecimal { + bigdecimal::BigDecimal::new(fee.as_raw().to_big_int(), 18) +} + +fn weight_to_decimal(weight: liquidity::balancer::v2::weighted::Weight) -> bigdecimal::BigDecimal { + bigdecimal::BigDecimal::new(weight.as_raw().to_big_int(), 18) +} + +fn scaling_factor_to_decimal( + scale: liquidity::balancer::v2::ScalingFactor, +) -> bigdecimal::BigDecimal { + bigdecimal::BigDecimal::new(scale.as_raw().to_big_int(), 18) +} diff --git a/crates/driver-hyperliquid-template/src/infra/solver/dto/mod.rs b/crates/driver-hyperliquid-template/src/infra/solver/dto/mod.rs new file mode 100644 index 0000000000..4b3ac42c53 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/solver/dto/mod.rs @@ -0,0 +1,11 @@ +//! DTOs modeling the HTTP REST interface of the solver. + +pub mod auction; +pub mod notification; +mod solution; + +pub use solution::Solutions; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct Error(pub String); diff --git a/crates/driver-hyperliquid-template/src/infra/solver/dto/notification.rs b/crates/driver-hyperliquid-template/src/infra/solver/dto/notification.rs new file mode 100644 index 0000000000..587d5c5c38 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/solver/dto/notification.rs @@ -0,0 +1,102 @@ +use crate::{ + domain::competition::{auction, solution}, + infra::notify, +}; + +pub fn new( + auction_id: Option, + solution_id: Option, + kind: notify::Kind, +) -> solvers_dto::notification::Notification { + solvers_dto::notification::Notification { + auction_id: auction_id.as_ref().map(|id| id.0), + solution_id: solution_id.map(solution_id_from_domain), + kind: match kind { + notify::Kind::Timeout => solvers_dto::notification::Kind::Timeout, + notify::Kind::EmptySolution => solvers_dto::notification::Kind::EmptySolution, + notify::Kind::SimulationFailed(block, tx, succeeded_once) => { + solvers_dto::notification::Kind::SimulationFailed { + block: block.0, + tx: solvers_dto::notification::Tx { + from: tx.from.into(), + to: tx.to.into(), + input: tx.input.into(), + value: tx.value.into(), + access_list: tx.access_list.into(), + }, + succeeded_once, + } + } + notify::Kind::ScoringFailed(scoring) => scoring.into(), + notify::Kind::NonBufferableTokensUsed(tokens) => { + solvers_dto::notification::Kind::NonBufferableTokensUsed { + tokens: tokens.into_iter().map(|token| token.0.0).collect(), + } + } + notify::Kind::SolverAccountInsufficientBalance(required) => { + solvers_dto::notification::Kind::SolverAccountInsufficientBalance { + required: required.0, + } + } + notify::Kind::DuplicatedSolutionId => { + solvers_dto::notification::Kind::DuplicatedSolutionId + } + notify::Kind::DriverError(reason) => { + solvers_dto::notification::Kind::DriverError { reason } + } + notify::Kind::Settled(kind) => match kind { + notify::Settlement::Success(hash) => solvers_dto::notification::Kind::Success { + transaction: hash.0, + }, + notify::Settlement::Revert(hash) => solvers_dto::notification::Kind::Revert { + transaction: hash.0, + }, + notify::Settlement::SimulationRevert => solvers_dto::notification::Kind::Cancelled, + notify::Settlement::Fail => solvers_dto::notification::Kind::Fail, + notify::Settlement::Expired => solvers_dto::notification::Kind::Expired, + }, + notify::Kind::PostprocessingTimedOut => { + solvers_dto::notification::Kind::PostprocessingTimedOut + } + notify::Kind::Banned { reason, until } => solvers_dto::notification::Kind::Banned { + reason: match reason { + notify::BanReason::UnsettledConsecutiveAuctions => { + solvers_dto::notification::BanReason::UnsettledConsecutiveAuctions + } + notify::BanReason::HighSettleFailureRate => { + solvers_dto::notification::BanReason::HighSettleFailureRate + } + }, + until, + }, + notify::Kind::DeserializationError(reason) => { + solvers_dto::notification::Kind::DeserializationError { reason } + } + }, + } +} + +fn solution_id_from_domain(id: solution::Id) -> solvers_dto::notification::SolutionId { + match id.solutions().len() { + 1 => solvers_dto::notification::SolutionId::Single(*id.solutions().first().unwrap()), + _ => solvers_dto::notification::SolutionId::Merged(id.solutions().to_vec()), + } +} + +impl From for solvers_dto::notification::Kind { + fn from(value: notify::ScoreKind) -> Self { + match value { + notify::ScoreKind::InvalidClearingPrices => { + solvers_dto::notification::Kind::InvalidClearingPrices + } + notify::ScoreKind::InvalidExecutedAmount => { + solvers_dto::notification::Kind::InvalidExecutedAmount + } + notify::ScoreKind::MissingPrice(token_address) => { + solvers_dto::notification::Kind::MissingPrice { + token_address: token_address.into(), + } + } + } + } +} diff --git a/crates/driver-hyperliquid-template/src/infra/solver/dto/solution.rs b/crates/driver-hyperliquid-template/src/infra/solver/dto/solution.rs new file mode 100644 index 0000000000..3b00f54155 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/solver/dto/solution.rs @@ -0,0 +1,331 @@ +use { + crate::{ + domain::{competition, eth, liquidity}, + infra::Solver, + util::Bytes, + }, + app_data::AppDataHash, + itertools::Itertools, + model::{ + DomainSeparator, + order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}, + }, + std::{collections::HashMap, str::FromStr}, +}; + +#[derive(derive_more::From)] +pub struct Solutions(solvers_dto::solution::Solutions); + +impl Solutions { + pub fn into_domain( + self, + auction: &competition::Auction, + liquidity: &[liquidity::Liquidity], + weth: eth::WethAddress, + solver: Solver, + flashloan_hints: &HashMap, + ) -> Result, super::Error> { + self.0.solutions + .into_iter() + .map(|solution| { + competition::Solution::new( + competition::solution::Id::new(solution.id), + solution + .trades + .iter() + .map(|trade| match trade { + solvers_dto::solution::Trade::Fulfillment(fulfillment) => { + let order = auction + .orders() + .iter() + .find(|order| order.uid == fulfillment.order.0) + // TODO this error should reference the UID + .ok_or(super::Error( + "invalid order UID specified in fulfillment".to_owned() + ))? + .clone(); + + competition::solution::trade::Fulfillment::new( + order, + fulfillment.executed_amount.into(), + match fulfillment.fee { + Some(fee) => competition::solution::trade::Fee::Dynamic( + competition::order::SellAmount(fee), + ), + None => competition::solution::trade::Fee::Static, + }, + ) + .map(competition::solution::Trade::Fulfillment) + .map_err(|err| super::Error(format!("invalid fulfillment: {err}"))) + } + solvers_dto::solution::Trade::Jit(jit) => { + let jit_order: JitOrder = jit.order.clone().into(); + Ok(competition::solution::Trade::Jit( + competition::solution::trade::Jit::new( + competition::order::Jit { + uid: jit_order.uid( + solver.eth.contracts().settlement_domain_separator(), + )?, + sell: eth::Asset { + amount: jit_order.0.sell_amount.into(), + token: jit_order.0.sell_token.into(), + }, + buy: eth::Asset { + amount: jit_order.0.buy_amount.into(), + token: jit_order.0.buy_token.into(), + }, + receiver: jit_order.0.receiver.into(), + partially_fillable: jit_order.0.partially_fillable, + valid_to: jit_order.0.valid_to.into(), + app_data: jit_order.0.app_data.into(), + side: match jit_order.0.kind { + solvers_dto::solution::Kind::Sell => competition::order::Side::Sell, + solvers_dto::solution::Kind::Buy => competition::order::Side::Buy, + }, + sell_token_balance: match jit_order.0.sell_token_balance { + solvers_dto::solution::SellTokenBalance::Erc20 => { + competition::order::SellTokenBalance::Erc20 + } + solvers_dto::solution::SellTokenBalance::Internal => { + competition::order::SellTokenBalance::Internal + } + solvers_dto::solution::SellTokenBalance::External => { + competition::order::SellTokenBalance::External + } + }, + buy_token_balance: match jit_order.0.buy_token_balance { + solvers_dto::solution::BuyTokenBalance::Erc20 => { + competition::order::BuyTokenBalance::Erc20 + } + solvers_dto::solution::BuyTokenBalance::Internal => { + competition::order::BuyTokenBalance::Internal + } + }, + signature: jit_order.signature( + solver.eth.contracts().settlement_domain_separator(), + )?, + }, + jit.executed_amount.into(), + jit.fee.unwrap_or_default().into(), + ) + .map_err(|err| super::Error(format!("invalid JIT trade: {err}")))?, + ))}, + }) + .try_collect()?, + solution + .prices + .into_iter() + .map(|(address, price)| (address.into(), price)) + .collect(), + solution + .pre_interactions + .into_iter() + .map(|interaction| eth::Interaction { + target: interaction.target.into(), + value: interaction.value.into(), + call_data: Bytes(interaction.calldata), + }) + .collect(), + solution + .interactions + .into_iter() + .map(|interaction| match interaction { + solvers_dto::solution::Interaction::Custom(interaction) => { + Ok(competition::solution::Interaction::Custom( + competition::solution::interaction::Custom { + target: interaction.target.into(), + value: interaction.value.into(), + call_data: interaction.calldata.into(), + allowances: interaction + .allowances + .into_iter() + .map(|allowance| { + eth::Allowance { + token: allowance.token.into(), + spender: allowance.spender.into(), + amount: allowance.amount, + } + .into() + }) + .collect(), + inputs: interaction + .inputs + .into_iter() + .map(|input| eth::Asset { + amount: input.amount.into(), + token: input.token.into(), + }) + .collect(), + outputs: interaction + .outputs + .into_iter() + .map(|input| eth::Asset { + amount: input.amount.into(), + token: input.token.into(), + }) + .collect(), + internalize: interaction.internalize, + }, + )) + } + solvers_dto::solution::Interaction::Liquidity(interaction) => { + let liquidity_id = usize::from_str(&interaction.id).map_err(|_| super::Error("invalid liquidity ID format".to_owned()))?; + let liquidity = liquidity + .iter() + .find(|liquidity| liquidity.id == liquidity_id) + .ok_or(super::Error( + "invalid liquidity ID specified in interaction".to_owned(), + ))? + .to_owned(); + Ok(competition::solution::Interaction::Liquidity( + competition::solution::interaction::Liquidity { + liquidity, + input: eth::Asset { + amount: interaction.input_amount.into(), + token: interaction.input_token.into(), + }, + output: eth::Asset { + amount: interaction.output_amount.into(), + token: interaction.output_token.into(), + }, + internalize: interaction.internalize, + }, + )) + } + }) + .try_collect()?, + solution + .post_interactions + .into_iter() + .map(|interaction| eth::Interaction { + target: interaction.target.into(), + value: interaction.value.into(), + call_data: Bytes(interaction.calldata), + }) + .collect(), + solver.clone(), + weth, + solution.gas.map(|gas| eth::Gas(gas.into())), + solver.config().fee_handler, + auction.surplus_capturing_jit_order_owners(), + solution.flashloans + // convert the flashloan info provided by the solver + .map(|f| f.iter().map(|(order, loan)|(order.into(), loan.into())).collect()) + // or copy over the relevant flashloan hints from the solve request + .unwrap_or_else(|| solution.trades.iter() + .filter_map(|t| { + let solvers_dto::solution::Trade::Fulfillment(trade) = &t else { + // we don't have any flashloan data on JIT orders + return None; + }; + let uid = competition::order::Uid::from(&trade.order); + Some(( + uid, + flashloan_hints.get(&uid).cloned()?, + )) + }).collect()), + ) + .map_err(|err| match err { + competition::solution::error::Solution::InvalidClearingPrices => { + super::Error("invalid clearing prices".to_owned()) + } + competition::solution::error::Solution::ProtocolFee(err) => { + super::Error(format!("could not incorporate protocol fee: {err}")) + } + competition::solution::error::Solution::InvalidJitTrade(err) => { + super::Error(format!("invalid jit trade: {err}")) + } + }) + }) + .collect() + } +} + +#[derive(derive_more::From)] +pub struct JitOrder(solvers_dto::solution::JitOrder); + +impl JitOrder { + fn raw_order_data(&self) -> OrderData { + OrderData { + sell_token: self.0.sell_token, + buy_token: self.0.buy_token, + receiver: Some(self.0.receiver), + sell_amount: self.0.sell_amount, + buy_amount: self.0.buy_amount, + valid_to: self.0.valid_to, + app_data: AppDataHash(self.0.app_data), + fee_amount: 0.into(), + kind: match self.0.kind { + solvers_dto::solution::Kind::Sell => OrderKind::Sell, + solvers_dto::solution::Kind::Buy => OrderKind::Buy, + }, + partially_fillable: self.0.partially_fillable, + sell_token_balance: match self.0.sell_token_balance { + solvers_dto::solution::SellTokenBalance::Erc20 => SellTokenSource::Erc20, + solvers_dto::solution::SellTokenBalance::Internal => SellTokenSource::Internal, + solvers_dto::solution::SellTokenBalance::External => SellTokenSource::External, + }, + buy_token_balance: match self.0.buy_token_balance { + solvers_dto::solution::BuyTokenBalance::Erc20 => BuyTokenDestination::Erc20, + solvers_dto::solution::BuyTokenBalance::Internal => BuyTokenDestination::Internal, + }, + } + } + + fn signature( + &self, + domain_separator: ð::DomainSeparator, + ) -> Result { + let mut signature = competition::order::Signature { + scheme: match self.0.signing_scheme { + solvers_dto::solution::SigningScheme::Eip712 => { + competition::order::signature::Scheme::Eip712 + } + solvers_dto::solution::SigningScheme::EthSign => { + competition::order::signature::Scheme::EthSign + } + solvers_dto::solution::SigningScheme::PreSign => { + competition::order::signature::Scheme::PreSign + } + solvers_dto::solution::SigningScheme::Eip1271 => { + competition::order::signature::Scheme::Eip1271 + } + }, + data: self.0.signature.clone().into(), + signer: Default::default(), + }; + + let signer = signature + .to_boundary_signature() + .recover_owner( + self.0.signature.as_slice(), + &DomainSeparator(domain_separator.0), + &self.raw_order_data().hash_struct(), + ) + .map_err(|e| super::Error(e.to_string()))?; + + if matches!( + self.0.signing_scheme, + solvers_dto::solution::SigningScheme::Eip1271 + ) { + // For EIP-1271 signatures the encoding logic prepends the signer to the raw + // signature bytes. This leads to the owner being encoded twice in + // the final settlement calldata unless we remove that from the raw + // data. + signature.data = Bytes(self.0.signature[20..].to_vec()); + } + + signature.signer = signer.into(); + + Ok(signature) + } + + fn uid(&self, domain: ð::DomainSeparator) -> Result { + let order_data = self.raw_order_data(); + let signature = self.signature(domain)?; + Ok(order_data + .uid(&DomainSeparator(domain.0), &signature.signer.into()) + .0 + .into()) + } +} diff --git a/crates/driver-hyperliquid-template/src/infra/solver/mod.rs b/crates/driver-hyperliquid-template/src/infra/solver/mod.rs new file mode 100644 index 0000000000..f41edefeec --- /dev/null +++ b/crates/driver-hyperliquid-template/src/infra/solver/mod.rs @@ -0,0 +1,376 @@ +use { + crate::{ + infra::{ + self, + }, + }, + driver::{ + domain::{ + competition::{ + auction::{self, Auction}, + bad_tokens, + order, + solution::{self, Solution}, + }, + eth, + liquidity, + }, + util, + infra::{ + blockchain::Ethereum, + config::file::FeeHandler, + persistence::{Persistence, S3}, + solver::Config, + }, + + }, + anyhow::Result, + derive_more::{From, Into}, + num::BigRational, + observe::tracing::tracing_headers, + reqwest::header::HeaderName, + std::{ + collections::HashMap, + time::{Duration, Instant}, + }, + thiserror::Error, + tracing::{Instrument, instrument}, +}; + +pub mod dto; + +// TODO At some point I should be checking that the names are unique, I don't +// think I'm doing that. +/// The solver name. The user can configure this to be anything that they like. +/// The name uniquely identifies each solver in case there's more than one of +/// them. +#[derive(Debug, Clone, From, Into)] +pub struct Name(pub String); + +impl Name { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone)] +pub struct Slippage { + pub relative: BigRational, + pub absolute: Option, +} + +#[derive(Clone, Copy, Debug)] +pub enum Liquidity { + /// Liquidity should be fetched and included in the auction sent to this + /// solver. + Fetch, + /// The solver does not need liquidity, so fetching can be skipped for this + /// solver. + Skip, +} + +#[derive(Clone, Copy, Debug)] +pub struct Timeouts { + /// Maximum time allocated for http request/reponse to propagate through + /// network. + pub http_delay: chrono::Duration, + /// Maximum time allocated for solver engines to return the solutions back + /// to the driver, in percentage of total driver deadline. + pub solving_share_of_deadline: util::Percent, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManageNativeToken { + /// If true wraps ETH address + pub wrap_address: bool, + /// If true inserts unwrap interactions + pub insert_unwraps: bool, +} + +/// Solvers are controlled by the driver. Their job is to search for solutions +/// to auctions. They do this in various ways, often by analyzing different AMMs +/// on the Ethereum blockchain. +#[derive(Debug, Clone)] +pub struct Solver { + client: reqwest::Client, + config: Config, + eth: Ethereum, + persistence: Persistence, +} + +impl Solver { + pub async fn try_new(config: Config, eth: Ethereum) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_TYPE, + "application/json".parse().unwrap(), + ); + headers.insert(reqwest::header::ACCEPT, "application/json".parse().unwrap()); + + for (key, val) in config.request_headers.iter() { + let header_name = HeaderName::try_from(key)?; + headers.insert(header_name, val.parse()?); + } + + let persistence = Persistence::build(&config).await; + + Ok(Self { + client: reqwest::ClientBuilder::new() + .default_headers(headers) + .build()?, + config, + eth, + persistence, + }) + } + + pub fn bad_token_detection(&self) -> &BadTokenDetection { + &self.config.bad_token_detection + } + + pub fn persistence(&self) -> Persistence { + self.persistence.clone() + } + + pub fn name(&self) -> &Name { + &self.config.name + } + + /// The slippage configuration of this solver. + pub fn slippage(&self) -> &Slippage { + &self.config.slippage + } + + /// The liquidity configuration of this solver + pub fn liquidity(&self) -> Liquidity { + self.config.liquidity + } + + /// The blockchain address of this solver. + pub fn address(&self) -> eth::Address { + self.config.account.address().into() + } + + /// The account which should be used to sign settlements for this solver. + pub fn account(&self) -> ethcontract::Account { + self.config.account.clone() + } + + /// Timeout configuration for this solver. + pub fn timeouts(&self) -> Timeouts { + self.config.timeouts + } + + /// Use limit orders for quoting instead of market orders + pub fn quote_using_limit_orders(&self) -> bool { + self.config.quote_using_limit_orders + } + + pub fn solution_merging(&self) -> SolutionMerging { + self.config.merge_solutions + } + + pub fn solver_native_token(&self) -> ManageNativeToken { + self.config.solver_native_token + } + + pub fn quote_tx_origin(&self) -> &Option { + &self.config.quote_tx_origin + } + + pub fn settle_queue_size(&self) -> usize { + self.config.settle_queue_size + } + + pub fn fetch_liquidity_at_block(&self) -> driver::infra::liquidity::AtBlock { + self.config.fetch_liquidity_at_block.clone() + } + + /// Make a POST request instructing the solver to solve an auction. + /// Allocates at most `timeout` time for the solving. + #[instrument(name = "solver_engine", skip_all)] + pub async fn solve( + &self, + auction: &Auction, + liquidity: &[liquidity::Liquidity], + ) -> Result, Error> { + let start = Instant::now(); + + let flashloan_hints = self.assemble_flashloan_hints(auction); + let weth = self.eth.contracts().weth_address(); + let auction_dto = dto::auction::new( + auction, + liquidity, + weth, + self.config.fee_handler, + self.config.solver_native_token, + &flashloan_hints, + auction.deadline(self.timeouts()).solvers(), + ); + + let body = { + // pre-allocate a big enough buffer to avoid re-allocating memory + // as the request gets serialized + const BYTES_PER_ORDER: usize = 1_300; + let mut buffer = Vec::with_capacity(auction.orders().len() * BYTES_PER_ORDER); + serde_json::to_writer(&mut buffer, &auction_dto).unwrap(); + String::from_utf8(buffer).expect("serde_json only writes valid utf8") + }; + + if let Some(id) = auction.id() { + // Only auctions with IDs are real auctions (/quote requests don't have an ID). + // Only for those it makes sense to archive them and measure the execution time. + self.persistence.archive_auction(id, &auction_dto); + ::observe::metrics::metrics().measure_auction_overhead( + start, + "driver", + "serialize_request", + ); + } + + let url = shared::url::join(&self.config.endpoint, "solve"); + super::observe::solver_request(&url, &body); + let timeout = match auction.deadline(self.timeouts()).solvers().remaining() { + Ok(timeout) => timeout, + Err(_) => { + tracing::warn!("auction deadline exceeded before sending request to solver"); + return Ok(Default::default()); + } + }; + let mut req = self + .client + .post(url.clone()) + .body(body) + .headers(tracing_headers()) + .timeout(timeout); + if let Some(id) = observe::distributed_tracing::request_id::from_current_span() { + req = req.header("X-REQUEST-ID", id); + } + super::observe::sending_solve_request(self.config.name.as_str(), timeout); + let started_at = std::time::Instant::now(); + let res = util::http::send(self.config.response_size_limit_max_bytes, req).await; + super::observe::solver_response( + &url, + res.as_deref(), + self.config.name.as_str(), + started_at.elapsed(), + ); + let res = res?; + let res: solvers_dto::solution::Solutions = + serde_json::from_str(&res).inspect_err(|err| { + tracing::warn!(res, ?err, "failed to parse solver response"); + self.notify( + auction.id(), + None, + notify::Kind::DeserializationError(format!("Request format invalid: {err}")), + ); + })?; + let solutions = dto::Solutions::from(res).into_domain( + auction, + liquidity, + weth, + self.clone(), + &flashloan_hints, + )?; + + super::observe::solutions(&solutions, auction.surplus_capturing_jit_order_owners()); + Ok(solutions) + } + + fn assemble_flashloan_hints(&self, auction: &Auction) -> HashMap { + if !self.config.flashloans_enabled { + return Default::default(); + } + + auction + .orders() + .iter() + .flat_map(|order| { + let hint = order.app_data.flashloan()?; + let flashloan = eth::Flashloan { + liquidity_provider: hint.liquidity_provider.into(), + protocol_adapter: hint.protocol_adapter.into(), + receiver: hint.receiver.into(), + token: hint.token.into(), + amount: hint.amount.into(), + }; + Some((order.uid, flashloan)) + }) + .collect() + } + + /// Make a fire and forget POST request to notify the solver about an event. + pub fn notify( + &self, + auction_id: Option, + solution_id: Option, + kind: notify::Kind, + ) { + let body = + serde_json::to_string(&dto::notification::new(auction_id, solution_id, kind)).unwrap(); + let url = shared::url::join(&self.config.endpoint, "notify"); + super::observe::solver_request(&url, &body); + let mut req = self.client.post(url).body(body).headers(tracing_headers()); + if let Some(id) = observe::distributed_tracing::request_id::from_current_span() { + req = req.header("X-REQUEST-ID", id); + } + let response_size = self.config.response_size_limit_max_bytes; + let future = async move { + if let Err(error) = util::http::send(response_size, req).await { + tracing::warn!(?error, "failed to notify solver"); + } + }; + tokio::task::spawn(future.in_current_span()); + } + + pub fn config(&self) -> &Config { + &self.config + } +} + +/// Controls whether or not the driver is allowed to merge multiple solutions +/// of the same solver to produce an overall better solution. +#[derive(Debug, Clone, Copy)] +pub enum SolutionMerging { + Allowed { + max_orders_per_merged_solution: usize, + }, + Forbidden, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP error: {0:?}")] + Http(#[from] util::http::Error), + #[error("JSON deserialization error: {0:?}")] + Deserialize(#[from] serde_json::Error), + #[error("solver dto error: {0}")] + Dto(#[from] dto::Error), +} + +impl Error { + pub fn is_timeout(&self) -> bool { + match self { + Self::Http(util::http::Error::Response(err)) => err.is_timeout(), + _ => false, + } + } +} + +#[derive(Debug, Clone)] +pub struct BadTokenDetection { + /// Tokens that are explicitly allow- or deny-listed. + pub tokens_supported: HashMap, + pub enable_simulation_strategy: bool, + pub enable_metrics_strategy: bool, + pub metrics_strategy_failure_ratio: f64, + pub metrics_strategy_required_measurements: u32, + pub metrics_strategy_log_only: bool, + pub metrics_strategy_token_freeze_time: Duration, +} diff --git a/crates/driver-hyperliquid-template/src/lib.rs b/crates/driver-hyperliquid-template/src/lib.rs new file mode 100644 index 0000000000..c488cc4b84 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/lib.rs @@ -0,0 +1,35 @@ +use axum::{ + routing::{get, post}, + Router, +}; +use std::net::SocketAddr; +use tower::ServiceBuilder; +use tower_http::trace::TraceLayer; +// pub mod api; +mod run; + +pub mod infra; + +pub use self::run::{run, start}; + +// pub fn app( +// ) -> Router { +// Router::new() +// .route("/quote", get(api::get_quote)) +// .route("/solve", post(api::solve)) +// .route("/reveal", post(api::reveal_calldata)) +// .route("/settle", post(api::settle)) +// .route("/notify", post(api::receive_notification)) +// .layer( +// ServiceBuilder::new() +// .layer(TraceLayer::new_for_http()) +// ) +// } +// pub async fn serve(app: Router, port: u16) { +// let addr = SocketAddr::from(([0, 0, 0, 0], port)); +// tracing::info!("listening on {}", addr); +// axum::Server::bind(&addr) +// .serve(app.into_make_service()) +// .await +// .unwrap(); +// } \ No newline at end of file diff --git a/crates/driver-hyperliquid-template/src/main.rs b/crates/driver-hyperliquid-template/src/main.rs new file mode 100644 index 0000000000..473d04e6c3 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() { + driver_hyperliquid_template::start(std::env::args()).await; +} diff --git a/crates/driver-hyperliquid-template/src/run.rs b/crates/driver-hyperliquid-template/src/run.rs new file mode 100644 index 0000000000..c1818e4c84 --- /dev/null +++ b/crates/driver-hyperliquid-template/src/run.rs @@ -0,0 +1,124 @@ +use { + crate::{ + // app, serve + infra::{ + Api + }, + }, + futures::future::join_all, + driver::infra::{ + self, + blockchain::{self, Ethereum}, + cli::{self,Args}, + config, Solver + }, + clap::Parser, + std::{net::SocketAddr, sync::Arc, time::Duration}, + tokio::sync::oneshot, +}; +pub async fn start(args: impl Iterator) { + let args = Args::parse_from(args); + run_with(args, None).await +} +pub async fn run( + args: impl Iterator, + addr_sender: Option>, +) { + let args = Args::parse_from(args); + run_with(args, addr_sender).await; +} +async fn run_with(args: Args, addr_sender: Option>) { + if std::env::var("RUST_LOG").is_err() { + std::env::set_var("RUST_LOG", "info"); + } + tracing_subscriber::fmt::init(); + // let app = app(); + // if let Some(sender) = addr_sender { + // let addr = SocketAddr::from(([127, 0, 0, 1], args.port)); + // sender.send(addr).unwrap(); + // } + let ethrpc = ethrpc(&args).await; + let web3 = ethrpc.web3().clone(); + let config = config::file::load(ethrpc.chain(), &args.config).await; + let commit_hash = option_env!("VERGEN_GIT_SHA").unwrap_or("COMMIT_INFO_NOT_FOUND"); + + tracing::info!(%commit_hash, "running driver with {config:#?}"); + + let (shutdown_sender, shutdown_receiver) = tokio::sync::oneshot::channel(); + let eth = ethereum(&config, ethrpc).await; + let api = Api{ + solvers: solvers(&config, ð).await, + addr: args.addr, + addr_sender, + }.serve(async{ + let _ = shutdown_receiver.await; + } + ); + futures::pin_mut!(api); + tokio::select! { + result = &mut api => panic!("serve task exited: {result:?}"), + _ = shutdown_signal() => { + tracing::info!("Gracefully shutting down API"); + shutdown_sender.send(()).expect("failed to send shutdown signal"); + // Shutdown timeout needs to be larger than the auction deadline + match tokio::time::timeout(Duration::from_secs(20), api).await { + Ok(inner) => inner.expect("API failed during shutdown"), + Err(_) => panic!("API shutdown exceeded timeout"), + } + } + }; + + // serve(app, args.port).await; +} +async fn ethereum(config: &infra::Config, ethrpc: blockchain::Rpc) -> Ethereum { + let gas = Arc::new( + blockchain::GasPriceEstimator::new(ethrpc.web3(), &config.gas_estimator, &config.mempools) + .await + .expect("initialize gas price estimator"), + ); + Ethereum::new(ethrpc, config.contracts.clone(), gas, config.tx_gas_limit).await +} +async fn ethrpc(args: &cli::Args) -> blockchain::Rpc { + let args = blockchain::RpcArgs { + url: args.ethrpc.clone(), + max_batch_size: args.ethrpc_max_batch_size, + max_concurrent_requests: args.ethrpc_max_concurrent_requests, + }; + blockchain::Rpc::try_new(args) + .await + .expect("connect ethereum RPC") +} + +#[cfg(unix)] +async fn shutdown_signal() { + // Intercept signals for graceful shutdown. Kubernetes sends sigterm, Ctrl-C + // sends sigint. + let sigterm = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .unwrap() + .recv() + .await + }; + let sigint = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .unwrap() + .recv() + .await; + }; + futures::pin_mut!(sigint); + futures::pin_mut!(sigterm); + futures::future::select(sigterm, sigint).await; +} + +async fn solvers(config: &config::Config, eth: &Ethereum) -> Vec { + join_all( + config + .solvers + .iter() + .map( + |config| async move { Solver::try_new(config.clone(), eth.clone()).await.unwrap() }, + ) + .collect::>(), + ) + .await +} diff --git a/crates/driver/src/boundary/liquidity/mod.rs b/crates/driver/src/boundary/liquidity/mod.rs index 5cff9649bb..34285028c8 100644 --- a/crates/driver/src/boundary/liquidity/mod.rs +++ b/crates/driver/src/boundary/liquidity/mod.rs @@ -144,6 +144,11 @@ impl Fetcher { }) .collect(); + tracing::info!( + "Fetching liquidity for {:?}", + pairs + ); + let block = match block { infra::liquidity::AtBlock::Recent => recent_block_cache::Block::Recent, infra::liquidity::AtBlock::Finalized => recent_block_cache::Block::Finalized, @@ -154,6 +159,11 @@ impl Fetcher { }; let liquidity = self.inner.get_liquidity(pairs, block).await?; + tracing::info!( + "Fetched liquidity: {:?}", + liquidity + ); + let liquidity = liquidity .into_iter() .enumerate() diff --git a/crates/driver/src/boundary/liquidity/uniswap/v2.rs b/crates/driver/src/boundary/liquidity/uniswap/v2.rs index 8e06bb54ad..8805545604 100644 --- a/crates/driver/src/boundary/liquidity/uniswap/v2.rs +++ b/crates/driver/src/boundary/liquidity/uniswap/v2.rs @@ -138,6 +138,14 @@ where { let router = IUniswapLikeRouter::Instance::new(config.router.0.into_alloy(), eth.web3().alloy.clone()); + + // ADD THIS LOGGING + tracing::info!( + "Initializing Uniswap V2 liquidity collector with router: {:?}, pool_code: {:?}", + config.router.0, + config.pool_code + ); + let settlement = eth.contracts().settlement().clone(); let pool_fetcher = { let factory = router.factory().call().await?; @@ -146,6 +154,11 @@ where init_code_digest: config.pool_code.into(), }; + tracing::info!( + "Uniswap V2 factory address: {:?}", + factory + ); + let pool_fetcher = PoolFetcher::new( reader(eth.web3().clone(), pair_provider), eth.web3().clone(), @@ -159,12 +172,20 @@ where )?) }; - Ok(Box::new(UniswapLikeLiquidity::with_allowances( + let liquidity_collector = UniswapLikeLiquidity::with_allowances( *router.address(), settlement, Box::new(NoAllowanceManaging), pool_fetcher, - ))) + ); + + // ADD THIS LOGGING + tracing::info!( + "Uniswap V2 liquidity collector initialized successfully with router: {:?}", + *router.address() + ); + + Ok(Box::new(liquidity_collector)) } /// An allowance manager that always reports no allowances. diff --git a/crates/driver/src/domain/competition/solution/encoding.rs b/crates/driver/src/domain/competition/solution/encoding.rs index aad3f57cd2..419f11b578 100644 --- a/crates/driver/src/domain/competition/solution/encoding.rs +++ b/crates/driver/src/domain/competition/solution/encoding.rs @@ -170,6 +170,8 @@ pub fn tx( interactions.push(approve(&approval.0)) } + tracing::debug!("interactions after encoding allowances = {:#?}", interactions.clone()); + // Encode interactions let slippage = slippage::Parameters { relative: solution.solver().slippage().relative.clone(), @@ -203,6 +205,13 @@ pub fn tx( interactions.push(unwrap(native_unwrap, contracts.weth())); } + tracing::debug!("tokens = {:#?}", tokens.clone()); + tracing::debug!("clearing_prices = {:#?}", clearing_prices.clone()); + tracing::debug!("interactions = {:#?}", interactions.clone()); + tracing::debug!("pre_interactions = {:#?}", pre_interactions.clone()); + tracing::debug!("post_interactions = {:#?}", post_interactions.clone()); + tracing::debug!("trades = {:#?}", trades.clone()); + let tx = contracts .settlement() .settle( @@ -216,6 +225,8 @@ pub fn tx( ], ) .into_inner(); + + tracing::debug!("settlement tx = {:#?}", tx); // Encode the auction id into the calldata let mut settle_calldata = tx.data.unwrap().0; @@ -317,6 +328,7 @@ fn unwrap(amount: eth::TokenAmount, weth: &contracts::WETH9) -> eth::Interaction } } +#[derive(Debug, Clone)] struct Trade { sell_token_index: eth::U256, buy_token_index: eth::U256, @@ -338,6 +350,7 @@ struct Price { buy_price: eth::U256, } +#[derive(Debug, Clone)] struct Flags { side: order::Side, partially_fillable: bool, diff --git a/crates/driver/src/infra/api/mod.rs b/crates/driver/src/infra/api/mod.rs index fbfa63ade5..38861933c5 100644 --- a/crates/driver/src/infra/api/mod.rs +++ b/crates/driver/src/infra/api/mod.rs @@ -24,7 +24,7 @@ use { tokio::sync::oneshot, }; -mod error; +pub mod error; pub mod routes; const REQUEST_BODY_LIMIT: usize = 10 * 1024 * 1024; diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index 4d5818d055..366b9de3e5 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -174,7 +174,10 @@ pub async fn run(args: Arguments) { verify_deployed_contract_constants(&settlement_contract, chain_id) .await .expect("Deployed contract constants don't match the ones in this binary"); + tracing::info!("settlement_contract: {:?}", settlement_contract.address()); + tracing::info!("chain_id: {:?}", chain_id); let domain_separator = DomainSeparator::new(chain_id, settlement_contract.address()); + tracing::info!("domain_separator: {:?}", domain_separator); let postgres_write = Postgres::try_new(args.db_write_url.as_str()).expect("failed to create database"); diff --git a/crates/shared/src/bad_token/token_owner_finder/mod.rs b/crates/shared/src/bad_token/token_owner_finder/mod.rs index 21cc299313..c2e8cf349d 100644 --- a/crates/shared/src/bad_token/token_owner_finder/mod.rs +++ b/crates/shared/src/bad_token/token_owner_finder/mod.rs @@ -206,7 +206,8 @@ impl TokenOwnerFindingStrategy { | Chain::Optimism | Chain::Avalanche | Chain::Polygon - | Chain::Lens => &[Self::Liquidity], + | Chain::Lens + | Chain::HyperEvmTestnet => &[Self::Liquidity], Chain::Hardhat => panic!("unsupported chain for token owner finding"), } } diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index 974582090c..bbc06cd4fc 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -580,6 +580,12 @@ impl OrderValidating for OrderValidator { let app_data = self.validate_app_data(&order.app_data, &full_app_data_override)?; let app_data_signer = app_data.inner.protocol.signer; + tracing::info!("app_data_signer: {:?}", app_data_signer); + tracing::info!("domain_separator: {:?}", domain_separator); + tracing::info!("order: {:?}", order); + tracing::info!("settlement_contract: {:?}", settlement_contract); + tracing::info!("full_app_data_override: {:?}", full_app_data_override); + let owner = order.verify_owner(domain_separator, app_data_signer)?; tracing::debug!(?owner, "recovered owner from order and signature"); let signing_scheme = order.signature.scheme(); diff --git a/crates/shared/src/price_estimation/native/coingecko.rs b/crates/shared/src/price_estimation/native/coingecko.rs index 078c3c00dd..ee424ef209 100644 --- a/crates/shared/src/price_estimation/native/coingecko.rs +++ b/crates/shared/src/price_estimation/native/coingecko.rs @@ -81,7 +81,7 @@ impl CoinGecko { Chain::Optimism => "optimistic-ethereum".to_string(), Chain::Bnb => "binance-smart-chain".to_string(), Chain::Lens => "lens".to_string(), - Chain::Sepolia | Chain::Goerli | Chain::Hardhat => { + Chain::Sepolia | Chain::Goerli | Chain::Hardhat | Chain::HyperEvmTestnet => { anyhow::bail!("unsupported network {}", chain.name()) } }; diff --git a/crates/shared/src/sources/mod.rs b/crates/shared/src/sources/mod.rs index c2963e6571..6b1fc4875e 100644 --- a/crates/shared/src/sources/mod.rs +++ b/crates/shared/src/sources/mod.rs @@ -70,6 +70,10 @@ pub fn defaults_for_network(chain: &Chain) -> Vec { ], Chain::Lens => vec![BaselineSource::UniswapV3], Chain::Sepolia => vec![BaselineSource::TestnetUniswapV2], + Chain::HyperEvmTestnet => vec![ + BaselineSource::UniswapV2, // HyperSwap V2 + BaselineSource::UniswapV3, // HyperSwap V3 + ], Chain::Hardhat => panic!("unsupported baseline sources for Hardhat"), } } diff --git a/crates/shared/src/sources/uniswap_v2/pair_provider.rs b/crates/shared/src/sources/uniswap_v2/pair_provider.rs index acd06a5e78..48e9655cd4 100644 --- a/crates/shared/src/sources/uniswap_v2/pair_provider.rs +++ b/crates/shared/src/sources/uniswap_v2/pair_provider.rs @@ -17,6 +17,13 @@ impl PairProvider { buffer[20..40].copy_from_slice(&token1); keccak256(&buffer) }; + tracing::info!( + "Salt: {:?} - init code digest: {:?} - factory: {:?} - pair: {:?}", + salt, + self.init_code_digest, + self.factory, + pair + ); create2_target_address(self.factory, &salt, &self.init_code_digest) } } diff --git a/crates/shared/src/sources/uniswap_v2/pool_fetching.rs b/crates/shared/src/sources/uniswap_v2/pool_fetching.rs index e2801e2ea9..ea24544d93 100644 --- a/crates/shared/src/sources/uniswap_v2/pool_fetching.rs +++ b/crates/shared/src/sources/uniswap_v2/pool_fetching.rs @@ -220,6 +220,15 @@ where #[instrument(skip_all)] async fn fetch(&self, token_pairs: HashSet, at_block: Block) -> Result> { let mut token_pairs: Vec<_> = token_pairs.into_iter().collect(); + // Log initial pairs being requested + tracing::info!( + "Fetching pools for {} pairs: {:?}", + token_pairs.len(), + token_pairs + .iter() + .map(|p| (p.get().0, p.get().1)) + .collect::>() + ); { let mut non_existent_pools = self.non_existent_pools.write().unwrap(); token_pairs.retain(|pair| non_existent_pools.cache_get(pair).is_none()); @@ -230,23 +239,57 @@ where .map(|pair| self.pool_reader.read_state(*pair, block)) .collect::>(); + tracing::info!( + "Reading pool state for {} pairs at block {:?}", + futures.len(), + block + ); + let results = future::try_join_all(futures).await?; let mut new_missing_pairs = vec![]; let mut pools = vec![]; for (result, key) in results.into_iter().zip(token_pairs) { match result { - Some(pool) => pools.push(pool), - None => new_missing_pairs.push(key), + Some(pool) => { + // Log pool address for each found pool + tracing::info!( + "Found pool: address={:?}, tokens=({:?}, {:?}), reserves=({}, {})", + pool.address, + pool.tokens.get().0, + pool.tokens.get().1, + pool.reserves.0, + pool.reserves.1 + ); + pools.push(pool); + } + None => { + tracing::debug!( + "Pool not found for pair: ({:?}, {:?})", + key.get().0, + key.get().1 + ); + new_missing_pairs.push(key); + } } } if !new_missing_pairs.is_empty() { - tracing::debug!(token_pairs = ?new_missing_pairs, "stop indexing liquidity"); + tracing::debug!( + "Caching {} non-existent pools: {:?}", + new_missing_pairs.len(), + new_missing_pairs + .iter() + .map(|p| (p.get().0, p.get().1)) + .collect::>() + ); let mut non_existent_pools = self.non_existent_pools.write().unwrap(); for pair in new_missing_pairs { non_existent_pools.cache_set(pair, ()); } } + + tracing::info!("Pool fetch complete: found {} pools", pools.len()); + Ok(pools) } } @@ -273,6 +316,12 @@ impl PoolReading for DefaultPoolReader { fn read_state(&self, pair: TokenPair, block: BlockId) -> BoxFuture<'_, Result>> { let pair_address = self.pair_provider.pair_address(&pair); + tracing::info!( + "Pair address: {:?} - pair: {:?}", + pair_address, + pair + ); + // Fetch ERC20 token balances of the pools to sanity check with reserves let token0 = ERC20::at(&self.web3, pair.get().0); let token1 = ERC20::at(&self.web3, pair.get().1); diff --git a/crates/solver/src/liquidity/uniswap_v2.rs b/crates/solver/src/liquidity/uniswap_v2.rs index 79566bb3af..aa9e382938 100644 --- a/crates/solver/src/liquidity/uniswap_v2.rs +++ b/crates/solver/src/liquidity/uniswap_v2.rs @@ -98,9 +98,22 @@ impl LiquidityCollecting for UniswapLikeLiquidity { pairs: HashSet, at_block: Block, ) -> Result> { + // ADD THIS LOGGING + tracing::info!( + "Fetching Uniswap V2 liquidity for {} pairs using router: {:?}", + pairs.len(), + self.inner.router + ); + let mut tokens = HashSet::new(); let mut result = Vec::new(); for pool in self.pool_fetcher.fetch(pairs, at_block).await? { + // ADD THIS LOGGING + tracing::info!( + "Found Uniswap V2 pool: {:?}", + pool + ); + tokens.insert(pool.tokens.get().0); tokens.insert(pool.tokens.get().1); @@ -112,6 +125,14 @@ impl LiquidityCollecting for UniswapLikeLiquidity { settlement_handling: self.inner.clone(), })) } + + // ADD THIS LOGGING + tracing::info!( + "Found {} Uniswap V2 pools with router: {:?}", + result.len(), + self.inner.router + ); + self.cache_allowances(tokens).await?; Ok(result) } diff --git a/crates/solver/src/liquidity_collector.rs b/crates/solver/src/liquidity_collector.rs index 05a8b1b751..14a5e276f4 100644 --- a/crates/solver/src/liquidity_collector.rs +++ b/crates/solver/src/liquidity_collector.rs @@ -31,10 +31,25 @@ impl LiquidityCollecting for LiquidityCollector { at_block: Block, ) -> Result> { let pairs = self.base_tokens.relevant_pairs(pairs.into_iter()); + tracing::info!("getting liquidity for {:?}", pairs); + + // ADD THIS LOGGING FOR LIQUIDITY SOURCES + tracing::info!( + "Liquidity sources count: {}", + self.liquidity_sources.len() + ); + let futures = self .liquidity_sources .iter() .map(|source| source.get_liquidity(pairs.clone(), at_block)); + + // ADD THIS LOGGING FOR FUTURES + tracing::info!( + "Futures: {:?}", + futures.len() + ); + let amms: Vec<_> = futures::future::join_all(futures) .await .into_iter() diff --git a/crates/solvers-dto/Cargo.toml b/crates/solvers-dto/Cargo.toml index 56377b6635..d62ed4777f 100644 --- a/crates/solvers-dto/Cargo.toml +++ b/crates/solvers-dto/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0" bytes-hex = { workspace = true } # may get marked as unused but it's used with serde app-data = { workspace = true } bigdecimal = { workspace = true, features = ["serde"] } -chrono = { workspace = true } +chrono = { workspace = true, features = ["serde"] } const-hex = { workspace = true } number = { workspace = true } serde = { workspace = true } diff --git a/deploy/k8s/00-namespace.yaml b/deploy/k8s/00-namespace.yaml new file mode 100644 index 0000000000..617e1f27be --- /dev/null +++ b/deploy/k8s/00-namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cow-protocol + labels: + name: cow-protocol diff --git a/deploy/k8s/01-config.yaml b/deploy/k8s/01-config.yaml new file mode 100644 index 0000000000..443eeb1ae6 --- /dev/null +++ b/deploy/k8s/01-config.yaml @@ -0,0 +1,119 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cow-protocol-config + namespace: cow-protocol +data: + # Environment configuration + ENV: "production" + CHAIN_ID: "1" + + # Database configuration + POSTGRES_DB: "cow_protocol" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "changeme" + + # External Database URLs - IMPORTANT: Update with your actual database endpoints + DB_WRITE_URL: "postgres://db-host:5432/cow_protocol?user=postgres&password=changeme" + DB_READ_URL: "postgres://db-host:5432/cow_protocol?user=postgres&password=changeme" + + # Ethereum RPC URLs - IMPORTANT: Update with your actual RPC endpoint + ETH_RPC_URL: "wss://mainnet.gateway.tenderly.co" + NODE_URL: "wss://mainnet.gateway.tenderly.co" + SIMULATION_NODE_URL: "wss://mainnet.gateway.tenderly.co" + ETHRPC: "wss://mainnet.gateway.tenderly.co" + + # Solver account private key - IMPORTANT: Update with your actual key + SOLVER_ACCOUNT: "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" + + # EthFlow contracts configuration + ETHFLOW_CONTRACTS: "0x04501b9b1d52e67f6862d157e00d13419d2d6e95" + ETHFLOW_INDEXING_START: "" + + # Orderbook configuration + ACCOUNT_BALANCES_SIMULATION: "true" + ACCOUNT_BALANCES_SIMULATOR: "Web3" + EIP1271_SKIP_CREATION_VALIDATION: "true" + ENABLE_EIP1271_ORDERS: "true" + PRICE_ESTIMATORS: "None" + PRICE_ESTIMATION_DRIVERS: "baseline|http://driver-service/baseline" + NATIVE_PRICE_ESTIMATORS: "baseline|http://driver-service/baseline" + DRIVERS: "baseline|http://driver-service/baseline" + BIND_ADDRESS: "0.0.0.0:80" + BASELINE_SOURCES: "None" + RUST_BACKTRACE: "1" + TOML_TRACE_ERROR: "1" + + # Autopilot configuration + LOG_FILTER: "warn,autopilot=info,shared=info,shared::price_estimation=info" + SETTLE_INTERVAL: "15s" + GAS_ESTIMATORS: "Native,Web3" + BLOCK_STREAM_POLL_INTERVAL: "1s" + NATIVE_PRICE_CACHE_MAX_UPDATE_SIZE: "100" + NATIVE_PRICE_CACHE_MAX_AGE: "20m" + SOLVER_TIME_LIMIT: "5" + SKIP_EVENT_SYNC: "true" + ETHRPC_MAX_BATCH_SIZE: "20" + + # Driver and Solver configuration + ADDR: "0.0.0.0:80" + LOG: "solvers=trace,shared=trace" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: driver-config + namespace: cow-protocol +data: + driver.toml: | + tx-gas-limit = "45000000" + + [[solver]] + name = "baseline" + endpoint = "http://baseline-service" + absolute-slippage = "40000000000000000" + relative-slippage = "0.1" + account = "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" + + [submission] + gas-price-cap = "1000000000000" + + [[submission.mempool]] + mempool = "public" + + [liquidity] + base-tokens = [ + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", # WETH + "0x6B175474E89094C44Da98b954EedeAC495271d0F", # DAI + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC + "0xdAC17F958D2ee523a2206206994597C13D831ec7", # USDT + "0xc00e94Cb662C3520282E6f5717214004A7f26888", # COMP + "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2", # MKR + "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", # WBTC + "0x6810e776880C02933D47DB1b9fc05908e5386b96", # GNO + ] + + [[liquidity.uniswap-v2]] + preset = "uniswap-v2" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: baseline-config + namespace: cow-protocol +data: + baseline.toml: | + chain-id = "1" + base-tokens = [ + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", # WETH + "0x6B175474E89094C44Da98b954EedeAC495271d0F", # DAI + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC + "0xdAC17F958D2ee523a2206206994597C13D831ec7", # USDT + "0xc00e94Cb662C3520282E6f5717214004A7f26888", # COMP + "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2", # MKR + "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", # WBTC + "0x6810e776880C02933D47DB1b9fc05908e5386b96", # GNO + ] + max-hops = 2 + max-partial-attempts = 5 + native-token-price-estimation-amount = "100000000000000000" diff --git a/deploy/k8s/02-db-migration-job.yaml b/deploy/k8s/02-db-migration-job.yaml new file mode 100644 index 0000000000..bbe9ee89f1 --- /dev/null +++ b/deploy/k8s/02-db-migration-job.yaml @@ -0,0 +1,19 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: db-migrations + namespace: cow-protocol +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: flyway + image: YOUR_REGISTRY/cow-protocol-migrations:latest + envFrom: + - configMapRef: + name: cow-protocol-config + env: + - name: FLYWAY_URL + value: "jdbc:postgresql://db-host:5432/$(POSTGRES_DB)?user=$(POSTGRES_USER)&password=$(POSTGRES_PASSWORD)" + backoffLimit: 3 diff --git a/deploy/k8s/03-orderbook.yaml b/deploy/k8s/03-orderbook.yaml new file mode 100644 index 0000000000..36608fcba9 --- /dev/null +++ b/deploy/k8s/03-orderbook.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Service +metadata: + name: orderbook-service + namespace: cow-protocol +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + name: api + - port: 9586 + targetPort: 9586 + name: metrics + selector: + app: orderbook +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: orderbook + namespace: cow-protocol +spec: + replicas: 1 + selector: + matchLabels: + app: orderbook + template: + metadata: + labels: + app: orderbook + spec: + containers: + - name: orderbook + image: YOUR_REGISTRY/cow-protocol-orderbook:latest + command: ["cargo", "run", "--bin", "orderbook"] + ports: + - containerPort: 80 + name: api + - containerPort: 9586 + name: metrics + envFrom: + - configMapRef: + name: cow-protocol-config diff --git a/deploy/k8s/04-autopilot.yaml b/deploy/k8s/04-autopilot.yaml new file mode 100644 index 0000000000..ae670c9eb1 --- /dev/null +++ b/deploy/k8s/04-autopilot.yaml @@ -0,0 +1,55 @@ +apiVersion: v1 +kind: Service +metadata: + name: autopilot-service + namespace: cow-protocol +spec: + type: ClusterIP + ports: + - port: 9589 + targetPort: 9589 + name: metrics + selector: + app: autopilot +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: autopilot + namespace: cow-protocol +spec: + replicas: 1 + selector: + matchLabels: + app: autopilot + template: + metadata: + labels: + app: autopilot + spec: + initContainers: + - name: wait-for-orderbook + image: busybox:1.35 + command: + - 'sh' + - '-c' + - | + echo "Waiting for orderbook service to accept connections on port 80..." + until nc -z orderbook-service.cow-protocol.svc.cluster.local 80; do + echo "Orderbook service not ready yet, waiting..." + sleep 3 + done + echo "Orderbook service is ready!" + containers: + - name: autopilot + image: YOUR_REGISTRY/cow-protocol-autopilot:latest + command: ["cargo", "run", "--bin", "autopilot"] + ports: + - containerPort: 9589 + name: metrics + envFrom: + - configMapRef: + name: cow-protocol-config + env: + - name: DRIVERS + value: "baseline|http://driver-service/baseline|$(SOLVER_ACCOUNT)" diff --git a/deploy/k8s/05-driver.yaml b/deploy/k8s/05-driver.yaml new file mode 100644 index 0000000000..1dd4ad4599 --- /dev/null +++ b/deploy/k8s/05-driver.yaml @@ -0,0 +1,47 @@ +apiVersion: v1 +kind: Service +metadata: + name: driver-service + namespace: cow-protocol +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + name: api + selector: + app: driver +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: driver + namespace: cow-protocol +spec: + replicas: 1 + selector: + matchLabels: + app: driver + template: + metadata: + labels: + app: driver + spec: + containers: + - name: driver + image: YOUR_REGISTRY/cow-protocol-driver:latest + command: ["cargo", "run", "--bin", "driver", "--", "--config", "/config/driver.toml"] + ports: + - containerPort: 80 + name: api + envFrom: + - configMapRef: + name: cow-protocol-config + volumeMounts: + - name: driver-config + mountPath: /config + readOnly: true + volumes: + - name: driver-config + configMap: + name: driver-config diff --git a/deploy/k8s/06-baseline.yaml b/deploy/k8s/06-baseline.yaml new file mode 100644 index 0000000000..7be216f5b0 --- /dev/null +++ b/deploy/k8s/06-baseline.yaml @@ -0,0 +1,47 @@ +apiVersion: v1 +kind: Service +metadata: + name: baseline-service + namespace: cow-protocol +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + name: api + selector: + app: baseline +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: baseline + namespace: cow-protocol +spec: + replicas: 1 + selector: + matchLabels: + app: baseline + template: + metadata: + labels: + app: baseline + spec: + containers: + - name: baseline + image: YOUR_REGISTRY/cow-protocol-baseline:latest + command: ["cargo", "run", "--bin", "solvers", "--", "baseline", "--config", "/config/baseline.toml"] + ports: + - containerPort: 80 + name: api + envFrom: + - configMapRef: + name: cow-protocol-config + volumeMounts: + - name: baseline-config + mountPath: /config + readOnly: true + volumes: + - name: baseline-config + configMap: + name: baseline-config diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000000..8af16b9024 --- /dev/null +++ b/deploy/k8s/README.md @@ -0,0 +1,231 @@ +# CoW Protocol Kubernetes Deployment + +Simple Kubernetes deployment for CoW Protocol services. + +## Architecture + +- **Orderbook** - Order management API (port 8080) +- **Autopilot** - Automated settlement service +- **Driver** - Settlement driver +- **Baseline** - Baseline solver +- **DB Migration Job** - Database setup + +## Prerequisites + +1. Kubernetes cluster (v1.20+) +2. kubectl configured +3. Docker images in your registry +4. External PostgreSQL database + +## Quick Start + +### 1. Update Configuration + +Edit [01-config.yaml](01-config.yaml) and update: + +```yaml +# Database - Update these +DB_WRITE_URL: "postgres://your-db-host:5432/cow_protocol?user=postgres&password=yourpassword" +DB_READ_URL: "postgres://your-db-host:5432/cow_protocol?user=postgres&password=yourpassword" + +# RPC endpoints - Update these +ETH_RPC_URL: "wss://your-rpc-endpoint" +NODE_URL: "wss://your-rpc-endpoint" +SIMULATION_NODE_URL: "wss://your-rpc-endpoint" + +# Solver account - Update this +SOLVER_ACCOUNT: "your-private-key" +``` + +### 2. Update Container Images + +Replace `YOUR_REGISTRY` in all deployment files: +- [02-db-migration-job.yaml](02-db-migration-job.yaml) +- [03-orderbook.yaml](03-orderbook.yaml) +- [04-autopilot.yaml](04-autopilot.yaml) +- [05-driver.yaml](05-driver.yaml) +- [06-baseline.yaml](06-baseline.yaml) + +Example: +```yaml +image: myregistry.io/cow-protocol-orderbook:latest +``` + +### 3. Deploy + +```bash +# Apply all manifests at once (recommended - init containers handle dependencies) +kubectl apply -f deploy/k8s/ + +# Wait for migration to complete +kubectl wait --for=condition=complete --timeout=300s job/db-migrations -n cow-protocol + +# Check status +kubectl get pods -n cow-protocol +``` + +Or deploy in order manually: + +```bash +# Step 1: Namespace and config +kubectl apply -f deploy/k8s/00-namespace.yaml +kubectl apply -f deploy/k8s/01-config.yaml + +# Step 2: Run database migration +kubectl apply -f deploy/k8s/02-db-migration-job.yaml +kubectl wait --for=condition=complete --timeout=300s job/db-migrations -n cow-protocol + +# Step 3: Deploy orderbook (depends on migration) +kubectl apply -f deploy/k8s/03-orderbook.yaml +kubectl wait --for=condition=available --timeout=300s deployment/orderbook -n cow-protocol + +# Step 4: Deploy driver and baseline (no dependencies) +kubectl apply -f deploy/k8s/05-driver.yaml +kubectl apply -f deploy/k8s/06-baseline.yaml + +# Step 5: Deploy autopilot (depends on orderbook via init container) +kubectl apply -f deploy/k8s/04-autopilot.yaml +``` + +## Service Dependencies + +The deployment includes init containers that manage service dependencies (similar to docker-compose `depends_on`): + +**Dependency Graph:** +``` +DB Migration Job + ↓ + Orderbook + ↓ + Autopilot (Driver + Baseline start in parallel, no dependencies) +``` + +**How it works:** +- **Orderbook**: Starts immediately after namespace and config are applied +- **Autopilot**: Has init container that waits for orderbook service port 80 to accept connections +- **Driver & Baseline**: No dependencies, start immediately in parallel + +**Init Containers:** +- Use `busybox` image with `nc -z` (netcat) to check if service port is accepting connections +- Wait in a loop until the service port responds +- Only then allow the main container to start + +**Note:** Services do not have health check endpoints, so we use TCP port checks (nc -z) to verify service availability instead of readiness/liveness probes. + +## Build Docker Images + +```bash +cd playground + +# Build and push all images +docker build -t YOUR_REGISTRY/cow-protocol-migrations:latest --target migrations -f Dockerfile .. +docker push YOUR_REGISTRY/cow-protocol-migrations:latest + +docker build -t YOUR_REGISTRY/cow-protocol-orderbook:latest --target production -f Dockerfile .. +docker push YOUR_REGISTRY/cow-protocol-orderbook:latest + +docker build -t YOUR_REGISTRY/cow-protocol-autopilot:latest --target production -f Dockerfile .. +docker push YOUR_REGISTRY/cow-protocol-autopilot:latest + +docker build -t YOUR_REGISTRY/cow-protocol-driver:latest --target production -f Dockerfile .. +docker push YOUR_REGISTRY/cow-protocol-driver:latest + +docker build -t YOUR_REGISTRY/cow-protocol-baseline:latest --target production -f Dockerfile .. +docker push YOUR_REGISTRY/cow-protocol-baseline:latest +``` + +## Accessing Services + +### Port Forward + +```bash +# Orderbook API +kubectl port-forward -n cow-protocol svc/orderbook-service 8080:80 + +# Metrics +kubectl port-forward -n cow-protocol svc/orderbook-service 9586:9586 +kubectl port-forward -n cow-protocol svc/autopilot-service 9589:9589 +``` + +Access at: +- Orderbook: http://localhost:8080 +- Metrics: http://localhost:9586/metrics + +### Expose via Ingress + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: cow-protocol + namespace: cow-protocol +spec: + rules: + - host: api.yourdomain.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: orderbook-service + port: + number: 80 +``` + +## Scaling + +```bash +# Scale services +kubectl scale deployment orderbook -n cow-protocol --replicas=3 +kubectl scale deployment autopilot -n cow-protocol --replicas=2 + +# Auto-scaling +kubectl autoscale deployment orderbook -n cow-protocol --cpu-percent=70 --min=2 --max=10 +``` + +## Monitoring + +All services expose Prometheus metrics: +- Orderbook: port 9586 +- Autopilot: port 9589 + +## Troubleshooting + +### Check Logs + +```bash +kubectl logs -f deployment/orderbook -n cow-protocol +kubectl logs -f deployment/autopilot -n cow-protocol +kubectl logs -f deployment/driver -n cow-protocol +kubectl logs -f deployment/baseline -n cow-protocol +``` + +### Check Events + +```bash +kubectl get events -n cow-protocol --sort-by='.lastTimestamp' +``` + +### Describe Resources + +```bash +kubectl describe pod -n cow-protocol +kubectl describe deployment -n cow-protocol +``` + +## Cleanup + +```bash +# Delete everything +kubectl delete namespace cow-protocol +``` + +## Production Tips + +1. **Secrets**: Use external secret managers (Vault, AWS Secrets Manager, etc.) +2. **Resources**: Adjust CPU/memory limits based on load +3. **Monitoring**: Set up Prometheus/Grafana +4. **Logging**: Use centralized logging (ELK, Loki) +5. **Backup**: Regular database backups +6. **SSL/TLS**: Configure at ingress level diff --git a/playground/docker-compose.fork.yml b/playground/docker-compose.fork.yml index e564106bea..5358404296 100644 --- a/playground/docker-compose.fork.yml +++ b/playground/docker-compose.fork.yml @@ -69,6 +69,8 @@ services: dockerfile: ./playground/Dockerfile restart: always command: ["cargo", "watch", "-x", "run --bin orderbook"] + extra_hosts: + - "host.docker.internal:host-gateway" environment: - NODE_URL=http://chain:8545 - DB_WRITE_URL=postgres://db:5432/?user=${POSTGRES_USER}&password=${POSTGRES_PASSWORD} @@ -79,9 +81,9 @@ services: - EIP1271_SKIP_CREATION_VALIDATION=true - ENABLE_EIP1271_ORDERS=true - PRICE_ESTIMATORS=None - - PRICE_ESTIMATION_DRIVERS=baseline|http://driver/baseline - - NATIVE_PRICE_ESTIMATORS=baseline|http://driver/baseline - - DRIVERS=baseline|http://driver/baseline + - PRICE_ESTIMATION_DRIVERS=baseline|http://host.docker.internal:9000/baseline + - NATIVE_PRICE_ESTIMATORS=baseline|http://host.docker.internal:9000/baseline + - DRIVERS=baseline|http://host.docker.internal:9000/baseline - BIND_ADDRESS=0.0.0.0:80 - CHAIN_ID=$CHAIN - BASELINE_SOURCES=None @@ -107,6 +109,8 @@ services: dockerfile: ./playground/Dockerfile restart: always command: ["cargo", "watch", "-x", "run --bin autopilot"] + extra_hosts: + - "host.docker.internal:host-gateway" environment: - DB_WRITE_URL=postgres://db:5432/?user=${POSTGRES_USER}&password=${POSTGRES_PASSWORD} - DB_READ_URL=postgres://db:5432/?user=${POSTGRES_USER}&password=${POSTGRES_PASSWORD} @@ -121,9 +125,9 @@ services: - NATIVE_PRICE_CACHE_MAX_UPDATE_SIZE=100 - NATIVE_PRICE_CACHE_MAX_AGE=20m - SOLVER_TIME_LIMIT=5 - - PRICE_ESTIMATION_DRIVERS=baseline|http://driver/baseline - - NATIVE_PRICE_ESTIMATORS=baseline|http://driver/baseline - - DRIVERS=baseline|http://driver/baseline|0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 + - PRICE_ESTIMATION_DRIVERS=baseline|http://host.docker.internal:9000/baseline + - NATIVE_PRICE_ESTIMATORS=baseline|http://host.docker.internal:9000/baseline + - DRIVERS=baseline|http://host.docker.internal:9000/baseline|0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 - SKIP_EVENT_SYNC=true - BASELINE_SOURCES=None - RUST_BACKTRACE=1 diff --git a/playground/docker-compose.yml b/playground/docker-compose.yml new file mode 100644 index 0000000000..24e37e3ebd --- /dev/null +++ b/playground/docker-compose.yml @@ -0,0 +1,185 @@ +services: + # Database for storing orders and settlements + db: + image: postgres:15 + restart: always + env_file: + - .env + environment: + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} + - POSTGRES_DB=${POSTGRES_DB:-cow_protocol} + - POSTGRES_HOST_AUTH_METHOD=trust + volumes: + - postgres_data:/var/lib/postgresql + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 5 + + # Database migrations + db-migrations: + build: + context: ../ + target: migrations + dockerfile: ./playground/Dockerfile + restart: on-failure + env_file: + - .env + environment: + - FLYWAY_URL=jdbc:postgresql://db:5432/${POSTGRES_DB:-cow_protocol}?user=${POSTGRES_USER:-postgres}&password=${POSTGRES_PASSWORD:-password} + depends_on: + db: + condition: service_healthy + + # Orderbook API service - Direct connection to public RPC + orderbook: + build: + context: ../ + target: localdev + dockerfile: ./playground/Dockerfile + restart: always + env_file: + - .env + command: ["cargo", "watch", "-x", "run --bin orderbook"] + environment: + - NODE_URL=${ETH_RPC_URL} + - DB_WRITE_URL=postgres://db:5432/${POSTGRES_DB:-cow_protocol}?user=${POSTGRES_USER:-postgres}&password=${POSTGRES_PASSWORD:-password} + - DB_READ_URL=postgres://db:5432/${POSTGRES_DB:-cow_protocol}?user=${POSTGRES_USER:-postgres}&password=${POSTGRES_PASSWORD:-password} + - ACCOUNT_BALANCES_SIMULATION=true + - ACCOUNT_BALANCES_SIMULATOR=Web3 + - SIMULATION_NODE_URL=${ETH_RPC_URL} + - EIP1271_SKIP_CREATION_VALIDATION=true + - ENABLE_EIP1271_ORDERS=true + - PRICE_ESTIMATORS=None + - PRICE_ESTIMATION_DRIVERS=baseline|http://driver/baseline + - NATIVE_PRICE_ESTIMATORS=baseline|http://driver/baseline + - DRIVERS=baseline|http://driver/baseline + - BIND_ADDRESS=0.0.0.0:80 + - CHAIN_ID=${CHAIN_ID} + - BASELINE_SOURCES=None + - RUST_BACKTRACE=1 + - TOML_TRACE_ERROR=1 + volumes: + - ../:/src + depends_on: + db-migrations: + condition: service_completed_successfully + ports: + - "8080:80" # Orderbook API + - "9586:9586" # Metrics + + # Autopilot service - Direct connection to public RPC + autopilot: + build: + context: ../ + target: localdev + dockerfile: ./playground/Dockerfile + restart: always + env_file: + - .env + command: ["cargo", "watch", "-x", "run --bin autopilot"] + environment: + - DB_WRITE_URL=postgres://db:5432/${POSTGRES_DB:-cow_protocol}?user=${POSTGRES_USER:-postgres}&password=${POSTGRES_PASSWORD:-password} + - DB_READ_URL=postgres://db:5432/${POSTGRES_DB:-cow_protocol}?user=${POSTGRES_USER:-postgres}&password=${POSTGRES_PASSWORD:-password} + - LOG_FILTER=warn,autopilot=info,shared=info,shared::price_estimation=info + - NODE_URL=${ETH_RPC_URL} + - SIMULATION_NODE_URL=${ETH_RPC_URL} + - SETTLE_INTERVAL=${SETTLE_INTERVAL:-15s} + - GAS_ESTIMATORS=Native,Web3 + - PRICE_ESTIMATORS=None + - NATIVE_PRICE_ESTIMATORS=baseline|http://driver/baseline + - BLOCK_STREAM_POLL_INTERVAL=1s + - NATIVE_PRICE_CACHE_MAX_UPDATE_SIZE=100 + - NATIVE_PRICE_CACHE_MAX_AGE=20m + - SOLVER_TIME_LIMIT=${SOLVER_TIME_LIMIT:-5} + - PRICE_ESTIMATION_DRIVERS=baseline|http://driver/baseline + - DRIVERS=baseline|http://driver/baseline|${SOLVER_ACCOUNT} + - SKIP_EVENT_SYNC=true + - BASELINE_SOURCES=None + - RUST_BACKTRACE=1 + - TOML_TRACE_ERROR=1 + - ETHFLOW_CONTRACTS + - ETHFLOW_INDEXING_START + - ETHRPC_MAX_BATCH_SIZE=20 + volumes: + - ../:/src + depends_on: + orderbook: + condition: service_started + ports: + - "9589:9589" # Metrics + + # Driver service - Direct connection to public RPC + driver: + build: + context: ../ + target: localdev + dockerfile: ./playground/Dockerfile + restart: always + env_file: + - .env + command: + [ + "cargo", + "watch", + "-x", + "run --bin driver -- --config configs/${ENV:-local}/driver.toml", + ] + environment: + - ETHRPC=${ETH_RPC_URL} + - ADDR=0.0.0.0:80 + - RUST_BACKTRACE=1 + - TOML_TRACE_ERROR=1 + volumes: + - ../:/src + ports: + - "9000:80" # Driver API & metrics + + # Baseline solver engine + baseline: + build: + context: ../ + target: localdev + dockerfile: ./playground/Dockerfile + restart: always + env_file: + - .env + command: + [ + "cargo", + "watch", + "-x", + "run --bin solvers -- baseline --config configs/${ENV:-local}/baseline.toml", + ] + environment: + - ADDR=0.0.0.0:80 + - LOG=solvers=trace,shared=trace + - RUST_BACKTRACE=1 + - TOML_TRACE_ERROR=1 + volumes: + - ../:/src + ports: + - "9001:80" # Solver API & metrics + + # CoW Explorer - Direct connection to public RPC + # explorer: + # build: + # context: . + # dockerfile: Dockerfile.explorer + # args: + # - CHAIN=${CHAIN_ID} + # - ETH_RPC_URL=${ETH_RPC_URL} + # restart: always + # env_file: + # - .env + # ports: + # - "8001:80" # CoW Explorer + # depends_on: + # - orderbook + +volumes: + postgres_data: