diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22d9ce42..98c82b71 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,8 +52,9 @@ jobs: uses: actions/checkout@v4 - uses: ./.github/actions/rust - name: Install cargo-nextest - # https://nexte.st/docs/installation/pre-built-binaries/#linux-x86_64 - run: curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin + uses: taiki-e/install-action@v2 + with: + tool: nextest - name: Install cargo-near run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/near/cargo-near/releases/download/cargo-near-v0.16.1/cargo-near-installer.sh | sh - name: Run tests @@ -75,12 +76,9 @@ jobs: components: llvm-tools-preview - name: Install tools - run: | - # Install cargo-llvm-cov for coverage - curl -LsSf https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz | tar xzf - -C ~/.cargo/bin - - # Install cargo-nextest for faster testing - curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ~/.cargo/bin + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov,nextest - name: Run tests with coverage env: diff --git a/.gitignore b/.gitignore index c6b0702d..7df7658e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,9 @@ # OS .DS_Store +# Environment files +.env +.env.local + # documentation site _site/ diff --git a/Cargo.lock b/Cargo.lock index fab70481..953dee9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,10 +220,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.5.2", "hyper-util", "itoa", "matchit", @@ -236,7 +236,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower 0.5.2", "tower-layer", @@ -252,13 +252,13 @@ checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -710,7 +710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1195,7 +1195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1527,6 +1527,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.7.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.7" @@ -1538,7 +1557,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.2.0", "indexmap 2.7.0", "slab", "tokio", @@ -1635,6 +1654,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.2.0" @@ -1646,6 +1676,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1653,7 +1694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.2.0", ] [[package]] @@ -1664,8 +1705,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1687,6 +1728,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.5.2" @@ -1696,9 +1761,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.7", + "http 1.2.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1715,8 +1780,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.2.0", + "hyper 1.5.2", "hyper-util", "rustls", "rustls-pki-types", @@ -1725,6 +1790,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1733,7 +1811,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.5.2", "hyper-util", "native-tls", "tokio", @@ -1750,9 +1828,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.5.2", "pin-project-lite", "socket2", "tokio", @@ -2470,7 +2548,7 @@ dependencies = [ "near-crypto", "near-jsonrpc-primitives", "near-primitives", - "reqwest", + "reqwest 0.12.12", "serde", "serde_json", "thiserror 2.0.11", @@ -2767,7 +2845,7 @@ dependencies = [ "near-sandbox-utils", "near-token", "rand", - "reqwest", + "reqwest 0.12.12", "serde", "serde_json", "sha2", @@ -3420,6 +3498,46 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.12" @@ -3431,13 +3549,13 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.7", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.5.2", "hyper-rustls", - "hyper-tls", + "hyper-tls 0.6.0", "hyper-util", "ipnet", "js-sys", @@ -3447,12 +3565,12 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 1.0.2", + "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tower 0.5.2", @@ -3624,7 +3742,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.14", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3637,7 +3755,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3655,6 +3773,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -4352,6 +4479,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4372,6 +4505,17 @@ dependencies = [ "syn 2.0.93", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -4380,7 +4524,17 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.6.0", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -4420,16 +4574,14 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] -name = "templar-bots" +name = "templar-accumulator" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", - "base64 0.22.1", "clap", "futures", "near-crypto", @@ -4437,9 +4589,7 @@ dependencies = [ "near-jsonrpc-primitives", "near-primitives", "near-sdk", - "near-workspaces", "templar-common", - "test-utils", "thiserror 2.0.11", "tokio", "tracing", @@ -4462,6 +4612,30 @@ dependencies = [ "thiserror 2.0.11", ] +[[package]] +name = "templar-liquidator" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "clap", + "futures", + "hex", + "near-account-id", + "near-crypto", + "near-jsonrpc-client", + "near-jsonrpc-primitives", + "near-primitives", + "near-sdk", + "reqwest 0.11.27", + "serde", + "templar-common", + "thiserror 2.0.11", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "templar-lst-oracle-contract" version = "1.1.0" @@ -4828,7 +5002,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -4847,8 +5021,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.2.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -5479,6 +5653,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if 1.0.0", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 5af85adb..72b0d7d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,15 @@ [workspace] resolver = "2" -members = ["bots", "common", "contract/*", "mock/*", "service/*", "test-utils", "universal-account"] +members = [ + "bots/accumulator", + "bots/liquidator", + "common", + "contract/*", + "mock/*", + "service/*", + "test-utils", + "universal-account", +] [workspace.package] license = "MIT" @@ -18,6 +27,7 @@ futures = "0.3.31" getrandom = { version = "0.2", features = ["custom"] } hex = { version = "0.4.3", features = ["serde"] } hex-literal = "0.4" +near-account-id = "1.1.4" near-contract-standards = "5.17.2" near-crypto = "0.31.1" near-jsonrpc-client = "0.18.0" diff --git a/bots/README.md b/bots/README.md deleted file mode 100644 index 47c75c50..00000000 --- a/bots/README.md +++ /dev/null @@ -1,364 +0,0 @@ -# Templar bots - -## Liquidator Bot for Templar - -This bot is designed to monitor Templars' lending markets on the Near blockchain and perform liquidations when borrowers fall below their collateralization ratio. -It uses near tooling to execute liquidations, transfers, signing etc... -The bot is built using the Near SDK, and it can be used as a running service. - -The bot is structured into several components: - -- `liquidator.rs`: Contains the Liquidator struct that handles the liquidation logic for a specific market and signer. -- `bin/liquidator-bot.rs`: An executable service that manages the liquidation process, running in a loop to check for liquidatable positions. -- `near.rs`: Contains the Near SDK logic and RPC calls to interact with the Near blockchain, including fetching prices, borrow positions, and updating prices. -- `swap.rs`: Contains the implementation for swapping assets dependent on the backend used (Rhea Finance, NEAR Intents). -- `lib.rs`: Defines network related configuration and constants used throughout the bot. This is a utility module that helps the bot to interact with the NEAR Blockchain and oracles. - -Prerequisites: - -- Rust (install via rustup) -- NEAR account -- NEAR CLI (for deploying and interacting with contracts) -- Deployed NEAR contracts for the lending protocol -- Oracle contract for price data - -Running the Bot: - -```bash -liquidator-service \ - --registries registry1.testnet --registries registry2.testnet \ - --signer-key ed25519:\ \ - --signer-account liquidator.testnet \ - --asset usdc.testnet \ - --swap rhea-swap \ - --network testnet \ - --timeout 60 \ - --concurrency 10 \ - --interval 600 \ - --registry-refresh-interval 3600 -``` - -Arguments: - -- `--registries`: A list of registries to query markets from which will be monitored for liquidations (e.g., templar-registry1.testnet). -- `--signer-key`: The private key of the signer account used to sign transactions. -- `--signer-account`: The NEAR account that will perform the liquidations (e.g., templar-liquidator.testnet). -- `--asset`: The asset to liquidate NEP-141 token account used for repayments (e.g., usdc.testnet). -- `--swap`: The swap to use for exchanging into the the desired asset (e.g., rhea-swap). -- `--network`: The NEAR network to connect to (e.g., testnet). -- `--timeout`: The timeout for RPC calls in seconds (default is 60 seconds). -- `--concurrency`: The number of concurrent liquidation attempts (default is 10). -- `--interval`: The interval in seconds for the service to check for liquidatable positions (default is 600 seconds). -- `--registry-refresh-interval`: The interval in seconds for the service to check for new markets on the registries (default is 3600 seconds - 1 hour). - -How it works: - -1. The bot fetches all deployments for each registry specified in the `--registries` argument. -1. The bot initializes a Liquidator object for each market fetched. -1. It continuously checks the status of borrowers in each market. -1. If a borrower is found to be liquidatable, it calculates the liquidation amount based on the borrower's collateral and debt. -1. It sends an `ft_transfer_call` RPC call to the smart contract to trigger the liquidation process. -1. The bot will repeat this process every `interval` seconds. -1. The bot logs the results of each liquidation attempt, including success or failure, and any relevant details about the borrower and market. -1. If the liquidation is successful, the bot updates the borrower's position and the market's state accordingly. -1. The bot handles errors and retries failed liquidation attempts based on the configured timeout and concurrency settings. -1. The bot can be monitored via logs or integrated with a monitoring system to alert on significant events, such as successful liquidations or errors. -1. The bot can be extended to support additional liquidation strategies. - -Liquidation Logic: -The liquidation logic is encapsulated within the `Liquidator` object, which is responsible for: - -- Checking a borrower's status to determine if they are below the required collateralization ratio. -- Calculating the liquidation amount based on the borrower's collateral and debt. - -```rust -#[instrument(skip(self), level = "debug")] -async fn liquidation_amount( - &self, - position: &BorrowPosition, - oracle_response: &OracleResponse, - configuration: MarketConfiguration, -) -> LiquidatorResult<(U128, U128)> { - let borrow_asset = &configuration.borrow_asset; - let price_pair = configuration - .price_oracle_configuration - .create_price_pair(oracle_response)?; - let min_liquidation_amount = configuration - .minimum_acceptable_liquidation_amount(position.collateral_asset_deposit, &price_pair) - .ok_or_else(|| { - LiquidatorError::MinimumLiquidationAmountError( - "Failed to calculate minimum acceptable liquidation amount".to_owned(), - ) - })?; - // Here we would check a quote for the swap to ensure desired profit margin is met - let quote_to_liquidate = self - .swap - .quote( - &self.asset, - &borrow_asset.clone().into_nep141().ok_or_else(|| { - LiquidatorError::StandardSupportError( - "Only NEP-141 borrow assets supported".to_owned(), - ) - })?, - min_liquidation_amount.into(), - ) - .await - .map_err(LiquidatorError::QuoteError)?; - Ok((quote_to_liquidate, min_liquidation_amount.into())) -} -``` - -- Deciding on whether the liquidation should happen or not (This calculation should be implemented by the liquidator according to their specific strategy or requirements.) - -```rust -#[instrument(skip(self), level = "debug")] -pub async fn should_liquidate( - &self, - swap_amount: U128, - liquidation_amount: U128, -) -> LiquidatorResult { - // TODO: Calculate optimal liquidation amount - // For purposes of this example implementation we will just use the minimum acceptable - // liquidation amount. - // Costs to take into account here are: - // - Gas fees - // - Price impact - // - Slippage - // All of this would be used in calculating both the optimal liquidation amount and wether to - // perform full or partial liquidation - Ok(true) -} -``` - -- Sending the `ft_transfer_call` RPC call to the borrow asset contract to trigger liquidation. -- Handling errors and retries for failed liquidation attempts. -- Logging the results of each liquidation attempt for monitoring and debugging purposes. - -## Key snippets - -### Getting a market configuration - -```rust -#[instrument(skip(self), level = "debug")] -async fn get_configuration(&self) -> LiquidatorResult { - view( - &self.client, - self.market.clone(), - "get_configuration", - json!({}), - ) - .await - .map_err(LiquidatorError::GetConfigurationError) -} -``` - -The liquidator will fetch the configuration for the given market in order to asses how to run the liquidations (i.e. which price oracle to query, which assets to send/swap...). - -### Getting oracle prices - -```rust -#[instrument(skip(self), level = "debug")] -async fn get_oracle_prices( - &self, - oracle: AccountId, - price_ids: &[PriceIdentifier], - age: u32, -) -> LiquidatorResult { - view( - &self.client, - oracle, - "list_ema_prices_no_older_than", - json!({ "price_ids": price_ids, "age": age }), - ) - .await - .map_err(LiquidatorError::PriceFetchError) -} -``` - -The liquidator will fetch the price data from the oracle contract in order to execute the liquidation and gauge whether the liquidation is profitable. - -### Fetching deployed markets - -```rust -#[instrument(skip(client), level = "debug")] -pub async fn list_deployments( - client: &JsonRpcClient, - registry: AccountId, - count: Option, - offset: Option, -) -> RpcResult> { - let mut all_deployments = Vec::new(); - let page_size = 500; - let mut current_offset = 0; - - loop { - let params = json!({ - "offset": current_offset, - "count": page_size, - }); - - let page = - view::>(client, registry.clone(), "list_deployments", params).await?; - - let fetched = page.len(); - - if fetched == 0 { - break; - } - - all_deployments.extend(page); - current_offset += fetched; - - if fetched < page_size { - break; - } - } - - Ok(all_deployments) -} - -#[instrument(skip(client), level = "debug")] -pub async fn list_all_deployments( - client: JsonRpcClient, - registries: Vec, - concurrency: usize, -) -> RpcResult> { - let all_markets: Vec = futures::stream::iter(registries) - .map(|registry| { - let client = client.clone(); - async move { list_deployments(&client, registry, None, None).await } - }) - .buffer_unordered(concurrency) - .try_concat() - .await?; - - Ok(all_markets) -} -``` - -The liquidator will periodically fetch all registries for all of their deployments (markets). - -### Getting the borrow positions for a market - -```rust -#[instrument(skip(self), level = "debug")] -async fn get_borrows(&self) -> LiquidatorResult { - let mut all_positions: BorrowPositions = HashMap::new(); - let page_size = 100; - let mut current_offset = 0; - - loop { - let params = json!({ - "offset": current_offset, - "count": page_size, - }); - - let page = view::( - &self.client, - self.market.clone(), - "list_borrow_positions", - params, - ) - .await - .map_err(LiquidatorError::ListBorrowPositionsError)?; - - let fetched = page.len(); - - if fetched == 0 { - break; - } - - all_positions.extend(page); - current_offset += fetched; - - if fetched < page_size { - break; - } - } - - Ok(all_positions) -} -``` - -The liquidator will query the market contract for all the borrow positions so that we can check each position for status. - -### Getting the borrow status - -```rust -#[instrument(skip(self), level = "debug")] -async fn get_borrow_status( - &self, - borrow: AccountId, - oracle_response: &OracleResponse, -) -> LiquidatorResult> { - view( - &self.client, - self.market.clone(), - "get_borrow_status", - &json!({ - "account_id": borrow, - "oracle_response": oracle_response, - }), - ) - .await - .map_err(LiquidatorError::FetchBorrowStatus) -} -``` - -The liquidator chech for the borrow status to know whether to run a liquidation in case of a `BorrowStatus::Liquidation` status. - -### Getting a swap quote - -```rust -async fn quote(&self, from: &AccountId, to: &AccountId, amount: U128) -> RpcResult { - let response: QuoteResponse = view( - &self.client, - self.contract.clone(), - "quote_by_output", - &QuoteRequest::new(from.clone(), to.clone(), amount), - ) - .await?; - Ok(response.amount) -} -``` - -When we need to swap assets, we want to get a quote on the swap for the given value so that we can better calculate the profitability of a liquidation. - -### Creating the liquidation transaction - -```rust -fn create_transfer_tx( - &self, - borrow: &AccountId, - liquidation_amount: U128, - nonce: u64, - block_hash: CryptoHash, -) -> LiquidatorResult { - let msg = serde_json::to_string(&DepositMsg::Liquidate(LiquidateMsg { - account_id: borrow.clone(), - amount: None, - }))?; - - Ok(Transaction::V0(TransactionV0 { - nonce, - receiver_id: self.asset.clone(), - block_hash, - signer_id: self.signer.account_id.clone(), - public_key: self.signer.public_key().clone(), - actions: vec![Action::FunctionCall(Box::new(FunctionCallAction { - method_name: "ft_transfer_call".to_string(), - args: serialize_and_encode(json!({ - "receiver_id": self.market, - "amount": liquidation_amount, - "msg": msg, - })), - gas: DEFAULT_GAS, - deposit: NearToken::from_yoctonear(1).as_yoctonear(), - }))], - })) -} -``` - -The liquidator creates a function call for transferring the given amount to the market contract with a `LiquidateMsg` in the `msg` field in order -to trigger the liquidation as part of the handler for `ft_transfer_call` (which triggers a function call after executing a transfer on the asset -contract). diff --git a/bots/Cargo.toml b/bots/accumulator/Cargo.toml similarity index 65% rename from bots/Cargo.toml rename to bots/accumulator/Cargo.toml index cd501083..3e0a6c6f 100644 --- a/bots/Cargo.toml +++ b/bots/accumulator/Cargo.toml @@ -1,38 +1,28 @@ [package] +name = "templar-accumulator" edition.workspace = true license.workspace = true -name = "templar-bots" repository.workspace = true version = "0.1.0" -[[bin]] -name = "liquidator" -path = "src/bin/liquidator-bot.rs" - [[bin]] name = "accumulator" -path = "src/bin/accumulator-bot.rs" +path = "src/main.rs" [dependencies] anyhow = { workspace = true } -async-trait = { workspace = true } -base64 = { workspace = true } clap = { workspace = true } futures = { workspace = true } near-crypto = { workspace = true } near-jsonrpc-client = { workspace = true } near-jsonrpc-primitives = { workspace = true } near-primitives = { workspace = true } -near-sdk = { workspace = true } +near-sdk = { workspace = true, features = ["non-contract-usage"] } templar-common = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -[dev-dependencies] -test-utils = { path = "../test-utils" } -near-workspaces = { workspace = true } - [lints] workspace = true diff --git a/bots/accumulator/README.md b/bots/accumulator/README.md new file mode 100644 index 00000000..134b68a7 --- /dev/null +++ b/bots/accumulator/README.md @@ -0,0 +1,165 @@ +# Templar Accumulator Bot + +Self-contained bot for applying interest to Templar Protocol borrow positions on NEAR blockchain. + +## Quick Start + +```bash +cargo build --release -p templar-accumulator --bin accumulator + +accumulator \ + --registries registry.testnet \ + --signer-key ed25519:YOUR_PRIVATE_KEY \ + --signer-account accumulator-bot.testnet \ + --network testnet +``` + +## CLI Arguments + +| Argument | Env Variable | Default | Description | +|----------|--------------|---------|-------------| +| `--registries` | `REGISTRIES_ACCOUNT_IDS` | Required | Registry contracts (space-separated) | +| `--signer-key` | `SIGNER_KEY` | Required | Private key (`ed25519:...`) | +| `--signer-account` | `SIGNER_ACCOUNT_ID` | Required | NEAR account for signing | +| `--network` | `NETWORK` | `testnet` | Network: `testnet` or `mainnet` | +| `--timeout` | `TIMEOUT` | `60` | RPC timeout in seconds | +| `--interval` | `INTERVAL` | `600` | Interval between runs (seconds) | +| `--registry-refresh-interval` | `REGISTRY_REFRESH_INTERVAL` | `3600` | Market refresh interval (seconds) | +| `--concurrency` | `CONCURRENCY` | `4` | Concurrent operations | + +## Features + +- **Self-Sufficient**: No external bot dependencies - standalone reference implementation +- **Multi-Market**: Monitors multiple markets across registries +- **Concurrent**: Configurable concurrency for throughput +- **Auto-Discovery**: Automatically discovers new markets +- **Resilient**: Failed accumulations don't stop processing + +## How It Works + +1. Discovers markets from registries +2. Fetches borrow positions (paginated, 100/page) +3. Applies interest to each position concurrently +4. Calls `apply_interest(account_id)` on market contract (300 TGas) + +## Production Deployment + +### Using Environment Variables + +```bash +#!/bin/bash +export REGISTRIES_ACCOUNT_IDS="registry1.near registry2.near" +export SIGNER_KEY="ed25519:..." +export SIGNER_ACCOUNT_ID="accumulator.near" +export NETWORK="mainnet" +export TIMEOUT="120" +export INTERVAL="600" +export CONCURRENCY="10" +export RUST_LOG="info,templar_accumulator=debug" + +./target/release/accumulator +``` + +### Systemd Service + +See `accumulator.service` file. + +1. Copy binary: `sudo cp target/release/accumulator /usr/local/bin/` +2. Create env file: `/etc/default/accumulator` +3. Install service: `sudo cp accumulator.service /etc/systemd/system/` +4. Start: `sudo systemctl enable accumulator && sudo systemctl start accumulator` +5. Monitor: `sudo journalctl -u accumulator -f` + +## Monitoring + +**Log Levels:** +```bash +export RUST_LOG="info" # Production +export RUST_LOG="debug,templar_accumulator=trace" # Development +``` + +**Key Metrics:** +- Success rate (successful/failed accumulations) +- Market coverage (number of markets monitored) +- Position count (positions processed per run) +- Error rate (RPC/transaction errors) + +## Performance Tuning + +**Concurrency:** +- Low (2-4): Conservative, lower RPC load +- Medium (4-8): Balanced +- High (8-16): Maximum throughput + +**Intervals:** +- Accumulation: How often to apply interest (default 600s) +- Registry Refresh: How often to discover markets (default 3600s) + +## Cost Considerations + +**Gas Usage:** +- Each `apply_interest()` call uses ~3.3 TGas in average +- Cost per call: ~0.0003 NEAR +- View calls (fetching positions) are free + +**Daily Cost Estimates:** +| Positions/Day | NEAR Cost | USD Cost ($5 NEAR) | +|---------------|-----------|---------------------| +| 100 | 0.03 | $0.15 | +| 1,000 | 0.3 | $1.50 | +| 10,000 | 3.0 | $15.00 | + +**Notes:** +- Actual gas usage varies by position complexity +- NEAR refunds unused gas automatically + +**Optimize:** +- Increase accumulation interval +- Filter positions needing updates (requires changes) +- Batch accounts (requires contract changes) + +## Error Handling + +- Failed accumulation: Logs error, continues processing +- Failed registry refresh: Uses existing market list +- RPC errors: Retries with exponential backoff (up to 5s) +- Transaction timeout: Waits then polls for status + +## Security + +- Use environment variables for private keys +- Restrict file permissions: `chmod 600 /etc/default/accumulator` +- Account needs minimal NEAR balance for gas (~10 NEAR) +- Monitor account balance and error rates + +## Troubleshooting + +**No accumulations:** +- Check borrow positions exist: `near contract call-function as-read-only market.testnet list_borrow_positions json-args '{"offset": 0, "count": 10}' network-config testnet now` +- Verify bot running: `systemctl status accumulator` +- Check balance: `near account view-account-summary accumulator.testnet network-config testnet now` + +**High failure rate:** +- Increase `--timeout` (default: 60s) +- Reduce `--concurrency` (default: 4) +- Check RPC endpoint health + +## Building + +```bash +cargo build --release -p templar-accumulator --bin accumulator +# Binary at: target/release/accumulator +``` + +## Development + +Self-contained reference implementation. Extend by modifying `src/lib.rs`: + +```rust +pub async fn accumulate(&self, borrow: AccountId) -> anyhow::Result<()> { + // Add custom logic (e.g., skip recent updates) + // Execute accumulation +} +``` + +RPC utilities in `src/rpc.rs`: `view()`, `send_tx()`, `get_access_key_data()`, `list_deployments()` diff --git a/bots/accumulator.service b/bots/accumulator/accumulator.service similarity index 100% rename from bots/accumulator.service rename to bots/accumulator/accumulator.service diff --git a/bots/src/accumulator.rs b/bots/accumulator/src/lib.rs similarity index 97% rename from bots/src/accumulator.rs rename to bots/accumulator/src/lib.rs index fd2e0392..7313a86f 100644 --- a/bots/src/accumulator.rs +++ b/bots/accumulator/src/lib.rs @@ -12,9 +12,10 @@ use near_primitives::{ use near_sdk::{serde_json::json, AccountId}; use tracing::{error, info, instrument}; -use crate::{ - near::{get_access_key_data, send_tx, serialize_and_encode, view}, - BorrowPositions, Network, DEFAULT_GAS, +pub mod rpc; + +use crate::rpc::{ + get_access_key_data, send_tx, serialize_and_encode, view, BorrowPositions, Network, DEFAULT_GAS, }; #[derive(Debug, Clone, Parser)] diff --git a/bots/src/bin/accumulator-bot.rs b/bots/accumulator/src/main.rs similarity index 96% rename from bots/src/bin/accumulator-bot.rs rename to bots/accumulator/src/main.rs index c3942adf..ce21d6ab 100644 --- a/bots/src/bin/accumulator-bot.rs +++ b/bots/accumulator/src/main.rs @@ -3,10 +3,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use clap::Parser; use near_crypto::InMemorySigner; use near_jsonrpc_client::JsonRpcClient; -use templar_bots::{ - accumulator::{Accumulator, Args}, - list_all_deployments, -}; +use templar_accumulator::{rpc::list_all_deployments, Accumulator, Args}; use tracing::{error, info}; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; diff --git a/bots/src/near.rs b/bots/accumulator/src/rpc.rs similarity index 52% rename from bots/src/near.rs rename to bots/accumulator/src/rpc.rs index 59eced40..0496d5a1 100644 --- a/bots/src/near.rs +++ b/bots/accumulator/src/rpc.rs @@ -1,5 +1,20 @@ -use std::time::Duration; +// SPDX-License-Identifier: MIT +//! RPC utilities for interacting with NEAR blockchain. +//! +//! This module provides helper functions for common NEAR RPC operations: +//! - `view()` - Query view methods on contracts +//! - `send_tx()` - Send signed transactions with retry logic +//! - `get_access_key_data()` - Fetch nonce and block hash for transaction signing +//! - `list_deployments()` - Paginated fetching of market deployments from registries +//! +//! # Error Handling +//! +//! All RPC operations return `RpcResult` which wraps various RPC-level errors. +//! These are converted to `LiquidatorError` at the application level. +use std::{collections::HashMap, time::Duration}; + +use futures::{StreamExt, TryStreamExt}; use near_crypto::Signer; use near_jsonrpc_client::{ errors::JsonRpcError, @@ -8,7 +23,7 @@ use near_jsonrpc_client::{ send_tx::RpcSendTransactionRequest, tx::{RpcTransactionError, RpcTransactionStatusRequest, TransactionInfo}, }, - JsonRpcClient, + JsonRpcClient, NEAR_MAINNET_RPC_URL, NEAR_TESTNET_RPC_URL, }; use near_jsonrpc_primitives::types::query::QueryResponseKind; use near_primitives::{ @@ -18,9 +33,11 @@ use near_primitives::{ views::{FinalExecutionStatus, QueryRequest, TxExecutionStatus}, }; use near_sdk::{ + near, serde::{de::DeserializeOwned, Serialize}, - serde_json, + Gas, }; +use templar_common::borrow::BorrowPosition; use tokio::time::Instant; use tracing::instrument; @@ -41,9 +58,9 @@ pub enum RpcError { SendTransactionError(#[from] JsonRpcError), /// Failed to deserialize response #[error("Failed to deserialize response: {0}")] - DeserializeError(#[from] serde_json::Error), + DeserializeError(#[from] near_sdk::serde_json::Error), /// Timeout exceeded - #[error("Timeout exceeded: {0}")] + #[error("Timeout exceeded after {0}s (waited {1}s)")] TimeoutError(u64, u64), /// No outcome for transaction #[error("No outcome for transaction: {0}")] @@ -67,6 +84,60 @@ pub enum AppError { pub type RpcResult = Result; pub type AppResult = Result; +/// Borrow positions map type +pub type BorrowPositions = HashMap; + +/// Default gas for transactions. 300 `TGas`. +pub const DEFAULT_GAS: u64 = Gas::from_tgas(300).as_gas(); + +/// Maximum interval between transaction status polls +const MAX_POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// Network configuration for NEAR +#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)] +#[near(serializers = [near_sdk::serde_json::json])] +pub enum Network { + /// NEAR mainnet + Mainnet, + /// NEAR testnet (default) + #[default] + Testnet, +} + +impl std::fmt::Display for Network { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + } + ) + } +} + +impl Network { + /// Get the RPC URL for this network + #[must_use] + pub fn rpc_url(&self) -> &str { + match self { + Network::Mainnet => NEAR_MAINNET_RPC_URL, + Network::Testnet => NEAR_TESTNET_RPC_URL, + } + } +} + +/// Get access key data (nonce and block hash) for transaction signing. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `signer` - Signer with the account and key to query +/// +/// # Returns +/// +/// Tuple of (nonce, block_hash) to use when constructing a transaction #[instrument(skip(client), level = "debug")] pub async fn get_access_key_data( client: &JsonRpcClient, @@ -97,12 +168,29 @@ pub async fn get_access_key_data( Ok((nonce, block_hash)) } +/// Serialize and encode data for NEAR contract calls. +/// +/// # Panics +/// +/// Panics if serialization fails (which should never happen for valid types) #[allow(clippy::expect_used, reason = "We know the serialization will succeed")] pub fn serialize_and_encode(data: impl Serialize) -> Vec { - serde_json::to_vec(&data).expect("Failed to serialize data") + near_sdk::serde_json::to_vec(&data).expect("Failed to serialize data") } -#[instrument(skip_all, level = "debug", fields(account_id = %account_id, method_name = %function_name, args = ?serde_json::to_string(&args)))] +/// Call a view method on a NEAR contract. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `account_id` - Contract account to call +/// * `function_name` - Name of the view method +/// * `args` - Arguments to pass (will be JSON serialized) +/// +/// # Returns +/// +/// Deserialized response of type T +#[instrument(skip_all, level = "debug", fields(account_id = %account_id, method_name = %function_name, args = ?near_sdk::serde_json::to_string(&args)))] pub async fn view( client: &JsonRpcClient, account_id: AccountId, @@ -127,11 +215,27 @@ pub async fn view( ))); }; - Ok(serde_json::from_slice(&result.result)?) + Ok(near_sdk::serde_json::from_slice(&result.result)?) } -const MAX_POLL_INTERVAL: Duration = Duration::from_secs(5); - +/// Send a signed transaction to NEAR with retry logic. +/// +/// This function handles: +/// - Transaction signing +/// - Timeout handling with exponential backoff +/// - Automatic retry on timeout errors +/// - Transaction status polling +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `signer` - Signer to sign the transaction +/// * `timeout` - Maximum time to wait in seconds +/// * `tx` - Unsigned transaction to send +/// +/// # Returns +/// +/// Final execution status of the transaction #[instrument(skip(client, signer), level = "debug")] pub async fn send_tx( client: &JsonRpcClient, @@ -171,7 +275,7 @@ pub async fn send_tx( tokio::time::sleep(poll_interval).await; - // Exponential backoff + // Exponential backoff up to MAX_POLL_INTERVAL poll_interval = std::cmp::min(poll_interval * 2, MAX_POLL_INTERVAL); let status = client @@ -202,3 +306,84 @@ pub async fn send_tx( Ok(outcome.into_outcome().status) } + +/// List all deployments from a single registry contract. +/// +/// Fetches all markets in pages of 500 until no more results. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `registry` - Registry contract account +/// * `_count` - Unused (kept for API compatibility) +/// * `_offset` - Unused (kept for API compatibility) +/// +/// # Returns +/// +/// Vector of all deployed market accounts +#[instrument(skip(client), level = "debug")] +#[allow(clippy::used_underscore_binding)] +pub async fn list_deployments( + client: &JsonRpcClient, + registry: AccountId, + _count: Option, + _offset: Option, +) -> RpcResult> { + let mut all_deployments = Vec::new(); + let page_size = 500; + let mut current_offset = 0; + + loop { + let params = near_sdk::serde_json::json!({ + "offset": current_offset, + "count": page_size, + }); + + let page = + view::>(client, registry.clone(), "list_deployments", params).await?; + + let fetched = page.len(); + + if fetched == 0 { + break; + } + + all_deployments.extend(page); + current_offset += fetched; + + if fetched < page_size { + break; + } + } + + Ok(all_deployments) +} + +/// List all deployments from multiple registry contracts concurrently. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `registries` - Vector of registry contract accounts +/// * `concurrency` - Maximum number of concurrent requests +/// +/// # Returns +/// +/// Vector of all deployed market accounts from all registries +#[instrument(skip(client), level = "debug")] +pub async fn list_all_deployments( + client: JsonRpcClient, + registries: Vec, + concurrency: usize, +) -> RpcResult> { + let all_markets: Vec = futures::stream::iter(registries) + .map(|registry| { + let client = client.clone(); + async move { list_deployments(&client, registry, None, None).await } + }) + .buffer_unordered(concurrency) + .try_concat() + .await?; + + Ok(all_markets) +} diff --git a/bots/liquidator/.dockerignore b/bots/liquidator/.dockerignore new file mode 100644 index 00000000..9836f278 --- /dev/null +++ b/bots/liquidator/.dockerignore @@ -0,0 +1,43 @@ +# Git +.git +.gitignore +.github + +# Documentation +*.md +!README.md +docs/ + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Rust build artifacts +target/ +**/*.rs.bk +*.pdb + +# Environment and secrets +.env +.env.local +*.key +*.pem + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# Test artifacts +coverage/ +*.profraw +*.profdata + +# Temporary files +tmp/ +temp/ diff --git a/bots/liquidator/.env.example b/bots/liquidator/.env.example new file mode 100644 index 00000000..d5c09b05 --- /dev/null +++ b/bots/liquidator/.env.example @@ -0,0 +1,123 @@ +# Templar Liquidator Configuration + +# ============================================ +# REQUIRED +# ============================================ + +SIGNER_ACCOUNT_ID=your-account.near +SIGNER_KEY=ed25519:YOUR_PRIVATE_KEY_HERE +REGISTRY_ACCOUNT_IDS=v1.tmplr.near + +# ============================================ +# NETWORK +# ============================================ + +NETWORK=mainnet +RPC_URL=https://free.rpc.fastnear.com + +# ============================================ +# LIQUIDATION STRATEGY +# ============================================ + +# Liquidation strategy: "partial" or "full" +# - partial: Liquidate a percentage of the position (see PARTIAL_PERCENTAGE) +# - full: Liquidate 100% of liquidatable amount +# Default: partial +LIQUIDATION_STRATEGY=partial + +# Partial liquidation percentage (1-100, only used with partial strategy) +# Default: 50 (liquidate 50% of position) +PARTIAL_PERCENTAGE=50 + +# Minimum profit margin in basis points +# Examples: 50 = 0.5%, 100 = 1%, 200 = 2% +# Default: 50 (0.5%) +MIN_PROFIT_BPS=50 + +# ============================================ +# COLLATERAL STRATEGY +# ============================================ + +# Collateral strategy: "hold", "swap-to-primary", or "swap-to-borrow" +# - hold: Keep all received collateral (no swaps) +# - swap-to-primary: Swap all collateral to a primary asset (e.g., USDC) +# - swap-to-borrow: Swap collateral back to borrow assets (assets used for liquidations) +# Default: hold +COLLATERAL_STRATEGY=hold + +# Primary asset for swap-to-primary strategy +# Only used when COLLATERAL_STRATEGY=swap-to-primary +# Format: nep141:contract_id or nep245:contract_id:token_id +# Example: nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1 (USDC) +# PRIMARY_ASSET=nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1 + +# ============================================ +# SWAP PROVIDER CONFIGURATION +# ============================================ + +# 1-Click API token (optional but recommended to avoid 0.1% fee) +# ONECLICK_API_TOKEN=your_jwt_token_here + +# Ref Finance contract +# Mainnet: v2.ref-finance.near +# Testnet: v2.ref-dev.testnet +REF_CONTRACT=v2.ref-finance.near + +# ============================================ +# INTERVALS +# ============================================ + +# Liquidation scan interval (seconds) +# How often to scan markets for liquidation opportunities +# Default: 600 (10 minutes) +LIQUIDATION_SCAN_INTERVAL=600 + +# Registry refresh interval (seconds) +# How often to check for new markets +# Default: 3600 (1 hour) +REGISTRY_REFRESH_INTERVAL=3600 + +# ============================================ +# OPTIONAL +# ============================================ + +# Transaction timeout in seconds +# Maximum time to wait for a transaction to complete +# Default: 60 +TRANSACTION_TIMEOUT=60 + +# Concurrency for operations +# Maximum number of concurrent operations +# Default: 10 +CONCURRENCY=10 + +# ============================================ +# MARKET FILTERING +# ============================================ + +# Allowed collateral assets (comma-separated) +# If specified, ONLY markets with these collateral assets will be processed +# If empty, all assets are allowed (unless in ignored list) +# Examples: +# - NEP-141: nep141:btc.omft.near +# - NEP-245: nep245:intents.near:nep141:btc.omft.near +#ALLOWED_COLLATERAL_ASSETS=nep141:btc.omft.near,nep141:wrap.near + +# Ignored collateral assets (comma-separated) +# Markets with these collateral assets will be filtered out +# Examples: +# - Ignore stNEAR: nep141:meta-pool.near +# - Ignore LINEAR: nep141:linear-protocol.near +#IGNORED_COLLATERAL_ASSETS=nep141:meta-pool.near + +# ============================================ +# TESTING & DEBUGGING +# ============================================ + +# Dry run mode - scan and log without executing +# When true, scans for liquidations but doesn't execute transactions +# Default: false +DRY_RUN=true + +# Logging +RUST_LOG=info \ No newline at end of file diff --git a/bots/liquidator/Cargo.toml b/bots/liquidator/Cargo.toml new file mode 100644 index 00000000..c4379f75 --- /dev/null +++ b/bots/liquidator/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "templar-liquidator" +edition.workspace = true +license.workspace = true +repository.workspace = true +version = "0.1.0" + +[lib] +path = "src/liquidator.rs" + +[[bin]] +name = "liquidator" +path = "src/main.rs" + +[dependencies] +async-trait = { workspace = true } +chrono = { version = "0.4", features = ["serde"] } +clap = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +near-account-id = { workspace = true } +near-crypto = { workspace = true } +near-jsonrpc-client = { workspace = true } +near-jsonrpc-primitives = { workspace = true } +near-primitives = { workspace = true } +near-sdk = { workspace = true, features = ["non-contract-usage"] } +reqwest = { version = "0.11", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +templar-common = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/bots/liquidator/Dockerfile b/bots/liquidator/Dockerfile new file mode 100644 index 00000000..35e870f9 --- /dev/null +++ b/bots/liquidator/Dockerfile @@ -0,0 +1,82 @@ +# Templar Liquidator Bot - Multi-stage Docker Build + +# ============================================ +# Build Stage +# ============================================ +FROM rust:1.85-bookworm AS builder + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create app directory +WORKDIR /app + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY common ./common +COPY bots ./bots +COPY contract ./contract +COPY mock ./mock +COPY service ./service +COPY test-utils ./test-utils +COPY universal-account ./universal-account + +# Build the liquidator binary in release mode +RUN cargo build --release -p templar-liquidator --bin liquidator + +# Strip debug symbols to reduce binary size +RUN strip target/release/liquidator + +# ============================================ +# Runtime Stage +# ============================================ +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for security +RUN useradd -m -u 1000 -s /bin/bash liquidator + +# Create app directory +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/target/release/liquidator /app/liquidator + +# Copy configuration templates +COPY --chown=liquidator:liquidator bots/liquidator/scripts ./scripts +COPY --chown=liquidator:liquidator bots/liquidator/.env.example ./.env.example + +# Set ownership +RUN chown -R liquidator:liquidator /app + +# Switch to non-root user +USER liquidator + +# Set environment variables +ENV RUST_LOG=info,templar_liquidator=debug +ENV RUST_BACKTRACE=1 + +# Health check +HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 \ + CMD pgrep -x liquidator || exit 1 + +# Labels for metadata +LABEL org.opencontainers.image.title="Templar Liquidator Bot" +LABEL org.opencontainers.image.description="Inventory-based liquidator bot for Templar Protocol" +LABEL org.opencontainers.image.vendor="Templar Protocol" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.source="https://github.com/templar-protocol/contracts" + +# Default command +ENTRYPOINT ["/app/liquidator"] + +# Default args (can be overridden) +CMD ["--help"] diff --git a/bots/liquidator/IMPLEMENTATION.md b/bots/liquidator/IMPLEMENTATION.md new file mode 100644 index 00000000..4612da62 --- /dev/null +++ b/bots/liquidator/IMPLEMENTATION.md @@ -0,0 +1,333 @@ +# Implementation Guide + +Technical architecture for developers building on or contributing to the liquidator. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ LiquidatorService │ +│ - Registry management │ +│ - Market discovery │ +│ - Scheduling │ +└──────────────────┬──────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Liquidator (per market) │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Scanner: Find liquidatable positions │ │ +│ └───────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Strategy: Calculate liquidation amount │ │ +│ └───────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Executor: Submit transactions │ │ +│ └───────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ InventoryManager │ +│ - Track available balances │ +│ - Record liquidation history │ +└─────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ InventoryRebalancer │ +│ - Apply collateral strategy │ +│ - Execute swaps via providers │ +└─────────────────────────────────────────────────┘ +``` + +## Core Components + +### 1. InventoryManager + +Tracks available assets for liquidation. + +**Location:** `src/inventory.rs` + +```rust +pub struct InventoryManager { + client: JsonRpcClient, + account_id: AccountId, + balances: HashMap, U128>, + collateral_balances: HashMap, U128>, +} +``` + +**Key methods:** +- `refresh()` - Update borrow asset balances +- `refresh_collateral()` - Update collateral asset balances +- `get_available_balance()` - Query balance for asset +- `record_liquidation()` - Track collateral→borrow mapping + +### 2. LiquidationStrategy + +Determines liquidation amount. + +**Location:** `src/liquidation_strategy.rs` + +```rust +pub trait LiquidationStrategy { + fn calculate_liquidation_amount( + &self, + position: &BorrowPosition, + available_balance: U128, + oracle_response: &OracleResponse, + configuration: &MarketConfiguration, + ) -> Result>; +} +``` + +**Implementations:** +- `PartialLiquidationStrategy` - Liquidate percentage of liquidatable amount +- `FullLiquidationStrategy` - Liquidate 100% of liquidatable amount (from contract) + +### 3. CollateralStrategy + +Post-liquidation rebalancing. + +**Location:** `src/collateral_strategy.rs` + +```rust +pub enum CollateralStrategy { + Hold, + SwapToPrimary { primary_asset: FungibleAsset }, + SwapToBorrow, +} +``` + +**SwapToBorrow routing:** +1. Check liquidation history for collateral asset +2. Find markets that use this collateral +3. Route to highest balance borrow asset + +### 4. Liquidator + +Main execution coordinator. + +**Location:** `src/liquidator.rs` + +```rust +pub struct Liquidator { + scanner: MarketScanner, + oracle_fetcher: OracleFetcher, + executor: LiquidationExecutor, + market: AccountId, + market_config: MarketConfiguration, + strategy: Arc, +} +``` + +**Flow:** +1. Scanner finds liquidatable positions +2. Check available inventory +3. Strategy calculates amount (capped by inventory) +4. Profitability check +5. Executor submits transaction + +### 5. SwapProvider + +Collateral swap execution. + +**Location:** `src/swap/` + +**Trait:** +```rust +#[async_trait] +pub trait SwapProvider { + async fn swap(&self, params: SwapParams) -> Result; +} +``` + +**Implementations:** +- `OneClickSwap` - NEAR Intents API +- `RefSwap` - Ref/Rhea Finance DEX + +## Configuration + +### Environment Variables + +**Core:** +```bash +SIGNER_ACCOUNT_ID=liquidator.near +SIGNER_KEY=ed25519:... +REGISTRY_ACCOUNT_IDS=v1.tmplr.near +NETWORK=mainnet +``` + +**Liquidation:** +```bash +LIQUIDATION_STRATEGY=partial # partial | full +PARTIAL_PERCENTAGE=50 +MIN_PROFIT_BPS=50 +``` + +**Collateral:** +```bash +COLLATERAL_STRATEGY=swap-to-borrow # hold | swap-to-primary | swap-to-borrow +PRIMARY_ASSET=nep141:usdc.near # For swap-to-primary +ONECLICK_API_TOKEN=... # Optional 1-Click auth +REF_CONTRACT=v2.ref-finance.near # Mainnet default +``` + +**Intervals:** +```bash +LIQUIDATION_SCAN_INTERVAL=600 # Seconds +REGISTRY_REFRESH_INTERVAL=3600 # Seconds +``` + +**Market Filtering:** +```bash +ALLOWED_COLLATERAL_ASSETS=nep141:btc.omft.near,nep141:wrap.near +IGNORED_COLLATERAL_ASSETS=nep141:meta-pool.near +``` + +## Execution Flow + +``` +1. Service Loop + ├─ Refresh registries (every REGISTRY_REFRESH_INTERVAL) + ├─ Refresh inventory + ├─ Run liquidation round + │ ├─ Scan markets + │ ├─ Execute liquidations + │ └─ Refresh collateral inventory + ├─ Rebalance inventory (apply collateral strategy) + └─ Sleep LIQUIDATION_SCAN_INTERVAL + +2. Liquidation Round + For each market: + ├─ Fetch liquidatable positions + ├─ Check inventory balance + ├─ Calculate amount (min: position, inventory, profitable amount) + ├─ Submit transaction + └─ Record collateral received + +3. Inventory Rebalancing + For each collateral holding: + ├─ Apply collateral strategy + ├─ If swap needed: query provider, execute swap + └─ Update inventory +``` + +## Adding New Features + +### New Liquidation Strategy + +1. Implement `LiquidationStrategy` trait +2. Add to `src/liquidation_strategy.rs` +3. Update `Args::build_config()` to parse new strategy name + +### New Collateral Strategy + +1. Add variant to `CollateralStrategy` enum +2. Implement routing logic in `InventoryRebalancer::rebalance()` +3. Update config parsing in `Args::build_config()` + +### New Swap Provider + +1. Implement `SwapProvider` trait +2. Add module to `src/swap/` +3. Update `SwapProviderImpl` enum +4. Add configuration parameters to `Args` + +### Market Scanner Extension + +Modify `src/scanner.rs`: +- `scan()` - Core scanning logic +- `fetch_positions()` - Position retrieval +- `identify_liquidatable()` - Health check logic + +## Error Handling + +**Retry:** RPC rate limits, network timeouts +**Skip:** Insufficient inventory, position healthy +**Fatal:** Invalid credentials, critical RPC failures + +## Testing + +```bash +cargo test # All unit tests +cargo llvm-cov --html # Coverage report +``` + +Tests are colocated with implementation in `#[cfg(test)]` modules: +- `src/inventory.rs` - Inventory management +- `src/profitability.rs` - Profit calculations +- `src/liquidation_strategy.rs` - Strategy logic +- `src/config.rs` - Configuration parsing +- `src/rpc.rs` - RPC client + +## Swap Providers + +### OneClick (NEAR Intents) + +**Flow:** +1. `POST /v0/quote` - Get deposit address +2. Create implicit account + register storage +3. Transfer tokens to deposit address +4. `POST /v0/deposit/submit` - Initiate swap +5. Poll `GET /v0/status` until SUCCESS + +**Config:** +```bash +ONECLICK_API_TOKEN=... # Optional (0% fee) +``` + +### Ref Finance + +**Flow:** +1. Register storage (if needed) +2. Call `ft_transfer_call` to REF contract +3. Parse swap result from callback + +**Config:** +```bash +REF_CONTRACT=v2.ref-finance.near # Mainnet +``` + +## Profitability Calculation + +```rust +expected_collateral_value = liquidated_amount * liquidation_bonus +profit = expected_collateral_value - liquidated_amount - gas_cost +min_profit = liquidated_amount * (min_profit_bps / 10000) + +profitable = profit >= min_profit +``` + +## Key Files + +- `src/main.rs` - Entry point +- `src/config.rs` - Configuration parsing +- `src/service.rs` - Main orchestrator +- `src/liquidator.rs` - Liquidation coordinator +- `src/scanner.rs` - Market scanning +- `src/executor.rs` - Transaction execution +- `src/inventory.rs` - Inventory management +- `src/rebalancer.rs` - Collateral rebalancing +- `src/liquidation_strategy.rs` - Liquidation strategies +- `src/collateral_strategy.rs` - Collateral strategies +- `src/swap/` - Swap provider implementations + +## Monitoring + +**Logs:** `RUST_LOG=info,templar_liquidator=debug` + +**Key metrics to track:** +- Liquidations per hour +- Success rate +- Average profit per liquidation +- Inventory turnover +- Swap success rate + +## Docker + +See [README.md](./README.md) for Docker commands. + +**Build context:** `contracts/` (repository root) +**Dockerfile location:** `bots/liquidator/Dockerfile` diff --git a/bots/liquidator/Makefile b/bots/liquidator/Makefile new file mode 100644 index 00000000..f575cd0a --- /dev/null +++ b/bots/liquidator/Makefile @@ -0,0 +1,50 @@ +# Templar Liquidator Bot + +.PHONY: help build start start-prod stop logs clean shell + +.DEFAULT_GOAL := help + +IMAGE := templar-liquidator +TAG := latest +COMPOSE := docker compose +ENV_FILE := .env + +help: ## Show available commands + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +build: ## Build Docker image + docker build -t $(IMAGE):$(TAG) -f Dockerfile ../.. + +build-clean: ## Build without cache + docker build --no-cache -t $(IMAGE):$(TAG) -f Dockerfile ../.. + +start: ## Start in dry-run mode + $(COMPOSE) --env-file $(ENV_FILE) up -d + +start-prod: ## Start in production mode + $(COMPOSE) --env-file $(ENV_FILE) -f docker-compose.prod.yml up -d + +stop: ## Stop all containers + $(COMPOSE) down + +restart: stop start ## Restart in dry-run mode + +logs: ## Follow container logs + $(COMPOSE) logs -f + +logs-tail: ## Show last 100 log lines + $(COMPOSE) logs --tail=100 + +shell: ## Open shell in container + docker exec -it templar-liquidator /bin/bash + +clean: ## Remove containers and images + $(COMPOSE) down -v + docker rmi $(IMAGE):$(TAG) 2>/dev/null || true + +ps: ## Show container status + $(COMPOSE) ps + +stats: ## Show resource usage + docker stats templar-liquidator --no-stream diff --git a/bots/liquidator/README.md b/bots/liquidator/README.md new file mode 100644 index 00000000..7b91ee7a --- /dev/null +++ b/bots/liquidator/README.md @@ -0,0 +1,113 @@ +# Templar Liquidator Bot + +Automated liquidation bot for Templar Protocol lending markets. + +## What is a Liquidator? + +Lending protocols allow users to borrow assets against collateral. When collateral value drops below required levels, positions become **under-collateralized** and risky for the protocol. Liquidators protect the protocol by: + +1. **Monitoring** borrower positions continuously +2. **Identifying** under-collateralized positions (health factor < 1) +3. **Executing** liquidations by repaying debt and receiving collateral at a discount +4. **Maintaining** protocol solvency and protecting lenders + +This bot uses an **inventory-based model**: it maintains balances of borrow assets, liquidates positions when profitable, receives collateral, and optionally rebalances inventory through automated swaps. + +## Quick Start + +### Docker (Recommended) + +```bash +cp .env.example .env +nano .env # Configure credentials +make build && make run +``` + +### Native + +```bash +cp .env.example .env +nano .env +cargo run --release +``` + +## Configuration + +See `.env.example` for all options. + +### Required + +```bash +SIGNER_ACCOUNT_ID=liquidator.near +SIGNER_KEY=ed25519:... +REGISTRY_ACCOUNT_IDS=v1.tmplr.near +``` + +### Liquidation + +```bash +LIQUIDATION_STRATEGY=partial # partial | full +PARTIAL_PERCENTAGE=50 # 1-100 (if partial) +MIN_PROFIT_BPS=50 # Minimum profit (basis points) +``` + +### Collateral Strategy + +```bash +COLLATERAL_STRATEGY=hold # hold | swap-to-primary | swap-to-borrow +# PRIMARY_ASSET=nep141:usdc.near # Required for swap-to-primary +``` + +- **hold** - Keep collateral as received +- **swap-to-primary** - Convert all to specified asset +- **swap-to-borrow** - Route back to borrow assets + +### Market Filtering + +```bash +# Process only specific collateral assets +ALLOWED_COLLATERAL_ASSETS=nep141:btc.omft.near,nep141:wrap.near + +# Ignore specific collateral assets +IGNORED_COLLATERAL_ASSETS=nep141:meta-pool.near +``` + +### Intervals + +```bash +LIQUIDATION_SCAN_INTERVAL=600 # Seconds between scans +REGISTRY_REFRESH_INTERVAL=3600 # Seconds between registry updates +``` + +## Docker Commands + +```bash +make build # Build image +make run # Run (dry-run mode) +make logs # View logs +make stop # Stop container +make help # Show all commands +``` + +## Production Deployment + +1. Configure `.env` with production credentials +2. Fund account with borrow assets for target markets +3. Test: `DRY_RUN=true make run && make logs` +4. Deploy: `DRY_RUN=false make prod` + +## Building + +```bash +cargo build --release +./target/release/liquidator --help +``` + +## Documentation + +- [IMPLEMENTATION.md](./IMPLEMENTATION.md) - Architecture and development guide +- [.env.example](./.env.example) - Full configuration reference + +## License + +MIT diff --git a/bots/liquidator/docker-compose.prod.yml b/bots/liquidator/docker-compose.prod.yml new file mode 100644 index 00000000..56da9fbe --- /dev/null +++ b/bots/liquidator/docker-compose.prod.yml @@ -0,0 +1,58 @@ +services: + liquidator: + image: templar-liquidator:latest + container_name: templar-liquidator-prod + restart: always + + env_file: + - .env + + environment: + - RUST_LOG=info,templar_liquidator=info + - RUST_BACKTRACE=0 + + command: [ + "--network", "${NETWORK:-mainnet}", + "--signer-account", "${SIGNER_ACCOUNT_ID}", + "--signer-key", "${SIGNER_KEY}", + "--registries", "${REGISTRY_ACCOUNT_IDS:-v1.tmplr.near}", + "--liquidation-strategy", "${LIQUIDATION_STRATEGY:-partial}", + "--partial-percentage", "${PARTIAL_PERCENTAGE:-50}", + "--min-profit-bps", "${MIN_PROFIT_BPS:-50}", + "--liquidation-scan-interval", "${LIQUIDATION_SCAN_INTERVAL:-600}", + "--registry-refresh-interval", "${REGISTRY_REFRESH_INTERVAL:-3600}", + "--concurrency", "${CONCURRENCY:-10}", + "--transaction-timeout", "${TRANSACTION_TIMEOUT:-60}", + "--collateral-strategy", "${COLLATERAL_STRATEGY:-hold}" + ] + + deploy: + resources: + limits: + cpus: '4' + memory: 4G + reservations: + cpus: '1' + memory: 1G + + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + compress: "true" + + # Health check + healthcheck: + test: ["CMD", "pgrep", "-x", "liquidator"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + # Security options + security_opt: + - no-new-privileges:true + read_only: false + tmpfs: + - /tmp diff --git a/bots/liquidator/docker-compose.yml b/bots/liquidator/docker-compose.yml new file mode 100644 index 00000000..0bec855f --- /dev/null +++ b/bots/liquidator/docker-compose.yml @@ -0,0 +1,61 @@ +services: + liquidator: + build: + context: ../.. + dockerfile: bots/liquidator/Dockerfile + image: templar-liquidator:latest + container_name: templar-liquidator + restart: unless-stopped + + # Environment variables from .env file + env_file: + - .env + + # Additional environment variables + environment: + - RUST_LOG=${RUST_LOG:-info,templar_liquidator=debug} + - RUST_BACKTRACE=${RUST_BACKTRACE:-1} + + command: [ + "--network", "${NETWORK:-mainnet}", + "--signer-account", "${SIGNER_ACCOUNT_ID}", + "--signer-key", "${SIGNER_KEY}", + "--registries", "${REGISTRY_ACCOUNT_IDS:-v1.tmplr.near}", + "--liquidation-strategy", "${LIQUIDATION_STRATEGY:-partial}", + "--partial-percentage", "${PARTIAL_PERCENTAGE:-50}", + "--min-profit-bps", "${MIN_PROFIT_BPS:-50}", + "--liquidation-scan-interval", "${LIQUIDATION_SCAN_INTERVAL:-600}", + "--registry-refresh-interval", "${REGISTRY_REFRESH_INTERVAL:-3600}", + "--concurrency", "${CONCURRENCY:-10}", + "--transaction-timeout", "${TRANSACTION_TIMEOUT:-60}", + "--collateral-strategy", "${COLLATERAL_STRATEGY:-hold}", + # "--oneclick-api-token", "${ONECLICK_API_TOKEN}", + # "--ref-contract", "${REF_CONTRACT:-v2.ref-finance.near}", + # "--primary-asset", "${PRIMARY_ASSET}", + "--dry-run" + ] + + # Resource limits + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + + # Logging configuration + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Health check + healthcheck: + test: ["CMD", "pgrep", "-x", "liquidator"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/bots/liquidator/scripts/run-mainnet.sh b/bots/liquidator/scripts/run-mainnet.sh new file mode 100755 index 00000000..d3c64b04 --- /dev/null +++ b/bots/liquidator/scripts/run-mainnet.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# USAGE: +# cp .env.example .env +# # Edit .env: set SIGNER_ACCOUNT_ID and SIGNER_KEY +# ./scripts/run-mainnet.sh + +set -e + +# Load .env file +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Validate required environment variables +if [ -z "$SIGNER_ACCOUNT_ID" ]; then + error "SIGNER_ACCOUNT_ID not set" + echo " Set in .env or: export SIGNER_ACCOUNT_ID=\"your-account.near\"" + exit 1 +fi + +if [ -z "$SIGNER_KEY" ]; then + error "SIGNER_KEY not set" + echo " Set in .env or: export SIGNER_KEY=\"ed25519:...\"" + exit 1 +fi + +# Configuration with defaults (see .env.example for all options) +NETWORK="${NETWORK:-mainnet}" +REGISTRIES="${REGISTRY_ACCOUNT_IDS:-v1.tmplr.near}" +LIQUIDATION_STRATEGY="${LIQUIDATION_STRATEGY:-partial}" +LIQUIDATION_SCAN_INTERVAL="${LIQUIDATION_SCAN_INTERVAL:-600}" +REGISTRY_REFRESH_INTERVAL="${REGISTRY_REFRESH_INTERVAL:-3600}" +CONCURRENCY="${CONCURRENCY:-10}" +PARTIAL_PERCENTAGE="${PARTIAL_PERCENTAGE:-50}" +TRANSACTION_TIMEOUT="${TRANSACTION_TIMEOUT:-60}" +MIN_PROFIT_BPS="${MIN_PROFIT_BPS:-50}" +DRY_RUN="${DRY_RUN:-true}" + +# Collateral strategy configuration +COLLATERAL_STRATEGY="${COLLATERAL_STRATEGY:-hold}" +PRIMARY_ASSET="${PRIMARY_ASSET}" + +# Swap provider configuration (both providers will be initialized automatically) +ONECLICK_API_TOKEN="${ONECLICK_API_TOKEN}" +REF_CONTRACT="${REF_CONTRACT:-v2.ref-finance.near}" # Mainnet default + +# Market filtering configuration +ALLOWED_COLLATERAL_ASSETS="${ALLOWED_COLLATERAL_ASSETS}" +IGNORED_COLLATERAL_ASSETS="${IGNORED_COLLATERAL_ASSETS}" + +# Build binary if needed +PROJECT_ROOT="$SCRIPT_DIR/../../.." +BINARY_PATH="$PROJECT_ROOT/target/debug/liquidator" + +if [ ! -f "$BINARY_PATH" ]; then + warn "Building liquidator binary..." + cd "$PROJECT_ROOT" + cargo build -p templar-liquidator --bin liquidator + if [ ! -f "$BINARY_PATH" ]; then + error "Build failed" + exit 1 + fi +fi + +# Print configuration +echo "" +info "Templar Liquidator - Mainnet (Inventory-Based)" +echo "" +echo " Network: $NETWORK" +echo " Account: $SIGNER_ACCOUNT_ID" +echo " Registries: $REGISTRIES" +echo " Liquidation Strategy: $LIQUIDATION_STRATEGY" +echo " Min Profit: ${MIN_PROFIT_BPS} bps" +echo " Dry Run: $DRY_RUN" + +# Show market filtering if configured +if [ -n "$ALLOWED_COLLATERAL_ASSETS" ]; then + echo " Allowed Assets: $ALLOWED_COLLATERAL_ASSETS" +fi +if [ -n "$IGNORED_COLLATERAL_ASSETS" ]; then + echo " Ignored Assets: $IGNORED_COLLATERAL_ASSETS" +fi + +echo "" + +if [ "$DRY_RUN" = "true" ]; then + info "✓ DRY RUN MODE (scan and log only, no liquidations)" +else + warn "WARNING: DRY_RUN=false - This WILL execute liquidations!" + warn "Min profit threshold: ${MIN_PROFIT_BPS} bps (${MIN_PROFIT_BPS}% = $((MIN_PROFIT_BPS/100))% profit)" + read -p "Continue? (yes/no) " -n 3 -r + echo + if [[ ! $REPLY =~ ^yes$ ]]; then + exit 0 + fi +fi + +# Set log level +export RUST_LOG="${RUST_LOG:-info,templar_liquidator=debug}" + +# Build command arguments +CMD_ARGS=( + "--network" "$NETWORK" + "--signer-account" "$SIGNER_ACCOUNT_ID" + "--signer-key" "$SIGNER_KEY" + "--liquidation-strategy" "$LIQUIDATION_STRATEGY" + "--liquidation-scan-interval" "$LIQUIDATION_SCAN_INTERVAL" + "--registry-refresh-interval" "$REGISTRY_REFRESH_INTERVAL" + "--concurrency" "$CONCURRENCY" + "--partial-percentage" "$PARTIAL_PERCENTAGE" + "--min-profit-bps" "$MIN_PROFIT_BPS" + "--transaction-timeout" "$TRANSACTION_TIMEOUT" +) + +for registry in $REGISTRIES; do + CMD_ARGS+=("--registries" "$registry") +done + +[ "$DRY_RUN" = "true" ] && CMD_ARGS+=("--dry-run") + +# Add RPC_URL if set +[ -n "$RPC_URL" ] && CMD_ARGS+=("--rpc-url" "$RPC_URL") + +# Add collateral strategy arguments +CMD_ARGS+=("--collateral-strategy" "$COLLATERAL_STRATEGY") +[ -n "$PRIMARY_ASSET" ] && CMD_ARGS+=("--primary-asset" "$PRIMARY_ASSET") +[ -n "$ONECLICK_API_TOKEN" ] && CMD_ARGS+=("--oneclick-api-token" "$ONECLICK_API_TOKEN") +[ -n "$REF_CONTRACT" ] && CMD_ARGS+=("--ref-contract" "$REF_CONTRACT") + +# Add market filtering arguments +if [ -n "$ALLOWED_COLLATERAL_ASSETS" ]; then + IFS=',' read -ra ASSETS <<< "$ALLOWED_COLLATERAL_ASSETS" + for asset in "${ASSETS[@]}"; do + CMD_ARGS+=("--allowed-collateral-assets" "$asset") + done +fi + +if [ -n "$IGNORED_COLLATERAL_ASSETS" ]; then + IFS=',' read -ra ASSETS <<< "$IGNORED_COLLATERAL_ASSETS" + for asset in "${ASSETS[@]}"; do + CMD_ARGS+=("--ignored-collateral-assets" "$asset") + done +fi + +info "Starting liquidator..." +echo "" +exec "$BINARY_PATH" "${CMD_ARGS[@]}" diff --git a/bots/liquidator/scripts/run-testnet.sh b/bots/liquidator/scripts/run-testnet.sh new file mode 100755 index 00000000..0228d2d6 --- /dev/null +++ b/bots/liquidator/scripts/run-testnet.sh @@ -0,0 +1,161 @@ +#!/bin/bash +# USAGE: +# cp .env.example .env +# # Edit .env: set SIGNER_ACCOUNT_ID and SIGNER_KEY +# ./scripts/run-testnet.sh + +set -e + +# Load .env file +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Validate required environment variables +if [ -z "$SIGNER_ACCOUNT_ID" ]; then + error "SIGNER_ACCOUNT_ID not set" + echo " Set in .env or: export SIGNER_ACCOUNT_ID=\"your-account.testnet\"" + exit 1 +fi + +if [ -z "$SIGNER_KEY" ]; then + error "SIGNER_KEY not set" + echo " Set in .env or: export SIGNER_KEY=\"ed25519:...\"" + exit 1 +fi + +# Configuration with testnet defaults +NETWORK="testnet" +REGISTRIES="${REGISTRY_ACCOUNT_IDS:-templar-registry.testnet}" +LIQUIDATION_STRATEGY="${LIQUIDATION_STRATEGY:-partial}" +LIQUIDATION_SCAN_INTERVAL="${LIQUIDATION_SCAN_INTERVAL:-600}" +REGISTRY_REFRESH_INTERVAL="${REGISTRY_REFRESH_INTERVAL:-3600}" +CONCURRENCY="${CONCURRENCY:-10}" +PARTIAL_PERCENTAGE="${PARTIAL_PERCENTAGE:-50}" +TRANSACTION_TIMEOUT="${TRANSACTION_TIMEOUT:-60}" +MIN_PROFIT_BPS="${MIN_PROFIT_BPS:-50}" +DRY_RUN="${DRY_RUN:-true}" + +# Collateral strategy configuration +COLLATERAL_STRATEGY="${COLLATERAL_STRATEGY:-hold}" +PRIMARY_ASSET="${PRIMARY_ASSET}" + +# Swap provider configuration (both providers will be initialized automatically) +ONECLICK_API_TOKEN="${ONECLICK_API_TOKEN}" +REF_CONTRACT="${REF_CONTRACT:-v2.ref-dev.testnet}" # Testnet default + +# Market filtering configuration +ALLOWED_COLLATERAL_ASSETS="${ALLOWED_COLLATERAL_ASSETS}" +IGNORED_COLLATERAL_ASSETS="${IGNORED_COLLATERAL_ASSETS}" + +# Build binary if needed +PROJECT_ROOT="$SCRIPT_DIR/../../.." +BINARY_PATH="$PROJECT_ROOT/target/debug/liquidator" + +if [ ! -f "$BINARY_PATH" ]; then + warn "Building liquidator binary..." + cd "$PROJECT_ROOT" + cargo build -p templar-liquidator --bin liquidator + if [ ! -f "$BINARY_PATH" ]; then + error "Build failed" + exit 1 + fi +fi + +# Print configuration +echo "" +info "Templar Liquidator - Testnet (Inventory-Based)" +echo "" +echo " Network: $NETWORK" +echo " Account: $SIGNER_ACCOUNT_ID" +echo " Registries: $REGISTRIES" +echo " Liquidation Strategy: $LIQUIDATION_STRATEGY" +echo " Min Profit: ${MIN_PROFIT_BPS} bps" +echo " Dry Run: $DRY_RUN" + +# Show market filtering if configured +if [ -n "$ALLOWED_COLLATERAL_ASSETS" ]; then + echo " Allowed Assets: $ALLOWED_COLLATERAL_ASSETS" +fi +if [ -n "$IGNORED_COLLATERAL_ASSETS" ]; then + echo " Ignored Assets: $IGNORED_COLLATERAL_ASSETS" +fi + +echo "" + +if [ "$DRY_RUN" = "true" ]; then + info "✓ DRY RUN MODE (scan and log only, no liquidations)" +elif [ "$MIN_PROFIT_BPS" -ge 5000 ]; then + info "✓ OBSERVATION MODE (min profit >= 50%)" +else + warn "WARNING: Min profit is ${MIN_PROFIT_BPS} bps" + read -p "Continue? (yes/no) " -n 3 -r + echo + if [[ ! $REPLY =~ ^yes$ ]]; then + exit 0 + fi +fi + +# Set log level +export RUST_LOG="${RUST_LOG:-info,templar_liquidator=debug}" + +# Build command arguments +CMD_ARGS=( + "--network" "$NETWORK" + "--signer-account" "$SIGNER_ACCOUNT_ID" + "--signer-key" "$SIGNER_KEY" + "--liquidation-strategy" "$LIQUIDATION_STRATEGY" + "--liquidation-scan-interval" "$LIQUIDATION_SCAN_INTERVAL" + "--registry-refresh-interval" "$REGISTRY_REFRESH_INTERVAL" + "--concurrency" "$CONCURRENCY" + "--partial-percentage" "$PARTIAL_PERCENTAGE" + "--min-profit-bps" "$MIN_PROFIT_BPS" + "--transaction-timeout" "$TRANSACTION_TIMEOUT" +) + +for registry in $REGISTRIES; do + CMD_ARGS+=("--registries" "$registry") +done + +[ "$DRY_RUN" = "true" ] && CMD_ARGS+=("--dry-run") + +# Add RPC_URL if set +[ -n "$RPC_URL" ] && CMD_ARGS+=("--rpc-url" "$RPC_URL") + +# Add collateral strategy arguments +CMD_ARGS+=("--collateral-strategy" "$COLLATERAL_STRATEGY") +[ -n "$PRIMARY_ASSET" ] && CMD_ARGS+=("--primary-asset" "$PRIMARY_ASSET") +[ -n "$ONECLICK_API_TOKEN" ] && CMD_ARGS+=("--oneclick-api-token" "$ONECLICK_API_TOKEN") +[ -n "$REF_CONTRACT" ] && CMD_ARGS+=("--ref-contract" "$REF_CONTRACT") + +# Add market filtering arguments +if [ -n "$ALLOWED_COLLATERAL_ASSETS" ]; then + IFS=',' read -ra ASSETS <<< "$ALLOWED_COLLATERAL_ASSETS" + for asset in "${ASSETS[@]}"; do + CMD_ARGS+=("--allowed-collateral-assets" "$asset") + done +fi + +if [ -n "$IGNORED_COLLATERAL_ASSETS" ]; then + IFS=',' read -ra ASSETS <<< "$IGNORED_COLLATERAL_ASSETS" + for asset in "${ASSETS[@]}"; do + CMD_ARGS+=("--ignored-collateral-assets" "$asset") + done +fi + +info "Starting liquidator..." +echo "" +exec "$BINARY_PATH" "${CMD_ARGS[@]}" diff --git a/bots/liquidator/src/config.rs b/bots/liquidator/src/config.rs new file mode 100644 index 00000000..9c4b4e80 --- /dev/null +++ b/bots/liquidator/src/config.rs @@ -0,0 +1,456 @@ +//! Configuration management for the liquidator bot. +//! +//! This module handles CLI argument parsing and service configuration creation. + +use std::{str::FromStr, sync::Arc}; + +use clap::Parser; +use near_sdk::AccountId; + +use crate::{ + liquidation_strategy::{FullLiquidationStrategy, PartialLiquidationStrategy}, + rpc::Network, + service::ServiceConfig, + CollateralStrategy, +}; + +/// Liquidation strategy argument type for CLI parsing +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiquidationStrategyArg { + /// Full liquidation (100%) + Full, + /// Partial liquidation (percentage specified separately) + Partial, +} + +impl FromStr for LiquidationStrategyArg { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "full" => Ok(Self::Full), + "partial" => Ok(Self::Partial), + _ => Err(format!( + "Invalid liquidation strategy: '{s}'. Valid options: 'full', 'partial'" + )), + } + } +} + +impl std::fmt::Display for LiquidationStrategyArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Full => write!(f, "full"), + Self::Partial => write!(f, "partial"), + } + } +} + +impl Default for LiquidationStrategyArg { + fn default() -> Self { + Self::Partial + } +} + +/// Validator function for `partial_percentage` range +fn validate_percentage(s: &str) -> Result { + let value: u8 = s + .parse() + .map_err(|_| format!("'{s}' is not a valid number"))?; + if value == 0 || value > 100 { + return Err(format!( + "Partial percentage must be between 1 and 100, got {value}" + )); + } + Ok(value) +} + +/// Command-line arguments for the liquidator bot. +#[derive(Debug, Clone, Parser)] +#[command(name = "templar-liquidator")] +#[command(about = "Inventory-based liquidator bot for Templar Protocol")] +pub struct Args { + /// Market registries to run liquidations for + #[arg(short, long, env = "REGISTRY_ACCOUNT_IDS")] + pub registries: Vec, + + /// Signer key to use for signing transactions + #[arg(short = 'k', long, env = "SIGNER_KEY")] + pub signer_key: near_crypto::SecretKey, + + /// Signer account ID + #[arg(short, long, env = "SIGNER_ACCOUNT_ID")] + pub signer_account: AccountId, + + /// Network to run liquidations on + #[arg(short, long, env = "NETWORK", default_value_t = Network::Testnet)] + pub network: Network, + + /// Custom RPC URL (overrides default network RPC) + #[arg(long, env = "RPC_URL")] + pub rpc_url: Option, + + /// Transaction timeout in seconds + #[arg(long, env = "TRANSACTION_TIMEOUT", default_value_t = 60)] + pub transaction_timeout: u64, + + /// Interval between liquidation scans in seconds + #[arg(long, env = "LIQUIDATION_SCAN_INTERVAL", default_value_t = 600)] + pub liquidation_scan_interval: u64, + + /// Registry refresh interval in seconds + #[arg(long, env = "REGISTRY_REFRESH_INTERVAL", default_value_t = 3600)] + pub registry_refresh_interval: u64, + + /// Concurrency for liquidations + #[arg(short, long, env = "CONCURRENCY", default_value_t = 10)] + pub concurrency: usize, + + /// Liquidation strategy: "partial" or "full" + #[arg(long, env = "LIQUIDATION_STRATEGY", default_value_t = LiquidationStrategyArg::default())] + pub liquidation_strategy: LiquidationStrategyArg, + + /// Partial liquidation percentage (1-100, only used with partial strategy) + #[arg(long, env = "PARTIAL_PERCENTAGE", value_parser = validate_percentage, default_value = "50")] + pub partial_percentage: u8, + + /// Minimum profit margin in basis points + #[arg(long, env = "MIN_PROFIT_BPS", default_value_t = 50)] + pub min_profit_bps: u32, + + /// Dry run mode - scan without executing transactions + #[arg(long, env = "DRY_RUN", default_value_t = false)] + pub dry_run: bool, + + /// Collateral strategy: "hold", "swap-to-primary", or "swap-to-borrow" + #[arg(long, env = "COLLATERAL_STRATEGY", default_value = "hold")] + pub collateral_strategy: String, + + /// Primary asset for `SwapToPrimary` strategy + #[arg(long, env = "PRIMARY_ASSET")] + pub primary_asset: Option, + + /// `OneClick` API token for swap authentication + #[arg(long, env = "ONECLICK_API_TOKEN")] + pub oneclick_api_token: Option, + + /// Ref Finance contract address + #[arg(long, env = "REF_CONTRACT")] + pub ref_contract: Option, + + /// Collateral asset allowlist for market filtering + #[arg(long, env = "ALLOWED_COLLATERAL_ASSETS", value_delimiter = ',')] + pub allowed_collateral_assets: Vec, + + /// Collateral assets to ignore in market filtering + #[arg(long, env = "IGNORED_COLLATERAL_ASSETS", value_delimiter = ',')] + pub ignored_collateral_assets: Vec, +} + +impl Args { + /// Parse command-line arguments + pub fn parse_args() -> Self { + Self::parse() + } + + /// Create a liquidation strategy from the arguments + pub fn create_strategy(&self) -> Arc { + match self.liquidation_strategy { + LiquidationStrategyArg::Full => { + tracing::info!("Using FullLiquidationStrategy (100% liquidation)"); + Arc::new(FullLiquidationStrategy::new(self.min_profit_bps)) + } + LiquidationStrategyArg::Partial => { + tracing::info!( + percentage = self.partial_percentage, + "Using PartialLiquidationStrategy" + ); + Arc::new(PartialLiquidationStrategy::new( + self.partial_percentage, + self.min_profit_bps, + )) + } + } + } + + /// Parse collateral strategy from config + fn parse_collateral_strategy(&self) -> CollateralStrategy { + use templar_common::asset::FungibleAsset; + + // Normalize: convert to lowercase and replace hyphens with underscores + let normalized = self.collateral_strategy.to_lowercase().replace('-', "_"); + + match normalized.as_str() { + "swap_to_primary" => { + let Some(ref primary_asset_str) = self.primary_asset else { + panic!("COLLATERAL_STRATEGY=swap-to-primary requires PRIMARY_ASSET to be set"); + }; + + let primary_asset = primary_asset_str.parse::>() + .unwrap_or_else(|_| panic!( + "Failed to parse PRIMARY_ASSET: '{primary_asset_str}'. Expected format: nep141:contract_id or nep245:contract_id:token_id" + )); + + tracing::info!( + primary_asset = %primary_asset, + "Using SwapToPrimary strategy" + ); + CollateralStrategy::SwapToPrimary { primary_asset } + } + "swap_to_borrow" => { + tracing::info!("Using SwapToBorrow strategy"); + CollateralStrategy::SwapToBorrow + } + "hold" => { + tracing::info!("Using Hold strategy (keep collateral as received)"); + CollateralStrategy::Hold + } + _ => panic!( + "Invalid collateral strategy: '{}'. Valid options: 'hold', 'swap-to-primary', 'swap-to-borrow'", + self.collateral_strategy + ), + } + } + + /// Build service configuration from arguments + pub fn build_config(&self) -> ServiceConfig { + let strategy = self.create_strategy(); + let collateral_strategy = self.parse_collateral_strategy(); + + // Parse collateral asset filters + let allowed_collateral_assets: Vec<_> = self + .allowed_collateral_assets + .iter() + .filter_map(|s| { + s.parse::>() + .map_err(|e| { + tracing::warn!( + asset = %s, + error = ?e, + "Failed to parse allowed collateral asset, skipping" + ); + e + }) + .ok() + }) + .collect(); + + let ignored_collateral_assets: Vec<_> = self + .ignored_collateral_assets + .iter() + .filter_map(|s| { + s.parse::>() + .map_err(|e| { + tracing::warn!( + asset = %s, + error = ?e, + "Failed to parse ignored collateral asset, skipping" + ); + e + }) + .ok() + }) + .collect(); + + // Log market filtering + if allowed_collateral_assets.is_empty() { + tracing::info!("Market filtering: processing all assets"); + } else { + tracing::info!( + allowed_assets = ?allowed_collateral_assets, + "Market filtering enabled with allowlist" + ); + } + + if !ignored_collateral_assets.is_empty() { + tracing::info!( + ignored_assets = ?ignored_collateral_assets, + "Market filtering: ignoring specified assets" + ); + } + + ServiceConfig { + registries: self.registries.clone(), + signer_key: self.signer_key.clone(), + signer_account: self.signer_account.clone(), + network: self.network, + rpc_url: self.rpc_url.clone(), + transaction_timeout: self.transaction_timeout, + liquidation_scan_interval: self.liquidation_scan_interval, + registry_refresh_interval: self.registry_refresh_interval, + concurrency: self.concurrency, + strategy, + collateral_strategy, + dry_run: self.dry_run, + oneclick_api_token: self.oneclick_api_token.clone(), + ref_contract: self.ref_contract.clone(), + allowed_collateral_assets, + ignored_collateral_assets, + } + } + + /// Log startup information + pub fn log_startup(&self) { + tracing::info!( + network = %self.network, + dry_run = self.dry_run, + "Starting liquidator bot" + ); + + if self.dry_run { + tracing::info!("DRY RUN MODE: Scanning only, no transactions will be executed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpc::Network; + + fn create_test_args() -> Args { + Args { + registries: vec!["registry.testnet".parse().unwrap()], + signer_key: "ed25519:5JQFYvABVhxnvvvULXqZUSP8QtEiRBMUi5dHfkqZmJ2FLVJqMn3mEhZpF8p8qvC6SvdZLd5VDSvkeVJdyBDZfGi1" + .parse() + .unwrap(), + signer_account: "liquidator.testnet".parse().unwrap(), + network: Network::Testnet, + rpc_url: None, + transaction_timeout: 60, + liquidation_scan_interval: 600, + registry_refresh_interval: 3600, + concurrency: 10, + liquidation_strategy: LiquidationStrategyArg::Partial, + partial_percentage: 50, + min_profit_bps: 100, + dry_run: false, + collateral_strategy: "hold".to_string(), + primary_asset: None, + oneclick_api_token: None, + ref_contract: None, + allowed_collateral_assets: vec![], + ignored_collateral_assets: vec![], + } + } + + #[test] + fn test_parse_collateral_strategy_swap_to_primary() { + let mut args = create_test_args(); + args.collateral_strategy = "swap-to-primary".to_string(); + args.primary_asset = Some("nep141:usdc.testnet".to_string()); + + let strategy = args.parse_collateral_strategy(); + assert!(matches!(strategy, CollateralStrategy::SwapToPrimary { .. })); + } + + #[test] + fn test_parse_collateral_strategy_swap_to_borrow() { + let mut args = create_test_args(); + args.collateral_strategy = "swap-to-borrow".to_string(); + + let strategy = args.parse_collateral_strategy(); + assert!(matches!(strategy, CollateralStrategy::SwapToBorrow)); + } + + #[test] + fn test_parse_collateral_strategy_hold() { + let mut args = create_test_args(); + args.collateral_strategy = "hold".to_string(); + + let strategy = args.parse_collateral_strategy(); + assert!(matches!(strategy, CollateralStrategy::Hold)); + } + + #[test] + fn test_create_strategy_full() { + let mut args = create_test_args(); + args.liquidation_strategy = LiquidationStrategyArg::Full; + args.min_profit_bps = 200; + + let strategy = args.create_strategy(); + assert_eq!(strategy.strategy_name(), "Full Liquidation"); + assert_eq!(strategy.max_liquidation_percentage(), 100); + } + + #[test] + fn test_create_strategy_partial() { + let mut args = create_test_args(); + args.liquidation_strategy = LiquidationStrategyArg::Partial; + args.partial_percentage = 75; + args.min_profit_bps = 150; + + let strategy = args.create_strategy(); + assert_eq!(strategy.strategy_name(), "Partial Liquidation"); + assert_eq!(strategy.max_liquidation_percentage(), 75); + } + + #[test] + fn test_build_config() { + let mut args = create_test_args(); + args.rpc_url = Some("https://custom.rpc.url".to_string()); + args.transaction_timeout = 90; + args.liquidation_scan_interval = 300; + args.registry_refresh_interval = 1800; + args.concurrency = 5; + args.dry_run = true; + args.oneclick_api_token = Some("test_token".to_string()); + args.ref_contract = Some("ref.testnet".to_string()); + args.allowed_collateral_assets = vec!["nep141:usdc.testnet".to_string()]; + args.ignored_collateral_assets = vec!["nep141:scam.testnet".to_string()]; + + let config = args.build_config(); + assert_eq!(config.registries.len(), 1); + assert_eq!(config.network, Network::Testnet); + assert_eq!(config.rpc_url, Some("https://custom.rpc.url".to_string())); + assert_eq!(config.transaction_timeout, 90); + assert_eq!(config.liquidation_scan_interval, 300); + assert_eq!(config.registry_refresh_interval, 1800); + assert_eq!(config.concurrency, 5); + assert!(config.dry_run); + assert_eq!(config.allowed_collateral_assets.len(), 1); + assert_eq!(config.ignored_collateral_assets.len(), 1); + } + + #[test] + fn test_network_display() { + assert_eq!(Network::Mainnet.to_string(), "mainnet"); + assert_eq!(Network::Testnet.to_string(), "testnet"); + } + + #[test] + fn test_liquidation_strategy_parsing() { + // Test valid strategies + assert_eq!( + "partial".parse::().unwrap(), + LiquidationStrategyArg::Partial + ); + assert_eq!( + "full".parse::().unwrap(), + LiquidationStrategyArg::Full + ); + assert_eq!( + "FULL".parse::().unwrap(), + LiquidationStrategyArg::Full + ); + + // Test invalid strategy + let result = "invalid".parse::(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid liquidation strategy")); + } + + #[test] + fn test_percentage_validation() { + // Valid percentages + assert_eq!(validate_percentage("1").unwrap(), 1); + assert_eq!(validate_percentage("50").unwrap(), 50); + assert_eq!(validate_percentage("100").unwrap(), 100); + + // Invalid percentages + assert!(validate_percentage("0").is_err()); + assert!(validate_percentage("101").is_err()); + assert!(validate_percentage("abc").is_err()); + assert!(validate_percentage("-5").is_err()); + } +} diff --git a/bots/liquidator/src/executor.rs b/bots/liquidator/src/executor.rs new file mode 100644 index 00000000..ad1259e7 --- /dev/null +++ b/bots/liquidator/src/executor.rs @@ -0,0 +1,237 @@ +//! Liquidation transaction executor module. +//! +//! Handles the creation and submission of liquidation transactions, +//! including inventory management and collateral strategy execution. + +use near_crypto::Signer; +use near_jsonrpc_client::JsonRpcClient; +use near_primitives::{ + hash::CryptoHash, + transaction::{Transaction, TransactionV0}, +}; +use near_sdk::{json_types::U128, AccountId}; +use std::sync::Arc; +use templar_common::{ + asset::{ + BorrowAsset, BorrowAssetAmount, CollateralAsset, CollateralAssetAmount, FungibleAsset, + }, + market::{DepositMsg, LiquidateMsg}, +}; +use tracing::{debug, error, info}; + +use crate::{ + inventory, + rpc::{check_transaction_success, get_access_key_data, send_tx}, + LiquidationOutcome, LiquidatorError, LiquidatorResult, +}; + +/// Liquidation transaction executor. +/// +/// Responsible for: +/// - Creating liquidation transactions +/// - Managing inventory reservations +/// - Executing transactions +/// - Collateral is added to inventory (rebalancer handles swaps) +pub struct LiquidationExecutor { + client: JsonRpcClient, + signer: Arc, + inventory: inventory::SharedInventory, + market: AccountId, + timeout: u64, + dry_run: bool, +} + +impl LiquidationExecutor { + /// Creates a new liquidation executor. + pub fn new( + client: JsonRpcClient, + signer: Arc, + inventory: inventory::SharedInventory, + market: AccountId, + timeout: u64, + dry_run: bool, + ) -> Self { + Self { + client, + signer, + inventory, + market, + timeout, + dry_run, + } + } + + /// Get reference to the shared inventory + pub fn inventory(&self) -> &inventory::SharedInventory { + &self.inventory + } + + /// Check if executor is in dry run mode + pub fn is_dry_run(&self) -> bool { + self.dry_run + } + + /// Creates a transfer transaction for liquidation. + fn create_transfer_tx( + &self, + borrow_asset: &FungibleAsset, + borrow_account: &AccountId, + liquidation_amount: U128, + collateral_amount: Option, + nonce: u64, + block_hash: CryptoHash, + ) -> LiquidatorResult { + let msg = near_sdk::serde_json::to_string(&DepositMsg::Liquidate(LiquidateMsg { + account_id: borrow_account.clone(), + amount: collateral_amount.map(Into::into), + }))?; + + let function_call = + borrow_asset.transfer_call_action(&self.market, liquidation_amount.into(), &msg); + + Ok(Transaction::V0(TransactionV0 { + nonce, + receiver_id: borrow_asset.contract_id().into(), + block_hash, + signer_id: self.signer.get_account_id(), + public_key: self.signer.public_key().clone(), + actions: vec![function_call.into()], + })) + } + + /// Executes a liquidation transaction. + /// + /// # Flow + /// 1. Reserve inventory + /// 2. Create and submit transaction + /// 3. Handle collateral based on strategy + /// 4. Release inventory on failure + #[tracing::instrument(skip(self, borrow_asset, collateral_asset), level = "info")] + pub async fn execute_liquidation( + &self, + borrow_account: &AccountId, + borrow_asset: &FungibleAsset, + collateral_asset: &FungibleAsset, + liquidation_amount: BorrowAssetAmount, + collateral_amount: CollateralAssetAmount, + expected_collateral_value: BorrowAssetAmount, + ) -> LiquidatorResult { + // Dry run mode - log and skip execution + if self.dry_run { + info!( + borrower = %borrow_account, + liquidation_amount = %u128::from(liquidation_amount), + collateral_amount = %u128::from(collateral_amount), + borrow_asset = %borrow_asset, + "DRY RUN: Liquidatable position found, skipping execution (dry run mode enabled)" + ); + return Ok(LiquidationOutcome::Liquidated); + } + + // Reserve inventory for this liquidation + self.inventory + .write() + .await + .reserve(borrow_asset, liquidation_amount)?; + + info!( + borrower = %borrow_account, + liquidation_amount = %u128::from(liquidation_amount), + borrow_asset = %borrow_asset, + "Reserved inventory for liquidation" + ); + + // Execute liquidation transaction + let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer) + .await + .map_err(LiquidatorError::AccessKeyDataError)?; + + let tx = self.create_transfer_tx( + borrow_asset, + borrow_account, + U128::from(liquidation_amount), + Some(U128::from(collateral_amount)), // Request specific collateral amount calculated by strategy + nonce, + block_hash, + )?; + + info!( + borrower = %borrow_account, + liquidation_amount = %u128::from(liquidation_amount), + expected_collateral_value = %u128::from(expected_collateral_value), + collateral_amount = %u128::from(collateral_amount), + "Submitting liquidation transaction" + ); + + let tx_start = std::time::Instant::now(); + let tx_result = send_tx(&self.client, &self.signer, self.timeout, tx).await; + + match tx_result { + Ok(outcome) => { + let tx_duration = tx_start.elapsed(); + + // Check if transaction AND all receipts succeeded + match check_transaction_success(&outcome) { + Ok(()) => { + info!( + borrower = %borrow_account, + liquidation_amount = %u128::from(liquidation_amount), + expected_collateral_value = %u128::from(expected_collateral_value), + collateral_amount = %u128::from(collateral_amount), + tx_duration_ms = tx_duration.as_millis(), + "Liquidation executed successfully (all receipts succeeded)" + ); + + // Record liquidation history and pending swap amount for rebalancing + self.inventory.write().await.record_liquidation( + borrow_asset, + collateral_asset, + U128::from(collateral_amount), + ); + + // Collateral is now in inventory - rebalancer will handle any swaps + debug!( + borrower = %borrow_account, + collateral_asset = %collateral_asset, + amount = %u128::from(collateral_amount), + "Collateral added to inventory" + ); + + Ok(LiquidationOutcome::Liquidated) + } + Err(error_msg) => { + // Receipt failed - release reserved inventory + self.inventory + .write() + .await + .release(borrow_asset, liquidation_amount); + + error!( + borrower = %borrow_account, + liquidation_amount = %u128::from(liquidation_amount), + error = %error_msg, + tx_hash = %outcome.transaction_outcome.id, + "Liquidation transaction had failed receipt, inventory released" + ); + Err(LiquidatorError::TransactionFailed(error_msg)) + } + } + } + Err(e) => { + // Release reserved inventory on RPC failure + self.inventory + .write() + .await + .release(borrow_asset, liquidation_amount); + + error!( + borrower = %borrow_account, + liquidation_amount = %u128::from(liquidation_amount), + error = ?e, + "Liquidation RPC call failed, inventory released" + ); + Err(LiquidatorError::LiquidationTransactionError(e)) + } + } + } +} diff --git a/bots/liquidator/src/inventory.rs b/bots/liquidator/src/inventory.rs new file mode 100644 index 00000000..13cf9f06 --- /dev/null +++ b/bots/liquidator/src/inventory.rs @@ -0,0 +1,906 @@ +//! Inventory management for liquidation bot. +//! +//! The `InventoryManager` tracks available balances across all markets and assets, +//! providing a unified view of the bot's capital. This enables inventory-based +//! liquidation where positions are only liquidated when sufficient inventory exists. + +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use near_jsonrpc_client::JsonRpcClient; +use near_sdk::{json_types::U128, serde::Serialize, AccountId}; +use templar_common::asset::{ + BorrowAsset, BorrowAssetAmount, CollateralAsset, CollateralAssetAmount, FungibleAsset, + FungibleAssetAmount, +}; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +use crate::rpc::{view, RpcError}; + +/// Result type for inventory operations +pub type InventoryResult = Result; + +/// Errors that can occur during inventory operations +#[derive(Debug, thiserror::Error)] +pub enum InventoryError { + #[error("Failed to fetch balance: {0}")] + FetchBalanceError(#[from] RpcError), + + #[error("Insufficient available balance: required {required}, available {available}")] + InsufficientBalance { required: u128, available: u128 }, + + #[error("Asset not tracked: {0}")] + AssetNotTracked(String), + + #[error("Invalid asset specification: {0}")] + InvalidAsset(String), +} + +/// Entry tracking a single asset's inventory +#[derive(Debug, Clone)] +struct InventoryEntry { + /// Total balance + balance: FungibleAssetAmount, + /// Amount reserved for pending liquidations + reserved: FungibleAssetAmount, + /// Last time this balance was updated + last_updated: Instant, +} + +impl InventoryEntry { + /// Get available (unreserved) balance + fn available(&self) -> FungibleAssetAmount { + FungibleAssetAmount::from( + u128::from(self.balance).saturating_sub(u128::from(self.reserved)), + ) + } + + /// Reserve amount for liquidation + fn reserve(&mut self, amount: FungibleAssetAmount) -> InventoryResult<()> { + let available = u128::from(self.available()); + let amount_u128 = u128::from(amount); + if amount_u128 > available { + return Err(InventoryError::InsufficientBalance { + required: amount_u128, + available, + }); + } + self.reserved = + FungibleAssetAmount::from(u128::from(self.reserved).saturating_add(amount_u128)); + Ok(()) + } + + /// Release reserved amount + fn release(&mut self, amount: FungibleAssetAmount) { + self.reserved = + FungibleAssetAmount::from(u128::from(self.reserved).saturating_sub(u128::from(amount))); + } + + /// Update balance after refresh + fn update_balance(&mut self, new_balance: FungibleAssetAmount) { + self.balance = new_balance; + self.last_updated = Instant::now(); + } +} + +/// Inventory manager for tracking bot's asset balances +/// +/// # Thread Safety +/// +/// The `InventoryManager` is wrapped in `Arc>` for shared access +/// across async tasks. Multiple readers can access inventory state +/// simultaneously, but writers have exclusive access. +pub struct InventoryManager { + /// RPC client for balance queries + client: JsonRpcClient, + /// Bot's account ID + account_id: AccountId, + /// Tracked borrow assets and their balances + inventory: HashMap, InventoryEntry>, + /// Tracked collateral assets (received from liquidations) + collateral_inventory: HashMap, InventoryEntry>, + /// Liquidation history: maps `collateral_asset` -> `borrow_asset` used to acquire it + /// This allows us to swap collateral back to the original borrow asset + liquidation_history: HashMap, FungibleAsset>, + /// Pending swap amounts: tracks collateral received from liquidations awaiting swap + /// Maps `collateral_asset` -> cumulative amount pending swap + pending_swaps: HashMap, U128>, + /// Minimum refresh interval to avoid excessive RPC calls + min_refresh_interval: Duration, + /// Last full refresh timestamp + last_full_refresh: Option, +} + +impl InventoryManager { + /// Creates a new inventory manager + /// + /// # Arguments + /// + /// * `client` - JSON-RPC client for blockchain queries + /// * `account_id` - Bot's account ID + pub fn new(client: JsonRpcClient, account_id: AccountId) -> Self { + Self { + client, + account_id, + inventory: HashMap::new(), + collateral_inventory: HashMap::new(), + liquidation_history: HashMap::new(), + pending_swaps: HashMap::new(), + min_refresh_interval: Duration::from_secs(30), + last_full_refresh: None, + } + } + + /// Sets minimum refresh interval + #[must_use] + pub fn with_min_refresh_interval(mut self, interval: Duration) -> Self { + self.min_refresh_interval = interval; + self + } + + /// Discovers assets from market configurations + /// + /// Extracts all unique borrow assets across markets and initializes + /// inventory entries with zero balance. + /// + /// # Arguments + /// + /// * `market_configs` - Iterator of market configurations + pub fn discover_assets<'a>( + &mut self, + market_configs: impl Iterator, + ) { + let mut discovered = 0; + let mut existing = 0; + + for config in market_configs { + let asset = config.borrow_asset.clone(); + + match self.inventory.entry(asset.clone()) { + std::collections::hash_map::Entry::Occupied(_) => { + existing += 1; + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(InventoryEntry { + balance: BorrowAssetAmount::from(0), + reserved: BorrowAssetAmount::from(0), + last_updated: Instant::now(), + }); + discovered += 1; + debug!(asset = %asset, "Discovered new asset"); + } + } + } + + info!( + discovered = discovered, + existing = existing, + total = self.inventory.len(), + "Discovered borrow assets from market configurations" + ); + } + + /// Discovers collateral assets from market configurations + pub fn discover_collateral_assets<'a>( + &mut self, + market_configs: impl Iterator, + ) { + let mut discovered = 0; + let mut existing = 0; + + for config in market_configs { + let asset = config.collateral_asset.clone(); + + match self.collateral_inventory.entry(asset.clone()) { + std::collections::hash_map::Entry::Occupied(_) => { + existing += 1; + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(InventoryEntry { + balance: CollateralAssetAmount::from(0), + reserved: CollateralAssetAmount::from(0), + last_updated: Instant::now(), + }); + discovered += 1; + debug!(asset = %asset, "Discovered new collateral asset"); + } + } + } + + info!( + discovered = discovered, + existing = existing, + total = self.collateral_inventory.len(), + "Discovered collateral assets from market configurations" + ); + } + + /// Refreshes all tracked asset balances + /// + /// # Errors + /// + /// Returns an error if the RPC call to fetch balances fails. + pub async fn refresh(&mut self) -> InventoryResult { + // Check if we should throttle + if let Some(last_refresh) = self.last_full_refresh { + if last_refresh.elapsed() < self.min_refresh_interval { + debug!( + elapsed_ms = last_refresh.elapsed().as_millis(), + min_interval_ms = self.min_refresh_interval.as_millis(), + "Skipping refresh - too soon since last refresh" + ); + return Ok(0); + } + } + + info!(asset_count = self.inventory.len(), "Refreshing inventory"); + + let mut refreshed = 0; + let mut errors = 0; + let mut updated_assets = Vec::new(); + + // Collect assets to query (clone to avoid borrow issues) + let assets_to_query: Vec> = + self.inventory.keys().cloned().collect(); + + for asset in assets_to_query { + match self.fetch_balance(&asset).await { + Ok(balance) => { + if let Some(entry) = self.inventory.get_mut(&asset) { + let old_balance = u128::from(entry.balance); + entry.update_balance(BorrowAssetAmount::from(balance.0)); + refreshed += 1; + + if balance.0 != old_balance { + updated_assets.push(format!( + "{}({}→{})", + asset.to_string().split(':').last().unwrap_or("unknown"), + old_balance, + balance.0 + )); + } + } + } + Err(e) => { + warn!( + asset = %asset, + error = %e, + "Failed to fetch balance" + ); + errors += 1; + } + } + } + + self.last_full_refresh = Some(Instant::now()); + + // Show all borrow assets with non-zero balance + let available_assets: Vec = self + .inventory + .iter() + .filter_map(|(asset, entry)| { + if u128::from(entry.balance) == 0 { + return None; + } + + // Extract readable name from asset string + let asset_str = asset.to_string(); + let readable_name = if let Some(stripped) = asset_str.strip_prefix("nep141:") { + // For nep141, show just the contract name + stripped.split('.').next().unwrap_or(stripped).to_string() + } else if let Some(stripped) = asset_str.strip_prefix("nep245:") { + // For nep245, show contract and token parts + let parts: Vec<&str> = stripped.split(':').collect(); + if parts.len() >= 2 { + // Show the token_id part (usually contains readable info) + parts[1].split('-').next().unwrap_or("unknown").to_string() + } else { + "unknown".to_string() + } + } else { + asset_str.split(':').last().unwrap_or("unknown").to_string() + }; + Some(readable_name) + }) + .collect(); + + if available_assets.is_empty() { + info!( + refreshed = refreshed, + errors = errors, + "Borrow asset inventory refresh complete - no assets with balance" + ); + } else { + info!( + refreshed = refreshed, + errors = errors, + available_borrow_assets = available_assets.join(", "), + "Borrow asset inventory refresh complete" + ); + } + + Ok(refreshed) + } + + /// Refreshes a single asset's balance + /// + /// # Arguments + /// + /// * `asset` - Asset to refresh + /// + /// # Errors + /// + /// Returns an error if balance fetching fails + pub async fn refresh_asset( + &mut self, + asset: &FungibleAsset, + ) -> InventoryResult<()> { + let balance = self.fetch_balance(asset).await?; + + if let Some(entry) = self.inventory.get_mut(asset) { + entry.update_balance(BorrowAssetAmount::from(balance.0)); + debug!( + asset = %asset, + balance = balance.0, + available = u128::from(entry.available()), + "Asset balance refreshed" + ); + } else { + return Err(InventoryError::AssetNotTracked(asset.to_string())); + } + + Ok(()) + } + + /// Fetches current balance for an asset from blockchain + async fn fetch_balance(&self, asset: &FungibleAsset) -> InventoryResult { + let balance_action = asset.balance_of_action(&self.account_id); + + let args: near_sdk::serde_json::Value = + near_sdk::serde_json::from_slice(&balance_action.args) + .map_err(RpcError::DeserializeError)?; + + let balance = view::( + &self.client, + asset.contract_id().into(), + &balance_action.method_name, + args, + ) + .await?; + + Ok(balance) + } + + /// Gets available (unreserved) balance for an asset + /// + /// # Arguments + /// + /// * `asset` - Asset to query + /// + /// # Returns + /// + /// Available balance, or 0 if asset not tracked + pub fn get_available_balance(&self, asset: &FungibleAsset) -> U128 { + U128::from(u128::from( + self.inventory + .get(asset) + .map_or(BorrowAssetAmount::from(0), |entry| entry.available()), + )) + } + + /// Gets total balance (including reserved) for an asset + pub fn get_total_balance(&self, asset: &FungibleAsset) -> U128 { + U128::from(u128::from( + self.inventory + .get(asset) + .map_or(BorrowAssetAmount::from(0), |entry| entry.balance), + )) + } + + /// Gets reserved balance for an asset + pub fn get_reserved_balance(&self, asset: &FungibleAsset) -> U128 { + U128::from(u128::from( + self.inventory + .get(asset) + .map_or(BorrowAssetAmount::from(0), |entry| entry.reserved), + )) + } + + /// Reserves balance for a liquidation + /// + /// # Arguments + /// + /// * `asset` - Asset to reserve + /// * `amount` - Amount to reserve + /// + /// # Errors + /// + /// Returns error if insufficient available balance or asset not tracked + pub fn reserve( + &mut self, + asset: &FungibleAsset, + amount: BorrowAssetAmount, + ) -> InventoryResult<()> { + let entry = self + .inventory + .get_mut(asset) + .ok_or_else(|| InventoryError::AssetNotTracked(asset.to_string()))?; + + entry.reserve(amount)?; + + debug!( + asset = %asset, + amount = u128::from(amount), + available = u128::from(entry.available()), + reserved = u128::from(entry.reserved), + "Reserved balance" + ); + + Ok(()) + } + + /// Releases reserved balance + /// + /// # Arguments + /// + /// * `asset` - Asset to release + /// * `amount` - Amount to release + pub fn release(&mut self, asset: &FungibleAsset, amount: BorrowAssetAmount) { + if let Some(entry) = self.inventory.get_mut(asset) { + entry.release(amount); + + debug!( + asset = %asset, + amount = u128::from(amount), + available = u128::from(entry.available()), + reserved = u128::from(entry.reserved), + "Released balance" + ); + } + } + + /// Gets all tracked assets + pub fn tracked_assets(&self) -> Vec> { + self.inventory.keys().cloned().collect() + } + + /// Gets snapshot of current inventory state for logging + pub fn snapshot(&self) -> InventorySnapshot { + InventorySnapshot { + entries: self + .inventory + .iter() + .map(|(asset, entry)| InventorySnapshotEntry { + asset: asset.to_string(), + total: u128::from(entry.balance), + available: u128::from(entry.available()), + reserved: u128::from(entry.reserved), + last_updated_ago_ms: u64::try_from(entry.last_updated.elapsed().as_millis()) + .unwrap_or(u64::MAX), + }) + .collect(), + } + } + + /// Refreshes all collateral asset balances + /// + /// Similar to `refresh()` but for collateral assets received from liquidations. + /// Returns a map of non-zero collateral balances. + /// + /// # Returns + /// + /// `HashMap` of asset name to balance for assets with non-zero balance + /// + /// # Errors + /// + /// Returns error if fetching fails + pub async fn refresh_collateral(&mut self) -> InventoryResult> { + info!( + collateral_asset_count = self.collateral_inventory.len(), + "Refreshing collateral inventory" + ); + + let mut non_zero_balances = HashMap::new(); + let mut refreshed = 0; + let mut errors = 0; + + // Collect assets to query (clone to avoid borrow issues) + let assets_to_query: Vec> = + self.collateral_inventory.keys().cloned().collect(); + + for asset in assets_to_query { + match self.fetch_collateral_balance(&asset).await { + Ok(balance) => { + if let Some(entry) = self.collateral_inventory.get_mut(&asset) { + entry.update_balance(CollateralAssetAmount::from(balance.0)); + refreshed += 1; + + if balance.0 > 0 { + non_zero_balances.insert(asset.to_string(), balance); + } + } + } + Err(e) => { + warn!( + collateral_asset = %asset, + error = %e, + "Failed to fetch collateral balance" + ); + errors += 1; + } + } + } + + if non_zero_balances.is_empty() { + info!( + refreshed = refreshed, + errors = errors, + "Collateral asset inventory refresh complete - no holdings" + ); + } else { + let assets_str = non_zero_balances + .iter() + .map(|(asset, balance)| format!("{}: {}", asset, balance.0)) + .collect::>() + .join(", "); + + info!( + refreshed = refreshed, + errors = errors, + collateral_holdings = assets_str, + "Collateral asset inventory refresh complete" + ); + } + + Ok(non_zero_balances) + } + + /// Fetches current balance for a collateral asset from blockchain + async fn fetch_collateral_balance( + &self, + asset: &FungibleAsset, + ) -> InventoryResult { + let balance_action = asset.balance_of_action(&self.account_id); + + let args: near_sdk::serde_json::Value = + near_sdk::serde_json::from_slice(&balance_action.args) + .map_err(RpcError::DeserializeError)?; + + let balance = view::( + &self.client, + asset.contract_id().into(), + &balance_action.method_name, + args, + ) + .await?; + + Ok(balance) + } + + /// Gets collateral inventory for iteration + pub fn collateral_holdings(&self) -> Vec<(FungibleAsset, U128)> { + self.collateral_inventory + .iter() + .filter_map(|(asset, entry)| { + let balance_u128 = u128::from(entry.balance); + if balance_u128 > 0 { + Some((asset.clone(), U128(balance_u128))) + } else { + None + } + }) + .collect() + } + + /// Gets current collateral balances without refreshing from RPC + /// + /// Returns a `HashMap` of asset string -> balance for assets with non-zero balance. + /// This is useful when you just want to check what's in memory without making RPC calls. + pub fn get_collateral_balances(&self) -> HashMap { + self.collateral_inventory + .iter() + .filter_map(|(asset, entry)| { + let balance_u128 = u128::from(entry.balance); + if balance_u128 > 0 { + Some((asset.to_string(), U128(balance_u128))) + } else { + None + } + }) + .collect() + } + + /// Records which borrow asset was used to acquire collateral and tracks pending swap amount + /// + /// Call this after a successful liquidation to track the relationship + /// between borrow and collateral assets for swap-to-borrow strategy. + /// + /// # Arguments + /// + /// * `borrow_asset` - Borrow asset used for liquidation + /// * `collateral_asset` - Collateral asset received + /// * `collateral_amount` - Amount of collateral received (cumulative if multiple liquidations) + pub fn record_liquidation( + &mut self, + borrow_asset: &FungibleAsset, + collateral_asset: &FungibleAsset, + collateral_amount: U128, + ) { + // Track liquidation history for swap-to-borrow strategy + self.liquidation_history + .insert(collateral_asset.clone(), borrow_asset.clone()); + + // Accumulate pending swap amount (in case of multiple liquidations before swap) + let current_pending = self + .pending_swaps + .get(collateral_asset) + .map_or(0, |amount| amount.0); + let new_pending = current_pending.saturating_add(collateral_amount.0); + self.pending_swaps + .insert(collateral_asset.clone(), U128(new_pending)); + + tracing::debug!( + borrow = %borrow_asset, + collateral = %collateral_asset, + amount = %collateral_amount.0, + total_pending = %new_pending, + "Recorded liquidation and pending swap amount" + ); + } + + /// Gets the borrow asset that was used to acquire a collateral asset + /// + /// Returns None if no history exists for this collateral. + pub fn get_liquidation_history( + &self, + collateral_asset: &FungibleAsset, + ) -> Option<&FungibleAsset> { + self.liquidation_history.get(collateral_asset) + } + + /// Gets pending swap amounts for collateral assets + /// + /// Returns only the amounts tracked from liquidations, not total balance. + /// This is used by the rebalancer to swap only liquidated collateral. + pub fn get_pending_swap_amounts(&self) -> HashMap { + self.pending_swaps + .iter() + .filter(|(_, amount)| amount.0 > 0) + .map(|(asset, amount)| (asset.to_string(), *amount)) + .collect() + } + + /// Updates the pending swap amount for a collateral asset + /// + /// Used when actual balance is less than pending amount to keep records in sync. + pub fn update_pending_swap_amount( + &mut self, + collateral_asset: &FungibleAsset, + new_amount: U128, + ) { + if new_amount.0 == 0 { + self.pending_swaps.remove(collateral_asset); + tracing::debug!( + collateral = %collateral_asset, + "Cleared pending swap amount (zero balance)" + ); + } else { + self.pending_swaps + .insert(collateral_asset.clone(), new_amount); + tracing::debug!( + collateral = %collateral_asset, + amount = %new_amount.0, + "Updated pending swap amount" + ); + } + } + + /// Clears liquidation history and pending swap amount for a collateral asset + /// + /// Should be called after swapping collateral back to borrow asset. + pub fn clear_liquidation_history(&mut self, collateral_asset: &FungibleAsset) { + let history_cleared = self.liquidation_history.remove(collateral_asset).is_some(); + let pending_cleared = self.pending_swaps.remove(collateral_asset); + + if history_cleared || pending_cleared.is_some() { + tracing::debug!( + collateral = %collateral_asset, + pending_amount = ?pending_cleared, + "Cleared liquidation history and pending swap amount after successful swap" + ); + } + } +} + +/// Snapshot of inventory state for logging/metrics +#[derive(Debug, Clone, Serialize)] +pub struct InventorySnapshot { + pub entries: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InventorySnapshotEntry { + pub asset: String, + pub total: u128, + pub available: u128, + pub reserved: u128, + pub last_updated_ago_ms: u64, +} + +/// Shared inventory manager for concurrent access +pub type SharedInventory = Arc>; + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn create_test_asset() -> FungibleAsset { + FungibleAsset::from_str("nep141:usdc.near").unwrap() + } + + #[test] + fn test_inventory_entry_reserve_release() { + let mut entry: InventoryEntry = InventoryEntry { + balance: BorrowAssetAmount::from(1000), + reserved: BorrowAssetAmount::from(0), + last_updated: Instant::now(), + }; + + // Initial state + assert_eq!(u128::from(entry.available()), 1000); + + // Reserve 300 + entry.reserve(BorrowAssetAmount::from(300)).unwrap(); + assert_eq!(u128::from(entry.available()), 700); + assert_eq!(u128::from(entry.reserved), 300); + + // Reserve another 200 + entry.reserve(BorrowAssetAmount::from(200)).unwrap(); + assert_eq!(u128::from(entry.available()), 500); + assert_eq!(u128::from(entry.reserved), 500); + + // Try to reserve more than available + let result = entry.reserve(BorrowAssetAmount::from(600)); + assert!(result.is_err()); + + // Release 300 + entry.release(BorrowAssetAmount::from(300)); + assert_eq!(u128::from(entry.available()), 800); + assert_eq!(u128::from(entry.reserved), 200); + + // Release remaining + entry.release(BorrowAssetAmount::from(200)); + assert_eq!(u128::from(entry.available()), 1000); + assert_eq!(u128::from(entry.reserved), 0); + } + + #[test] + fn test_inventory_manager_reserve_release() { + let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); + let account_id = AccountId::from_str("test.near").unwrap(); + let mut inventory = InventoryManager::new(client, account_id); + + let asset = create_test_asset(); + + // Add asset manually + inventory.inventory.insert( + asset.clone(), + InventoryEntry { + balance: BorrowAssetAmount::from(1000), + reserved: BorrowAssetAmount::from(0), + last_updated: Instant::now(), + }, + ); + + // Check available balance + assert_eq!(inventory.get_available_balance(&asset).0, 1000); + + // Reserve 300 + inventory + .reserve(&asset, BorrowAssetAmount::from(300)) + .unwrap(); + assert_eq!(inventory.get_available_balance(&asset).0, 700); + assert_eq!(inventory.get_reserved_balance(&asset).0, 300); + + // Release 100 + inventory.release(&asset, BorrowAssetAmount::from(100)); + assert_eq!(inventory.get_available_balance(&asset).0, 800); + assert_eq!(inventory.get_reserved_balance(&asset).0, 200); + } + + #[test] + fn test_inventory_reserve_insufficient_balance() { + let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); + let account_id = AccountId::from_str("test.near").unwrap(); + let mut inventory = InventoryManager::new(client, account_id); + + let asset = create_test_asset(); + + inventory.inventory.insert( + asset.clone(), + InventoryEntry { + balance: BorrowAssetAmount::from(100), + reserved: BorrowAssetAmount::from(0), + last_updated: Instant::now(), + }, + ); + + // Try to reserve more than available + let result = inventory.reserve(&asset, BorrowAssetAmount::from(200)); + assert!(result.is_err()); + } + + #[test] + fn test_inventory_get_total_balance() { + let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); + let account_id = AccountId::from_str("test.near").unwrap(); + let mut inventory = InventoryManager::new(client, account_id); + + let asset = create_test_asset(); + + inventory.inventory.insert( + asset.clone(), + InventoryEntry { + balance: BorrowAssetAmount::from(1000), + reserved: BorrowAssetAmount::from(300), + last_updated: Instant::now(), + }, + ); + + assert_eq!(inventory.get_total_balance(&asset).0, 1000); + assert_eq!(inventory.get_available_balance(&asset).0, 700); + assert_eq!(inventory.get_reserved_balance(&asset).0, 300); + } + + #[test] + fn test_liquidation_history() { + let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); + let account_id = AccountId::from_str("test.near").unwrap(); + let mut inventory = InventoryManager::new(client, account_id); + + let borrow_asset = + templar_common::asset::FungibleAsset::::nep141( + "usdc.testnet".parse().unwrap(), + ); + let collateral_asset = templar_common::asset::FungibleAsset::< + templar_common::asset::CollateralAsset, + >::nep141("btc.testnet".parse().unwrap()); + + let collateral_str = collateral_asset.to_string(); + + // Initially no history + assert_eq!(inventory.get_liquidation_history(&collateral_asset), None); + + // Record liquidation with amount + inventory.record_liquidation(&borrow_asset, &collateral_asset, U128(1000)); + assert_eq!( + inventory.get_liquidation_history(&collateral_asset), + Some(&borrow_asset) + ); + + // Check pending swap amount + let pending = inventory.get_pending_swap_amounts(); + assert_eq!(pending.get(&collateral_str), Some(&U128(1000))); + + // Clear history (should also clear pending amount) + inventory.clear_liquidation_history(&collateral_asset); + assert_eq!(inventory.get_liquidation_history(&collateral_asset), None); + let pending_after = inventory.get_pending_swap_amounts(); + assert_eq!(pending_after.get(&collateral_str), None); + } + + #[test] + fn test_collateral_balances_empty() { + let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); + let account_id = AccountId::from_str("test.near").unwrap(); + let inventory = InventoryManager::new(client, account_id); + + let balances = inventory.get_collateral_balances(); + assert!(balances.is_empty()); + } +} diff --git a/bots/liquidator/src/liquidation_strategy.rs b/bots/liquidator/src/liquidation_strategy.rs new file mode 100644 index 00000000..97b9c81b --- /dev/null +++ b/bots/liquidator/src/liquidation_strategy.rs @@ -0,0 +1,467 @@ +//! Liquidation strategy implementations. +//! +//! This module provides flexible, configurable strategies for determining +//! liquidation amounts and profitability. The Strategy pattern enables: +//! - Partial vs. full liquidations +//! - Custom profitability calculations +//! - Risk management policies +//! - Gas cost optimization +//! +//! # Architecture +//! +//! Strategies implement the `LiquidationStrategy` trait, which provides +//! methods for calculating optimal liquidation amounts and determining +//! whether a liquidation should proceed based on profitability criteria. + +use near_sdk::json_types::U128; +use templar_common::{ + asset::CollateralAssetAmount, borrow::BorrowPosition, market::MarketConfiguration, + oracle::pyth::OracleResponse, +}; +use tracing::debug; + +use crate::LiquidatorResult; + +/// Minimum liquidation amount for 6-decimal tokens (e.g., USDC, USDT) +/// This represents approximately $0.02 USD for stablecoins +const MIN_LIQUIDATION_AMOUNT_6_DECIMALS: u128 = 20_000; + +/// Core trait for liquidation strategies. +/// +/// Implementations of this trait define how liquidation amounts are calculated +/// and whether liquidations should proceed based on profitability and risk criteria. +pub trait LiquidationStrategy: Send + Sync + std::fmt::Debug { + /// Calculates the optimal liquidation amount for a position. + /// + /// # Arguments + /// + /// * `position` - The borrow position to liquidate + /// * `oracle_response` - Current price oracle data + /// * `configuration` - Market configuration + /// * `available_balance` - Available balance in the liquidation asset + /// + /// # Returns + /// + /// The optimal liquidation amount in borrow asset units, or `None` if + /// the position should not be liquidated. + /// + /// # Errors + /// Returns an error if price pair retrieval fails or position calculations fail. + fn calculate_liquidation_amount( + &self, + position: &BorrowPosition, + oracle_response: &OracleResponse, + configuration: &MarketConfiguration, + available_balance: U128, + ) -> LiquidatorResult>; + + /// Determines if a liquidation should proceed based on profitability. + /// + /// In the inventory-based model, we liquidate using available inventory, + /// so there's no swap cost. Profitability is based purely on: + /// - Expected collateral value vs liquidation amount + /// - Gas cost + /// + /// # Arguments + /// + /// * `liquidation_amount` - Amount to be used for liquidation (borrow asset) + /// * `expected_collateral_value` - Expected value of collateral in borrow asset units + /// * `gas_cost_estimate` - Estimated gas cost in borrow asset units + /// + /// # Returns + /// + /// `true` if the liquidation should proceed, `false` otherwise. + /// + /// # Errors + /// Returns an error if profitability calculations fail. + fn should_liquidate( + &self, + liquidation_amount: U128, + expected_collateral_value: U128, + gas_cost_estimate: U128, + ) -> LiquidatorResult; + + /// Returns the strategy name for logging and debugging. + fn strategy_name(&self) -> &'static str; + + /// Returns the maximum liquidation percentage (0-100). + /// + /// # Default + /// + /// Returns 100 (full liquidation) by default. + fn max_liquidation_percentage(&self) -> u8 { + 100 + } +} + +/// Partial liquidation strategy. +/// +/// This strategy liquidates a configured percentage of the position to minimize +/// market impact and gas costs while still profiting from the liquidation. +/// +/// # Benefits +/// +/// - Reduced market impact +/// - Lower gas costs +/// - Faster execution +/// - Multiple liquidators can participate +/// +/// # Tradeoffs +/// +/// - May leave position partially underwater +/// - Requires multiple transactions for full liquidation +/// - More complex profitability calculations +#[derive(Debug, Clone, Copy)] +pub struct PartialLiquidationStrategy { + /// Target liquidation percentage (0-100) + pub target_percentage: u8, + /// Minimum profit margin in basis points (e.g., 50 = 0.5%) + pub min_profit_margin_bps: u32, +} + +impl PartialLiquidationStrategy { + /// Creates a new partial liquidation strategy. + /// + /// # Arguments + /// + /// * `target_percentage` - Target liquidation percentage (1-100) + /// * `min_profit_margin_bps` - Minimum profit margin in basis points + /// + /// # Panics + /// + /// Panics if `target_percentage` is 0 or > 100. + /// + /// # Example + /// + /// ``` + /// use templar_bots::strategy::PartialLiquidationStrategy; + /// + /// // Liquidate 50% of position, require 0.5% profit margin + /// let strategy = PartialLiquidationStrategy::new(50, 50); + /// ``` + #[must_use] + pub fn new(target_percentage: u8, min_profit_margin_bps: u32) -> Self { + assert!( + target_percentage > 0 && target_percentage <= 100, + "Target percentage must be between 1 and 100" + ); + + Self { + target_percentage, + min_profit_margin_bps, + } + } + + /// Creates a strategy that liquidates 50% of positions (recommended default). + #[must_use] + pub fn default_partial() -> Self { + Self { + target_percentage: 50, + min_profit_margin_bps: 50, // 0.5% profit margin + } + } +} + +impl LiquidationStrategy for PartialLiquidationStrategy { + #[tracing::instrument(skip(self, position, oracle_response, configuration), level = "debug")] + fn calculate_liquidation_amount( + &self, + position: &BorrowPosition, + oracle_response: &OracleResponse, + configuration: &MarketConfiguration, + available_balance: U128, + ) -> LiquidatorResult> { + // For partial liquidation: + // 1. Calculate target collateral (percentage of total) + // 2. Calculate minimum borrow amount needed for that collateral + // This ensures the liquidation amount matches the collateral we'll request + + let price_pair = configuration + .price_oracle_configuration + .create_price_pair(oracle_response)?; + + // Calculate target collateral amount (e.g., 50% of total) + let total_collateral = position.collateral_asset_deposit; + let target_collateral_u128 = + u128::from(total_collateral) * u128::from(self.target_percentage) / 100; + let target_collateral = CollateralAssetAmount::from(target_collateral_u128); + + // Calculate minimum acceptable liquidation amount for this collateral + let min_for_target = + configuration.minimum_acceptable_liquidation_amount(target_collateral, &price_pair); + + let Some(liquidation_amount) = min_for_target else { + tracing::warn!( + target_collateral = %target_collateral_u128, + "Could not calculate minimum liquidation amount from target collateral" + ); + return Ok(None); + }; + + // Add a small buffer (0.1%) to account for rounding differences + let liquidation_u128: u128 = liquidation_amount.into(); + let buffer = liquidation_u128 / 1000; // 0.1% buffer + let liquidation_with_buffer = liquidation_u128.saturating_add(buffer.max(1)); + + // Ensure we don't exceed available balance + let available_u128: u128 = available_balance.into(); + + let final_liquidation_amount = if liquidation_with_buffer > available_u128 { + debug!( + requested = %liquidation_with_buffer, + available = %available_u128, + "Insufficient balance, using available amount" + ); + available_balance + } else { + U128(liquidation_with_buffer) + }; + + // Ensure the amount is economically viable (at least ~$0.02 USD value) + // This prevents wasting gas on dust liquidations + let final_u128: u128 = final_liquidation_amount.into(); + + if final_u128 < MIN_LIQUIDATION_AMOUNT_6_DECIMALS { + tracing::warn!( + amount = %final_u128, + minimum_threshold = %MIN_LIQUIDATION_AMOUNT_6_DECIMALS, + available_balance = %available_u128, + "Liquidation amount too small to be economically viable (< $0.02)" + ); + return Ok(None); + } + + debug!( + target_collateral = %target_collateral_u128, + total_collateral = %u128::from(total_collateral), + liquidation_amount = %liquidation_with_buffer, + base_amount = %liquidation_u128, + buffer = %buffer, + percentage = %self.target_percentage, + "Calculated partial liquidation amount with buffer" + ); + + Ok(Some(final_liquidation_amount)) + } + + #[tracing::instrument(skip(self), level = "debug")] + fn should_liquidate( + &self, + liquidation_amount: U128, + expected_collateral_value: U128, + gas_cost_estimate: U128, + ) -> LiquidatorResult { + // Calculate total cost (liquidation amount + gas) + // In inventory model: we spend liquidation_amount from inventory + gas + let liquidation_u128: u128 = liquidation_amount.into(); + let gas_cost_u128: u128 = gas_cost_estimate.into(); + let total_cost = liquidation_u128.saturating_add(gas_cost_u128); + + // Calculate minimum acceptable revenue based on profit margin + let profit_margin_multiplier = 10_000 + self.min_profit_margin_bps; + let min_revenue = (total_cost * u128::from(profit_margin_multiplier)) / 10_000; + + // Check if expected collateral value meets minimum revenue requirement + let collateral_value_u128: u128 = expected_collateral_value.into(); + let is_profitable = collateral_value_u128 >= min_revenue; + + let net_profit = collateral_value_u128.saturating_sub(total_cost); + + debug!( + liquidation_amount = %liquidation_u128, + gas_cost = %gas_cost_u128, + total_cost = %total_cost, + expected_collateral_value = %collateral_value_u128, + min_revenue = %min_revenue, + net_profit = %net_profit, + profit_margin_bps = %self.min_profit_margin_bps, + is_profitable = %is_profitable, + "Profitability check (inventory-based)" + ); + + Ok(is_profitable) + } + + fn strategy_name(&self) -> &'static str { + "Partial Liquidation" + } + + fn max_liquidation_percentage(&self) -> u8 { + self.target_percentage + } +} + +/// Full liquidation strategy. +/// +/// This strategy liquidates the entire position in a single transaction, +/// maximizing immediate profit but potentially incurring higher costs. +#[derive(Debug, Clone, Copy)] +pub struct FullLiquidationStrategy { + /// Minimum profit margin in basis points + pub min_profit_margin_bps: u32, +} + +impl FullLiquidationStrategy { + /// Creates a new full liquidation strategy. + #[must_use] + pub fn new(min_profit_margin_bps: u32) -> Self { + Self { + min_profit_margin_bps, + } + } +} + +impl LiquidationStrategy for FullLiquidationStrategy { + #[tracing::instrument(skip(self, position, oracle_response, configuration), level = "debug")] + fn calculate_liquidation_amount( + &self, + position: &BorrowPosition, + oracle_response: &OracleResponse, + configuration: &MarketConfiguration, + available_balance: U128, + ) -> LiquidatorResult> { + let price_pair = configuration + .price_oracle_configuration + .create_price_pair(oracle_response)?; + + let full_amount = configuration + .minimum_acceptable_liquidation_amount(position.collateral_asset_deposit, &price_pair); + + let Some(amount) = full_amount else { + tracing::warn!( + collateral_deposit = %position.collateral_asset_deposit, + "Could not calculate full liquidation amount from collateral" + ); + return Ok(None); + }; + + // Add a small buffer (0.1%) to account for rounding differences + // between bot calculation and contract calculation + let amount_u128: u128 = amount.into(); + let buffer = amount_u128 / 1000; // 0.1% buffer + let amount_with_buffer = amount_u128.saturating_add(buffer.max(1)); + + // Check if we have enough balance + let available_u128: u128 = available_balance.into(); + + if amount_with_buffer > available_u128 { + tracing::warn!( + required = %amount_with_buffer, + available = %available_u128, + "Insufficient inventory balance for full liquidation" + ); + return Ok(None); + } + + debug!( + amount = %amount_with_buffer, + base_amount = %amount_u128, + buffer = %buffer, + "Calculated full liquidation amount with buffer" + ); + + Ok(Some(U128(amount_with_buffer))) + } + + #[tracing::instrument(skip(self), level = "debug")] + fn should_liquidate( + &self, + liquidation_amount: U128, + expected_collateral_value: U128, + gas_cost_estimate: U128, + ) -> LiquidatorResult { + // Same profitability logic as partial strategy + let liquidation_u128: u128 = liquidation_amount.into(); + let gas_cost_u128: u128 = gas_cost_estimate.into(); + + let total_cost = liquidation_u128.saturating_add(gas_cost_u128); + let profit_margin_multiplier = 10_000 + self.min_profit_margin_bps; + let min_revenue = (total_cost * u128::from(profit_margin_multiplier)) / 10_000; + + let collateral_value_u128: u128 = expected_collateral_value.into(); + let is_profitable = collateral_value_u128 >= min_revenue; + + let net_profit = collateral_value_u128.saturating_sub(total_cost); + + debug!( + liquidation_amount = %liquidation_u128, + gas_cost = %gas_cost_u128, + total_cost = %total_cost, + expected_collateral_value = %collateral_value_u128, + min_revenue = %min_revenue, + net_profit = %net_profit, + is_profitable = %is_profitable, + "Full liquidation profitability check (inventory-based)" + ); + + Ok(is_profitable) + } + + fn strategy_name(&self) -> &'static str { + "Full Liquidation" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_partial_strategy_creation() { + let strategy = PartialLiquidationStrategy::new(50, 50); + assert_eq!(strategy.target_percentage, 50); + assert_eq!(strategy.min_profit_margin_bps, 50); + assert_eq!(strategy.strategy_name(), "Partial Liquidation"); + assert_eq!(strategy.max_liquidation_percentage(), 50); + } + + #[test] + #[should_panic(expected = "Target percentage must be between 1 and 100")] + fn test_partial_strategy_invalid_percentage() { + let _ = PartialLiquidationStrategy::new(0, 50); + } + + #[test] + #[should_panic(expected = "Target percentage must be between 1 and 100")] + fn test_partial_strategy_percentage_too_high() { + let _ = PartialLiquidationStrategy::new(101, 50); + } + + #[test] + fn test_full_strategy_creation() { + let strategy = FullLiquidationStrategy::new(100); + assert_eq!(strategy.min_profit_margin_bps, 100); + assert_eq!(strategy.strategy_name(), "Full Liquidation"); + assert_eq!(strategy.max_liquidation_percentage(), 100); + } + + #[test] + fn test_profitability_check() { + let strategy = PartialLiquidationStrategy::new(50, 50); // 0.5% profit margin + + // Profitable case: collateral_value > (liquidation_amount + gas) * 1.005 + // Cost: 1100 (1000 liquidation + 100 gas), Min revenue: 1105, Collateral: 1110 + let is_profitable = strategy + .should_liquidate( + U128(1000), // liquidation amount + U128(1110), // expected collateral value + U128(100), // gas cost + ) + .unwrap(); + assert!(is_profitable, "Should be profitable"); + + // Not profitable case: collateral_value < (liquidation_amount + gas) * 1.005 + // Cost: 1100, Min revenue: 1105, Collateral: 1100 + let is_not_profitable = strategy + .should_liquidate( + U128(1000), // liquidation amount + U128(1100), // collateral value too low + U128(100), // gas cost + ) + .unwrap(); + assert!(!is_not_profitable, "Should not be profitable"); + } + + // Note: Gas cost check removed - gas costs are negligible on NEAR + // (typically < 0.1% of liquidation value even with 150 TGas at $100 NEAR) +} diff --git a/bots/liquidator/src/liquidator.rs b/bots/liquidator/src/liquidator.rs new file mode 100644 index 00000000..667df287 --- /dev/null +++ b/bots/liquidator/src/liquidator.rs @@ -0,0 +1,509 @@ +//! Liquidator bot with modular architecture. +//! +//! Provides inventory-based liquidation with: +//! - Modular component architecture +//! - Pluggable liquidation strategies +//! - Error handling +//! - Gas cost estimation and profitability analysis +//! +//! Components: +//! - `service`: Bot lifecycle management +//! - `scanner`: Market position scanning +//! - `executor`: Transaction execution +//! - `oracle`: Price fetching +//! - `profitability`: Cost/profit calculations +//! - `inventory`: Asset balance tracking +//! - `strategy`: Liquidation amount calculations +//! - `rebalancer`: Post-liquidation inventory rebalancing +//! - `swap`: Swap provider implementations +//! +//! service.run().await; +//! # Ok(()) +//! # } +//! ``` + +use std::sync::Arc; + +use near_crypto::Signer; +use near_jsonrpc_client::JsonRpcClient; +use near_sdk::{json_types::U128, AccountId}; +use templar_common::{ + asset::{CollateralAsset, FungibleAsset}, + borrow::{BorrowPosition, BorrowStatus}, + market::MarketConfiguration, + oracle::pyth::OracleResponse, +}; +use tracing::{debug, info}; + +use crate::liquidation_strategy::LiquidationStrategy; + +// Modules +pub mod config; +pub mod executor; +pub mod inventory; +pub mod liquidation_strategy; +pub mod oracle; +pub mod profitability; +pub mod rebalancer; +pub mod rpc; +pub mod scanner; +pub mod service; +pub mod swap; + +// Re-exports for convenience +pub use config::Args; +pub use executor::LiquidationExecutor; +pub use inventory::InventoryManager; +pub use oracle::OracleFetcher; +pub use profitability::ProfitabilityCalculator; +pub use rebalancer::{InventoryRebalancer, RebalanceMetrics}; +pub use scanner::MarketScanner; +pub use service::{LiquidatorService, ServiceConfig}; + +// Error conversions +use crate::rpc::AppError; + +impl From for LiquidatorError { + fn from(err: AppError) -> Self { + LiquidatorError::SwapProviderError(err) + } +} + +impl From for LiquidatorError { + fn from(err: inventory::InventoryError) -> Self { + match err { + inventory::InventoryError::InsufficientBalance { .. } => { + LiquidatorError::InsufficientBalance + } + _ => LiquidatorError::StrategyError(err.to_string()), + } + } +} + +/// Result of a liquidation attempt +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiquidationOutcome { + /// Position was successfully liquidated + Liquidated, + /// Position is healthy and not liquidatable + NotLiquidatable, + /// Position is liquidatable but unprofitable + Unprofitable, +} + +/// Errors that can occur during liquidation operations. +#[derive(Debug, thiserror::Error)] +pub enum LiquidatorError { + #[error("Failed to fetch borrow status: {0}")] + FetchBorrowStatus(rpc::RpcError), + #[error("Failed to serialize data: {0}")] + SerializeError(#[from] near_sdk::serde_json::Error), + #[error("Price pair retrieval error: {0}")] + PricePairError(#[from] templar_common::market::error::RetrievalError), + #[error("Swap provider error: {0}")] + SwapProviderError(AppError), + #[error("Failed to get market configuration: {0}")] + GetConfigurationError(rpc::RpcError), + #[error("Failed to fetch oracle prices: {0}")] + PriceFetchError(rpc::RpcError), + #[error("Failed to get access key data: {0}")] + AccessKeyDataError(rpc::RpcError), + #[error("Liquidation transaction error: {0}")] + LiquidationTransactionError(rpc::RpcError), + #[error("Transaction failed: {0}")] + TransactionFailed(String), + #[error("Failed to list borrow positions: {0}")] + ListBorrowPositionsError(rpc::RpcError), + #[error("Failed to fetch balance: {0}")] + FetchBalanceError(rpc::RpcError), + #[error("Failed to list deployments: {0}")] + ListDeploymentsError(rpc::RpcError), + #[error("Strategy error: {0}")] + StrategyError(String), + #[error("Insufficient balance for liquidation")] + InsufficientBalance, +} + +pub type LiquidatorResult = Result; + +/// Collateral management strategy +#[derive(Debug, Clone)] +pub enum CollateralStrategy { + /// Hold collateral as received (default) + Hold, + /// Swap collateral to a primary asset (e.g., USDC) + SwapToPrimary { + /// Primary asset to swap to + primary_asset: FungibleAsset, + }, + /// Swap collateral back to borrow assets (assets used for liquidations) + SwapToBorrow, +} + +/// Production-grade liquidator with modular architecture. +/// +/// This liquidator orchestrates specialized modules: +/// - Scanner: Fetches and evaluates borrow positions +/// - Oracle: Fetches price data +/// - Profitability: Calculates costs and profits +/// - Executor: Executes liquidation transactions +/// - Inventory: Manages asset balances +pub struct Liquidator { + /// Market scanner for position fetching + scanner: scanner::MarketScanner, + /// Oracle fetcher for price data + oracle_fetcher: oracle::OracleFetcher, + /// Liquidation executor + executor: executor::LiquidationExecutor, + /// Market contract to liquidate positions in + pub market: AccountId, + /// Market configuration (cached) + market_config: MarketConfiguration, + /// Liquidation strategy + strategy: Arc, +} + +impl Liquidator { + /// Creates a new liquidator instance. + /// + /// # Arguments + /// + /// * `client` - JSON-RPC client for blockchain communication + /// * `signer` - Transaction signer + /// * `inventory` - Shared inventory manager + /// * `market` - Market contract account ID + /// * `market_config` - Market configuration + /// * `strategy` - Liquidation strategy + /// * `collateral_strategy` - Collateral management strategy + /// * `timeout` - Transaction timeout in seconds + /// * `dry_run` - If true, scan and log without executing liquidations + /// * `swap_provider` - Optional swap provider for collateral swaps + #[allow(clippy::too_many_arguments)] + pub fn new( + client: &JsonRpcClient, + signer: Arc, + inventory: &inventory::SharedInventory, + market: AccountId, + market_config: MarketConfiguration, + strategy: Arc, + _collateral_strategy: CollateralStrategy, + timeout: u64, + dry_run: bool, + _swap_provider: Option, + ) -> Self { + let scanner = scanner::MarketScanner::new(client.clone(), market.clone()); + let oracle_fetcher = oracle::OracleFetcher::new(client.clone()); + let executor = executor::LiquidationExecutor::new( + client.clone(), + signer, + inventory.clone(), + market.clone(), + timeout, + dry_run, + ); + + Self { + scanner, + oracle_fetcher, + executor, + market, + market_config, + strategy, + } + } + + /// Get reference to the scanner (for compatibility checks) + pub fn scanner(&self) -> &scanner::MarketScanner { + &self.scanner + } + + /// Performs a single liquidation using inventory-based model and modular architecture. + /// + /// # Flow + /// 1. Scanner: Check if position is liquidatable + /// 2. Strategy: Calculate liquidation amount + /// 3. Estimate collateral value for profitability check + /// 4. Profitability: Check if profitable + /// 5. Executor: Execute liquidation (contract calculates optimal collateral to restore to MCR) + #[tracing::instrument(skip(self, position, oracle_response), level = "info", fields( + borrower = %borrow_account, + market = %self.market + ))] + pub async fn liquidate( + &self, + borrow_account: AccountId, + position: BorrowPosition, + oracle_response: OracleResponse, + ) -> Result { + use templar_common::number::Decimal; + + // Step 1: Check liquidation status + let status = self + .scanner + .get_borrow_status(&borrow_account, &oracle_response) + .await + .map_err(LiquidatorError::FetchBorrowStatus)?; + + let Some(BorrowStatus::Liquidation(reason)) = status else { + debug!( + borrower = %borrow_account, + "Position is healthy, not liquidatable" + ); + return Ok(LiquidationOutcome::NotLiquidatable); + }; + + if self.executor.is_dry_run() { + info!( + borrower = %borrow_account, + reason = ?reason, + collateral = %position.collateral_asset_deposit, + "DRY RUN: Found liquidatable position" + ); + } else { + info!( + borrower = %borrow_account, + reason = ?reason, + collateral = %position.collateral_asset_deposit, + "Position is liquidatable" + ); + } + + // Step 2: Calculate liquidatable collateral first + // We need to know the actual liquidatable amount before calculating liquidation_amount + let price_pair = self + .market_config + .price_oracle_configuration + .create_price_pair(&oracle_response)?; + let liquidatable_collateral = position.liquidatable_collateral( + &price_pair, + self.market_config.borrow_mcr_liquidation, + self.market_config.liquidation_maximum_spread, + ); + + debug!( + borrower = %borrow_account, + liquidatable_collateral = %u128::from(liquidatable_collateral), + total_collateral = %u128::from(position.collateral_asset_deposit), + "Calculated liquidatable collateral" + ); + + // Step 3: Calculate liquidation amount based on liquidatable collateral + let available_balance = self + .executor + .inventory() + .read() + .await + .get_available_balance(&self.market_config.borrow_asset); + + debug!( + borrower = %borrow_account, + available_balance = %available_balance.0, + collateral_deposit = %position.collateral_asset_deposit, + "Calculating liquidation amount" + ); + + // Create a temporary position with liquidatable collateral for strategy calculation + let mut adjusted_position = position.clone(); + adjusted_position.collateral_asset_deposit = liquidatable_collateral; + + let Some(liquidation_amount) = self.strategy.calculate_liquidation_amount( + &adjusted_position, + &oracle_response, + &self.market_config, + available_balance, + )? + else { + tracing::warn!( + borrower = %borrow_account, + available_balance = %available_balance.0, + borrow_asset = %self.market_config.borrow_asset, + liquidatable_collateral = %u128::from(liquidatable_collateral), + total_collateral = %u128::from(position.collateral_asset_deposit), + "Cannot calculate liquidation amount (check: sufficient inventory, position viability, minimum value threshold)" + ); + return Ok(LiquidationOutcome::NotLiquidatable); + }; + + info!( + borrower = %borrow_account, + liquidation_amount = %liquidation_amount.0, + "Calculated liquidation amount" + ); + + // Step 4: Calculate collateral amount that corresponds to the liquidation amount + // The strategy already calculated liquidation_amount based on liquidatable collateral + + // Calculate target collateral as percentage of liquidatable amount + let target_percentage_decimal = + Decimal::from(u64::from(self.strategy.max_liquidation_percentage())) + / Decimal::from(100u64); + let target_collateral_decimal = + Decimal::from(u128::from(liquidatable_collateral)) * target_percentage_decimal; + let target_collateral_u128 = target_collateral_decimal.to_u128_floor().unwrap_or(0); + + // Use the target collateral, capped at liquidatable amount + let collateral_amount = + U128(target_collateral_u128.min(u128::from(liquidatable_collateral))); + + // Calculate expected value for profitability + let expected_collateral_value = + profitability::ProfitabilityCalculator::convert_collateral_to_borrow_asset( + collateral_amount, + &oracle_response, + &self.market_config, + ) + .unwrap_or(collateral_amount); + + info!( + borrower = %borrow_account, + liquidation_amount = %liquidation_amount.0, + collateral_amount = %collateral_amount.0, + liquidatable_collateral = %u128::from(liquidatable_collateral), + total_collateral = %u128::from(position.collateral_asset_deposit), + estimated_collateral_value = %expected_collateral_value.0, + target_percentage = %self.strategy.max_liquidation_percentage(), + "Calculated target collateral based on liquidatable amount" + ); + + // Step 5: Check profitability + + let gas_cost = profitability::ProfitabilityCalculator::convert_gas_cost_to_borrow_asset( + profitability::ProfitabilityCalculator::DEFAULT_GAS_COST_USD, + &oracle_response, + &self.market_config, + ) + .unwrap_or(U128(50_000)); + + // Calculate detailed profitability metrics + let (net_profit, profit_pct) = + profitability::ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral_value, + gas_cost, + ); + + let is_profitable = self.strategy.should_liquidate( + liquidation_amount, + expected_collateral_value, + gas_cost, + )?; + + // Log detailed profitability analysis + info!( + borrower = %borrow_account, + liquidation_amount = %liquidation_amount.0, + expected_collateral_value = %expected_collateral_value.0, + gas_cost = %gas_cost.0, + expected_revenue = %expected_collateral_value.0, + net_profit = %net_profit, + profit_percentage = %profit_pct, + is_profitable = is_profitable, + "Profitability analysis" + ); + + if !is_profitable { + let prefix = if self.executor.is_dry_run() { + "DRY RUN: " + } else { + "" + }; + info!( + borrower = %borrow_account, + "{}Liquidation not profitable, skipping", prefix + ); + return Ok(LiquidationOutcome::Unprofitable); + } + + // Step 6: Execute liquidation (contract determines optimal collateral amount) + self.executor + .execute_liquidation( + &borrow_account, + &self.market_config.borrow_asset, + &self.market_config.collateral_asset, + templar_common::asset::BorrowAssetAmount::from(liquidation_amount.0), + templar_common::asset::CollateralAssetAmount::from(collateral_amount.0), + templar_common::asset::BorrowAssetAmount::from(expected_collateral_value.0), + ) + .await + } + + /// Runs liquidations for all eligible positions in the market. + #[tracing::instrument(skip(self, _concurrency), level = "info", fields(market = %self.market))] + pub async fn run_liquidations(&self, _concurrency: usize) -> LiquidatorResult { + let max_percentage = self.strategy.max_liquidation_percentage(); + + info!( + strategy = %self.strategy.strategy_name(), + percentage = max_percentage, + "Starting liquidation run" + ); + + // Fetch oracle prices + let oracle_response = self + .oracle_fetcher + .get_oracle_prices( + self.market_config + .price_oracle_configuration + .account_id + .clone(), + &[ + self.market_config + .price_oracle_configuration + .borrow_asset_price_id, + self.market_config + .price_oracle_configuration + .collateral_asset_price_id, + ], + self.market_config + .price_oracle_configuration + .price_maximum_age_s, + ) + .await?; + + if oracle_response.is_empty() { + return Ok(()); + } + + // Scan for positions + let borrows = self.scanner.get_all_borrows().await?; + if borrows.is_empty() { + info!("No borrow positions found"); + return Ok(()); + } + + info!(positions = borrows.len(), "Evaluating positions"); + + // Process positions + let mut liquidated = 0; + let mut not_liquidatable = 0; + let mut unprofitable = 0; + let mut failed = 0; + let total = borrows.len(); + + for (i, (account, position)) in borrows.into_iter().enumerate() { + match self + .liquidate(account.clone(), position, oracle_response.clone()) + .await + { + Ok(LiquidationOutcome::Liquidated) => liquidated += 1, + Ok(LiquidationOutcome::NotLiquidatable) => not_liquidatable += 1, + Ok(LiquidationOutcome::Unprofitable) => unprofitable += 1, + Err(e) => { + tracing::warn!(borrower = %account, error = %e, "Liquidation failed"); + failed += 1; + } + } + + if i < total - 1 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + + info!( + liquidated, + not_liquidatable, unprofitable, failed, "Liquidation run completed" + ); + + Ok(()) + } +} diff --git a/bots/liquidator/src/main.rs b/bots/liquidator/src/main.rs new file mode 100644 index 00000000..41402d7c --- /dev/null +++ b/bots/liquidator/src/main.rs @@ -0,0 +1,30 @@ +use templar_liquidator::{Args, LiquidatorService}; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +#[tokio::main] +async fn main() { + // Initialize tracing + tracing_subscriber::registry() + .with( + fmt::layer() + .with_target(false) + .with_thread_ids(false) + .with_line_number(false) + .with_file(false), + ) + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,templar_liquidator=debug")), + ) + .init(); + + // Parse arguments and build configuration + let args = Args::parse_args(); + args.log_startup(); + + let config = args.build_config(); + + // Create and run service + let service = LiquidatorService::new(config); + service.run().await; +} diff --git a/bots/liquidator/src/oracle.rs b/bots/liquidator/src/oracle.rs new file mode 100644 index 00000000..b5dd1723 --- /dev/null +++ b/bots/liquidator/src/oracle.rs @@ -0,0 +1,314 @@ +//! Oracle price fetching module. +//! +//! Handles fetching prices from various oracle types including: +//! - Standard Pyth oracles +//! - LST oracles with price transformers + +use near_jsonrpc_client::JsonRpcClient; +use near_sdk::{serde_json::json, AccountId}; +use std::collections::HashMap; +use templar_common::{ + number::Decimal, + oracle::{ + price_transformer::PriceTransformer, + pyth::{OracleResponse, PriceIdentifier}, + }, +}; +use tracing::{debug, info, warn}; + +use crate::{ + rpc::{view, RpcError}, + LiquidatorError, LiquidatorResult, +}; + +/// Oracle price fetcher. +/// +/// Responsible for: +/// - Fetching prices from Pyth oracles +/// - Handling LST oracles with transformers +/// - Applying price transformations +pub struct OracleFetcher { + client: JsonRpcClient, + /// Cache of which oracles are LST oracles (`oracle_account` -> `underlying_oracle`) + lst_oracle_cache: std::sync::Arc>>>, +} + +impl OracleFetcher { + /// Creates a new oracle fetcher. + pub fn new(client: JsonRpcClient) -> Self { + Self { + client, + lst_oracle_cache: std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new())), + } + } + + /// Checks if the oracle is an LST oracle by attempting to fetch its underlying oracle ID. + #[tracing::instrument(skip(self), level = "debug")] + async fn is_lst_oracle(&self, oracle: &AccountId) -> LiquidatorResult> { + // Check cache first + { + let cache = self.lst_oracle_cache.read().await; + if let Some(cached) = cache.get(oracle) { + return Ok(cached.clone()); + } + } + + // Try to fetch underlying oracle ID + let underlying_oracle: Result = + view(&self.client, oracle.clone(), "oracle_id", json!({})).await; + + let result = if let Ok(underlying) = underlying_oracle { + debug!( + oracle = %oracle, + underlying = %underlying, + "Detected LST oracle" + ); + Some(underlying) + } else { + debug!(oracle = %oracle, "Standard Pyth oracle (no oracle_id method)"); + None + }; + + // Cache the result + { + let mut cache = self.lst_oracle_cache.write().await; + cache.insert(oracle.clone(), result.clone()); + } + + Ok(result) + } + + /// Fetches current oracle prices. + /// + /// Detects oracle type and uses the appropriate method: + /// - LST oracles: Fetch from underlying oracle and apply transformers + /// - Pyth oracles: Direct fetch with `list_ema_prices_unsafe` or `list_ema_prices_no_older_than` + #[tracing::instrument(skip(self), level = "debug")] + pub async fn get_oracle_prices( + &self, + oracle: AccountId, + price_ids: &[PriceIdentifier], + age: u32, + ) -> LiquidatorResult { + // Check if this is an LST oracle upfront + if let Some(underlying_oracle) = self.is_lst_oracle(&oracle).await? { + debug!( + oracle = %oracle, + underlying = %underlying_oracle, + "Using LST oracle approach with transformers" + ); + return self + .get_oracle_prices_with_transformers(oracle, price_ids, age, underlying_oracle) + .await; + } + + // Standard Pyth oracle - try unsafe method first (faster) + let result: Result = view( + &self.client, + oracle.clone(), + "list_ema_prices_unsafe", + json!({ "price_ids": price_ids }), + ) + .await; + + match result { + Ok(response) => Ok(response), + Err(e) => { + let error_msg = format!("{e:?}"); + debug!("First oracle call failed for {}: {}", oracle, error_msg); + + // If method not found, try the standard method with age validation + if error_msg.contains("MethodNotFound") || error_msg.contains("MethodResolveError") + { + debug!( + "Oracle {} doesn't support list_ema_prices_unsafe, trying list_ema_prices_no_older_than", + oracle + ); + + match view( + &self.client, + oracle.clone(), + "list_ema_prices_no_older_than", + json!({ "price_ids": price_ids, "age": age }), + ) + .await + { + Ok(response) => { + info!( + "Successfully fetched prices from {} using list_ema_prices_no_older_than", + oracle + ); + Ok(response) + } + Err(fallback_err) => Err(LiquidatorError::PriceFetchError(fallback_err)), + } + } else { + Err(LiquidatorError::PriceFetchError(e)) + } + } + } + } + + /// Fetches prices from LST oracle by calling underlying Pyth oracle and applying transformers. + #[tracing::instrument(skip(self), level = "debug")] + async fn get_oracle_prices_with_transformers( + &self, + lst_oracle: AccountId, + price_ids: &[PriceIdentifier], + age: u32, + underlying_oracle: AccountId, + ) -> LiquidatorResult { + info!( + oracle = %lst_oracle, + underlying = %underlying_oracle, + "Fetching LST oracle prices with transformers" + ); + + // Get transformers for each price ID + let mut transformers: HashMap = HashMap::new(); + let mut underlying_price_ids: Vec = Vec::new(); + + for &price_id in price_ids { + match view::>( + &self.client, + lst_oracle.clone(), + "get_transformer", + json!({ "price_identifier": price_id }), + ) + .await + { + Ok(Some(transformer)) => { + debug!( + price_id = ?price_id, + underlying_id = ?transformer.price_id, + "Found price transformer" + ); + underlying_price_ids.push(transformer.price_id); + transformers.insert(price_id, transformer); + } + Ok(None) => { + debug!(price_id = ?price_id, "No transformer, using price ID as-is"); + underlying_price_ids.push(price_id); + } + Err(e) => { + warn!( + price_id = ?price_id, + error = %e, + "Failed to get transformer, skipping market" + ); + return Ok(HashMap::new()); + } + } + } + + debug!( + underlying_oracle = %underlying_oracle, + underlying_price_ids = ?underlying_price_ids, + "Fetching prices from underlying Pyth oracle" + ); + + // Fetch prices from underlying Pyth oracle + let mut underlying_prices = + Box::pin(self.get_oracle_prices(underlying_oracle.clone(), &underlying_price_ids, age)) + .await?; + + if underlying_prices.is_empty() { + warn!("Underlying oracle returned no prices, skipping market"); + return Ok(HashMap::new()); + } + + // Apply transformers to get final prices + let mut final_prices: OracleResponse = HashMap::new(); + + for (&original_price_id, transformer) in &transformers { + if let Some(Some(underlying_price)) = underlying_prices.remove(&transformer.price_id) { + // Fetch the input value for transformation + match self + .fetch_transformer_input(&transformer.call, &lst_oracle) + .await + { + Ok(input) => { + if let Some(transformed_price) = + transformer.action.apply(underlying_price, input) + { + debug!( + price_id = ?original_price_id, + "Successfully transformed price" + ); + final_prices.insert(original_price_id, Some(transformed_price)); + } else { + warn!( + price_id = ?original_price_id, + "Price transformation returned None" + ); + final_prices.insert(original_price_id, None); + } + } + Err(e) => { + warn!( + price_id = ?original_price_id, + error = %e, + "Failed to fetch transformer input" + ); + final_prices.insert(original_price_id, None); + } + } + } else { + warn!( + price_id = ?original_price_id, + underlying_id = ?transformer.price_id, + "Underlying price not found in oracle response" + ); + final_prices.insert(original_price_id, None); + } + } + + // Add prices that didn't need transformation + for &price_id in price_ids { + if !transformers.contains_key(&price_id) { + if let Some(price) = underlying_prices.remove(&price_id) { + final_prices.insert(price_id, price); + } + } + } + + info!( + oracle = %lst_oracle, + price_count = final_prices.len(), + "Successfully fetched and transformed LST oracle prices" + ); + + Ok(final_prices) + } + + /// Fetches the input value needed for price transformation (e.g., LST redemption rate). + async fn fetch_transformer_input( + &self, + call: &templar_common::oracle::price_transformer::Call, + _oracle: &AccountId, + ) -> Result { + // Use the rpc_call() method to create a view query + let query = call.rpc_call(); + + // Execute the query using the RPC client + let request = near_jsonrpc_client::methods::query::RpcQueryRequest { + block_reference: near_primitives::types::BlockReference::latest(), + request: query, + }; + + let response = self.client.call(request).await.map_err(RpcError::from)?; + + // Parse the result + if let near_jsonrpc_primitives::types::query::QueryResponseKind::CallResult(result) = + response.kind + { + let value: Decimal = near_sdk::serde_json::from_slice(&result.result) + .map_err(RpcError::DeserializeError)?; + Ok(value) + } else { + Err(RpcError::WrongResponseKind( + "Expected CallResult".to_string(), + )) + } + } +} diff --git a/bots/liquidator/src/profitability.rs b/bots/liquidator/src/profitability.rs new file mode 100644 index 00000000..dea5c43a --- /dev/null +++ b/bots/liquidator/src/profitability.rs @@ -0,0 +1,354 @@ +//! Profitability calculation module. +//! +//! Handles cost/profit calculations for liquidations including: +//! - Gas cost conversions +//! - Collateral value conversions +//! - Profitability metrics + +use near_sdk::json_types::U128; +use templar_common::{market::MarketConfiguration, oracle::pyth::OracleResponse}; +use tracing::debug; + +use crate::{LiquidatorError, LiquidatorResult}; + +/// Profitability calculator for liquidations. +/// +/// Responsible for: +/// - Converting gas costs to borrow asset units +/// - Converting collateral to borrow asset value +/// - Calculating profit metrics +pub struct ProfitabilityCalculator; + +impl ProfitabilityCalculator { + /// Default gas cost estimate in USD + /// ~$0.05 USD for a liquidation transaction (conservative estimate for 0.01 NEAR at ~$5) + pub const DEFAULT_GAS_COST_USD: f64 = 0.05; + + /// Converts USD gas cost estimate to borrow asset units using oracle prices. + /// + /// Formula: `gas_cost_borrow_asset = gas_cost_usd / borrow_asset_usd_price * 10^borrow_decimals` + /// + /// # Arguments + /// + /// * `gas_cost_usd` - Gas cost in USD (e.g., 0.05 for $0.05) + /// * `oracle_response` - Oracle price data containing borrow asset/USD price + /// * `configuration` - Market configuration containing borrow asset price ID and decimals + /// + /// # Returns + /// + /// Gas cost denominated in borrow asset base units + /// + /// # Errors + /// + /// Returns an error if the borrow asset price is not found in the oracle response + pub fn convert_gas_cost_to_borrow_asset( + gas_cost_usd: f64, + oracle_response: &OracleResponse, + configuration: &MarketConfiguration, + ) -> LiquidatorResult { + // Get borrow asset price from oracle configuration + let borrow_price_id = configuration + .price_oracle_configuration + .borrow_asset_price_id; + let borrow_decimals = configuration + .price_oracle_configuration + .borrow_asset_decimals; + + let borrow_price = oracle_response + .get(&borrow_price_id) + .and_then(|opt| opt.as_ref()) + .ok_or_else(|| { + LiquidatorError::StrategyError("Borrow asset price not found in oracle".to_string()) + })?; + + // Convert price to USD value + // Price format: price * 10^expo + // Note: i64 to f64 conversion may lose precision, but acceptable for price calculations + #[allow(clippy::cast_precision_loss)] + let borrow_usd = (borrow_price.price.0 as f64) * 10f64.powi(borrow_price.expo); + + // Convert gas cost from USD to borrow asset + // gas_cost_borrow = (gas_cost_usd / borrow_usd) * 10^borrow_decimals + let gas_cost_borrow = (gas_cost_usd / borrow_usd) * 10f64.powi(borrow_decimals); + + // Note: f64 to u128 conversion may truncate, but result should fit within u128 range + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Ok(U128(gas_cost_borrow as u128)) + } + + /// Converts collateral asset amount to borrow asset units using oracle prices. + /// + /// Formula: `borrow_value = (collateral_amount * collateral_usd_price) / borrow_usd_price` + /// + /// # Arguments + /// + /// * `collateral_amount` - Amount in collateral asset base units + /// * `oracle_response` - Oracle price data containing both asset prices + /// * `configuration` - Market configuration containing price IDs and decimals + /// + /// # Returns + /// + /// Collateral value denominated in borrow asset base units + /// + /// # Errors + /// + /// Returns an error if collateral or borrow asset prices are not found in the oracle response + pub fn convert_collateral_to_borrow_asset( + collateral_amount: U128, + oracle_response: &OracleResponse, + configuration: &MarketConfiguration, + ) -> LiquidatorResult { + let oracle_config = &configuration.price_oracle_configuration; + + // Get collateral price + let collateral_price = oracle_response + .get(&oracle_config.collateral_asset_price_id) + .and_then(|opt| opt.as_ref()) + .ok_or_else(|| { + LiquidatorError::StrategyError( + "Collateral asset price not found in oracle".to_string(), + ) + })?; + + // Get borrow price + let borrow_price = oracle_response + .get(&oracle_config.borrow_asset_price_id) + .and_then(|opt| opt.as_ref()) + .ok_or_else(|| { + LiquidatorError::StrategyError("Borrow asset price not found in oracle".to_string()) + })?; + + // Convert prices to f64 for calculation + // Price format: price * 10^expo + // Note: i64 to f64 may lose precision, acceptable for price calculations + #[allow(clippy::cast_precision_loss)] + let collateral_usd = (collateral_price.price.0 as f64) * 10f64.powi(collateral_price.expo); + #[allow(clippy::cast_precision_loss)] + let borrow_usd = (borrow_price.price.0 as f64) * 10f64.powi(borrow_price.expo); + + // Convert collateral to borrow asset units + // Step 1: Convert collateral to USD value + #[allow(clippy::cast_precision_loss)] + let collateral_amount_f64 = collateral_amount.0 as f64; + let collateral_decimals = oracle_config.collateral_asset_decimals; + let collateral_value_usd = + (collateral_amount_f64 / 10f64.powi(collateral_decimals)) * collateral_usd; + + // Step 2: Convert USD value to borrow asset units + let borrow_decimals = oracle_config.borrow_asset_decimals; + let borrow_value = (collateral_value_usd / borrow_usd) * 10f64.powi(borrow_decimals); + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Ok(U128(borrow_value as u128)) + } + + /// Calculates detailed profitability metrics for a liquidation. + /// + /// Returns (`net_profit`, `profit_percentage`) + pub fn calculate_profit_metrics( + liquidation_amount: U128, + expected_collateral_value: U128, + gas_cost: U128, + ) -> (u128, u64) { + let liquidation_cost = liquidation_amount.0; + let gas_cost_u128 = gas_cost.0; + let total_cost = liquidation_cost + gas_cost_u128; + let expected_revenue = expected_collateral_value.0; + + let net_profit = expected_revenue.saturating_sub(total_cost); + + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + let profit_percentage = if total_cost > 0 { + ((net_profit as f64 / total_cost as f64) * 100.0) as u64 + } else { + 0 + }; + + debug!( + liquidation_cost = %liquidation_cost, + gas_cost = %gas_cost_u128, + total_cost = %total_cost, + expected_revenue = %expected_revenue, + net_profit = %net_profit, + profit_percentage = %profit_percentage, + "Calculated profitability metrics" + ); + + (net_profit, profit_percentage) + } +} + +#[cfg(test)] +mod tests { + use near_sdk::json_types::U128; + + use super::ProfitabilityCalculator; + + #[test] + fn test_calculate_profit_metrics_basic() { + let liquidation_amount = U128(1000); + let expected_collateral = U128(1200); // 20% profit before gas + let gas_cost = U128(50); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + // Net profit: 1200 - (1000 + 50) = 150 + assert_eq!(net_profit, 150); + // Profit %: (150 / 1050) * 100 = 14% + assert_eq!(profit_pct, 14); + } + + #[test] + fn test_calculate_profit_metrics_zero_profit() { + let liquidation_amount = U128(1000); + let expected_collateral = U128(1000); + let gas_cost = U128(0); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + assert_eq!(net_profit, 0); + assert_eq!(profit_pct, 0); + } + + #[test] + fn test_calculate_profit_metrics_loss() { + let liquidation_amount = U128(1000); + let expected_collateral = U128(900); // 10% loss + let gas_cost = U128(50); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + // Loss scenario: 900 - 1050 = -150, but saturating_sub makes it 0 + assert_eq!(net_profit, 0); + assert_eq!(profit_pct, 0); + } + + #[test] + fn test_calculate_profit_metrics_high_profit() { + let liquidation_amount = U128(1000); + let expected_collateral = U128(2000); // 100% profit before gas + let gas_cost = U128(100); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + // Net profit: 2000 - 1100 = 900 + assert_eq!(net_profit, 900); + // Profit %: (900 / 1100) * 100 = 81% + assert_eq!(profit_pct, 81); + } + + #[test] + fn test_calculate_profit_metrics_zero_cost() { + let liquidation_amount = U128(0); + let expected_collateral = U128(1000); + let gas_cost = U128(0); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + assert_eq!(net_profit, 1000); + // Division by zero protected, returns 0 + assert_eq!(profit_pct, 0); + } + + #[test] + fn test_calculate_profit_metrics_with_gas() { + let liquidation_amount = U128(10_000); + let expected_collateral = U128(11_500); + let gas_cost = U128(500); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + // Net profit: 11500 - (10000 + 500) = 1000 + assert_eq!(net_profit, 1000); + // Profit %: (1000 / 10500) * 100 = 9% + assert_eq!(profit_pct, 9); + } + + #[test] + fn test_calculate_profit_metrics_large_amounts() { + let liquidation_amount = U128(1_000_000_000_000); // 1T units + let expected_collateral = U128(1_100_000_000_000); // 10% profit + let gas_cost = U128(1_000_000_000); // 1B units + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + // Should handle large numbers + assert!(net_profit > 0); + assert!(profit_pct < 100); + } + + #[test] + fn test_calculate_profit_metrics_minimal_profit() { + let liquidation_amount = U128(10_000); + let expected_collateral = U128(10_101); // ~1% profit + let gas_cost = U128(100); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + // Net profit: 10101 - 10100 = 1 + assert_eq!(net_profit, 1); + // Profit %: very small, likely rounds to 0 + assert_eq!(profit_pct, 0); + } + + #[test] + fn test_calculate_profit_metrics_percentage_rounding() { + let liquidation_amount = U128(1000); + let expected_collateral = U128(1550); // 55% profit before gas + let gas_cost = U128(0); + + let (net_profit, profit_pct) = ProfitabilityCalculator::calculate_profit_metrics( + liquidation_amount, + expected_collateral, + gas_cost, + ); + + assert_eq!(net_profit, 550); + // Profit %: (550 / 1000) * 100 = 55% + assert_eq!(profit_pct, 55); + } + + #[test] + #[allow(clippy::assertions_on_constants)] + fn test_default_gas_cost_constant() { + // Verify the default gas cost constant is reasonable + assert!(ProfitabilityCalculator::DEFAULT_GAS_COST_USD > 0.0); + assert!(ProfitabilityCalculator::DEFAULT_GAS_COST_USD < 1.0); + } +} diff --git a/bots/liquidator/src/rebalancer.rs b/bots/liquidator/src/rebalancer.rs new file mode 100644 index 00000000..97c26278 --- /dev/null +++ b/bots/liquidator/src/rebalancer.rs @@ -0,0 +1,540 @@ +//! Inventory rebalancing service for post-liquidation portfolio management. +//! +//! Automatically rebalances the bot's asset inventory after liquidations by +//! swapping received collateral based on configured strategy. +//! +//! Supports multiple strategies: +//! - **Hold**: Keep all collateral as received +//! - **`SwapToPrimary`**: Convert all collateral to a single primary asset +//! - **`SwapToBorrow`**: Convert collateral back to original borrow assets + +use std::{sync::Arc, time::Instant}; + +use near_primitives::views::FinalExecutionStatus; +use near_sdk::json_types::U128; +use templar_common::asset::{ + AssetClass, BorrowAsset, CollateralAsset, FungibleAsset, FungibleAssetAmount, +}; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn, Instrument}; + +use crate::{ + inventory::InventoryManager, + swap::{SwapProvider, SwapProviderImpl}, + CollateralStrategy, +}; + +/// Rebalancing operation metrics +#[derive(Debug, Clone, Default)] +pub struct RebalanceMetrics { + /// Total swaps attempted + pub swaps_attempted: u64, + /// Successful swaps + pub swaps_successful: u64, + /// Failed swaps + pub swaps_failed: u64, + /// Total input amount swapped (in smallest units) + pub total_input_amount: u128, + /// Total swap latency in milliseconds + pub total_latency_ms: u128, + /// NEP-245 tokens skipped (not swappable) + pub nep245_skipped: u64, + /// Assets with no target market + pub no_target_skipped: u64, +} + +impl RebalanceMetrics { + /// Average swap latency in milliseconds + pub fn avg_latency_ms(&self) -> u128 { + if self.swaps_successful > 0 { + self.total_latency_ms / u128::from(self.swaps_successful) + } else { + 0 + } + } + + /// Success rate as percentage (0-100) + #[allow(clippy::cast_precision_loss)] + pub fn success_rate(&self) -> f64 { + if self.swaps_attempted > 0 { + (self.swaps_successful as f64 / self.swaps_attempted as f64) * 100.0 + } else { + 0.0 + } + } + + /// Log metrics summary + pub fn log_summary(&self) { + if self.swaps_attempted == 0 { + info!("No collateral swaps attempted - no liquidation history found"); + return; + } + + info!( + swaps_attempted = self.swaps_attempted, + swaps_successful = self.swaps_successful, + swaps_failed = self.swaps_failed, + success_rate = format!("{:.2}%", self.success_rate()), + avg_latency_ms = self.avg_latency_ms(), + nep245_skipped = self.nep245_skipped, + no_target_skipped = self.no_target_skipped, + "Rebalancing metrics summary" + ); + } +} + +/// Inventory rebalancer for post-liquidation portfolio management. +pub struct InventoryRebalancer { + /// Shared inventory manager + inventory: Arc>, + /// Swap provider for collateral rebalancing + oneclick_provider: Option, + /// Rebalancing strategy + strategy: CollateralStrategy, + /// Rebalancing metrics + metrics: RebalanceMetrics, + /// Dry run mode + dry_run: bool, +} + +impl InventoryRebalancer { + /// Creates a new inventory rebalancer + pub fn new( + inventory: Arc>, + oneclick_provider: Option, + strategy: CollateralStrategy, + dry_run: bool, + ) -> Self { + Self { + inventory, + oneclick_provider, + strategy, + metrics: RebalanceMetrics::default(), + dry_run, + } + } + + /// Get current rebalancing metrics + pub fn metrics(&self) -> &RebalanceMetrics { + &self.metrics + } + + /// Reset metrics at start of each round + pub fn reset_metrics(&mut self) { + self.metrics = RebalanceMetrics::default(); + } + + /// Rebalance inventory based on configured strategy + pub async fn rebalance(&mut self) { + let swap_span = tracing::debug_span!("collateral_swap_round"); + + async { + // Get pending swap amounts (only liquidated collateral, not entire balance) + let collateral_balances = self.inventory.read().await.get_pending_swap_amounts(); + + if collateral_balances.is_empty() { + debug!("No liquidated collateral pending swap"); + return; + } + + info!( + collateral_count = collateral_balances.len(), + strategy = ?self.strategy, + "Starting inventory rebalancing for liquidated collateral" + ); + + // Execute swaps based on strategy + let strategy = self.strategy.clone(); + match strategy { + CollateralStrategy::Hold => { + info!("Collateral strategy is Hold - keeping all collateral"); + } + CollateralStrategy::SwapToPrimary { primary_asset } => { + self.swap_to_primary(&collateral_balances, &primary_asset) + .await; + } + CollateralStrategy::SwapToBorrow => { + self.swap_to_borrow(&collateral_balances).await; + } + } + + // Log metrics + self.metrics.log_summary(); + + // Refresh inventories after swaps + if self.metrics.swaps_successful > 0 { + info!("Refreshing inventories after successful swaps"); + let _ = self.inventory.write().await.refresh().await; + let _ = self.inventory.write().await.refresh_collateral().await; + } + } + .instrument(swap_span) + .await; + } + + /// Swap all collateral to a single primary asset + async fn swap_to_primary( + &mut self, + collateral_balances: &std::collections::HashMap, + primary_asset: &FungibleAsset, + ) { + if self.oneclick_provider.is_none() { + warn!("Swap provider not configured"); + return; + } + + // Get actual on-chain balances for verification + let actual_balances = self.inventory.read().await.get_collateral_balances(); + + for (collateral_asset_str, pending_balance) in collateral_balances { + // Skip if already the primary asset + if collateral_asset_str == &primary_asset.to_string() { + debug!( + asset = %collateral_asset_str, + "Skipping swap - already primary asset" + ); + continue; + } + + // Verify actual balance matches pending amount + let actual_balance = actual_balances.get(collateral_asset_str).map_or(0, |b| b.0); + + if actual_balance == 0 { + warn!( + collateral = %collateral_asset_str, + pending = %pending_balance.0, + "No actual balance found - tokens may have been transferred externally. Skipping swap." + ); + continue; + } + + // Use the minimum of pending and actual to avoid "insufficient balance" errors + let swap_amount = std::cmp::min(pending_balance.0, actual_balance); + if swap_amount < pending_balance.0 { + warn!( + collateral = %collateral_asset_str, + pending = %pending_balance.0, + actual = %actual_balance, + swap = %swap_amount, + "Actual balance is less than pending - using actual balance for swap" + ); + } + + info!( + collateral = %collateral_asset_str, + swap_amount = %swap_amount, + "Preparing to swap collateral balance to primary asset" + ); + + // Parse asset + match collateral_asset_str.parse::>() { + Ok(collateral_asset) => { + self.execute_swap( + &collateral_asset, + primary_asset, + FungibleAssetAmount::from(U128(swap_amount)), + ) + .await; + } + Err(e) => { + error!( + asset = %collateral_asset_str, + error = ?e, + "Failed to parse asset" + ); + } + } + } + } + + /// Verifies and reconciles pending swap amount with actual balance + /// + /// Returns the amount that should be swapped (min of pending and actual). + /// Clears pending swap if balance is zero, updates if balance is less than pending. + async fn verify_swap_balance( + &self, + collateral_asset_str: &str, + pending_balance: U128, + actual_balances: &std::collections::HashMap, + ) -> Option<(FungibleAsset, u128)> { + // Parse collateral asset + let collateral_asset = collateral_asset_str + .parse::>() + .ok()?; + + // Verify actual balance + let actual_balance = actual_balances.get(collateral_asset_str).map_or(0, |b| b.0); + + if actual_balance == 0 { + warn!( + collateral = %collateral_asset_str, + pending = %pending_balance.0, + "No actual balance found - tokens may have been transferred externally or by previous swap. Clearing pending swap record." + ); + self.inventory + .write() + .await + .clear_liquidation_history(&collateral_asset); + return None; + } + + // Use minimum of pending and actual + let swap_amount = std::cmp::min(pending_balance.0, actual_balance); + if swap_amount < pending_balance.0 { + warn!( + collateral = %collateral_asset_str, + pending = %pending_balance.0, + actual = %actual_balance, + swap = %swap_amount, + "Actual balance is less than pending - updating pending amount to match actual balance" + ); + self.inventory + .write() + .await + .update_pending_swap_amount(&collateral_asset, U128(actual_balance)); + } + + Some((collateral_asset, swap_amount)) + } + + /// Swap collateral back to borrow assets based on liquidation history + async fn swap_to_borrow( + &mut self, + collateral_balances: &std::collections::HashMap, + ) { + if self.oneclick_provider.is_none() { + warn!("Swap provider not configured"); + return; + } + + // Build swap plan + let swap_plan: Vec<(String, String, U128)> = { + let inventory_read = self.inventory.read().await; + + // Get actual on-chain balances for verification + let actual_balances = inventory_read.get_collateral_balances(); + + let mut plan = Vec::new(); + for (collateral_asset_str, pending_balance) in collateral_balances { + info!( + collateral = %collateral_asset_str, + pending_balance = %pending_balance.0, + "Checking liquidation history for swap target" + ); + + // Verify balance and get swap amount + let Some((collateral_asset, swap_amount)) = self + .verify_swap_balance(collateral_asset_str, *pending_balance, &actual_balances) + .await + else { + continue; + }; + + // Get liquidation history + let Some(target) = inventory_read.get_liquidation_history(&collateral_asset) else { + debug!( + collateral = %collateral_asset_str, + "No liquidation history, skipping" + ); + continue; + }; + + let target_asset_str = target.to_string(); + info!( + collateral = %collateral_asset_str, + target = %target_asset_str, + swap_amount = %swap_amount, + "Found liquidation history" + ); + + // Skip if already the target asset + if collateral_asset_str == &target_asset_str { + debug!( + asset = %collateral_asset_str, + "Already target asset, skipping" + ); + continue; + } + + plan.push(( + collateral_asset_str.clone(), + target_asset_str, + U128(swap_amount), + )); + } + + plan + }; // Read lock released + + // Execute swaps with parsed assets + for (from_str, to_str, amount) in swap_plan { + info!( + from = %from_str, + to = %to_str, + amount = %amount.0, + "Attempting to swap collateral" + ); + + // Parse assets + match ( + from_str.parse::>(), + to_str.parse::>(), + ) { + (Ok(from_asset), Ok(to_asset)) => { + self.execute_swap(&from_asset, &to_asset, FungibleAssetAmount::from(amount)) + .await; + } + _ => { + error!( + from = %from_str, + to = %to_str, + "Failed to parse assets for swap" + ); + } + } + } + } + + /// Execute a swap with metrics tracking + #[allow(clippy::too_many_lines)] + async fn execute_swap( + &mut self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + input_amount: FungibleAssetAmount, + ) where + T: AssetClass, + { + self.metrics.swaps_attempted += 1; + let swap_start = Instant::now(); + + // Select swap provider + let (swap_provider, provider_name) = + if let Some(provider) = self.select_provider(from_asset, to_asset) { + let name = provider.provider_name(); + (provider, name) + } else { + self.metrics.swaps_failed += 1; + info!( + from = %from_asset, + to = %to_asset, + "No swap provider available" + ); + return; + }; + + info!( + from = %from_asset, + to = %to_asset, + input_amount = %u128::from(input_amount), + provider = %provider_name, + "Starting swap execution" + ); + + // Verify provider supports assets + if !swap_provider.supports_assets(from_asset, to_asset) { + self.metrics.swaps_failed += 1; + warn!( + from = %from_asset, + to = %to_asset, + provider = %provider_name, + "Provider does not support these assets" + ); + return; + } + + // Check dry run mode + if self.dry_run { + info!( + from = %from_asset, + to = %to_asset, + input_amount = %u128::from(input_amount), + provider = %provider_name, + "[DRY RUN] Skipping swap" + ); + return; + } + + // Execute swap with input amount (all providers use input-based swaps for rebalancing) + info!( + from = %from_asset, + to = %to_asset, + input_amount = %u128::from(input_amount), + "Executing input-based swap" + ); + + // Execute swap + match swap_provider.swap(from_asset, to_asset, input_amount).await { + Ok(FinalExecutionStatus::SuccessValue(_)) => { + let latency = swap_start.elapsed().as_millis(); + self.metrics.swaps_successful += 1; + self.metrics.total_input_amount += u128::from(input_amount); + self.metrics.total_latency_ms += latency; + + info!( + from = %from_asset, + to = %to_asset, + input = %u128::from(input_amount), + latency_ms = latency, + "Swap completed successfully" + ); + + // Clear liquidation history for this collateral + self.inventory + .write() + .await + .clear_liquidation_history(from_asset); + } + Ok(status) => { + self.metrics.swaps_failed += 1; + error!( + from = %from_asset, + to = %to_asset, + status = ?status, + "Swap failed with unexpected status" + ); + } + Err(e) => { + self.metrics.swaps_failed += 1; + error!( + from = %from_asset, + to = %to_asset, + error = %e, + "Swap execution failed" + ); + } + } + } + + /// Selects the swap provider for the given asset pair + fn select_provider( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + ) -> Option<&SwapProviderImpl> + where + F: AssetClass, + T: AssetClass, + { + // Use 1-Click API for all swaps + if let Some(provider) = self.oneclick_provider.as_ref() { + if provider.supports_assets(from_asset, to_asset) { + debug!( + from = %from_asset, + to = %to_asset, + "Using 1-Click API" + ); + return Some(provider); + } + warn!( + from = %from_asset, + to = %to_asset, + "Asset pair not supported" + ); + } else { + warn!("Swap provider not available"); + } + + None + } +} diff --git a/bots/liquidator/src/rpc.rs b/bots/liquidator/src/rpc.rs new file mode 100644 index 00000000..d75659a5 --- /dev/null +++ b/bots/liquidator/src/rpc.rs @@ -0,0 +1,584 @@ +//! RPC utilities for interacting with NEAR blockchain. +//! +//! This module provides helper functions for common NEAR RPC operations: +//! - `view()` - Query view methods on contracts +//! - `send_tx()` - Send signed transactions with retry logic +//! - `get_access_key_data()` - Fetch nonce and block hash for transaction signing +//! - `list_deployments()` - Paginated fetching of market deployments from registries +//! +//! # Error Handling +//! +//! All RPC operations return `RpcResult` which wraps various RPC-level errors. +//! These are converted to `LiquidatorError` at the application level. + +use std::{collections::HashMap, time::Duration}; + +use futures::{StreamExt, TryStreamExt}; +use near_crypto::Signer; +use near_jsonrpc_client::{ + errors::JsonRpcError, + methods::{ + query::{RpcQueryError, RpcQueryRequest}, + send_tx::RpcSendTransactionRequest, + tx::{RpcTransactionError, RpcTransactionStatusRequest, TransactionInfo}, + }, + JsonRpcClient, NEAR_MAINNET_RPC_URL, NEAR_TESTNET_RPC_URL, +}; +use near_jsonrpc_primitives::types::query::QueryResponseKind; +use near_primitives::{ + hash::CryptoHash, + transaction::{SignedTransaction, Transaction}, + types::{AccountId, BlockReference}, + views::{FinalExecutionOutcomeView, FinalExecutionStatus, QueryRequest, TxExecutionStatus}, +}; +use near_sdk::{ + near, + serde::{de::DeserializeOwned, Deserialize, Serialize}, + Gas, +}; +use templar_common::borrow::BorrowPosition; +use tokio::time::Instant; + +/// Error types for RPC operations +#[derive(Debug, thiserror::Error)] +pub enum RpcError { + /// Failed to query view method + #[error("Failed to query view method: {0}")] + ViewMethodError(#[from] JsonRpcError), + /// Failed to get access key data + #[error("Failed to get access key data: {0}")] + AccessKeyDataError(JsonRpcError), + /// Got wrong response kind from RPC + #[error("Got wrong response kind from RPC: {0}")] + WrongResponseKind(String), + /// Failed to send transaction + #[error("Failed to send transaction: {0}")] + SendTransactionError(#[from] JsonRpcError), + /// Failed to deserialize response + #[error("Failed to deserialize response: {0}")] + DeserializeError(#[from] near_sdk::serde_json::Error), + /// Timeout exceeded + #[error("Timeout exceeded after {0}s (waited {1}s)")] + TimeoutError(u64, u64), + /// No outcome for transaction + #[error("No outcome for transaction: {0}")] + NoOutcome(String), +} + +/// Error types for application-level operations +#[derive(Debug, thiserror::Error)] +pub enum AppError { + /// RPC operation failed + #[error("RPC error: {0}")] + Rpc(#[from] RpcError), + /// Validation error + #[error("Validation error: {0}")] + ValidationError(String), + /// Serialization error + #[error("Serialization error: {0}")] + SerializationError(String), +} + +pub type RpcResult = Result; +pub type AppResult = Result; + +/// Borrow positions map type +pub type BorrowPositions = HashMap; + +/// Default gas for transactions. 300 `TGas`. +pub const DEFAULT_GAS: u64 = Gas::from_tgas(300).as_gas(); + +/// Default timeout for view call requests (seconds) +const VIEW_CALL_TIMEOUT_SECS: u64 = 30; + +/// Maximum interval between transaction status polls +const MAX_POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// Network configuration for NEAR +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)] +#[near(serializers = [near_sdk::serde_json::json])] +pub enum Network { + /// NEAR mainnet + Mainnet, + /// NEAR testnet (default) + #[default] + Testnet, +} + +impl std::fmt::Display for Network { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + } + ) + } +} + +impl Network { + /// Get the RPC URL for this network + #[must_use] + pub fn rpc_url(&self) -> &str { + match self { + Network::Mainnet => NEAR_MAINNET_RPC_URL, + Network::Testnet => NEAR_TESTNET_RPC_URL, + } + } +} + +/// Contract source metadata as defined by NEP-330 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ContractSourceMetadata { + /// Contract version (semver format) + pub version: String, + /// Link to source code repository + #[serde(skip_serializing_if = "Option::is_none")] + pub link: Option, + /// Standards implemented by the contract + #[serde(skip_serializing_if = "Option::is_none")] + pub standards: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Standard { + pub standard: String, + pub version: String, +} + +/// Get contract source metadata (NEP-330) +/// +/// Returns `None` if the contract doesn't implement NEP-330 or the call fails. +pub async fn get_contract_version( + client: &JsonRpcClient, + contract_id: &AccountId, +) -> Option { + let result: Result = view( + client, + contract_id.clone(), + "contract_source_metadata", + near_sdk::serde_json::json!({}), + ) + .await; + + match result { + Ok(metadata) => Some(metadata.version), + Err(_) => None, + } +} + +/// Get access key data (nonce and block hash) for transaction signing. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `signer` - Signer with the account and key to query +/// +/// # Returns +/// +/// Tuple of (nonce, block_hash) to use when constructing a transaction +#[tracing::instrument(skip(client), level = "debug")] +pub async fn get_access_key_data( + client: &JsonRpcClient, + signer: &Signer, +) -> RpcResult<(u64, CryptoHash)> { + let access_key_query_response = client + .call(RpcQueryRequest { + block_reference: BlockReference::latest(), + request: QueryRequest::ViewAccessKey { + account_id: signer.get_account_id(), + public_key: signer.public_key().clone(), + }, + }) + .await + .map_err(RpcError::AccessKeyDataError)?; + + let nonce = match access_key_query_response.kind { + QueryResponseKind::AccessKey(access_key) => access_key.nonce + 1, + _ => { + return Err(RpcError::WrongResponseKind(format!( + "Expected AccessKey got {:?}", + access_key_query_response.kind + ))); + } + }; + let block_hash = access_key_query_response.block_hash; + + Ok((nonce, block_hash)) +} + +/// Serialize and encode data for NEAR contract calls. +/// +/// # Panics +/// +/// Panics if serialization fails (which should never happen for valid types) +#[allow(clippy::expect_used, reason = "We know the serialization will succeed")] +pub fn serialize_and_encode(data: impl Serialize) -> Vec { + near_sdk::serde_json::to_vec(&data).expect("Failed to serialize data") +} + +/// Call a view method on a NEAR contract. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `account_id` - Contract account to call +/// * `function_name` - Name of the view method +/// * `args` - Arguments to pass (will be JSON serialized) +/// +/// # Returns +/// +/// Deserialized response of type T +#[tracing::instrument(skip_all, level = "debug", fields(account_id = %account_id, method_name = %function_name, args = ?near_sdk::serde_json::to_string(&args)))] +pub async fn view( + client: &JsonRpcClient, + account_id: AccountId, + function_name: &str, + args: impl Serialize, +) -> RpcResult { + // Add timeout for view calls to prevent hanging + let timeout_duration = tokio::time::Duration::from_secs(VIEW_CALL_TIMEOUT_SECS); + + let response = tokio::time::timeout( + timeout_duration, + client.call(RpcQueryRequest { + block_reference: BlockReference::latest(), + request: QueryRequest::CallFunction { + account_id: account_id.clone(), + method_name: function_name.to_owned(), + args: serialize_and_encode(&args).into(), + }, + }), + ) + .await + .map_err(|_| RpcError::TimeoutError(VIEW_CALL_TIMEOUT_SECS, VIEW_CALL_TIMEOUT_SECS))??; + + let QueryResponseKind::CallResult(result) = response.kind else { + return Err(RpcError::WrongResponseKind(format!( + "Expected CallResult got {:?}", + response.kind + ))); + }; + + Ok(near_sdk::serde_json::from_slice(&result.result)?) +} + +/// Send a signed transaction and wait for finality. +/// +/// Returns the full execution outcome including all receipts. +/// Use `check_transaction_success()` to verify if all receipts succeeded. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `signer` - Transaction signer +/// * `timeout` - Maximum seconds to wait for finality +/// * `tx` - Unsigned transaction to send +/// +/// # Returns +/// +/// Returns `FinalExecutionOutcomeView` containing transaction status and all receipt outcomes +#[tracing::instrument(skip(client, signer), level = "debug")] +pub async fn send_tx( + client: &JsonRpcClient, + signer: &Signer, + timeout: u64, + tx: Transaction, +) -> RpcResult { + let (tx_hash, _size) = tx.get_hash_and_size(); + + let called_at = Instant::now(); + let signature = signer.sign(tx_hash.as_ref()); + let deadline = called_at + Duration::from_secs(timeout); + let result = match client + .call(RpcSendTransactionRequest { + signed_transaction: SignedTransaction::new(signature, tx), + wait_until: TxExecutionStatus::Final, + }) + .await + { + Ok(res) => res, + Err(e) => { + loop { + if !matches!(e.handler_error(), Some(RpcTransactionError::TimeoutError)) { + return Err(e.into()); + } + + // Poll with exponential backoff + let mut poll_interval = Duration::from_millis(500); + + loop { + if Instant::now() >= deadline { + return Err(RpcError::TimeoutError( + timeout, + called_at.elapsed().as_secs(), + )); + } + + tokio::time::sleep(poll_interval).await; + + // Exponential backoff up to MAX_POLL_INTERVAL + poll_interval = std::cmp::min(poll_interval * 2, MAX_POLL_INTERVAL); + + let status = client + .call(RpcTransactionStatusRequest { + transaction_info: TransactionInfo::TransactionId { + sender_account_id: signer.get_account_id(), + tx_hash, + }, + wait_until: TxExecutionStatus::Final, + }) + .await; + + let Err(e) = status else { + break; + }; + + if !matches!(e.handler_error(), Some(RpcTransactionError::TimeoutError)) { + return Err(e.into()); + } + } + } + } + }; + + let Some(outcome) = result.final_execution_outcome else { + return Err(RpcError::NoOutcome(tx_hash.to_string())); + }; + + Ok(outcome.into_outcome()) +} + +/// Checks if a transaction and all its receipts succeeded. +/// +/// A transaction can have status Success but contain failed receipts. +/// This function checks both the transaction status and all receipt outcomes. +/// +/// # Arguments +/// +/// * `outcome` - The final execution outcome from `send_tx` +/// +/// # Returns +/// +/// * `Ok(())` if transaction and all receipts succeeded +/// * `Err(String)` with error description if any receipt failed +/// +/// # Errors +/// +/// Returns an error if the transaction or any receipt failed +pub fn check_transaction_success(outcome: &FinalExecutionOutcomeView) -> Result<(), String> { + use near_primitives::views::ExecutionStatusView; + + // Check main transaction status + match &outcome.status { + FinalExecutionStatus::Failure(err) => { + return Err(format!("Transaction failed: {err:?}")); + } + FinalExecutionStatus::NotStarted => { + return Err("Transaction not started".to_string()); + } + FinalExecutionStatus::Started => { + return Err("Transaction still in progress".to_string()); + } + FinalExecutionStatus::SuccessValue(_) => { + // Continue to check receipts + } + } + + // Check all receipt outcomes + for receipt in &outcome.receipts_outcome { + match &receipt.outcome.status { + ExecutionStatusView::Failure(err) => { + // Try to extract the actual error message from TxExecutionError + let error_msg = extract_error_message(err); + return Err(format!("Receipt {} failed: {}", receipt.id, error_msg)); + } + ExecutionStatusView::Unknown => { + return Err(format!("Receipt {} status unknown", receipt.id)); + } + ExecutionStatusView::SuccessValue(_) | ExecutionStatusView::SuccessReceiptId(_) => { + // This receipt succeeded, continue checking others + } + } + } + + Ok(()) +} + +/// Extracts a human-readable error message from `TxExecutionError` +fn extract_error_message(err: &near_primitives::errors::TxExecutionError) -> String { + use near_primitives::errors::{ActionErrorKind, TxExecutionError}; + + match err { + TxExecutionError::ActionError(action_err) => { + match &action_err.kind { + ActionErrorKind::FunctionCallError(fc_err) => { + // Extract the actual contract panic message + format!("{fc_err:?}") + } + other => format!("{other:?}"), + } + } + TxExecutionError::InvalidTxError(_) => format!("{err:?}"), + } +} + +/// List all deployments from a single registry contract. +/// +/// Fetches all markets in pages of 500 until no more results. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `registry` - Registry contract account +/// * `_count` - Unused (kept for API compatibility) +/// * `_offset` - Unused (kept for API compatibility) +/// +/// # Returns +/// +/// Vector of all deployed market accounts +#[tracing::instrument(skip(client), level = "debug")] +#[allow(clippy::used_underscore_binding)] +pub async fn list_deployments( + client: &JsonRpcClient, + registry: AccountId, + _count: Option, + _offset: Option, +) -> RpcResult> { + let mut all_deployments = Vec::new(); + let page_size = 500; + let mut current_offset = 0; + + loop { + let params = near_sdk::serde_json::json!({ + "offset": current_offset, + "count": page_size, + }); + + let page = + view::>(client, registry.clone(), "list_deployments", params).await?; + + let fetched = page.len(); + + if fetched == 0 { + break; + } + + all_deployments.extend(page); + current_offset += fetched; + + if fetched < page_size { + break; + } + } + + Ok(all_deployments) +} + +/// List all deployments from multiple registry contracts concurrently. +/// +/// # Arguments +/// +/// * `client` - JSON-RPC client instance +/// * `registries` - Vector of registry contract accounts +/// * `concurrency` - Maximum number of concurrent requests +/// +/// # Returns +/// +/// Vector of all deployed market accounts from all registries +#[tracing::instrument(skip(client), level = "debug")] +pub async fn list_all_deployments( + client: JsonRpcClient, + registries: Vec, + concurrency: usize, +) -> RpcResult> { + let all_markets: Vec = futures::stream::iter(registries) + .map(|registry| { + let client = client.clone(); + async move { list_deployments(&client, registry, None, None).await } + }) + .buffer_unordered(concurrency) + .try_concat() + .await?; + + Ok(all_markets) +} + +#[cfg(test)] +mod tests { + use super::*; + use near_sdk::serde_json::json; + + #[test] + fn test_serialize_and_encode() { + let data = json!({"key": "value", "number": 42}); + let encoded = serialize_and_encode(&data); + + // Should be valid JSON bytes + assert!(!encoded.is_empty()); + + // Should be able to deserialize back + let decoded: near_sdk::serde_json::Value = + near_sdk::serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded["key"], "value"); + assert_eq!(decoded["number"], 42); + } + + #[test] + fn test_serialize_and_encode_empty_object() { + let data = json!({}); + let encoded = serialize_and_encode(&data); + assert_eq!(encoded, b"{}"); + } + + #[test] + fn test_serialize_and_encode_array() { + let data = json!([1, 2, 3]); + let encoded = serialize_and_encode(&data); + let decoded: Vec = near_sdk::serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded, vec![1, 2, 3]); + } + + #[test] + fn test_network_display() { + assert_eq!(Network::Mainnet.to_string(), "mainnet"); + assert_eq!(Network::Testnet.to_string(), "testnet"); + } + + #[test] + fn test_network_rpc_url() { + assert_eq!(Network::Mainnet.rpc_url(), NEAR_MAINNET_RPC_URL); + assert_eq!(Network::Testnet.rpc_url(), NEAR_TESTNET_RPC_URL); + } + + #[test] + fn test_network_default() { + let network = Network::default(); + assert_eq!(network.to_string(), "testnet"); + } + + #[test] + fn test_rpc_error_display() { + let error = RpcError::WrongResponseKind("unexpected type".to_string()); + let display = format!("{error}"); + assert!(display.contains("unexpected type")); + } + + #[test] + fn test_app_error_from_rpc_error() { + let rpc_error = RpcError::WrongResponseKind("test".to_string()); + let app_error: AppError = rpc_error.into(); + let display = format!("{app_error}"); + assert!(display.contains("RPC error")); + } + + #[test] + fn test_timeout_error_display() { + let error = RpcError::TimeoutError(60, 65); + let display = format!("{error}"); + assert!(display.contains("60")); + assert!(display.contains("65")); + } +} diff --git a/bots/liquidator/src/scanner.rs b/bots/liquidator/src/scanner.rs new file mode 100644 index 00000000..b61316fe --- /dev/null +++ b/bots/liquidator/src/scanner.rs @@ -0,0 +1,195 @@ +//! Market position scanner module. +//! +//! Handles scanning markets for borrow positions and checking liquidation status. + +use near_jsonrpc_client::JsonRpcClient; +use near_sdk::{serde_json::json, AccountId}; +use std::collections::HashMap; +use templar_common::{ + borrow::{BorrowPosition, BorrowStatus}, + oracle::pyth::OracleResponse, +}; +use tracing::{debug, info}; + +use crate::{ + rpc::{view, RpcError}, + LiquidatorError, LiquidatorResult, +}; + +/// Type alias for borrow positions map +pub type BorrowPositions = HashMap; + +/// Market position scanner. +/// +/// Responsible for: +/// - Fetching all borrow positions from a market +/// - Checking liquidation status of positions +/// - Pagination handling for large markets +/// - Market version compatibility checking (NEP-330) +pub struct MarketScanner { + client: JsonRpcClient, + market: AccountId, +} + +impl MarketScanner { + /// Minimum supported contract version (semver). + /// Markets with version < 1.0.0 will be skipped. + pub const MIN_SUPPORTED_VERSION: (u32, u32, u32) = (1, 0, 0); +} + +impl MarketScanner { + /// Creates a new market scanner. + pub fn new(client: JsonRpcClient, market: AccountId) -> Self { + Self { client, market } + } + + /// Fetches borrow status for an account. + #[tracing::instrument(skip(self, oracle_response), level = "debug")] + pub async fn get_borrow_status( + &self, + account_id: &AccountId, + oracle_response: &OracleResponse, + ) -> Result, RpcError> { + view( + &self.client, + self.market.clone(), + "get_borrow_status", + &json!({ + "account_id": account_id, + "oracle_response": oracle_response, + }), + ) + .await + } + + /// Fetches all borrow positions from the market with pagination. + #[tracing::instrument(skip(self), level = "debug")] + pub async fn get_all_borrows(&self) -> LiquidatorResult { + let mut all_positions: BorrowPositions = HashMap::new(); + let page_size = 500; + let mut current_offset = 0; + + loop { + let page: BorrowPositions = view( + &self.client, + self.market.clone(), + "list_borrow_positions", + json!({ + "offset": current_offset, + "count": page_size, + }), + ) + .await + .map_err(LiquidatorError::ListBorrowPositionsError)?; + + let fetched = page.len(); + if fetched == 0 { + break; + } + + debug!( + market = %self.market, + offset = current_offset, + fetched = fetched, + "Fetched borrow positions page" + ); + + all_positions.extend(page); + current_offset += fetched; + + if fetched < page_size { + break; + } + } + + info!( + market = %self.market, + total_positions = all_positions.len(), + "Fetched all borrow positions" + ); + + Ok(all_positions) + } + + /// Checks if a position is liquidatable. + /// + /// Returns `Some(reason)` if the position is liquidatable with the liquidation reason, + /// or `None` if the position is not liquidatable. + /// + /// # Errors + /// + /// Returns an error if the borrow status cannot be fetched + pub async fn is_liquidatable( + &self, + account_id: &AccountId, + oracle_response: &OracleResponse, + ) -> LiquidatorResult> { + let status = self + .get_borrow_status(account_id, oracle_response) + .await + .map_err(LiquidatorError::FetchBorrowStatus)?; + + match status { + Some(BorrowStatus::Liquidation(reason)) => Ok(Some(format!("{reason:?}"))), + Some(_) | None => Ok(None), + } + } + + /// Tests if the market is compatible by verifying its version via NEP-330. + /// + /// # Errors + /// + /// Returns an error if the market version is not supported, including the + /// actual version and minimum required version in the error message. + #[tracing::instrument(skip(self), level = "debug")] + pub async fn test_market_compatibility(&self) -> LiquidatorResult<()> { + use crate::rpc::get_contract_version; + + let Some(version_string) = get_contract_version(&self.client, &self.market).await else { + info!( + market = %self.market, + "Contract does not implement NEP-330 (contract_source_metadata), assuming compatible" + ); + return Ok(()); + }; + + // Parse semver (e.g., "1.2.3" or "0.1.0") + let parts: Vec<&str> = version_string.split('.').collect(); + let (major, minor, patch) = if let [maj, min, pat] = parts.as_slice() { + let major = maj.parse::().unwrap_or(0); + let minor = min.parse::().unwrap_or(0); + let patch = pat.parse::().unwrap_or(0); + (major, minor, patch) + } else { + info!( + market = %self.market, + version = %version_string, + "Invalid semver format, assuming compatible" + ); + return Ok(()); + }; + + let is_compatible = (major, minor, patch) >= Self::MIN_SUPPORTED_VERSION; + + if is_compatible { + info!( + market = %self.market, + version = %version_string, + "Market is compatible and supported" + ); + Ok(()) + } else { + let (min_major, min_minor, min_patch) = Self::MIN_SUPPORTED_VERSION; + let error_msg = format!( + "Market version {version_string} is not supported (minimum required: {min_major}.{min_minor}.{min_patch})" + ); + info!( + market = %self.market, + version = %version_string, + min_version = %format!("{min_major}.{min_minor}.{min_patch}"), + "Skipping market - unsupported contract version" + ); + Err(LiquidatorError::StrategyError(error_msg)) + } + } +} diff --git a/bots/liquidator/src/service.rs b/bots/liquidator/src/service.rs new file mode 100644 index 00000000..8b219842 --- /dev/null +++ b/bots/liquidator/src/service.rs @@ -0,0 +1,576 @@ +//! Liquidator service lifecycle management. +//! +//! This module handles the bot's main operational loop including: +//! - Registry refresh (discovering and validating markets) +//! - Inventory refresh (updating asset balances) +//! - Liquidation rounds (scanning and executing liquidations) + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use near_crypto::{InMemorySigner, Signer}; +use near_jsonrpc_client::JsonRpcClient; +use near_sdk::AccountId; +use tokio::{ + select, + sync::RwLock, + time::{interval, sleep, Duration as TokioDuration, MissedTickBehavior}, +}; +use tracing::Instrument; + +use crate::{ + inventory::InventoryManager, + liquidation_strategy::LiquidationStrategy, + rebalancer::InventoryRebalancer, + rpc::{list_all_deployments, view, Network}, + CollateralStrategy, Liquidator, LiquidatorError, +}; + +/// Configuration for the liquidator service +#[derive(Debug)] +pub struct ServiceConfig { + /// Market registries to monitor + pub registries: Vec, + /// Signer key for transactions + pub signer_key: near_crypto::SecretKey, + /// Signer account ID + pub signer_account: AccountId, + /// Network to operate on + pub network: Network, + /// RPC URL + pub rpc_url: Option, + /// Transaction timeout in seconds + pub transaction_timeout: u64, + /// Interval between liquidation scans in seconds + pub liquidation_scan_interval: u64, + /// Registry refresh interval in seconds + pub registry_refresh_interval: u64, + /// Concurrency for liquidations + pub concurrency: usize, + /// Liquidation strategy + pub strategy: Arc, + /// Collateral strategy + pub collateral_strategy: CollateralStrategy, + /// Dry run mode - scan without executing + pub dry_run: bool, + /// `OneClick` API token for swap authentication + pub oneclick_api_token: Option, + /// Ref Finance contract address for NEP-141 swaps + pub ref_contract: Option, + /// Collateral asset allowlist for market filtering + pub allowed_collateral_assets: + Vec>, + /// Collateral assets to ignore in market filtering + pub ignored_collateral_assets: + Vec>, +} + +/// Liquidator service that manages the bot lifecycle +pub struct LiquidatorService { + config: ServiceConfig, + client: JsonRpcClient, + signer: Signer, + inventory: Arc>, + markets: HashMap, + /// Swap provider used by rebalancer + #[allow(dead_code)] + oneclick_provider: Option, + rebalancer: InventoryRebalancer, +} + +impl LiquidatorService { + /// Create a new liquidator service + pub fn new(config: ServiceConfig) -> Self { + let rpc_url = config + .rpc_url + .as_deref() + .unwrap_or_else(|| config.network.rpc_url()); + + tracing::info!(rpc_url = %rpc_url, "Connecting to RPC"); + + let client = JsonRpcClient::connect(rpc_url); + let signer = InMemorySigner::from_secret_key( + config.signer_account.clone(), + config.signer_key.clone(), + ); + + let inventory = Arc::new(RwLock::new(InventoryManager::new( + client.clone(), + config.signer_account.clone(), + ))); + + // Create swap provider for rebalancer + let (_, oneclick_provider) = + Self::create_swap_providers(&config, &client, Arc::new(signer.clone())); + + // Initialize rebalancer with swap provider + let rebalancer = InventoryRebalancer::new( + inventory.clone(), + oneclick_provider.clone(), + config.collateral_strategy.clone(), + config.dry_run, + ); + + Self { + config, + client, + signer, + inventory, + markets: HashMap::new(), + oneclick_provider, + rebalancer, + } + } + + /// Creates swap providers for collateral rebalancing + fn create_swap_providers( + config: &ServiceConfig, + client: &JsonRpcClient, + signer: Arc, + ) -> ( + Option, + Option, + ) { + use crate::swap::{OneClickSwap, RefSwap, SwapProviderImpl}; + + // No swap providers needed for Hold strategy + if matches!(config.collateral_strategy, CollateralStrategy::Hold) { + tracing::info!("Collateral strategy is Hold, no swap providers needed"); + return (None, None); + } + + tracing::info!("Creating swap providers for collateral rebalancing"); + + // Initialize Ref Finance provider for NEP-141 tokens + let ref_provider = if let Some(ref contract_str) = config.ref_contract { + match contract_str.parse::() { + Ok(contract) => { + let ref_swap = RefSwap::new(contract.clone(), client.clone(), signer.clone()); + tracing::info!( + contract = %contract, + "Ref Finance provider initialized" + ); + Some(SwapProviderImpl::ref_finance(ref_swap)) + } + Err(e) => { + tracing::error!( + contract = %contract_str, + error = ?e, + "Invalid REF_CONTRACT address" + ); + None + } + } + } else { + tracing::warn!( + "REF_CONTRACT not configured - set to v2.ref-finance.near (mainnet) or ref-finance-101.testnet" + ); + None + }; + + // Initialize OneClick provider for NEP-245 and NEP-141 tokens + let oneclick_provider = { + let oneclick = OneClickSwap::new( + client.clone(), + signer, + None, + config.oneclick_api_token.clone(), + ); + if config.oneclick_api_token.is_some() { + tracing::info!("1-Click API provider initialized with authentication"); + } else { + tracing::warn!( + "1-Click API provider initialized without authentication (0.1% fee applies)" + ); + } + Some(SwapProviderImpl::oneclick(oneclick)) + }; + + (ref_provider, oneclick_provider) + } + + /// Run the service event loop + pub async fn run(mut self) { + // Create intervals for periodic tasks + let mut registry_interval = interval(TokioDuration::from_secs( + self.config.registry_refresh_interval, + )); + registry_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + let mut liquidation_interval = interval(TokioDuration::from_secs( + self.config.liquidation_scan_interval, + )); + liquidation_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + // Run initial registry refresh immediately + match self.refresh_registry().await { + Ok(()) => { + tracing::info!("Initial registry refresh completed successfully"); + } + Err(e) => { + tracing::error!( + error = %e, + "Initial registry refresh failed, will retry later" + ); + } + } + + // Reset the registry interval to start timing from now + registry_interval.reset(); + + loop { + select! { + _ = registry_interval.tick() => { + match self.refresh_registry().await { + Ok(()) => { + tracing::info!("Registry refresh completed successfully"); + } + Err(e) => { + if is_rate_limit_error(&e) { + tracing::error!( + error = %e, + "Rate limit hit during registry refresh, will retry in 60 seconds" + ); + // Reset interval to retry in 60 seconds + registry_interval.reset_after(TokioDuration::from_secs(60)); + } else { + tracing::error!( + error = %e, + "Registry refresh failed, will retry in 5 minutes" + ); + // Reset interval to retry in 5 minutes + registry_interval.reset_after(TokioDuration::from_secs(300)); + } + + if self.markets.is_empty() { + tracing::warn!("No markets available yet, skipping liquidation round"); + continue; + } + } + } + } + _ = liquidation_interval.tick() => { + // Refresh borrow asset inventory before liquidations + self.refresh_inventory().await; + + // Run liquidation round + self.run_liquidation_round().await; + + // Refresh collateral inventory after liquidations + match self.inventory.write().await.refresh_collateral().await { + Ok(balances) => { + let count = balances.len(); + if count > 0 { + tracing::info!( + collateral_asset_count = count, + "Collateral inventory refreshed" + ); + } + } + Err(e) => { + tracing::warn!( + error = ?e, + "Failed to refresh collateral inventory" + ); + } + } + + // Rebalance inventory based on collateral strategy + self.rebalancer.rebalance().await; + + tracing::info!( + interval_seconds = self.config.liquidation_scan_interval, + "Liquidation round completed" + ); + } + } + } + } + + /// Check if a market should be processed based on asset filtering rules. + /// + /// Returns (`should_process`, `reason_if_filtered`). + fn should_process_market( + &self, + config: &templar_common::market::MarketConfiguration, + ) -> (bool, Option) { + let collateral_asset = &config.collateral_asset; + + // Check ignore list + if !self.config.ignored_collateral_assets.is_empty() { + for ignored_asset in &self.config.ignored_collateral_assets { + if collateral_asset == ignored_asset { + return ( + false, + Some(format!("collateral '{collateral_asset}' is in ignore list")), + ); + } + } + } + + // Check allow list + if !self.config.allowed_collateral_assets.is_empty() { + let is_allowed = self + .config + .allowed_collateral_assets + .iter() + .any(|allowed_asset| collateral_asset == allowed_asset); + + if !is_allowed { + return ( + false, + Some(format!("collateral '{collateral_asset}' not in allowlist")), + ); + } + } + + (true, None) + } + + /// Refresh the market registry + #[allow(clippy::too_many_lines)] + async fn refresh_registry(&mut self) -> Result<(), LiquidatorError> { + let refresh_span = tracing::debug_span!("registry_refresh"); + + async { + tracing::info!( + registries = ?self.config.registries, + "Refreshing registry deployments" + ); + + let all_markets = list_all_deployments( + self.client.clone(), + self.config.registries.clone(), + self.config.concurrency, + ) + .await + .map_err(LiquidatorError::ListDeploymentsError)?; + + tracing::info!( + market_count = all_markets.len(), + markets = ?all_markets, + "Found deployments from registries" + ); + + // Fetch configurations for all markets + let mut market_configs = Vec::new(); + for market in &all_markets { + // Check contract version using NEP-330 + let version_result = crate::rpc::get_contract_version(&self.client, market).await; + + if let Some(version) = version_result { + // Parse semver and verify compatibility + let parts: Vec<&str> = version.split('.').collect(); + let is_supported = if let [maj, min, _patch] = parts.as_slice() { + let major = maj.parse::().unwrap_or(0); + let minor = min.parse::().unwrap_or(0); + (major, minor) >= (1, 0) + } else { + tracing::warn!( + market = %market, + version = %version, + "Invalid semver format, skipping" + ); + false + }; + + if !is_supported { + tracing::info!( + market = %market, + version = %version, + min_required = "1.0.0", + "Skipping market - unsupported version" + ); + continue; + } + } else { + tracing::info!( + market = %market, + "Contract missing NEP-330 metadata, skipping" + ); + continue; + } + + // Fetch market configuration + match view::( + &self.client, + market.clone(), + "get_configuration", + near_sdk::serde_json::json!({}), + ) + .await + { + Ok(config) => { + tracing::debug!( + market = %market, + borrow_asset = %config.borrow_asset, + collateral_asset = %config.collateral_asset, + "Fetched market configuration" + ); + + // Apply market filtering rules + let (should_process, filter_reason) = self.should_process_market(&config); + + if should_process { + market_configs.push((market.clone(), config)); + } else { + tracing::info!( + market = %market, + collateral_asset = %config.collateral_asset, + reason = filter_reason.unwrap_or_default(), + "Market filtered out" + ); + } + } + Err(e) => { + tracing::warn!( + market = %market, + error = ?e, + "Failed to fetch market configuration, skipping" + ); + } + } + } + + // Discover assets from all market configurations + { + let mut inventory_guard = self.inventory.write().await; + inventory_guard.discover_assets(market_configs.iter().map(|(_, config)| config)); + inventory_guard + .discover_collateral_assets(market_configs.iter().map(|(_, config)| config)); + } + + // Create liquidators for each market + let mut supported_markets = HashMap::new(); + let mut unsupported_markets = Vec::new(); + + for (market, config) in market_configs { + tracing::debug!(market = %market, "Creating liquidator for market"); + + // Clone Signer enum + let signer = Arc::new(self.signer.clone()); + + let liquidator = Liquidator::new( + &self.client, + signer, + &self.inventory, + market.clone(), + config, + self.config.strategy.clone(), + self.config.collateral_strategy.clone(), + self.config.transaction_timeout, + self.config.dry_run, + None, + ); + + // Test market compatibility + match liquidator.scanner().test_market_compatibility().await { + Ok(()) => { + supported_markets.insert(market, liquidator); + } + Err(_) => { + unsupported_markets.push(market); + } + } + } + + if !unsupported_markets.is_empty() { + tracing::debug!( + unsupported_count = unsupported_markets.len(), + unsupported = ?unsupported_markets, + "Filtered out unsupported markets" + ); + } + + self.markets = supported_markets; + Ok(()) + } + .instrument(refresh_span) + .await + } + + /// Refresh inventory balances + async fn refresh_inventory(&self) { + let inventory_span = tracing::debug_span!("inventory_refresh"); + + async { + // Refresh borrow assets + match self.inventory.write().await.refresh().await { + Ok(refreshed) => { + tracing::debug!( + refreshed_count = refreshed, + "Borrow inventory refresh completed" + ); + } + Err(e) => { + tracing::warn!( + error = ?e, + "Failed to refresh borrow inventory" + ); + } + } + } + .instrument(inventory_span) + .await; + } + + /// Run a single liquidation round across all markets + async fn run_liquidation_round(&self) { + let liquidation_span = tracing::debug_span!("liquidation_round"); + + async { + for (i, (market, liquidator)) in self.markets.iter().enumerate() { + let market_span = tracing::debug_span!("market", market = %market); + + let result = async { + tracing::info!(market = %market, "Scanning market for liquidations"); + liquidator.run_liquidations(self.config.concurrency).await + } + .instrument(market_span) + .await; + + // Handle errors gracefully + match result { + Ok(()) => { + tracing::info!(market = %market, "Market scan completed"); + } + Err(e) => { + if is_rate_limit_error(&e) { + tracing::error!( + market = %market, + error = %e, + "Rate limit hit while scanning market, sleeping 60 seconds before continuing" + ); + sleep(Duration::from_secs(60)).await; + } else { + tracing::error!( + market = %market, + error = %e, + "Failed to scan market, continuing to next market" + ); + } + } + } + + // Add delay between markets to avoid rate limiting (except after last market) + if i < self.markets.len() - 1 { + let delay_seconds = 5; + tracing::debug!( + "Waiting {}s before next market to avoid rate limits", + delay_seconds + ); + sleep(Duration::from_secs(delay_seconds)).await; + } + } + } + .instrument(liquidation_span) + .await; + } +} + +/// Check if an error is a rate limit error +fn is_rate_limit_error(error: &LiquidatorError) -> bool { + let error_msg = error.to_string(); + error_msg.contains("TooManyRequests") + || error_msg.contains("429") + || error_msg.contains("rate limit") +} diff --git a/bots/liquidator/src/swap/mod.rs b/bots/liquidator/src/swap/mod.rs new file mode 100644 index 00000000..5e13ac3b --- /dev/null +++ b/bots/liquidator/src/swap/mod.rs @@ -0,0 +1,163 @@ +//! Swap provider implementations for liquidation operations. +//! +//! This module provides a flexible, extensible architecture for integrating +//! different swap/exchange protocols (Ref Finance, 1-Click API, etc.) used +//! during liquidation operations. +//! +//! # Architecture +//! +//! The module follows the Strategy pattern to allow runtime selection of swap +//! providers while maintaining a consistent interface. This enables: +//! - Easy addition of new swap providers without modifying existing code +//! - Testability through mock implementations +//! - Type-safe asset handling across different token standards (NEP-141, NEP-245) +//! +//! # Example +//! +//! ```no_run +//! use templar_bots::swap::{SwapProvider, RefSwap}; +//! use near_jsonrpc_client::JsonRpcClient; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); +//! let swap_provider = RefSwap::new( +//! "v2.ref-finance.near".parse()?, +//! client, +//! signer, +//! ); +//! +//! // Get quote +//! let quote = swap_provider.quote(&from_asset, &to_asset, output_amount).await?; +//! +//! // Execute swap +//! let result = swap_provider.swap(&from_asset, &to_asset, quote).await?; +//! # Ok(()) +//! # } +//! ``` + +pub mod oneclick; +pub mod provider; +pub mod r#ref; + +// Re-export for convenience +pub use oneclick::OneClickSwap; +pub use provider::SwapProviderImpl; +pub use r#ref::RefSwap; + +use near_primitives::views::FinalExecutionStatus; +use near_sdk::AccountId; +use templar_common::asset::{AssetClass, FungibleAsset, FungibleAssetAmount}; + +use crate::rpc::AppResult; + +/// Core trait for swap provider implementations. +/// +/// This trait defines the interface that all swap providers must implement, +/// enabling polymorphic usage of different DEX protocols. +/// +/// # Type Safety +/// +/// The trait uses generic `AssetClass` bounds to ensure compile-time type safety +/// when working with different asset types (collateral vs borrow assets). +/// +/// # Object Safety +/// +/// This trait is object-safe, allowing for dynamic dispatch via `Box`. +#[async_trait::async_trait] +pub trait SwapProvider: Send + Sync { + /// Quotes the input amount needed to obtain a specific output amount. + /// + /// # Arguments + /// + /// * `from_asset` - The asset to swap from + /// * `to_asset` - The asset to swap to + /// * `output_amount` - The desired output amount + /// + /// # Returns + /// + /// The input amount required to obtain the desired output amount, + /// including slippage and fees. + /// + /// # Errors + /// + /// Returns `AppError` if: + /// - The asset pair is not supported + /// - The liquidity is insufficient + /// - The RPC call fails + async fn quote( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + output_amount: FungibleAssetAmount, + ) -> AppResult>; + + /// Executes a swap operation. + /// + /// # Arguments + /// + /// * `from_asset` - The asset to swap from + /// * `to_asset` - The asset to swap to + /// * `amount` - The input amount to swap + /// + /// # Returns + /// + /// The final execution status of the swap transaction. + /// + /// # Errors + /// + /// Returns `AppError` if: + /// - The transaction fails to execute + /// - The slippage exceeds acceptable limits + /// - The deadline is exceeded + async fn swap( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + amount: FungibleAssetAmount, + ) -> AppResult; + + /// Returns the name of the swap provider for logging and debugging. + fn provider_name(&self) -> &'static str; + + /// Returns a debug representation of the provider. + fn debug_name(&self) -> String { + self.provider_name().to_string() + } + + /// Checks if the provider supports a given asset pair. + /// + /// # Default Implementation + /// + /// The default implementation returns `true` for all pairs. Providers + /// should override this if they have specific asset restrictions. + fn supports_assets( + &self, + _from_asset: &FungibleAsset, + _to_asset: &FungibleAsset, + ) -> bool { + true + } + + /// Ensures an account is registered with a token contract's storage. + /// + /// This method calls `storage_deposit` on the token contract to register + /// the account before it can receive tokens. This is required by NEP-141. + /// + /// # Arguments + /// + /// * `token_contract` - The token contract to register with + /// * `account_id` - The account to register + /// + /// # Returns + /// + /// Returns `Ok(())` if registration succeeds or the account is already registered. + /// + /// # Errors + /// + /// Returns `AppError` if the registration transaction fails. + async fn ensure_storage_registration( + &self, + token_contract: &FungibleAsset, + account_id: &AccountId, + ) -> AppResult<()>; +} diff --git a/bots/liquidator/src/swap/oneclick.rs b/bots/liquidator/src/swap/oneclick.rs new file mode 100644 index 00000000..4851cc26 --- /dev/null +++ b/bots/liquidator/src/swap/oneclick.rs @@ -0,0 +1,1078 @@ +//! 1-Click API swap provider for NEAR Intents. +//! +//! Provides swap functionality using the 1-Click API, which simplifies +//! NEAR Intents cross-chain swaps through a REST interface. +//! +//! ## Three-phase process: +//! 1. **Quote**: Request quote and receive deposit address +//! 2. **Deposit**: Transfer tokens to deposit address +//! 3. **Poll**: Monitor swap status until completion + +use near_crypto::Signer; +use near_jsonrpc_client::JsonRpcClient; +use near_primitives::views::FinalExecutionStatus; +use near_sdk::{ + json_types::U128, + serde::{Deserialize, Serialize}, +}; +use std::sync::Arc; +use templar_common::asset::{AssetClass, FungibleAsset, FungibleAssetAmount}; +use tracing::{debug, error, info, warn}; + +use crate::rpc::{get_access_key_data, send_tx, view, AppError, AppResult}; +use crate::swap::SwapProvider; + +use near_account_id::AccountType; +use near_primitives::{ + action::Action, + transaction::{Transaction, TransactionV0}, + types::AccountId, +}; + +/// 1-Click API base URL +const ONECLICK_API_BASE: &str = "https://1click.chaindefuser.com"; + +/// Default maximum slippage in basis points (3% = 300 bps) +pub const DEFAULT_MAX_SLIPPAGE_BPS: u32 = 300; + +/// Default transaction timeout in seconds +const DEFAULT_TIMEOUT: u64 = 120; + +/// Polling interval for swap status checks in seconds +const POLL_INTERVAL_SECONDS: u64 = 10; + +/// Maximum time to wait for swap completion in seconds (4 minutes) +const MAX_SWAP_WAIT_SECONDS: u64 = 240; + +/// Swap type for the 1-Click API +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SwapType { + /// Exact input amount, variable output + ExactInput, + /// Exact output amount, variable input + ExactOutput, + /// Flexible input amount + FlexInput, + /// Any input amount + AnyInput, +} + +/// Storage balance bounds from NEP-145 +#[derive(Debug, Deserialize)] +struct StorageBalanceBounds { + /// Minimum storage deposit required + min: U128, + /// Maximum storage deposit allowed (optional) + #[allow(dead_code)] + max: Option, +} + +/// Quote request for the 1-Click API +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct QuoteRequest { + /// If true, simulates quote without generating deposit address + dry: bool, + /// Deposit mode: SIMPLE or MEMO + deposit_mode: String, + /// Type of swap + swap_type: SwapType, + /// Slippage tolerance in basis points + slippage_tolerance: u32, + /// Origin asset ID (format: `nep141:CONTRACT_ID`) + origin_asset: String, + /// Deposit type: `ORIGIN_CHAIN` + deposit_type: String, + /// Destination asset ID (format: `nep141:CONTRACT_ID`) + destination_asset: String, + /// Amount in smallest unit + amount: String, + /// Refund address + refund_to: String, + /// Refund type: `ORIGIN_CHAIN` + refund_type: String, + /// Recipient address + recipient: String, + /// Recipient type: `DESTINATION_CHAIN` + recipient_type: String, + /// Deadline as ISO timestamp + deadline: String, + /// Referral identifier (optional, lowercase only) + #[serde(skip_serializing_if = "Option::is_none")] + referral: Option, + /// Quote waiting time in milliseconds + #[serde(skip_serializing_if = "Option::is_none")] + quote_waiting_time_ms: Option, +} + +/// Quote details from the 1-Click API +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct Quote { + /// Address to deposit tokens to + deposit_address: String, + /// Optional memo for deposit + deposit_memo: Option, + /// Actual input amount (may differ from requested) + amount_in: String, + /// Formatted input amount + #[allow(dead_code)] + amount_in_formatted: String, + /// Input amount in USD + #[serde(skip_serializing_if = "Option::is_none")] + #[allow(dead_code)] + amount_in_usd: Option, + /// Minimum input amount + #[allow(dead_code)] + min_amount_in: String, + /// Expected output amount + amount_out: String, + /// Formatted output amount + #[allow(dead_code)] + amount_out_formatted: String, + /// Output amount in USD + #[serde(skip_serializing_if = "Option::is_none")] + #[allow(dead_code)] + amount_out_usd: Option, + /// Minimum output amount + #[allow(dead_code)] + min_amount_out: String, + /// Deadline for the swap + #[allow(dead_code)] + deadline: String, + /// Time when quote becomes inactive + #[allow(dead_code)] + time_when_inactive: String, + /// Estimated time in seconds + time_estimate: u64, +} + +/// Quote response from the 1-Click API +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct QuoteResponse { + /// Timestamp of the quote + #[allow(dead_code)] + timestamp: String, + /// Signature for verification + #[allow(dead_code)] + signature: String, + /// The quote details + quote: Quote, +} + +/// Deposit submission request +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DepositSubmitRequest { + /// Transaction hash of the deposit + tx_hash: String, + /// Deposit address from quote + deposit_address: String, + /// NEAR sender account (optional) + #[serde(skip_serializing_if = "Option::is_none")] + near_sender_account: Option, + /// Memo if required + #[serde(skip_serializing_if = "Option::is_none")] + memo: Option, +} + +/// Swap status from the 1-Click API +#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SwapStatus { + /// Waiting for deposit + PendingDeposit, + /// Deposit transaction detected but not yet confirmed + KnownDepositTx, + /// Deposit received, processing swap + Processing, + /// Swap completed successfully + Success, + /// Deposit amount was incomplete + IncompleteDeposit, + /// Swap was refunded + Refunded, + /// Swap failed + Failed, +} + +/// Status response from the 1-Click API +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StatusResponse { + /// Current status + status: SwapStatus, + /// Last update timestamp (optional, can be null during early stages) + #[allow(dead_code)] + updated_at: Option, + /// Swap details (optional) + #[serde(skip_serializing_if = "Option::is_none")] + #[allow(dead_code)] + swap_details: Option, +} + +/// Detailed swap information +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SwapDetails { + /// Intent transaction hashes + #[serde(default)] + #[allow(dead_code)] + intent_hashes: Vec, + /// NEAR transaction hashes + #[serde(default)] + #[allow(dead_code)] + near_tx_hashes: Vec, + /// Actual input amount (`null` during `PENDING_DEPOSIT`) + #[allow(dead_code)] + amount_in: Option, + /// Formatted input amount (`null` during `PENDING_DEPOSIT`) + #[allow(dead_code)] + amount_in_formatted: Option, + /// USD value of input amount (`null` during `PENDING_DEPOSIT`) + #[allow(dead_code)] + amount_in_usd: Option, + /// Actual output amount (`null` during `PENDING_DEPOSIT`) + #[allow(dead_code)] + amount_out: Option, + /// Formatted output amount (`null` during `PENDING_DEPOSIT`) + #[allow(dead_code)] + amount_out_formatted: Option, + /// USD value of output amount (`null` during `PENDING_DEPOSIT`) + #[allow(dead_code)] + amount_out_usd: Option, + /// Slippage in basis points (`null` during `PENDING_DEPOSIT`) + #[allow(dead_code)] + slippage: Option, + /// Origin chain transaction hashes + #[serde(default)] + #[allow(dead_code)] + origin_chain_tx_hashes: Vec, + /// Destination chain transaction hashes + #[serde(default)] + #[allow(dead_code)] + destination_chain_tx_hashes: Vec, + /// Refunded amount if applicable + #[serde(skip_serializing_if = "Option::is_none")] + #[allow(dead_code)] + refunded_amount: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TxHashWithExplorer { + #[allow(dead_code)] + hash: String, + #[allow(dead_code)] + explorer_url: String, +} + +/// 1-Click API swap provider +#[derive(Debug, Clone)] +pub struct OneClickSwap { + /// NEAR RPC client + client: JsonRpcClient, + /// Transaction signer + signer: Arc, + /// Maximum slippage in basis points + max_slippage_bps: u32, + /// Transaction timeout + timeout: u64, + /// HTTP client for API calls + http_client: reqwest::Client, + /// Optional API token for fee reduction + api_token: Option, +} + +impl OneClickSwap { + /// Creates a new 1-Click API swap provider. + /// + /// # Arguments + /// + /// * `client` - NEAR RPC client for transaction submission + /// * `signer` - Transaction signer + /// * `max_slippage_bps` - Maximum slippage in basis points (default: 300 = 3%) + /// * `api_token` - Optional API token to avoid 0.1% fee + pub fn new( + client: JsonRpcClient, + signer: Arc, + max_slippage_bps: Option, + api_token: Option, + ) -> Self { + Self { + client, + signer, + max_slippage_bps: max_slippage_bps.unwrap_or(DEFAULT_MAX_SLIPPAGE_BPS), + timeout: DEFAULT_TIMEOUT, + http_client: reqwest::Client::new(), + api_token, + } + } + + /// Converts a `FungibleAsset` to 1-Click asset ID format. + /// + /// 1-Click asset identifiers follow the format: + /// - NEAR NEP-141: `nep141:` + /// + /// For NEP-245 tokens, we extract the underlying token ID. + fn to_oneclick_asset_id(asset: &FungibleAsset) -> String { + match asset.clone().into_nep141() { + Some(contract_id) => format!("nep141:{contract_id}"), + None => { + // NEP-245: extract underlying asset + if let Some((_, token_id)) = asset.clone().into_nep245() { + // Token ID should already be in format "nep141:..." + token_id.to_string() + } else { + // Fallback + format!("nep141:{}", asset.contract_id()) + } + } + } + } + + /// Requests a quote from the 1-Click API. + #[tracing::instrument(skip(self), level = "debug")] + async fn request_quote( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + input_amount: FungibleAssetAmount, + ) -> AppResult { + let from_asset_id = Self::to_oneclick_asset_id(from_asset); + let to_asset_id = Self::to_oneclick_asset_id(to_asset); + let recipient = self.signer.get_account_id().to_string(); + + info!( + from_contract = %from_asset.contract_id(), + to_contract = %to_asset.contract_id(), + from_oneclick_id = %from_asset_id, + to_oneclick_id = %to_asset_id, + "Converting assets to 1-Click format" + ); + + // Calculate deadline (30 minutes from now) + let deadline = chrono::Utc::now() + chrono::Duration::minutes(30); + let deadline_str = deadline.to_rfc3339(); + + // For liquidation bot, we always deposit via NEAR Intents contract + // since our bot runs on NEAR and holds NEP-141 tokens. + // ORIGIN_CHAIN would be used if we were depositing from another blockchain (e.g., ETH on Ethereum) + let deposit_type = "INTENTS"; + + let request = QuoteRequest { + dry: false, // We want a real quote with deposit address + deposit_mode: "SIMPLE".to_string(), + // For post-liquidation swaps, we use EXACT_INPUT because we're swapping + // the collateral we HAVE (received from liquidation), not requesting a specific output. + // EXACT_INPUT: we specify exact amount we want to swap, API tells us how much we'll receive + swap_type: SwapType::ExactInput, + slippage_tolerance: self.max_slippage_bps, + origin_asset: from_asset_id.clone(), + deposit_type: deposit_type.to_string(), + destination_asset: to_asset_id.clone(), + amount: u128::from(input_amount).to_string(), // Input amount we're swapping + refund_to: recipient.clone(), + // INTENTS: refunds go back to our NEAR account within Intents contract + refund_type: "INTENTS".to_string(), + recipient: recipient.clone(), + // INTENTS: swapped tokens delivered to our NEAR account within Intents contract + recipient_type: "INTENTS".to_string(), + deadline: deadline_str, + referral: Some("templar-liquidator".to_string()), // Track bot usage + quote_waiting_time_ms: Some(5000), // Wait up to 5 seconds for quote + }; + + let url = format!("{ONECLICK_API_BASE}/v0/quote"); + + // Log the full request for debugging + info!( + origin_asset = %request.origin_asset, + destination_asset = %request.destination_asset, + amount = %request.amount, + swap_type = ?request.swap_type, + "Sending quote request to 1-Click API" + ); + + let mut req = self.http_client.post(&url).json(&request); + + // Add API token if available + if let Some(token) = &self.api_token { + req = req.bearer_auth(token); + } + + let response = req.send().await.map_err(|e| { + error!(?e, "Failed to send quote request"); + AppError::ValidationError(format!("Quote request failed: {e}")) + })?; + + let status = response.status(); + let response_text = response.text().await.map_err(|e| { + error!(?e, "Failed to read response"); + AppError::ValidationError(format!("Failed to read response: {e}")) + })?; + + if !status.is_success() { + use reqwest::StatusCode; + let error_msg = match status { + StatusCode::BAD_REQUEST => { + format!("Bad Request - Invalid input data: {response_text}") + } + StatusCode::UNAUTHORIZED => { + format!("Unauthorized - JWT token is invalid or missing: {response_text}") + } + StatusCode::NOT_FOUND => { + format!("Not Found - Endpoint or resource not found: {response_text}") + } + _ => format!("Quote request failed with status {status}: {response_text}"), + }; + error!( + status = %status, + response = %response_text, + "Quote request failed" + ); + return Err(AppError::ValidationError(error_msg)); + } + + let quote_response: QuoteResponse = near_sdk::serde_json::from_str(&response_text) + .map_err(|e| { + error!(?e, response = %response_text, "Failed to parse quote response"); + AppError::ValidationError(format!("Invalid quote response: {e}")) + })?; + + info!( + amount_in = %quote_response.quote.amount_in, + amount_out = %quote_response.quote.amount_out, + min_amount_in = %quote_response.quote.min_amount_in, + min_amount_out = %quote_response.quote.min_amount_out, + deposit_address = %quote_response.quote.deposit_address, + time_estimate = %quote_response.quote.time_estimate, + "Quote received from 1-Click API" + ); + + Ok(quote_response) + } + + /// Registers storage for an account in a NEP-141 token contract. + async fn ensure_storage_deposit( + &self, + token_contract: &FungibleAsset, + account_id: &AccountId, + ) -> AppResult<()> { + use near_primitives::transaction::{Action, FunctionCallAction}; + use near_sdk::Gas; + + const MAX_REASONABLE_DEPOSIT: u128 = 100_000_000_000_000_000_000_000; // 0.1 NEAR + + info!( + token = %token_contract.contract_id(), + account = %account_id, + "Registering storage deposit for account" + ); + + // Query storage_balance_bounds to get minimum deposit required + let bounds: StorageBalanceBounds = view( + &self.client, + token_contract.contract_id().into(), + "storage_balance_bounds", + near_sdk::serde_json::json!({}), + ) + .await + .map_err(|e| { + error!(?e, token = %token_contract.contract_id(), "Failed to query storage_balance_bounds"); + AppError::Rpc(e) + })?; + + let min_deposit = bounds.min.0; + + // Validate minimum deposit is reasonable (less than 0.1 NEAR) + if min_deposit > MAX_REASONABLE_DEPOSIT { + return Err(AppError::ValidationError(format!( + "Storage deposit minimum ({min_deposit} yoctoNEAR) exceeds reasonable limit ({MAX_REASONABLE_DEPOSIT} yoctoNEAR / 0.1 NEAR)" + ))); + } + + #[allow(clippy::cast_precision_loss)] + let min_deposit_near = min_deposit as f64 / 1e24; + + info!( + token = %token_contract.contract_id(), + min_deposit_near = %min_deposit_near, + "Using storage deposit minimum from contract" + ); + + let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer).await?; + + let storage_deposit_action = FunctionCallAction { + method_name: "storage_deposit".to_string(), + args: near_sdk::serde_json::to_vec(&near_sdk::serde_json::json!({ + "account_id": account_id, + "registration_only": true, + })) + .map_err(|e| AppError::ValidationError(format!("Failed to serialize args: {e}")))?, + gas: Gas::from_tgas(10).as_gas(), + deposit: min_deposit, + }; + + let tx = Transaction::V0(TransactionV0 { + nonce, + receiver_id: token_contract.contract_id().into(), + block_hash, + signer_id: self.signer.get_account_id(), + public_key: self.signer.public_key().clone(), + actions: vec![Action::FunctionCall(Box::new(storage_deposit_action))], + }); + + let outcome = send_tx(&self.client, &self.signer, self.timeout, tx).await?; + + match outcome.status { + FinalExecutionStatus::SuccessValue(_) => { + info!( + account = %account_id, + "Storage deposit successful" + ); + Ok(()) + } + FinalExecutionStatus::Failure(failure) => { + // Storage deposit can fail if: + // 1. Already registered (common) + // 2. Contract doesn't support storage_deposit (NEP-245 multi-tokens) + // Both cases are fine - we can proceed with the transfer + debug!( + account = %account_id, + failure = ?failure, + "Storage deposit failed (likely already registered or not required)" + ); + Ok(()) + } + _ => { + debug!(status = ?outcome.status, "Unexpected storage deposit status"); + Ok(()) + } + } + } + + /// Deposits tokens to the 1-Click deposit address. + #[allow(clippy::too_many_lines)] + async fn deposit_tokens( + &self, + from_asset: &FungibleAsset, + deposit_address: &str, + amount: U128, + _memo: Option<&str>, + ) -> AppResult { + info!( + asset = %from_asset, + deposit_address = %deposit_address, + amount = %amount.0, + "Depositing tokens to 1-Click" + ); + + // Parse deposit address as NEAR account ID + let deposit_account: AccountId = deposit_address.parse().map_err(|e| { + error!(?e, deposit_address = %deposit_address, "Invalid deposit address"); + AppError::ValidationError(format!("Invalid deposit address: {e}")) + })?; + + // For implicit accounts, we need to ensure they exist first + // by sending a small amount of NEAR to create the account + if deposit_account.get_account_type() == AccountType::NearImplicitAccount { + info!( + deposit_account = %deposit_account, + "Creating implicit account with NEAR transfer" + ); + + let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer).await?; + + // Send 1 yoctoNEAR to create the implicit account (minimum amount needed) + let create_account_tx = Transaction::V0(TransactionV0 { + nonce, + receiver_id: deposit_account.clone(), + block_hash, + signer_id: self.signer.get_account_id(), + public_key: self.signer.public_key().clone(), + actions: vec![Action::Transfer(near_primitives::action::TransferAction { + deposit: 1, // 1 yoctoNEAR + })], + }); + + // Send transaction but don't fail if account already exists + match send_tx(&self.client, &self.signer, self.timeout, create_account_tx).await { + Ok(_) => { + info!( + deposit_account = %deposit_account, + "Implicit account created successfully" + ); + + // Wait for account creation to propagate (1-2 blocks) + // This prevents race conditions with storage registration + tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await; + } + Err(e) => { + // If account already exists, that's fine + warn!( + deposit_account = %deposit_account, + error = ?e, + "Failed to create implicit account (may already exist)" + ); + } + } + } + + // Ensure the deposit address is registered for storage + // Skip for NEP-245 tokens (they handle storage internally) + if from_asset.clone().into_nep141().is_some() { + self.ensure_storage_deposit(from_asset, &deposit_account) + .await?; + } else { + debug!( + token = %from_asset.contract_id(), + "Skipping storage_deposit for NEP-245 token (handles storage internally)" + ); + } + + // Get transaction parameters + let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer).await?; + + // Create deposit transaction + // Use simple ft_transfer (not ft_transfer_call) for INTENTS depositType + // because the implicit account doesn't have a contract to handle callbacks + let tx = Transaction::V0(TransactionV0 { + nonce, + receiver_id: from_asset.contract_id().into(), + block_hash, + signer_id: self.signer.get_account_id(), + public_key: self.signer.public_key().clone(), + actions: vec![Action::FunctionCall(Box::new( + from_asset.transfer_action(&deposit_account, amount.into()), + ))], + }); + + // Get the transaction hash before sending + let (tx_hash, _) = tx.get_hash_and_size(); + let tx_hash_str = tx_hash.to_string(); + + let outcome = send_tx(&self.client, &self.signer, self.timeout, tx).await?; + + match &outcome.status { + FinalExecutionStatus::SuccessValue(_) => { + info!("Deposit transaction succeeded"); + } + FinalExecutionStatus::Failure(failure) => { + error!( + failure = ?failure, + "Deposit transaction failed with detailed error" + ); + return Err(AppError::ValidationError(format!( + "Deposit transaction failed: {failure:?}" + ))); + } + _ => { + warn!(status = ?outcome.status, "Unexpected transaction status"); + } + }; + + // Check if the deposit was refunded by fetching transaction outcome and checking receipts + match self + .check_deposit_refunded(&tx_hash_str, &deposit_account, amount) + .await + { + Ok(Some(refund_amount)) => { + error!( + tx_hash = %tx_hash_str, + deposit_account = %deposit_account, + refund_amount = %refund_amount.0, + "Deposit was refunded - 1-Click rejected the deposit" + ); + return Err(AppError::ValidationError(format!( + "Deposit was refunded by 1-Click deposit address (amount: {})", + refund_amount.0 + ))); + } + Ok(None) => { + info!(tx_hash = %tx_hash_str, "Deposit was accepted (not refunded)"); + } + Err(e) => { + warn!( + error = ?e, + "Failed to check if deposit was refunded, assuming accepted" + ); + } + } + + Ok(tx_hash_str) + } + + /// Checks if a deposit was refunded by examining transaction receipts. + /// + /// Returns the amount refunded if the deposit was rejected, or None if successful. + async fn check_deposit_refunded( + &self, + tx_hash: &str, + deposit_account: &AccountId, + _amount: U128, + ) -> AppResult> { + use near_jsonrpc_client::methods::tx::{RpcTransactionStatusRequest, TransactionInfo}; + use near_primitives::views::TxExecutionStatus; + + // Parse tx hash + let tx_hash_parsed = tx_hash + .parse() + .map_err(|e| AppError::ValidationError(format!("Invalid tx hash: {e}")))?; + + // Fetch transaction outcome + let tx_result = self + .client + .call(RpcTransactionStatusRequest { + transaction_info: TransactionInfo::TransactionId { + sender_account_id: self.signer.get_account_id(), + tx_hash: tx_hash_parsed, + }, + wait_until: TxExecutionStatus::Final, + }) + .await + .map_err(|e| AppError::Rpc(e.into()))?; + + // Check receipt outcomes for token transfers + // If we see a transfer TO deposit_account followed by a transfer FROM deposit_account + // back to us, extract the refund amount + let mut tokens_sent = false; + let mut refund_amount: Option = None; + + // Get receipts from the transaction result + let receipts = match &tx_result.final_execution_outcome { + Some(outcome) => { + match outcome { + near_primitives::views::FinalExecutionOutcomeViewEnum::FinalExecutionOutcome(o) => { + &o.receipts_outcome + } + near_primitives::views::FinalExecutionOutcomeViewEnum::FinalExecutionOutcomeWithReceipt(_o) => { + // For this variant, we need to construct a vec with the single receipt + // Since we can't easily return different types, let's just return empty + // and check the transaction outcome logs instead + &Vec::new() + } + } + } + None => { + return Err(AppError::ValidationError( + "No execution outcome".to_string(), + )) + } + }; + + for receipt in receipts { + for log in &receipt.outcome.logs { + // Check for NEP-141 transfer events + if log.contains("EVENT_JSON") && log.contains("ft_transfer") { + // Parse the event to check direction and extract amount + if log.contains(&format!("\"new_owner_id\":\"{deposit_account}\"")) { + tokens_sent = true; + } + if log.contains(&format!("\"old_owner_id\":\"{deposit_account}\"")) + && log.contains(&format!( + "\"new_owner_id\":\"{}\"", + self.signer.get_account_id() + )) + { + // Extract amount from the event JSON + // Format: EVENT_JSON:{"standard":"nep141",...,"data":[{"amount":"..."}]} + if let Some(amount_str) = Self::extract_transfer_amount(log) { + if let Ok(amount_value) = amount_str.parse::() { + refund_amount = Some(U128(amount_value)); + } + } + } + } + } + } + + // Return refund amount if both sent and returned + if tokens_sent && refund_amount.is_some() { + Ok(refund_amount) + } else { + Ok(None) + } + } + + /// Extracts the transfer amount from a NEP-141 `EVENT_JSON` log entry. + fn extract_transfer_amount(log: &str) -> Option { + // Format: EVENT_JSON:{"standard":"nep141",...,"data":[{"amount":"12345",...}]} + // Find the "amount" field value + if let Some(amount_start) = log.find(r#""amount":""#) { + let amount_start = amount_start + r#""amount":""#.len(); + if let Some(amount_end) = log[amount_start..].find('"') { + return Some(log[amount_start..amount_start + amount_end].to_string()); + } + } + None + } + + /// Notifies 1-Click API of the deposit. + async fn submit_deposit( + &self, + tx_hash: &str, + deposit_address: &str, + memo: Option<&str>, + ) -> AppResult<()> { + let request = DepositSubmitRequest { + tx_hash: tx_hash.to_string(), + deposit_address: deposit_address.to_string(), + near_sender_account: Some(self.signer.get_account_id().to_string()), + memo: memo.map(String::from), + }; + + let url = format!("{ONECLICK_API_BASE}/v0/deposit/submit"); + let mut req = self.http_client.post(&url).json(&request); + + if let Some(token) = &self.api_token { + req = req.bearer_auth(token); + } + + let response = req.send().await.map_err(|e| { + error!(?e, "Failed to submit deposit"); + AppError::ValidationError(format!("Deposit submit failed: {e}")) + })?; + + if !response.status().is_success() { + use reqwest::StatusCode; + let status = response.status(); + let response_text = response.text().await.unwrap_or_default(); + let error_msg = match status { + StatusCode::BAD_REQUEST => { + format!("Bad Request - Invalid deposit data: {response_text}") + } + StatusCode::UNAUTHORIZED => { + format!("Unauthorized - JWT token is invalid: {response_text}") + } + StatusCode::NOT_FOUND => { + format!("Not Found - Deposit address not found: {response_text}") + } + _ => format!("Deposit submission failed with status {status}: {response_text}"), + }; + error!( + status = %status, + response = %response_text, + "Deposit submission failed" + ); + return Err(AppError::ValidationError(error_msg)); + } + + info!("Deposit submitted to 1-Click API"); + Ok(()) + } + + /// Polls the swap status until completion. + async fn poll_swap_status( + &self, + deposit_address: &str, + memo: Option<&str>, + max_wait_seconds: u64, + ) -> AppResult { + let max_attempts = max_wait_seconds / POLL_INTERVAL_SECONDS; + + info!( + deposit_address = %deposit_address, + max_wait_seconds = %max_wait_seconds, + "Polling swap status" + ); + + for attempt in 1..=max_attempts { + tokio::time::sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECONDS)).await; + + let mut url = format!("{ONECLICK_API_BASE}/v0/status?depositAddress={deposit_address}"); + if let Some(m) = memo { + url.push_str(&format!("&depositMemo={m}")); + } + + let mut req = self.http_client.get(&url); + if let Some(token) = &self.api_token { + req = req.bearer_auth(token); + } + + let response = match req.send().await { + Ok(r) => r, + Err(e) => { + warn!(?e, attempt = %attempt, "Failed to fetch status"); + continue; + } + }; + + if !response.status().is_success() { + use reqwest::StatusCode; + let status_code = response.status(); + let error_text = response.text().await.unwrap_or_default(); + match status_code { + StatusCode::UNAUTHORIZED => warn!( + attempt = %attempt, + "Unauthorized - JWT token may be invalid" + ), + StatusCode::NOT_FOUND => warn!( + attempt = %attempt, + deposit_address = %deposit_address, + "Deposit address not found - swap may not have been initiated yet" + ), + _ => warn!( + status = %status_code, + attempt = %attempt, + error = %error_text, + "Status request failed" + ), + } + continue; + } + + // Get raw response text for debugging + let response_text = match response.text().await { + Ok(t) => t, + Err(e) => { + warn!(?e, attempt = %attempt, "Failed to read status response text"); + continue; + } + }; + + debug!(response = %response_text, "Raw status response"); + + let status_response: StatusResponse = match near_sdk::serde_json::from_str( + &response_text, + ) { + Ok(s) => s, + Err(e) => { + warn!(?e, response = %response_text, attempt = %attempt, "Failed to parse status response"); + continue; + } + }; + + info!( + attempt = %attempt, + status = ?status_response.status, + "Swap status update" + ); + + match status_response.status { + SwapStatus::Success => { + info!("Swap completed successfully"); + return Ok(SwapStatus::Success); + } + SwapStatus::Failed | SwapStatus::Refunded => { + error!(status = ?status_response.status, "Swap failed or refunded"); + return Ok(status_response.status); + } + SwapStatus::PendingDeposit + | SwapStatus::KnownDepositTx + | SwapStatus::Processing => { + debug!(status = ?status_response.status, "Swap still in progress"); + // Continue polling + } + SwapStatus::IncompleteDeposit => { + warn!("Incomplete deposit detected"); + return Ok(SwapStatus::IncompleteDeposit); + } + } + } + + warn!("Swap status polling timed out"); + Err(AppError::ValidationError( + "Swap did not complete within timeout".to_string(), + )) + } +} + +#[async_trait::async_trait] +impl SwapProvider for OneClickSwap { + #[tracing::instrument(skip(self), level = "debug", fields( + provider = %self.provider_name(), + from = %from_asset.to_string(), + to = %to_asset.to_string(), + output_amount = %output_amount + ))] + async fn quote( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + output_amount: FungibleAssetAmount, + ) -> AppResult> { + // Silence unused warnings - parameters needed for tracing + let _ = (from_asset, to_asset, output_amount); + + // OneClick uses EXACT_INPUT mode, so output-based quotes are not supported. + // For the rebalancer use case, call swap() directly with the input amount. + Err(AppError::ValidationError( + "OneClick provider only supports EXACT_INPUT swaps. Use swap() directly with input amount.".to_string() + )) + } + + #[tracing::instrument(skip(self), level = "info", fields( + provider = %self.provider_name(), + from = %from_asset.to_string(), + to = %to_asset.to_string(), + amount = %amount + ))] + async fn swap( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + amount: FungibleAssetAmount, + ) -> AppResult { + // Step 1: Get quote with deposit address + let quote_response = self.request_quote(from_asset, to_asset, amount).await?; + + let deposit_address = "e_response.quote.deposit_address; + let memo = quote_response.quote.deposit_memo.as_deref(); + let input_amount_str = "e_response.quote.amount_in; + + let input_amount: u128 = input_amount_str.parse().map_err(|e| { + error!(?e, amount = %input_amount_str, "Failed to parse input amount"); + AppError::ValidationError(format!("Invalid input amount: {e}")) + })?; + + // Step 2: Deposit tokens + let tx_hash = self + .deposit_tokens(from_asset, deposit_address, U128(input_amount), memo) + .await?; + + // Step 3: Notify 1-Click of deposit + self.submit_deposit(&tx_hash, deposit_address, memo).await?; + + // Step 4: Poll for completion + let status = self + .poll_swap_status(deposit_address, memo, MAX_SWAP_WAIT_SECONDS) + .await?; + + if status == SwapStatus::Success { + info!("1-Click swap completed successfully"); + Ok(FinalExecutionStatus::SuccessValue("".as_bytes().to_vec())) + } else { + error!(status = ?status, "Swap did not succeed"); + Err(AppError::ValidationError(format!( + "Swap failed with status: {status:?}" + ))) + } + } + + fn provider_name(&self) -> &'static str { + "1-Click API (NEAR Intents)" + } + + fn supports_assets( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + ) -> bool { + // 1-Click API only supports NEP-245 (NEAR Intents) tokens + // These are cross-chain assets wrapped in the intents.near contract + from_asset.clone().into_nep245().is_some() && to_asset.clone().into_nep245().is_some() + } + + async fn ensure_storage_registration( + &self, + token_contract: &FungibleAsset, + account_id: &AccountId, + ) -> AppResult<()> { + // Delegate to the existing ensure_storage_deposit method + self.ensure_storage_deposit(token_contract, account_id) + .await + } +} diff --git a/bots/liquidator/src/swap/provider.rs b/bots/liquidator/src/swap/provider.rs new file mode 100644 index 00000000..3dfa5f9e --- /dev/null +++ b/bots/liquidator/src/swap/provider.rs @@ -0,0 +1,101 @@ +//! Concrete swap provider enum for dynamic dispatch. +//! +//! Since the `SwapProvider` trait has generic methods, it cannot be made into +//! a trait object. This module provides a concrete enum that can be used +//! for dynamic dispatch while maintaining type safety. + +use near_primitives::views::FinalExecutionStatus; +use near_sdk::AccountId; +use templar_common::asset::{AssetClass, FungibleAsset, FungibleAssetAmount}; + +use crate::rpc::AppResult; + +use super::{oneclick::OneClickSwap, r#ref::RefSwap, SwapProvider}; + +/// Concrete swap provider implementation that can be used for dynamic dispatch. +/// +/// This enum wraps all supported swap providers and implements `SwapProvider`, +/// allowing it to be used where dynamic dispatch is needed. +#[derive(Debug, Clone)] +pub enum SwapProviderImpl { + /// Ref Finance classic AMM provider (v2.ref-finance.near) + RefFinance(RefSwap), + /// 1-Click API provider for NEP-245 cross-chain swaps + OneClick(OneClickSwap), +} + +impl SwapProviderImpl { + /// Creates a Ref Finance provider variant. + pub fn ref_finance(provider: RefSwap) -> Self { + Self::RefFinance(provider) + } + + /// Creates a 1-Click API provider variant. + pub fn oneclick(provider: OneClickSwap) -> Self { + Self::OneClick(provider) + } +} + +#[async_trait::async_trait] +impl SwapProvider for SwapProviderImpl { + async fn quote( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + output_amount: FungibleAssetAmount, + ) -> AppResult> { + match self { + Self::RefFinance(provider) => provider.quote(from_asset, to_asset, output_amount).await, + Self::OneClick(provider) => provider.quote(from_asset, to_asset, output_amount).await, + } + } + + async fn swap( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + amount: FungibleAssetAmount, + ) -> AppResult { + match self { + Self::RefFinance(provider) => provider.swap(from_asset, to_asset, amount).await, + Self::OneClick(provider) => provider.swap(from_asset, to_asset, amount).await, + } + } + + fn provider_name(&self) -> &'static str { + match self { + Self::RefFinance(provider) => provider.provider_name(), + Self::OneClick(provider) => provider.provider_name(), + } + } + + fn supports_assets( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + ) -> bool { + match self { + Self::RefFinance(provider) => provider.supports_assets(from_asset, to_asset), + Self::OneClick(provider) => provider.supports_assets(from_asset, to_asset), + } + } + + async fn ensure_storage_registration( + &self, + token_contract: &FungibleAsset, + account_id: &AccountId, + ) -> AppResult<()> { + match self { + Self::RefFinance(provider) => { + provider + .ensure_storage_registration(token_contract, account_id) + .await + } + Self::OneClick(provider) => { + provider + .ensure_storage_registration(token_contract, account_id) + .await + } + } + } +} diff --git a/bots/liquidator/src/swap/ref.rs b/bots/liquidator/src/swap/ref.rs new file mode 100644 index 00000000..b2185337 --- /dev/null +++ b/bots/liquidator/src/swap/ref.rs @@ -0,0 +1,595 @@ +//! Ref Finance swap provider for NEP-141 tokens. +//! +//! Integrates with Ref Finance AMM contract for token swaps with automatic routing +//! through wNEAR for pairs without direct pools. + +use std::sync::Arc; + +use near_crypto::Signer; +use near_jsonrpc_client::JsonRpcClient; +use near_primitives::{ + action::Action, + transaction::{Transaction, TransactionV0}, + views::FinalExecutionStatus, +}; +use near_sdk::{ + json_types::U128, + serde::{Deserialize, Serialize}, + AccountId, Gas, +}; +use templar_common::asset::{AssetClass, FungibleAsset, FungibleAssetAmount}; +use tracing::{debug, info}; + +use crate::rpc::{get_access_key_data, send_tx, view, AppError, AppResult}; + +use super::SwapProvider; + +/// Storage balance bounds from NEP-145 +#[derive(Debug, Deserialize)] +struct StorageBalanceBounds { + /// Minimum storage deposit required + min: U128, + /// Maximum storage deposit allowed (optional) + #[allow(dead_code)] + max: Option, +} + +/// Ref/Rhea Finance swap provider for NEP-141 tokens. +#[derive(Debug, Clone)] +pub struct RefSwap { + /// Ref Finance contract account ID + pub contract: AccountId, + /// JSON-RPC client + pub client: JsonRpcClient, + /// Transaction signer + pub signer: Arc, + /// wNEAR contract for routing + pub wnear_contract: AccountId, + /// Maximum slippage in basis points + pub max_slippage_bps: u32, + /// Ref Finance indexer URL + pub indexer_url: String, +} + +impl RefSwap { + /// Creates a new Ref Finance swap provider + pub fn new(contract: AccountId, client: JsonRpcClient, signer: Arc) -> Self { + #[allow(clippy::expect_used)] + Self { + contract, + client, + signer, + wnear_contract: "wrap.near".parse().expect("wrap.near is a valid AccountId"), + max_slippage_bps: Self::DEFAULT_MAX_SLIPPAGE_BPS, + indexer_url: "https://indexer.ref.finance".to_string(), + } + } + + /// Default slippage tolerance (0.5%) + pub const DEFAULT_MAX_SLIPPAGE_BPS: u32 = 50; + + /// Default transaction timeout + const DEFAULT_TIMEOUT: u64 = 30; + + /// Validates that both assets are NEP-141 tokens + fn validate_nep141_assets( + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + ) -> AppResult<()> { + if from_asset.clone().into_nep141().is_none() || to_asset.clone().into_nep141().is_none() { + return Err(AppError::ValidationError( + "RefSwap currently only supports NEP-141 tokens".to_string(), + )); + } + Ok(()) + } + + /// Finds the best pool for swapping between two tokens by querying the contract. + /// Returns `pool_id` if found, otherwise None. + async fn find_best_pool( + &self, + token_in: &AccountId, + token_out: &AccountId, + ) -> AppResult> { + #[derive(Deserialize)] + struct PoolInfo { + token_account_ids: Vec, + shares_total_supply: String, + } + + use near_jsonrpc_client::methods::query::RpcQueryRequest; + use near_primitives::types::{BlockReference, Finality}; + use near_primitives::views::QueryRequest; + + // Search common pool ranges for direct pairs + let search_ranges = vec![ + (0, 500), + (500, 1500), + (1500, 2500), + (2500, 3500), + (3500, 4500), + (4500, 5500), + (5500, 6700), + ]; + + for (start, end) in search_ranges { + let batch_size = 100; + let mut from_index = start; + + while from_index < end { + let limit = std::cmp::min(batch_size, end - from_index); + + let args = near_sdk::serde_json::json!({ + "from_index": from_index, + "limit": limit + }); + + let request = RpcQueryRequest { + block_reference: BlockReference::Finality(Finality::Final), + request: QueryRequest::CallFunction { + account_id: self.contract.clone(), + method_name: "get_pools".to_string(), + args: args.to_string().into_bytes().into(), + }, + }; + + let response = self.client.call(request).await.map_err(|e| { + AppError::ValidationError(format!("Failed to query pools: {e}")) + })?; + + let result = match response.kind { + near_jsonrpc_primitives::types::query::QueryResponseKind::CallResult( + result, + ) => result.result, + _ => { + return Err(AppError::ValidationError( + "Unexpected response type".to_string(), + )) + } + }; + + let pools: Vec = + near_sdk::serde_json::from_slice(&result).map_err(|e| { + AppError::SerializationError(format!("Failed to parse pools: {e}")) + })?; + + if pools.is_empty() { + break; + } + + // Search for matching pool in this batch + for (idx, pool) in pools.iter().enumerate() { + let pool_id = from_index + idx as u64; + if pool.token_account_ids.len() == 2 + && ((pool.token_account_ids[0] == *token_in + && pool.token_account_ids[1] == *token_out) + || (pool.token_account_ids[0] == *token_out + && pool.token_account_ids[1] == *token_in)) + && pool.shares_total_supply != "0" + { + info!(pool_id, "Found direct pool"); + return Ok(Some(pool_id)); + } + } + + from_index += limit; + } + } + + info!( + token_in = %token_in, + token_out = %token_out, + "No direct pool found after scanning common ranges" + ); + Ok(None) + } + + /// Finds a two-hop route through wNEAR by querying the contract. + /// Returns (`pool1_id`, `pool2_id`) if found. + async fn find_two_hop_route( + &self, + token_in: &AccountId, + token_out: &AccountId, + ) -> AppResult> { + #[derive(Deserialize)] + struct PoolInfo { + token_account_ids: Vec, + shares_total_supply: String, + } + + use near_jsonrpc_client::methods::query::RpcQueryRequest; + use near_primitives::types::{BlockReference, Finality}; + use near_primitives::views::QueryRequest; + + let mut pool1_opt: Option = None; + let mut pool2_opt: Option = None; + + // Search common pool ranges for liquid wNEAR pairs + let search_ranges = vec![ + (0, 500), + (500, 1500), + (1500, 2500), + (2500, 3500), + (3500, 4500), + (4500, 5500), + (5500, 6700), + ]; + + for (start, end) in search_ranges { + let batch_size = 100; + let mut from_index = start; + + while from_index < end { + let limit = std::cmp::min(batch_size, end - from_index); + + let args = near_sdk::serde_json::json!({ + "from_index": from_index, + "limit": limit + }); + + let request = RpcQueryRequest { + block_reference: BlockReference::Finality(Finality::Final), + request: QueryRequest::CallFunction { + account_id: self.contract.clone(), + method_name: "get_pools".to_string(), + args: args.to_string().into_bytes().into(), + }, + }; + + let response = self.client.call(request).await.map_err(|e| { + AppError::ValidationError(format!("Failed to query pools: {e}")) + })?; + + let result = match response.kind { + near_jsonrpc_primitives::types::query::QueryResponseKind::CallResult( + result, + ) => result.result, + _ => { + return Err(AppError::ValidationError( + "Unexpected response type".to_string(), + )) + } + }; + + let pools: Vec = + near_sdk::serde_json::from_slice(&result).map_err(|e| { + AppError::SerializationError(format!("Failed to parse pools: {e}")) + })?; + + if pools.is_empty() { + break; + } + + // Search for wNEAR routes in this batch + for (idx, pool) in pools.iter().enumerate() { + let pool_id = from_index + idx as u64; + + if pool.token_account_ids.len() == 2 && pool.shares_total_supply != "0" { + // Check for pool1: token_in -> wNEAR + if pool1_opt.is_none() + && ((pool.token_account_ids[0] == *token_in + && pool.token_account_ids[1] == self.wnear_contract) + || (pool.token_account_ids[0] == self.wnear_contract + && pool.token_account_ids[1] == *token_in)) + { + pool1_opt = Some(pool_id); + info!(pool_id, "Found pool1: {} -> wNEAR", token_in); + } + + // Check for pool2: wNEAR -> token_out + if pool2_opt.is_none() + && ((pool.token_account_ids[0] == self.wnear_contract + && pool.token_account_ids[1] == *token_out) + || (pool.token_account_ids[0] == *token_out + && pool.token_account_ids[1] == self.wnear_contract)) + { + pool2_opt = Some(pool_id); + info!(pool_id, "Found pool2: wNEAR -> {}", token_out); + } + + // If we found both pools, we're done + if let (Some(p1), Some(p2)) = (pool1_opt, pool2_opt) { + info!(pool1 = p1, pool2 = p2, "Found two-hop route through wNEAR"); + return Ok(Some((p1, p2))); + } + } + } + + from_index += limit; + } + } + + info!( + token_in = %token_in, + token_out = %token_out, + wnear = %self.wnear_contract, + pool1_found = pool1_opt.is_some(), + pool2_found = pool2_opt.is_some(), + "No two-hop route found" + ); + Ok(None) + } +} + +/// Swap action for Ref Finance swaps +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SwapAction { + pool_id: u64, + token_in: AccountId, + token_out: AccountId, + #[serde(skip_serializing_if = "Option::is_none")] + amount_in: Option, + min_amount_out: String, +} + +/// Swap request message +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct SwapMsg { + force: u8, + actions: Vec, +} + +#[async_trait::async_trait] +impl SwapProvider for RefSwap { + #[tracing::instrument(skip(self), level = "debug", fields( + provider = %self.provider_name(), + from = %from_asset.to_string(), + to = %to_asset.to_string(), + output_amount = %_output_amount + ))] + async fn quote( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + _output_amount: FungibleAssetAmount, + ) -> AppResult> { + Self::validate_nep141_assets(from_asset, to_asset)?; + + Err(AppError::ValidationError( + "Quote not supported - use direct swap instead".to_string(), + )) + } + + #[tracing::instrument(skip(self), level = "info", fields( + provider = %self.provider_name(), + from = %from_asset.to_string(), + to = %to_asset.to_string(), + amount = %amount + ))] + async fn swap( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + amount: FungibleAssetAmount, + ) -> AppResult { + Self::validate_nep141_assets(from_asset, to_asset)?; + + let token_in = from_asset.contract_id(); + let token_out = to_asset.contract_id(); + + let token_in_owned: AccountId = token_in.into(); + let token_out_owned: AccountId = token_out.into(); + + info!( + from_contract = %token_in_owned, + to_contract = %token_out_owned, + amount = %amount, + "Attempting Ref Finance swap" + ); + + // Try to find direct pool first + let pool_id_opt = self + .find_best_pool(&token_in_owned, &token_out_owned) + .await?; + + let (swap_msg, intermediate_token) = if let Some(pool_id) = pool_id_opt { + // Direct swap + let amount_u128 = u128::from(amount); + let min_amount_out = + U128::from(amount_u128 * (10000 - u128::from(self.max_slippage_bps)) / 10000); + + debug!( + pool_id, + min_amount_out = %min_amount_out.0, + slippage_bps = self.max_slippage_bps, + "Using direct pool" + ); + + let msg = SwapMsg { + force: 0, + actions: vec![SwapAction { + pool_id, + token_in: token_in_owned.clone(), + token_out: token_out_owned.clone(), + amount_in: None, + min_amount_out: min_amount_out.0.to_string(), + }], + }; + + (msg, None) + } else { + // Try two-hop routing through wNEAR + let (pool1, pool2) = self + .find_two_hop_route(&token_in_owned, &token_out_owned) + .await? + .ok_or_else(|| { + AppError::ValidationError(format!( + "No swap path found for {token_in_owned} -> {token_out_owned}" + )) + })?; + + let amount_u128 = u128::from(amount); + let min_amount_out = + U128::from(amount_u128 * (10000 - u128::from(self.max_slippage_bps)) / 10000); + + debug!( + pool1, + pool2, + min_amount_out = %min_amount_out.0, + slippage_bps = self.max_slippage_bps, + "Using two-hop route through wNEAR" + ); + + let msg = SwapMsg { + force: 0, + actions: vec![ + SwapAction { + pool_id: pool1, + token_in: token_in_owned.clone(), + token_out: self.wnear_contract.clone(), + amount_in: None, + min_amount_out: "1".to_string(), + }, + SwapAction { + pool_id: pool2, + token_in: self.wnear_contract.clone(), + token_out: token_out_owned.clone(), + amount_in: None, + min_amount_out: min_amount_out.0.to_string(), + }, + ], + }; + + (msg, Some(self.wnear_contract.clone())) + }; + + let msg_string = near_sdk::serde_json::to_string(&swap_msg).map_err(|e| { + AppError::SerializationError(format!("Failed to serialize swap message: {e}")) + })?; + + // Register storage for output token and intermediate token if needed + let our_account = self.signer.get_account_id(); + self.ensure_storage_registration(to_asset, &our_account) + .await?; + + if let Some(intermediate) = intermediate_token { + let wnear_asset: FungibleAsset = + FungibleAsset::nep141(intermediate); + self.ensure_storage_registration(&wnear_asset, &our_account) + .await?; + } + + let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer).await?; + + // Execute swap via ft_transfer_call + let tx = Transaction::V0(TransactionV0 { + nonce, + receiver_id: from_asset.contract_id().into(), + block_hash, + signer_id: self.signer.get_account_id(), + public_key: self.signer.public_key().clone(), + actions: vec![Action::FunctionCall(Box::new( + from_asset.transfer_call_action(&self.contract, amount, &msg_string), + ))], + }); + + let outcome = send_tx(&self.client, &self.signer, Self::DEFAULT_TIMEOUT, tx).await?; + + info!("Ref Finance swap executed successfully"); + + Ok(outcome.status) + } + + fn provider_name(&self) -> &'static str { + "RefFinance" + } + + fn supports_assets( + &self, + from_asset: &FungibleAsset, + to_asset: &FungibleAsset, + ) -> bool { + from_asset.clone().into_nep141().is_some() && to_asset.clone().into_nep141().is_some() + } + + async fn ensure_storage_registration( + &self, + token_contract: &FungibleAsset, + account_id: &AccountId, + ) -> AppResult<()> { + const MAX_REASONABLE_DEPOSIT: u128 = 100_000_000_000_000_000_000_000; // 0.1 NEAR + + // Query storage_balance_bounds to get minimum deposit required + let bounds: StorageBalanceBounds = view( + &self.client, + token_contract.contract_id().into(), + "storage_balance_bounds", + near_sdk::serde_json::json!({}), + ) + .await + .map_err(|e| { + debug!(?e, token = %token_contract.contract_id(), "Failed to query storage_balance_bounds"); + AppError::Rpc(e) + })?; + + let min_deposit = bounds.min.0; + + // Validate minimum deposit is reasonable (less than 0.1 NEAR) + if min_deposit > MAX_REASONABLE_DEPOSIT { + return Err(AppError::ValidationError(format!( + "Storage deposit minimum ({min_deposit} yoctoNEAR) exceeds reasonable limit ({MAX_REASONABLE_DEPOSIT} yoctoNEAR / 0.1 NEAR)" + ))); + } + + #[allow(clippy::cast_precision_loss)] + let min_deposit_near = min_deposit as f64 / 1e24; + + debug!( + token = %token_contract.contract_id(), + min_deposit_near = %min_deposit_near, + "Using storage deposit minimum from contract" + ); + + let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer).await?; + + let storage_deposit_action = near_primitives::action::FunctionCallAction { + method_name: "storage_deposit".to_string(), + args: near_sdk::serde_json::to_vec(&near_sdk::serde_json::json!({ + "account_id": account_id, + "registration_only": true, + })) + .map_err(|e| { + AppError::SerializationError(format!( + "Failed to serialize storage_deposit args: {e}" + )) + })?, + gas: Gas::from_tgas(10).as_gas(), + deposit: min_deposit, + }; + + let tx = Transaction::V0(TransactionV0 { + nonce, + receiver_id: token_contract.contract_id().into(), + block_hash, + signer_id: self.signer.get_account_id(), + public_key: self.signer.public_key().clone(), + actions: vec![Action::FunctionCall(Box::new(storage_deposit_action))], + }); + + match send_tx(&self.client, &self.signer, Self::DEFAULT_TIMEOUT, tx).await { + Ok(_) => { + debug!( + account = %account_id, + token = %token_contract.contract_id(), + "Storage registration successful" + ); + Ok(()) + } + Err(e) => { + // If already registered, that's fine + let error_msg = e.to_string(); + if error_msg.contains("The account") && error_msg.contains("is already registered") + { + debug!( + account = %account_id, + token = %token_contract.contract_id(), + "Account already registered" + ); + Ok(()) + } else { + Err(AppError::Rpc(e)) + } + } + } + } +} diff --git a/bots/src/bin/liquidator-bot.rs b/bots/src/bin/liquidator-bot.rs deleted file mode 100644 index f4a01781..00000000 --- a/bots/src/bin/liquidator-bot.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::{ - collections::HashMap, - sync::Arc, - time::{Duration, Instant}, -}; - -use clap::Parser; -use near_crypto::InMemorySigner; -use near_jsonrpc_client::JsonRpcClient; -use near_sdk::AccountId; -use templar_bots::{ - liquidator::{Args, Liquidator, LiquidatorError, LiquidatorResult}, - list_all_deployments, - swap::{RheaSwap, SwapType}, -}; -use tokio::time::sleep; -use tracing::info; -use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; - -#[tokio::main] -async fn main() -> LiquidatorResult { - tracing_subscriber::registry() - .with(fmt::layer()) - .with(EnvFilter::from_default_env()) - .init(); - - let args = Args::parse(); - let client = JsonRpcClient::connect(args.network.rpc_url()); - let signer = Arc::new(InMemorySigner::from_secret_key( - args.signer_account.clone(), - args.signer_key.clone(), - )); - let swap = match args.swap { - SwapType::RheaSwap => Arc::new(RheaSwap::new( - args.swap.account_id(args.network), - client.clone(), - signer.clone(), - )), - }; - let asset = Arc::new(args.asset); - - let registry_refresh_interval = Duration::from_secs(args.registry_refresh_interval); - let mut next_refresh = Instant::now(); - let mut markets = HashMap::>::new(); - - loop { - if Instant::now() >= next_refresh { - info!("Refreshing registry deployments"); - let all_markets = - list_all_deployments(client.clone(), args.registries.clone(), args.concurrency) - .await - .map_err(LiquidatorError::ListDeploymentsError)?; - info!("Found {} deployments", all_markets.len()); - markets = all_markets - .into_iter() - .map(|market| { - let liquidator = Liquidator::new( - // All clones are Arcs so this is cheap - client.clone(), - signer.clone(), - asset.clone(), - // This is the only true clone - market.clone(), - swap.clone(), - args.timeout, - ); - (market, liquidator) - }) - .collect(); - next_refresh = Instant::now() + registry_refresh_interval; - } - - for (market, liquidator) in &markets { - info!("Running liquidations for market: {}", market); - liquidator.run_liquidations(args.concurrency).await?; - } - - info!( - "Liquidation job done, sleeping for {} seconds before next run", - args.interval - ); - // Sleep for the specified interval before the next iteration - sleep(Duration::from_secs(args.interval)).await; - } -} diff --git a/bots/src/lib.rs b/bots/src/lib.rs deleted file mode 100644 index 615cc6d1..00000000 --- a/bots/src/lib.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::collections::HashMap; - -use clap::ValueEnum; -use futures::{StreamExt, TryStreamExt}; -use near::{view, RpcResult}; -use near_jsonrpc_client::{JsonRpcClient, NEAR_MAINNET_RPC_URL, NEAR_TESTNET_RPC_URL}; -use near_sdk::{near, serde_json::json, AccountId, Gas}; -use templar_common::borrow::BorrowPosition; -use tracing::instrument; - -pub mod accumulator; -pub mod liquidator; -pub mod near; -pub mod swap; - -type BorrowPositions = HashMap; - -/// Default gas for updating price data. 300 `TeraGas`. -pub const DEFAULT_GAS: u64 = Gas::from_tgas(300).as_gas(); - -#[derive(Debug, Clone, Copy, Default, ValueEnum)] -#[near(serializers = [json])] -pub enum Network { - Mainnet, - #[default] - Testnet, -} - -impl std::fmt::Display for Network { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Network::Mainnet => "mainnet", - Network::Testnet => "testnet", - } - ) - } -} - -impl Network { - #[must_use] - pub fn rpc_url(&self) -> &str { - match self { - Network::Mainnet => NEAR_MAINNET_RPC_URL, - Network::Testnet => NEAR_TESTNET_RPC_URL, - } - } -} - -#[instrument(skip(client), level = "debug")] -pub async fn list_deployments( - client: &JsonRpcClient, - registry: AccountId, - count: Option, - offset: Option, -) -> RpcResult> { - let mut all_deployments = Vec::new(); - let page_size = 500; - let mut current_offset = 0; - - loop { - let params = json!({ - "offset": current_offset, - "count": page_size, - }); - - let page = - view::>(client, registry.clone(), "list_deployments", params).await?; - - let fetched = page.len(); - - if fetched == 0 { - break; - } - - all_deployments.extend(page); - current_offset += fetched; - - if fetched < page_size { - break; - } - } - - Ok(all_deployments) -} - -#[instrument(skip(client), level = "debug")] -pub async fn list_all_deployments( - client: JsonRpcClient, - registries: Vec, - concurrency: usize, -) -> RpcResult> { - let all_markets: Vec = futures::stream::iter(registries) - .map(|registry| { - let client = client.clone(); - async move { list_deployments(&client, registry, None, None).await } - }) - .buffer_unordered(concurrency) - .try_concat() - .await?; - - Ok(all_markets) -} diff --git a/bots/src/liquidator.rs b/bots/src/liquidator.rs deleted file mode 100644 index cc5d53ae..00000000 --- a/bots/src/liquidator.rs +++ /dev/null @@ -1,873 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use clap::Parser; -use futures::{StreamExt, TryStreamExt}; -use near_crypto::{SecretKey, Signer}; -use near_jsonrpc_client::JsonRpcClient; -use near_primitives::{ - hash::CryptoHash, - transaction::{Transaction, TransactionV0}, -}; -use near_sdk::{ - json_types::U128, - serde_json::{self, json}, - AccountId, -}; -use templar_common::{ - asset::{AssetClass, BorrowAsset, FungibleAsset, FungibleAssetParseError}, - borrow::{BorrowPosition, BorrowStatus}, - market::{error::RetrievalError, DepositMsg, LiquidateMsg, MarketConfiguration}, - oracle::pyth::{OracleResponse, PriceIdentifier}, -}; -use tracing::{error, info, instrument, warn}; - -use crate::{ - near::{get_access_key_data, send_tx, view, AppError, RpcError}, - swap::{Swap, SwapType}, - BorrowPositions, Network, -}; - -/// Errors that can occur during liquidation operations. -#[derive(Debug, thiserror::Error)] -pub enum LiquidatorError { - /// Error while fetching borrow status. - #[error("Failed to fetch borrow status: {0}")] - FetchBorrowStatus(RpcError), - /// Error serializing data. - #[error("Failed to serialize data: {0}")] - SerializeError(#[from] serde_json::Error), - /// Price pair retrieval error. - #[error("Failed to get price pair: {0}")] - PricePairError(#[from] RetrievalError), - /// Error calculating minimum acceptable liquidation amount. - #[error("Failed to calculate minimum acceptable liquidation amount: {0}")] - MinimumLiquidationAmountError(String), - /// Standart support error. - #[error("Standard support error: {0}")] - StandardSupportError(String), - /// Quote error. - #[error("Failed to get quote: {0}")] - QuoteError(AppError), - /// Error fetching market configuration. - #[error("Failed to get market configuration: {0}")] - GetConfigurationError(RpcError), - /// Error fetching oracle prices. - #[error("Failed to fetch oracle prices: {0}")] - PriceFetchError(RpcError), - /// Access key data retrieval error. - #[error("Failed to get access key data: {0}")] - AccessKeyDataError(RpcError), - /// Swap transaction error. - #[error("Swap transaction error: {0}")] - SwapTransactionError(AppError), - /// Liquidation transaction error. - #[error("Liquidation transaction error: {0}")] - LiquidationTransactionError(RpcError), - /// Error while fetching borrow positions. - #[error("Failed to list borrow positions: {0}")] - ListBorrowPositionsError(RpcError), - /// Error fetching registry deployments. - #[error("Failed to list deployments: {0}")] - ListDeploymentsError(RpcError), - /// Error fetching minimum acceptable liquidation amount. - #[error("Failed to fetch balance: {0}")] - FetchBalanceError(RpcError), - /// Asset parsing error. - #[error("Asset parsing error: {0}")] - AssetParseError(FungibleAssetParseError), -} - -pub type LiquidatorResult = Result; - -#[derive(Debug, Clone, Parser)] -pub struct Args { - /// Market registries to run liquidations for - #[arg(short, long, env = "REGISTRY_ACCOUNT_IDS")] - pub registries: Vec, - /// Swap to use for liquidations - #[arg(long, env = "SWAP_TYPE")] - pub swap: SwapType, - /// Signer key to use for signing transactions. - #[arg(short = 'k', long, env = "SIGNER_KEY")] - pub signer_key: SecretKey, - /// Signer `AccountId`. - #[arg(short, long, env = "SIGNER_ACCOUNT_ID")] - pub signer_account: AccountId, - /// Asset specification (NEP-141 or NEP-245) to liquidate with - "nep141:contract.near" (NEP-141) or "`nep245:contract.near:token_id`" (NEP-245) - #[arg(short, long, env = "ASSET_SPEC")] - pub asset: FungibleAsset, - /// Network to run liquidations on - #[arg(short, long, env = "NETWORK", default_value_t = Network::Testnet)] - pub network: Network, - /// Timeout for transactions - #[arg(short, long, env = "TIMEOUT", default_value_t = 60)] - pub timeout: u64, - /// Interval between liquidation attempts - #[arg(short, long, env = "INTERVAL", default_value_t = 600)] - pub interval: u64, - /// Registry refresh interval in seconds - #[arg(long, env = "REGISTRY_REFRESH_INTERVAL", default_value_t = 3600)] - pub registry_refresh_interval: u64, - /// Concurency for liquidations - #[arg(short, long, env = "CONCURRENCY", default_value_t = 10)] - pub concurrency: usize, -} - -pub struct Liquidator { - client: JsonRpcClient, - signer: Arc, - asset: Arc>, - pub market: AccountId, - timeout: u64, - swap: Arc, -} - -impl Liquidator { - #[must_use] - pub fn new( - client: JsonRpcClient, - signer: Arc, - asset: Arc>, - market: AccountId, - swap: Arc, - timeout: u64, - ) -> Self { - Self { - client, - signer, - asset, - market, - timeout, - swap, - } - } - - /// Gets the asset specification for testing purposes. - #[cfg(test)] - pub fn asset(&self) -> &FungibleAsset { - &self.asset - } - - /// Gets the timeout for testing purposes. - #[cfg(test)] - pub fn timeout(&self) -> u64 { - self.timeout - } - - #[instrument(skip(self), level = "debug")] - async fn get_borrow_status( - &self, - account_id: AccountId, - oracle_response: &OracleResponse, - ) -> Result, RpcError> { - let params = json!({ - "account_id": account_id, - "oracle_response": oracle_response, - }); - - let result = view( - &self.client, - self.market.clone(), - "get_borrow_status", - ¶ms, - ) - .await?; - - Ok(result) - } - - /// Creates a transfer transaction for liquidation. - /// - /// # Errors - /// - /// Returns `LiquidatorError::SerializationError` if message serialization fails, - /// or `LiquidatorError::TransactionBuildError` if transaction building fails. - pub fn create_transfer_tx( - &self, - borrow_asset: &FungibleAsset, - borrow: &AccountId, - liquidation_amount: U128, - nonce: u64, - block_hash: CryptoHash, - ) -> LiquidatorResult { - let msg = serde_json::to_string(&DepositMsg::Liquidate(LiquidateMsg { - account_id: borrow.clone(), - // TODO: This should be an amount expected to receive - amount: None, - }))?; - - let function_call = - borrow_asset.transfer_call_action(&self.market, liquidation_amount.into(), &msg); - - Ok(Transaction::V0(TransactionV0 { - nonce, - receiver_id: borrow_asset.contract_id().into(), - block_hash, - signer_id: self.signer.get_account_id(), - public_key: self.signer.public_key().clone(), - actions: vec![function_call.into()], - })) - } - - #[instrument(skip(self), level = "debug")] - pub async fn liquidate( - &self, - borrow: AccountId, - position: BorrowPosition, - oracle_response: OracleResponse, - configuration: MarketConfiguration, - ) -> LiquidatorResult { - let Some(status) = self - .get_borrow_status(borrow.clone(), &oracle_response) - .await - .map_err(LiquidatorError::FetchBorrowStatus)? - else { - info!("Borrow status not found"); - return Ok(()); - }; - - let BorrowStatus::Liquidation(reason) = status else { - info!("Borrow status is not liquidation"); - return Ok(()); - }; - - info!("Liquidation reason: {reason:?}"); - - let liquidation_amount = self - .liquidation_amount(&position, &oracle_response, &configuration) - .await?; - - let borrow_asset = &configuration.borrow_asset; - let collateral_asset = &configuration.collateral_asset; - - let swap_output_amount = if self.asset.as_ref() == borrow_asset { - let asset_balance = self.get_asset_balance(self.asset.as_ref()).await?; - if asset_balance >= liquidation_amount { - 0.into() - } else { - (liquidation_amount.0 - asset_balance.0).into() - } - } else { - liquidation_amount - }; - - let swap_amount = self - .swap - .quote(self.asset.as_ref(), borrow_asset, swap_output_amount) - .await - .map_err(LiquidatorError::QuoteError)?; - - let available = self.get_asset_balance(self.asset.as_ref()).await?; - - if available < swap_amount { - warn!("Insufficient asset balance for liquidation: {available:?} < {swap_amount:?}"); - return Ok(()); - } - - // Implement this function based on your liquidation strategy - if !self - .should_liquidate(swap_amount, liquidation_amount) - .await? - { - info!("Skipping liquidation due to insufficient conditions"); - return Ok(()); - } - - if swap_amount > 0.into() { - match self - .swap - .swap(self.asset.as_ref(), borrow_asset, swap_amount) - .await - { - Ok(_) => { - info!("Swap successful"); - } - Err(e) => { - error!("Swap failed: {e}"); - return Err(LiquidatorError::SwapTransactionError(e)); - } - }; - } - - let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer) - .await - .map_err(LiquidatorError::AccessKeyDataError)?; - - let transfer_call_tx = - self.create_transfer_tx(borrow_asset, &borrow, liquidation_amount, nonce, block_hash)?; - - match send_tx(&self.client, &self.signer, self.timeout, transfer_call_tx).await { - Ok(_) => { - info!("Liquidation successful"); - } - Err(e) => { - error!("Liquidation failed: {e}"); - return Err(LiquidatorError::LiquidationTransactionError(e)); - } - } - - if self.asset.as_ref() == &collateral_asset.clone().coerce::() { - match self - .swap - .swap( - collateral_asset, - &self.asset, - position.collateral_asset_deposit.into(), - ) - .await - { - Ok(_) => { - info!("Swap successful"); - } - Err(e) => { - error!("Swap failed: {e}"); - } - } - } - - Ok(()) - } - - #[instrument(skip(self), level = "debug")] - async fn liquidation_amount( - &self, - position: &BorrowPosition, - oracle_response: &OracleResponse, - configuration: &MarketConfiguration, - ) -> LiquidatorResult { - let price_pair = configuration - .price_oracle_configuration - .create_price_pair(oracle_response)?; - let min_liquidation_amount = configuration - .minimum_acceptable_liquidation_amount(position.collateral_asset_deposit, &price_pair) - .ok_or_else(|| { - LiquidatorError::MinimumLiquidationAmountError( - "Failed to calculate minimum acceptable liquidation amount".to_owned(), - ) - })?; - Ok(min_liquidation_amount.into()) - } - - #[instrument(skip(self), level = "debug")] - async fn get_configuration(&self) -> LiquidatorResult { - view( - &self.client, - self.market.clone(), - "get_configuration", - json!({}), - ) - .await - .map_err(LiquidatorError::GetConfigurationError) - } - - #[instrument(skip(self), level = "debug")] - async fn get_oracle_prices( - &self, - oracle: AccountId, - price_ids: &[PriceIdentifier], - age: u32, - ) -> LiquidatorResult { - view( - &self.client, - oracle, - "list_ema_prices_no_older_than", - json!({ "price_ids": price_ids, "age": age }), - ) - .await - .map_err(LiquidatorError::PriceFetchError) - } - - #[instrument(skip(self), level = "debug")] - async fn get_asset_balance( - &self, - asset: &FungibleAsset, - ) -> LiquidatorResult { - let balance_action = asset.balance_of_action(&self.signer.get_account_id()); - - let args: serde_json::Value = serde_json::from_slice(&balance_action.args) - .map_err(LiquidatorError::SerializeError)?; - - let balance = view::( - &self.client, - asset.contract_id().into(), - &balance_action.method_name, - args, - ) - .await - .map_err(LiquidatorError::FetchBalanceError)?; - - Ok(balance) - } - - #[instrument(skip(self), level = "debug")] - async fn get_borrows(&self) -> LiquidatorResult { - let mut all_positions: BorrowPositions = HashMap::new(); - let page_size = 500; - let mut current_offset = 0; - - loop { - let params = json!({ - "offset": current_offset, - "count": page_size, - }); - - let page = view::( - &self.client, - self.market.clone(), - "list_borrow_positions", - params, - ) - .await - .map_err(LiquidatorError::ListBorrowPositionsError)?; - - let fetched = page.len(); - - if fetched == 0 { - break; - } - - all_positions.extend(page); - current_offset += fetched; - - if fetched < page_size { - break; - } - } - - Ok(all_positions) - } - - #[instrument(skip(self), level = "info")] - pub async fn run_liquidations(&self, concurrency: usize) -> LiquidatorResult { - let configuration = self.get_configuration().await?; - let oracle_response = self - .get_oracle_prices( - configuration.price_oracle_configuration.account_id.clone(), - &[ - configuration - .price_oracle_configuration - .borrow_asset_price_id, - configuration - .price_oracle_configuration - .collateral_asset_price_id, - ], - configuration.price_oracle_configuration.price_maximum_age_s, - ) - .await?; - - let borrows = self.get_borrows().await?; - - if borrows.is_empty() { - info!("No borrow positions found"); - return Ok(()); - } - - futures::stream::iter(borrows) - .map(|(borrow, position)| { - let oracle_response = oracle_response.clone(); - let configuration = configuration.clone(); - async move { - self.liquidate(borrow, position, oracle_response, configuration) - .await - } - }) - .buffer_unordered(concurrency) - .try_for_each(|_result| async { Ok(()) }) - .await?; - - Ok(()) - } - - #[instrument(skip(self), level = "debug")] - pub async fn should_liquidate( - &self, - swap_amount: U128, - liquidation_amount: U128, - ) -> LiquidatorResult { - // TODO: Calculate optimal liquidation amount - // For purposes of this example implementation we will just use the minimum acceptable - // liquidation amount. - // Costs to take into account here are: - // - Gas fees - // - Price impact - // - Slippage - // All of this would be used in calculating both the optimal liquidation amount and wether to - // perform full or partial liquidation - Ok(true) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::swap::Swap; - use near_crypto::{InMemorySigner, SecretKey}; - use near_jsonrpc_client::JsonRpcClient; - use near_primitives::views::FinalExecutionStatus; - use near_sdk::{json_types::U128, AccountId}; - use std::sync::Arc; - use templar_common::asset::{AssetClass, BorrowAsset, FungibleAsset, FungibleAssetParseError}; - - /// Mock swap implementation for testing - #[derive(Debug, Clone)] - pub struct MockSwap { - /// Exchange rate from input to output (e.g., 1.0 means 1:1 ratio) - exchange_rate: f64, - } - - impl MockSwap { - pub fn new(exchange_rate: f64) -> Self { - Self { exchange_rate } - } - } - - #[async_trait::async_trait] - impl Swap for MockSwap { - async fn quote( - &self, - _from_asset: &FungibleAsset, - _to_asset: &FungibleAsset, - output_amount: U128, - ) -> crate::near::AppResult { - // Calculate input amount needed to get desired output - #[allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss - )] - let input_amount = (output_amount.0 as f64 / self.exchange_rate) as u128; - Ok(U128(input_amount)) - } - - async fn swap( - &self, - _from_asset: &FungibleAsset, - _to_asset: &FungibleAsset, - _amount: U128, - ) -> crate::near::AppResult { - // Mock successful swap - in real implementation this would execute the swap - Ok(FinalExecutionStatus::SuccessValue(vec![])) - } - } - - #[tokio::test] - async fn test_liquidator_bot_creation_integration() { - // Integration test for creating a liquidator bot with realistic parameters - - let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); - let signer_key = SecretKey::from_seed(near_crypto::KeyType::ED25519, "test-liquidator"); - let liquidator_account_id: AccountId = "liquidator.testnet".parse().unwrap(); - let signer = Arc::new(InMemorySigner::from_secret_key( - liquidator_account_id, - signer_key, - )); - let market_id: AccountId = "market.testnet".parse().unwrap(); - let swap = Arc::new(MockSwap::new(1.0)); - - // Test with NEP-141 asset (USDC-like token) - let usdc_asset = Arc::new(FungibleAsset::::nep141( - "usdc.testnet".parse().unwrap(), - )); - - let liquidator = Liquidator::new( - client.clone(), - signer.clone(), - usdc_asset.clone(), - market_id.clone(), - swap.clone(), - 120, // 2 minute timeout - ); - - // Verify liquidator properties - assert_eq!(liquidator.asset(), &*usdc_asset); - assert_eq!(liquidator.timeout(), 120); - assert_eq!(liquidator.market, market_id); - - println!("✓ USDC liquidator bot created successfully"); - - // Test with NEP-245 asset (multi-token) - let mt_asset = Arc::new(FungibleAsset::::nep245( - "multitoken.testnet".parse().unwrap(), - "eth".to_string(), - )); - - let mt_liquidator = Liquidator::new( - client, - signer, - mt_asset.clone(), - market_id.clone(), - swap, - 60, - ); - - assert_eq!(mt_liquidator.asset(), &*mt_asset); - assert_eq!(mt_liquidator.timeout(), 60); - - println!("✓ Multi-token liquidator bot created successfully"); - println!("✓ Liquidator bot integration test completed"); - } - - #[tokio::test] - async fn test_liquidator_bot_should_liquidate_logic() { - // Test the liquidator's decision-making logic - - let client = JsonRpcClient::connect("https://rpc.testnet.near.org"); - let signer_key = SecretKey::from_seed(near_crypto::KeyType::ED25519, "test-liquidator"); - let liquidator_account_id: AccountId = "liquidator.testnet".parse().unwrap(); - let signer = Arc::new(InMemorySigner::from_secret_key( - liquidator_account_id, - signer_key, - )); - let market_id: AccountId = "market.testnet".parse().unwrap(); - let swap = Arc::new(MockSwap::new(1.0)); - - let usdc_asset = Arc::new(FungibleAsset::::nep141( - "usdc.testnet".parse().unwrap(), - )); - - let liquidator = Liquidator::new(client, signer, usdc_asset, market_id, swap, 60); - - // Test should_liquidate logic with different amounts - let small_swap_amount = U128(100); - let small_liquidation_amount = U128(200); - - let should_liquidate_small = liquidator - .should_liquidate(small_swap_amount, small_liquidation_amount) - .await - .unwrap(); - - assert!(should_liquidate_small, "Should liquidate small amounts"); - - let large_swap_amount = U128(10_000); - let large_liquidation_amount = U128(20_000); - - let should_liquidate_large = liquidator - .should_liquidate(large_swap_amount, large_liquidation_amount) - .await - .unwrap(); - - assert!(should_liquidate_large, "Should liquidate large amounts"); - - println!("✓ Liquidator decision logic working correctly"); - } - - #[test] - fn test_liquidator_creation() { - // Test that we can create a liquidator instance with different configurations - - // Setup mock components - let client = JsonRpcClient::connect("http://localhost:3030"); - let signer_key = SecretKey::from_seed(near_crypto::KeyType::ED25519, "test-key"); - let liquidator_account_id: AccountId = "liquidator.test.near".parse().unwrap(); - let signer = Arc::new(InMemorySigner::from_secret_key( - liquidator_account_id, - signer_key, - )); - let market_id: AccountId = "market.test.near".parse().unwrap(); - let swap = Arc::new(MockSwap::new(1.0)); - - // Test NEP-141 asset - let nep141_asset = Arc::new(FungibleAsset::::nep141( - "token.near".parse().unwrap(), - )); - - let _liquidator = Liquidator::new( - client.clone(), - signer.clone(), - nep141_asset.clone(), - market_id.clone(), - swap.clone(), - 60, - ); - - // Verify liquidator was created successfully - // Note: We can't directly access private fields, but we can verify the liquidator - // was constructed without panicking - println!("Liquidator created successfully"); - - // Test NEP-245 asset - let nep245_asset = Arc::new(FungibleAsset::::nep245( - "multi.near".parse().unwrap(), - "token123".to_string(), - )); - - let _liquidator_mt = Liquidator::new( - client, - signer, - nep245_asset, - market_id, - swap, - 120, // Different timeout - ); - - // Verify multi-token liquidator was created successfully - println!("Multi-token liquidator created successfully"); - } - - #[tokio::test] - async fn test_mock_swap_functionality() { - // Test the mock swap implementation used in integration tests - - let swap = MockSwap::new(2.0); // 1 input = 2 output - - let from_asset = FungibleAsset::::nep141("input.near".parse().unwrap()); - let to_asset = FungibleAsset::::nep141("output.near".parse().unwrap()); - - // Test quote functionality - let output_amount = near_sdk::json_types::U128(100); - let quote_result = swap.quote(&from_asset, &to_asset, output_amount).await; - - assert!(quote_result.is_ok(), "Quote should succeed"); - let input_needed = quote_result.unwrap(); - assert_eq!( - input_needed.0, 50, - "Should need 50 input tokens to get 100 output tokens at 2:1 rate" - ); - - // Test swap functionality - let swap_amount = near_sdk::json_types::U128(25); - let swap_result = swap.swap(&from_asset, &to_asset, swap_amount).await; - - assert!(swap_result.is_ok(), "Swap should succeed"); - // Mock always returns success, so we just verify it doesn't error - } - - #[test] - fn test_asset_specifications() { - // Test different asset specification formats - - // NEP-141 - let nep141: Result, _> = "nep141:token.near".parse(); - assert!(nep141.is_ok(), "NEP-141 parsing should succeed"); - - let asset = nep141.unwrap(); - assert_eq!(asset.to_string(), "nep141:token.near"); - assert_eq!( - asset.contract_id(), - "token.near".parse::().unwrap() - ); - - // NEP-245 - let nep245: Result, _> = "nep245:multi.near:token123".parse(); - assert!(nep245.is_ok(), "NEP-245 parsing should succeed"); - - let asset = nep245.unwrap(); - assert_eq!(asset.to_string(), "nep245:multi.near:token123"); - assert_eq!( - asset.contract_id(), - "multi.near".parse::().unwrap() - ); - - // Invalid formats should fail - let invalid: Result, _> = "invalid".parse(); - assert!(invalid.is_err(), "Invalid format should fail parsing"); - } - - #[test] - fn test_asset_spec_nep141_parsing() { - let spec: FungibleAsset = "nep141:token.near".parse().unwrap(); - assert!(spec.clone().into_nep141().is_some()); - assert!(spec.clone().into_nep245().is_none()); - assert_eq!( - spec.contract_id(), - "token.near".parse::().unwrap() - ); - assert_eq!(spec.to_string(), "nep141:token.near"); - } - - #[test] - fn test_asset_spec_nep245_parsing() { - let spec: FungibleAsset = "nep245:multi.near:token123".parse().unwrap(); - assert!(spec.clone().into_nep141().is_none()); - assert!(spec.clone().into_nep245().is_some()); - assert_eq!( - spec.contract_id(), - "multi.near".parse::().unwrap() - ); - assert_eq!(spec.to_string(), "nep245:multi.near:token123"); - } - - #[test] - fn test_asset_spec_invalid_format() { - assert!(matches!( - "invalid".parse::>(), - Err(FungibleAssetParseError::InvalidFormat) - )); - assert!(matches!( - "nep141".parse::>(), - Err(FungibleAssetParseError::InvalidFormat) - )); - assert!(matches!( - "nep245:contract".parse::>(), - Err(FungibleAssetParseError::InvalidFormat) - )); - } - - #[test] - fn test_asset_spec_invalid_account_id() { - assert!(matches!( - "nep141:a".parse::>(), - Err(FungibleAssetParseError::InvalidAccountId(_)) - )); - } - - #[test] - fn test_asset_spec_empty_token_id() { - assert!(matches!( - "nep245:contract.near:".parse::>(), - Err(FungibleAssetParseError::EmptyTokenId) - )); - } - - #[test] - fn test_asset_methods() { - let nep141_spec: FungibleAsset = "nep141:token.near".parse().unwrap(); - assert!(nep141_spec.clone().into_nep141().is_some()); - assert!(nep141_spec.clone().into_nep245().is_none()); - - let nep245_spec: FungibleAsset = "nep245:multi.near:token123".parse().unwrap(); - assert!(nep245_spec.clone().into_nep141().is_none()); - assert!(nep245_spec.clone().into_nep245().is_some()); - } - - #[test] - fn test_asset_compatibility() { - let nep141_spec: FungibleAsset = "nep141:token.near".parse().unwrap(); - let account_id: AccountId = "user.near".parse().unwrap(); - - // Test that we can get the balance action - let balance_action = nep141_spec.balance_of_action(&account_id); - assert_eq!(balance_action.method_name, "ft_balance_of"); - - let nep245_spec: FungibleAsset = "nep245:multi.near:token123".parse().unwrap(); - let balance_action = nep245_spec.balance_of_action(&account_id); - assert_eq!(balance_action.method_name, "mt_balance_of"); - } - - #[test] - fn test_fungible_asset_compatibility() { - // Test that we can create FungibleAsset directly - let fungible_asset = - FungibleAsset::::nep141("token.near".parse::().unwrap()); - - assert!(fungible_asset.clone().into_nep141().is_some()); - assert_eq!( - fungible_asset.contract_id(), - "token.near".parse::().unwrap() - ); - assert_eq!(fungible_asset.to_string(), "nep141:token.near"); - - // Test NEP-245 - let fungible_asset = FungibleAsset::::nep245( - "multi.near".parse::().unwrap(), - "token123".to_string(), - ); - - assert!(fungible_asset.clone().into_nep245().is_some()); - assert_eq!( - fungible_asset.contract_id(), - "multi.near".parse::().unwrap() - ); - assert_eq!(fungible_asset.to_string(), "nep245:multi.near:token123"); - } -} diff --git a/bots/src/swap.rs b/bots/src/swap.rs deleted file mode 100644 index 2415f040..00000000 --- a/bots/src/swap.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::sync::Arc; - -use clap::ValueEnum; -use near_crypto::Signer; -use near_jsonrpc_client::JsonRpcClient; -use near_primitives::{ - action::Action, - transaction::{Transaction, TransactionV0}, - views::FinalExecutionStatus, -}; -use near_sdk::{json_types::U128, near, serde_json, AccountId}; - -use crate::{ - near::{get_access_key_data, send_tx, view, AppError, AppResult}, - Network, -}; -use templar_common::asset::{AssetClass, FungibleAsset}; - -#[async_trait::async_trait] -pub trait Swap { - /// Quotes the amount needed to swap from `from_asset` to obtain `output_amount` of `to_asset`. - async fn quote( - &self, - from_asset: &FungibleAsset, - to_asset: &FungibleAsset, - output_amount: U128, - ) -> AppResult; - - /// Swaps `amount` of `from_asset` to `to_asset`. - async fn swap( - &self, - from_asset: &FungibleAsset, - to_asset: &FungibleAsset, - amount: U128, - ) -> AppResult; -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum SwapType { - RheaSwap, -} - -impl SwapType { - #[must_use] - #[allow( - clippy::unwrap_used, - reason = "We know the contract IDs are valid NEAR account IDs." - )] - pub fn account_id(self, network: Network) -> AccountId { - match self { - SwapType::RheaSwap => match network { - Network::Mainnet => "dclv2.ref-labs.near".parse().unwrap(), - Network::Testnet => "dclv2.ref-dev.testnet".parse().unwrap(), - }, - } - } -} - -#[derive(Debug, Clone)] -pub struct RheaSwap { - pub contract: AccountId, - pub client: JsonRpcClient, - pub signer: Arc, -} - -impl RheaSwap { - pub fn new(contract: AccountId, client: JsonRpcClient, signer: Arc) -> Self { - Self { - contract, - client, - signer, - } - } - - /// Default fee tier for `RheaSwap` DCL pools (0.2%) - const DEFAULT_FEE: u32 = 2000; -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -#[near(serializers = [json, borsh])] -struct QuoteRequest { - pool_ids: Vec, - input_token: AccountId, - output_token: AccountId, - output_amount: U128, - tag: Option, -} - -impl QuoteRequest { - pub fn new( - from_asset: &FungibleAsset, - to_asset: &FungibleAsset, - output_amount: U128, - fee: u32, - ) -> Self { - let input_token: AccountId = from_asset.contract_id().into(); - let output_token: AccountId = to_asset.contract_id().into(); - - // Create pool ID in the format: input_token|output_token|fee - let pool_id = format!("{input_token}|{output_token}|{fee}"); - - Self { - pool_ids: vec![pool_id], - tag: None, - input_token, - output_token, - output_amount, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -#[near(serializers = [json, borsh])] -struct QuoteResponse { - amount: U128, - tag: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -#[near(serializers = [json, borsh])] -enum SwapRequestMsg { - SwapByOutput { - pool_ids: Vec, - output_token: AccountId, - output_amount: U128, - }, -} - -impl SwapRequestMsg { - pub fn new( - from_asset: &FungibleAsset, - to_asset: &FungibleAsset, - output_amount: U128, - fee: u32, - ) -> Self { - let input_token: AccountId = from_asset.contract_id().into(); - let output_token: AccountId = to_asset.contract_id().into(); - - // Create pool ID in the format: input_token|output_token|fee - let pool_id = format!("{input_token}|{output_token}|{fee}"); - - Self::SwapByOutput { - pool_ids: vec![pool_id], - output_token, - output_amount, - } - } -} - -#[async_trait::async_trait] -impl Swap for RheaSwap { - async fn quote( - &self, - from_asset: &FungibleAsset, - to_asset: &FungibleAsset, - output_amount: U128, - ) -> AppResult { - // TODO: For now, Rhea only supports NEP-141 tokens - if from_asset.clone().into_nep141().is_none() || to_asset.clone().into_nep141().is_none() { - return Err(AppError::ValidationError( - "RheaSwap currently only supports NEP-141 tokens".to_string(), - )); - } - - let response: QuoteResponse = view( - &self.client, - self.contract.clone(), - "quote_by_output", - &QuoteRequest::new(from_asset, to_asset, output_amount, Self::DEFAULT_FEE), - ) - .await?; - Ok(response.amount) - } - - async fn swap( - &self, - from_asset: &FungibleAsset, - to_asset: &FungibleAsset, - amount: U128, - ) -> AppResult { - // TODO: For now, Rhea only supports NEP-141 tokens - if from_asset.clone().into_nep141().is_none() || to_asset.clone().into_nep141().is_none() { - return Err(AppError::ValidationError( - "RheaSwap currently only supports NEP-141 tokens".to_string(), - )); - } - - let msg = SwapRequestMsg::new(from_asset, to_asset, amount, Self::DEFAULT_FEE); - let (nonce, block_hash) = get_access_key_data(&self.client, &self.signer).await?; - - let msg_string = serde_json::to_string(&msg).map_err(|e| { - AppError::SerializationError(format!("Failed to serialize swap message: {e}")) - })?; - - let tx = Transaction::V0(TransactionV0 { - nonce, - receiver_id: from_asset.contract_id().into(), - block_hash, - signer_id: self.signer.get_account_id(), - public_key: self.signer.public_key().clone(), - actions: vec![Action::FunctionCall(Box::new( - from_asset.transfer_call_action(&self.contract, amount.into(), &msg_string), - ))], - }); - - send_tx(&self.client, &self.signer, 10, tx) - .await - .map_err(AppError::from) - } -} diff --git a/common/src/asset.rs b/common/src/asset.rs index e3c72727..c96be7bc 100644 --- a/common/src/asset.rs +++ b/common/src/asset.rs @@ -14,7 +14,7 @@ use near_sdk::{ AccountId, AccountIdRef, Gas, NearToken, Promise, }; -use crate::number::Decimal; +use crate::{number::Decimal, panic_with_message}; /// Assets may be configuread as one of the supported asset types. /// @@ -57,12 +57,21 @@ enum FungibleAssetKind { } impl FungibleAsset { - /// Really depends on the implementation, but this should suffice, since - /// normal implementations use < 3TGas. + /// Gas for simple transfers (`ft_transfer`) pub const GAS_FT_TRANSFER: Gas = Gas::from_tgas(6); - /// NEAR Intents implementation uses < 4TGas. + + /// Gas for simple NEP-245 transfers (`mt_transfer`) pub const GAS_MT_TRANSFER: Gas = Gas::from_tgas(7); + /// Gas for `transfer_call` operations (includes callback to receiver) + /// NEP-141 `ft_transfer_call`: Transfer + receiver callback execution + /// Needs extra gas for the receiver contract logic (e.g., market liquidation) + pub const GAS_FT_TRANSFER_CALL: Gas = Gas::from_tgas(100); + + /// Gas for NEP-245 `mt_transfer_call` operations + /// NEAR Intents `mt_transfer_call`: Transfer + receiver callback + collateral transfer back + pub const GAS_MT_TRANSFER_CALL: Gas = Gas::from_tgas(150); + #[allow(clippy::missing_panics_doc, clippy::unwrap_used)] pub fn transfer(&self, receiver_id: AccountId, amount: FungibleAssetAmount) -> Promise { match self.kind { @@ -94,6 +103,42 @@ impl FungibleAsset { } } + /// Creates a simple `ft_transfer` action (no callback). + #[cfg(not(target_arch = "wasm32"))] + pub fn transfer_action( + &self, + receiver_id: &AccountId, + amount: FungibleAssetAmount, + ) -> FunctionCallAction { + let (method_name, args, gas) = match self.kind { + FungibleAssetKind::Nep141(_) => ( + "ft_transfer", + json!({ + "receiver_id": receiver_id, + "amount": u128::from(amount).to_string(), + }), + Self::GAS_FT_TRANSFER, + ), + FungibleAssetKind::Nep245 { ref token_id, .. } => ( + "mt_transfer", + json!({ + "receiver_id": receiver_id, + "token_id": token_id, + "amount": u128::from(amount).to_string(), + }), + Self::GAS_MT_TRANSFER, + ), + }; + + FunctionCallAction { + method_name: method_name.to_string(), + #[allow(clippy::unwrap_used)] + args: serde_json::to_vec(&args).unwrap(), + gas: gas.as_gas(), + deposit: 1, // 1 yoctoNEAR for security + } + } + #[cfg(not(target_arch = "wasm32"))] pub fn transfer_call_action( &self, @@ -105,19 +150,19 @@ impl FungibleAsset { FungibleAssetKind::Nep141(_) => ( json!({ "receiver_id": receiver_id, - "amount": u128::from(amount), + "amount": u128::from(amount).to_string(), "msg": msg, }), - Self::GAS_FT_TRANSFER, + Self::GAS_FT_TRANSFER_CALL, ), FungibleAssetKind::Nep245 { ref token_id, .. } => ( json!({ "receiver_id": receiver_id, "token_id": token_id, - "amount": u128::from(amount), + "amount": u128::from(amount).to_string(), "msg": msg, }), - Self::GAS_MT_TRANSFER, + Self::GAS_MT_TRANSFER_CALL, ), }; @@ -264,7 +309,9 @@ impl std::str::FromStr for FungibleAsset { type Err = FungibleAssetParseError; fn from_str(s: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); + // Use splitn to limit splits - important for NEP-245 where token_id can contain colons + // e.g., "nep245:intents.near:nep141:btc.omft.near" should split into 3 parts max + let parts: Vec<&str> = s.splitn(3, ':').collect(); match parts.as_slice() { ["nep141", contract_id] => { @@ -306,13 +353,13 @@ mod sealed { } pub trait AssetClass: sealed::Sealed + Copy + Clone + Send + Sync + std::fmt::Debug {} -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [borsh, json])] pub struct CollateralAsset; impl sealed::Sealed for CollateralAsset {} impl AssetClass for CollateralAsset {} -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [borsh, json])] pub struct BorrowAsset; impl sealed::Sealed for BorrowAsset {} @@ -386,7 +433,7 @@ impl FungibleAssetAmount { .amount .0 .checked_add(other.into().amount.0) - .unwrap_or_else(|| env::panic_str(&format!("Arithmetic overflow: {message}"))) + .unwrap_or_else(|| panic_with_message(&format!("Arithmetic overflow: {message}"))) .into(), ..self } @@ -415,7 +462,7 @@ impl FungibleAssetAmount { .amount .0 .checked_sub(other.into().amount.0) - .unwrap_or_else(|| env::panic_str(&format!("Arithmetic underflow: {message}"))) + .unwrap_or_else(|| panic_with_message(&format!("Arithmetic underflow: {message}"))) .into(), ..self } diff --git a/common/src/borrow.rs b/common/src/borrow.rs index 15da9f15..3a2227a2 100644 --- a/common/src/borrow.rs +++ b/common/src/borrow.rs @@ -63,7 +63,9 @@ pub struct BorrowPosition { borrow_asset_principal: BorrowAssetAmount, pub interest: Accumulator, pub fees: BorrowAssetAmount, + #[serde(default)] borrow_asset_in_flight: BorrowAssetAmount, + #[serde(default)] collateral_asset_in_flight: CollateralAssetAmount, pub liquidation_lock: CollateralAssetAmount, } @@ -336,7 +338,7 @@ impl> BorrowPositionRef { .0 .checked_sub(prev_end_timestamp_ms) .unwrap_or_else(|| { - env::panic_str(&format!( + crate::panic_with_message(&format!( "Invariant violation: Snapshot timestamp decrease at time chunk #{}.", u64::from(snapshot.time_chunk.0), )) diff --git a/common/src/chunked_append_only_list.rs b/common/src/chunked_append_only_list.rs index 37275c01..d5aef290 100644 --- a/common/src/chunked_append_only_list.rs +++ b/common/src/chunked_append_only_list.rs @@ -1,5 +1,5 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use near_sdk::{env, near, store::Vector, BorshStorageKey, IntoStorageKey}; +use near_sdk::{near, store::Vector, BorshStorageKey, IntoStorageKey}; #[derive(Debug, Clone, Copy, BorshSerialize, BorshStorageKey, PartialEq, Eq, PartialOrd, Ord)] enum StorageKey { @@ -56,11 +56,11 @@ impl .inner .len() .checked_sub(1) - .unwrap_or_else(|| env::panic_str("Inconsistent state: len == 0")); + .unwrap_or_else(|| crate::panic_with_message("Inconsistent state: len == 0")); let v = self .inner .get_mut(last_inner) - .unwrap_or_else(|| env::panic_str("Inconsistent state: tail dne")); + .unwrap_or_else(|| crate::panic_with_message("Inconsistent state: tail dne")); v.push(item); } self.last_chunk_next_index = (self.last_chunk_next_index + 1) % CHUNK_SIZE; @@ -80,7 +80,7 @@ impl .and_then(|last_index| self.inner.get_mut(last_index)) .and_then(|v| v.last_mut()) else { - env::panic_str("Cannot replace_last in empty list"); + crate::panic_with_message("Cannot replace_last in empty list"); }; *entry = item; } diff --git a/common/src/lib.rs b/common/src/lib.rs index 37b0ddd9..12f144da 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -16,6 +16,26 @@ pub mod supply; pub mod time_chunk; pub mod withdrawal_queue; +/// Panic helper that works in both WASM and native contexts. +/// +/// In WASM contexts (contract compilation), uses `near_sdk::env::panic_str`. +/// In native contexts (bots, tests), uses standard `panic!`. +#[cfg(target_arch = "wasm32")] +#[inline] +pub fn panic_with_message(msg: &str) -> ! { + near_sdk::env::panic_str(msg); +} + +/// Panic helper that works in both WASM and native contexts. +/// +/// In WASM contexts (contract compilation), uses `near_sdk::env::panic_str`. +/// In native contexts (bots, tests), uses standard `panic!`. +#[cfg(not(target_arch = "wasm32"))] +#[inline] +pub fn panic_with_message(msg: &str) -> ! { + panic!("{}", msg); +} + /// Approximation of `1 / (1000 * 60 * 60 * 24 * 365.2425)`. /// /// exact = 0.00000000003168873850681143096456210346297... diff --git a/common/src/market/impl.rs b/common/src/market/impl.rs index 0cabf88d..5ac112c5 100644 --- a/common/src/market/impl.rs +++ b/common/src/market/impl.rs @@ -64,7 +64,7 @@ pub struct Market { impl Market { pub fn new(prefix: impl IntoStorageKey, configuration: MarketConfiguration) -> Self { if let Err(e) = configuration.validate() { - env::panic_str(&e.to_string()); + crate::panic_with_message(&e.to_string()); } let prefix = prefix.into_storage_key(); diff --git a/common/src/market/mod.rs b/common/src/market/mod.rs index 423d0297..a536990e 100644 --- a/common/src/market/mod.rs +++ b/common/src/market/mod.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::num::NonZeroU16; -use near_sdk::{env, near, AccountId}; +use near_sdk::{near, AccountId}; use crate::{ asset::{BorrowAssetAmount, CollateralAssetAmount}, @@ -58,7 +58,7 @@ impl YieldWeights { self.r#static .values() .try_fold(self.supply, |a, b| a.checked_add(*b)) - .unwrap_or_else(|| env::panic_str("Total weight overflow")) + .unwrap_or_else(|| crate::panic_with_message("Total weight overflow")) } pub fn static_share(&self, account_id: &AccountId) -> Decimal { diff --git a/common/src/oracle/price_transformer.rs b/common/src/oracle/price_transformer.rs index 9a87914b..a7737571 100644 --- a/common/src/oracle/price_transformer.rs +++ b/common/src/oracle/price_transformer.rs @@ -1,6 +1,6 @@ use near_sdk::{ json_types::{Base64VecU8, U64}, - near, AccountId, Gas, NearToken, Promise, + near, AccountId, Gas, }; use crate::number::Decimal; @@ -66,14 +66,23 @@ impl Call { ) } - pub fn promise(&self) -> Promise { - Promise::new(self.account_id.clone()).function_call( + pub fn promise(&self) -> near_sdk::Promise { + near_sdk::Promise::new(self.account_id.clone()).function_call( self.method_name.clone(), self.args.0.clone(), - NearToken::from_near(0), + near_sdk::NearToken::from_near(0), Gas::from_gas(self.gas.0), ) } + + #[cfg(not(target_arch = "wasm32"))] + pub fn rpc_call(&self) -> near_primitives::views::QueryRequest { + near_primitives::views::QueryRequest::CallFunction { + account_id: self.account_id.clone(), + method_name: self.method_name.clone(), + args: self.args.0.clone().into(), + } + } } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/common/src/supply.rs b/common/src/supply.rs index e5f9a315..8810d3ee 100644 --- a/common/src/supply.rs +++ b/common/src/supply.rs @@ -1,6 +1,6 @@ use std::ops::{Deref, DerefMut}; -use near_sdk::{env, json_types::U64, near, require, AccountId}; +use near_sdk::{json_types::U64, near, require, AccountId}; use crate::{ accumulator::{AccumulationRecord, Accumulator}, @@ -298,7 +298,7 @@ impl<'a> SupplyPositionGuard<'a> { incoming.activate_at_snapshot_index == newest.activate_at_snapshot_index }) else { - env::panic_str("Invariant violation: Market incoming entry should exist if position incoming entry exists"); + crate::panic_with_message("Invariant violation: Market incoming entry should exist if position incoming entry exists"); }; market_incoming.amount = market_incoming.amount.unwrap_sub(newest.amount, "Invariant violation: Market incoming >= position incoming should hold for all snapshot indices"); @@ -392,7 +392,9 @@ impl<'a> SupplyPositionGuard<'a> { let Some(U64(started_at_block_timestamp_ms)) = self.0.position.started_at_block_timestamp_ms else { - env::panic_str("Invariant violation: Position with deposit has no timestamp"); + crate::panic_with_message( + "Invariant violation: Position with deposit has no timestamp", + ); }; let supply_duration = block_timestamp_ms.saturating_sub(started_at_block_timestamp_ms); @@ -401,7 +403,7 @@ impl<'a> SupplyPositionGuard<'a> { .configuration .supply_withdrawal_fee .of(withdrawal_amount, supply_duration) - .unwrap_or_else(|| env::panic_str("Fee calculation overflow")) + .unwrap_or_else(|| crate::panic_with_message("Fee calculation overflow")) .min(withdrawal_amount); let amount_to_account = withdrawal_amount.saturating_sub(amount_to_fees); diff --git a/common/src/time_chunk.rs b/common/src/time_chunk.rs index 4f493283..05b381a9 100644 --- a/common/src/time_chunk.rs +++ b/common/src/time_chunk.rs @@ -35,7 +35,9 @@ impl TimeChunkConfiguration { pub fn duration_ms(&self) -> u64 { match self { TimeChunkConfiguration::V0(V0::BlockTimestampMs { divisor }) => divisor.0, - TimeChunkConfiguration::V0(_) => env::panic_str("Unsupported time chunk configuration"), + TimeChunkConfiguration::V0(_) => { + crate::panic_with_message("Unsupported time chunk configuration") + } TimeChunkConfiguration::V1(v1) => v1.duration_ms.0, } } diff --git a/common/src/withdrawal_queue.rs b/common/src/withdrawal_queue.rs index 8470e947..5caa593b 100644 --- a/common/src/withdrawal_queue.rs +++ b/common/src/withdrawal_queue.rs @@ -1,6 +1,6 @@ use std::num::NonZeroU32; -use near_sdk::{collections::LookupMap, env, near, AccountId, BorshStorageKey, IntoStorageKey}; +use near_sdk::{collections::LookupMap, near, AccountId, BorshStorageKey, IntoStorageKey}; use crate::asset::BorrowAssetAmount; @@ -33,7 +33,7 @@ enum StorageKey { } fn inconsistent_state() -> T { - env::panic_str("Inconsistent state") + crate::panic_with_message("Inconsistent state") } impl WithdrawalQueue { diff --git a/service/relayer/tests/relayer.rs b/service/relayer/tests/relayer.rs index 883599a7..eddc1515 100644 --- a/service/relayer/tests/relayer.rs +++ b/service/relayer/tests/relayer.rs @@ -106,9 +106,9 @@ fn create_execute_message( #[fixture] async fn init_test(#[future(awt)] worker: Worker) -> InitTest { - tracing_subscriber::fmt() + let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) - .init(); + .try_init(); setup_test!(worker extract(c) accounts(borrow_user, relay_user, ua_deployer)); let rpc_addr = worker.rpc_addr();