diff --git a/Cargo.lock b/Cargo.lock index 1df05625..e6d19f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -602,8 +602,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -620,13 +630,38 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.103", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.103", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.103", ] @@ -687,22 +722,23 @@ dependencies = [ [[package]] name = "diesel" -version = "2.2.10" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff3e1edb1f37b4953dd5176916347289ed43d7119cc2e6c7c3f7849ff44ea506" +checksum = "cb300b92bb5f1a44ce412aeb36a7387931bf7a5238b4570bc1d0b9c8a78fde4e" dependencies = [ "bitflags 2.9.1", "byteorder", "diesel_derives", + "downcast-rs 2.0.2", "itoa", "pq-sys", ] [[package]] name = "diesel_derives" -version = "2.2.5" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d4216021b3ea446fd2047f5c8f8fe6e98af34508a254a01e4d6bc1e844f84d" +checksum = "f8dc7010a1e9f98f10c746da308c3da09416a87498731cc18cc666f55b5fc53d" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", @@ -713,9 +749,9 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ "syn 2.0.103", ] @@ -761,13 +797,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dsl_auto_type" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling", + "darling 0.21.3", "either", "heck", "proc-macro2", @@ -1269,7 +1311,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1470,6 +1512,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1694,7 +1747,7 @@ dependencies = [ "soroban-sdk", "soroban-token-sdk", "stellar-rpc-client", - "stellar-strkey 0.0.9", + "stellar-strkey 0.0.13", "stellar-xdr", "tokio", "tokio-test", @@ -2530,10 +2583,11 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" dependencies = [ + "serde_core", "serde_derive", ] @@ -2559,11 +2613,20 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -2572,14 +2635,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -2620,7 +2684,7 @@ version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.103", @@ -2725,6 +2789,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "soroban-builtin-sdk-macros" version = "23.0.1" @@ -2876,7 +2950,7 @@ version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b38abe20199c5d9fbff232381aa4e8e83302b34e82e38fbb090f41f1284fc920" dependencies = [ - "darling", + "darling 0.20.11", "heck", "itertools", "macro-string", @@ -3263,20 +3337,22 @@ dependencies = [ [[package]] name = "tokio" -version = "1.45.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "slab", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3584,7 +3660,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" dependencies = [ - "downcast-rs", + "downcast-rs 1.2.1", "libm", "num-traits", "paste", diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index abfadc93..ced2867e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,6 +27,14 @@ To deploy new smart contracts to testnet, run the `scripts/initialize.ts` script npm run init ``` +Or if you want to use a mock oracle to allow changing the price of a token run: + +``` +npm run init:mock-oracle +``` + +You'll have to grab that oracle's address and place it in .env for liquidation bot to know to use it. + To update the code of already initialized contracts in-place, use the `scripts/upgrade.ts` script. ``` diff --git a/contracts/loan_manager/src/storage.rs b/contracts/loan_manager/src/storage.rs index 068ea09b..d9a21439 100644 --- a/contracts/loan_manager/src/storage.rs +++ b/contracts/loan_manager/src/storage.rs @@ -47,35 +47,38 @@ pub struct Loan { } /* Contract events */ -#[contractevent(topics = ["AdminAdded"])] +#[contractevent(topics = ["admin_added"])] pub struct EventAdminAdded { pub admin: Address, } -#[contractevent(topics = ["OracleAdded"])] +#[contractevent(topics = ["oracle_added"])] pub struct EventOracleAdded { pub oracle: Address, } -#[contractevent(topics = ["PoolAddressAdded"])] +#[contractevent(topics = ["pool_address_added"])] pub struct EventPoolAddressAdded { pub pool_address: Address, } -#[contractevent(topics = ["LoanCreated"])] +#[contractevent(topics = ["loan_created"])] pub struct EventLoanCreated { + #[topic] pub loan_id: LoanId, pub loan: Loan, } -#[contractevent(topics = ["LoanUpdated"])] +#[contractevent(topics = ["loan_updated"])] pub struct EventLoanUpdated { + #[topic] pub loan_id: LoanId, pub loan: Loan, } -#[contractevent(topics = ["LoanDeleted"])] +#[contractevent(topics = ["loan_deleted"])] pub struct EventLoanDeleted { + #[topic] pub loan_id: LoanId, } diff --git a/contracts/loan_pool/src/storage.rs b/contracts/loan_pool/src/storage.rs index 0f45bced..f06fafbf 100644 --- a/contracts/loan_pool/src/storage.rs +++ b/contracts/loan_pool/src/storage.rs @@ -64,57 +64,57 @@ enum PoolDataKey { } /* Contract events */ -#[contractevent(topics = ["PoolStatusUpdated"])] +#[contractevent(topics = ["pool_status_updated"])] pub struct EventPoolStatusUpdated { pub pool_status: PoolStatus, } -#[contractevent(topics = ["LoanManagerAddressAdded"])] +#[contractevent(topics = ["loan_manager_address_added"])] pub struct EventLoanManagerAddressAdded { pub loan_manager_addr: Address, } -#[contractevent(topics = ["CurrencyAdded"])] +#[contractevent(topics = ["currency_added"])] pub struct EventCurrencyAdded { pub currency: Currency, } -#[contractevent(topics = ["LiquidationThresholdChanged"])] +#[contractevent(topics = ["liquidation_threshold_changed"])] pub struct EventLiquidationThresholdChanged { pub threshold: i128, } -#[contractevent(topics = ["TotalSharesUpdated"])] +#[contractevent(topics = ["total_shares_updated"])] pub struct EventTotalSharesUpdated { pub amount: i128, } -#[contractevent(topics = ["TotalBalanceChanged"])] +#[contractevent(topics = ["total_balance_changed"])] pub struct EventTotalBalanceChanged { pub amount: i128, } -#[contractevent(topics = ["AvailableBalanceChanged"])] +#[contractevent(topics = ["available_balance_changed"])] pub struct EventAvailableBalanceChanged { pub amount: i128, } -#[contractevent(topics = ["AccrualChanged"])] +#[contractevent(topics = ["accrual_changed"])] pub struct EventAccrualChanged { pub accrual: i128, } -#[contractevent(topics = ["AccrualLastUpdated"])] +#[contractevent(topics = ["accrual_last_updated"])] pub struct EventAccrualLastUpdated { pub timestamp: u64, } -#[contractevent(topics = ["InterestRateMultiplierChanged"])] +#[contractevent(topics = ["interest_rate_multiplier_changed"])] pub struct EventInterestMultiplierChanged { pub multiplier: i128, } -#[contractevent(topics = ["PositionsUpdated"])] +#[contractevent(topics = ["positions_updated"])] pub struct EventPositionsUpdated { pub addr: Address, pub positions: Positions, diff --git a/liquidation-bot/Cargo.toml b/liquidation-bot/Cargo.toml index 0e8cae34..14ef237f 100644 --- a/liquidation-bot/Cargo.toml +++ b/liquidation-bot/Cargo.toml @@ -7,21 +7,21 @@ edition = "2021" local = [] [dependencies] -diesel = { version = "2.2.6", features = ["postgres"] } +diesel = { version = "2.2.12", features = ["postgres"] } dotenvy = "0.15" -log = "0.4.25" -env_logger = "0.11.6" +log = "0.4.27" +env_logger = "0.11.8" stellar-rpc-client = "23.0.1" -tokio = { version = "1.45.0", features = ["full"] } -serde_json = "1.0.140" +tokio = { version = "1.47.1", features = ["full"] } +serde_json = "1.0.143" stellar-xdr = { version = "23.0.0", features=["base64", "alloc", "serde"] } serde = { version = "1.0", features = ["derive"] } -stellar-strkey = "0.0.9" +stellar-strkey = "0.0.13" base64 = "0.22.1" anyhow = "1.0.98" soroban-client = "0.5.1" -soroban-sdk = "23.0.0" -soroban-token-sdk = "23.0.0" +soroban-sdk = "23.0.2" +soroban-token-sdk = "23.0.2" once_cell = "1.21" [dev-dependencies] diff --git a/liquidation-bot/migrations/2025-01-23-111009_create_loans/up.sql b/liquidation-bot/migrations/2025-01-23-111009_create_loans/up.sql index 1c112243..1a956de5 100644 --- a/liquidation-bot/migrations/2025-01-23-111009_create_loans/up.sql +++ b/liquidation-bot/migrations/2025-01-23-111009_create_loans/up.sql @@ -1,10 +1,11 @@ -- Your SQL goes here CREATE TABLE loans ( - id SERIAL PRIMARY KEY, + borrower_address TEXT NOT NULL, + nonce BIGINT NOT NULL, borrowed_amount BIGINT NOT NULL, borrowed_from TEXT NOT NULL, - borrower TEXT NOT NULL, collateral_amount BIGINT NOT NULL, collateral_from TEXT NOT NULL, - unpaid_interest BIGINT NOT NULL -) + unpaid_interest BIGINT NOT NULL, + PRIMARY KEY (borrower_address, nonce) +); diff --git a/liquidation-bot/src/bin/liquidation_bot.rs b/liquidation-bot/src/bin/liquidation_bot.rs index d8e1dbb3..d3989cbe 100644 --- a/liquidation-bot/src/bin/liquidation_bot.rs +++ b/liquidation-bot/src/bin/liquidation_bot.rs @@ -1,19 +1,19 @@ use core::time::Duration; use dotenvy::dotenv; -use log::{error, info, warn}; +use log::{debug, info, warn}; use once_cell::sync::OnceCell; use std::collections::HashSet; use std::{cell::RefCell, env, rc::Rc, str::FromStr, thread}; -use stellar_xdr::curr::StringM; -use self::models::{Loan, Price}; +use self::models::{Loan, LoanId, Price}; use self::schema::loans::dsl::loans; use self::schema::prices::dsl::prices; use anyhow::{Error, Result}; use diesel::prelude::*; use liquidation_bot::utils::{ - asset_to_scval, decode_loan_from_simulate_response, extract_i128_from_result, Asset, + asset_to_scval, extract_i128_from_result, parse_loan_from_rpc_event, parse_loan_id_from_topic, + Asset, }; use liquidation_bot::*; use soroban_client::{ @@ -23,11 +23,10 @@ use soroban_client::{ network::{NetworkPassphrase, Networks}, operation::Operation, soroban_rpc::{ - EventResponse, EventType, GetEventsResponse, SendTransactionResponse, - SendTransactionStatus, TransactionStatus, + EventResponse, EventType, SendTransactionResponse, SendTransactionStatus, TransactionStatus, }, - transaction::{ScVal, TransactionBehavior, TransactionBuilder, TransactionBuilderBehavior}, - xdr::ScSymbol, + transaction::{TransactionBehavior, TransactionBuilder, TransactionBuilderBehavior}, + xdr::{ScMap, ScMapEntry, ScSymbol, ScVal, StringM, VecM}, EventFilter, Options, Pagination, Server, Topic, }; @@ -84,168 +83,164 @@ async fn main() -> Result<(), Error> { let mut ledger = rpc_client.get_latest_ledger().await?.sequence - history_depth; loop { - let GetEventsResponse { - events, - latest_ledger: new_ledger, - .. - } = fetch_events(ledger, &server).await?; - ledger = new_ledger as u32; + let loan_events = fetch_events(ledger, &server).await?; + ledger = loan_events.latest_ledger as u32; - find_loans_from_events(events, db_connection, &server, &source_account).await?; + process_events(loan_events, db_connection).await?; fetch_prices(db_connection, &server, &source_account).await?; - find_liquidateable(db_connection, &server, &source_account).await?; + find_liquidateable(db_connection, &server).await?; info!("Sleeping for {SLEEP_TIME_SECONDS} seconds."); thread::sleep(Duration::from_secs(SLEEP_TIME_SECONDS)) } } -async fn fetch_events(ledger: u32, server: &Server) -> Result { - info!("Fetching new loans from Loan Manager."); - - let symbol_loan_created: StringM<32> = "LoanCreated".try_into().unwrap(); - let topic_loan_created = Topic::Val(ScVal::Symbol(ScSymbol(symbol_loan_created))); - - let symbol_loan_updated: StringM<32> = "LoanUpdated".try_into().unwrap(); - let topic_loan_updated = Topic::Val(ScVal::Symbol(ScSymbol(symbol_loan_updated))); +struct LoanEvents { + loan_created_events: Vec, + loan_updated_events: Vec, + loan_deleted_events: Vec, + latest_ledger: u64, +} - let symbol_loan_deleted: StringM<32> = "LoanDeleted".try_into().unwrap(); - let topic_loan_deleted = Topic::Val(ScVal::Symbol(ScSymbol(symbol_loan_deleted))); +async fn fetch_events(ledger: u32, server: &Server) -> Result { + info!("Fetching new loans from Loan Manager."); let BotConfig { loan_manager_id, .. } = get_config(); - let event_filter = EventFilter::new(EventType::Contract) - .topic(vec![topic_loan_created]) - .topic(vec![topic_loan_deleted]) - .topic(vec![topic_loan_updated]) - .contract(&loan_manager_id); - let limit = Some(100); - - let events_response = server - .get_events(Pagination::From(ledger), vec![event_filter], limit) + + let loan_created = ScVal::Symbol(ScSymbol("loan_created".try_into().unwrap())); + let loan_created_events = server + .get_events( + Pagination::From(ledger), + vec![EventFilter::new(EventType::Contract) + .topic(vec![Topic::Val(loan_created), Topic::Any]) + .contract(loan_manager_id)], + None, + ) .await?; - info!("{:#?}", events_response); - info!("{}", loan_manager_id); - info!("{}", ledger); - Ok(events_response) -} -async fn find_loans_from_events( - events: Vec, - db_connection: &mut PgConnection, - server: &Server, - source_account: &Rc>, -) -> Result<(), Error> { - for event in events { - info!("topic: {:#?}", event.topic()); - info!("value: {:#?}", event.value()); - } - Ok(()) + let loan_updated = ScVal::Symbol(ScSymbol("loan_updated".try_into().unwrap())); + let loan_updated_events = server + .get_events( + Pagination::From(ledger), + vec![EventFilter::new(EventType::Contract) + .topic(vec![Topic::Val(loan_updated), Topic::Any]) + .contract(loan_manager_id)], + None, + ) + .await?; + + let loan_deleted = ScVal::Symbol(ScSymbol("loan_deleted".try_into().unwrap())); + let loan_deleted_events = server + .get_events( + Pagination::From(ledger), + vec![EventFilter::new(EventType::Contract) + .topic(vec![Topic::Val(loan_deleted), Topic::Any]) + .contract(loan_manager_id)], + None, + ) + .await?; + + Ok(LoanEvents { + loan_created_events: loan_created_events.events, + loan_updated_events: loan_updated_events.events, + loan_deleted_events: loan_deleted_events.events, + latest_ledger: loan_created_events.latest_ledger, + }) } -async fn fetch_loan_to_db( - loan: Vec, +async fn process_events( + loan_events: LoanEvents, db_connection: &mut PgConnection, - server: &Server, - source_account: &Rc>, ) -> Result<(), Error> { - let config = get_config(); - let method = "get_loan"; - let loan_owner = &loan[1]; - info!("Fetching loan {} to database", loan_owner); - let args = vec![Address::to_sc_val( - &Address::from_string(loan_owner) - .map_err(|e| anyhow::anyhow!("Account::from_string failed: {}", e))?, - ) - .map_err(|e| anyhow::anyhow!("Address::to_sc_val failed: {}", e))?]; - - let read_loan_op = Operation::new() - .invoke_contract(&config.loan_manager_id, method, args, None) - .expect("Cannot create invoke_contract operation"); - - // Build the transaction - #[cfg(feature = "local")] - let network = Networks::standalone(); - - #[cfg(not(feature = "local"))] - let network = Networks::testnet(); - - let mut builder = TransactionBuilder::new(source_account.clone(), network, None); - builder.fee(1000u32); - builder.add_operation(read_loan_op); - - let mut tx = builder.build(); - tx.sign(std::slice::from_ref(&config.source_keypair)); - - // Simulate transaction and handle response - let response = server.simulate_transaction(&tx, None).await?; - - // TODO: Add test - match response.to_result() { - Some(result) => { - let loan = decode_loan_from_simulate_response(result)?; - save_loan(db_connection, loan)?; + info!( + "Updating database with new loan events. {} create events, {} updated events, {} deleted events", + loan_events.loan_created_events.len(), + loan_events.loan_updated_events.len(), + loan_events.loan_deleted_events.len() + ); + + for event in loan_events.loan_created_events { + match parse_loan_from_rpc_event(&event.value()) { + Ok(loan) => { + debug!("Successfully parsed created loan: {:#?}", loan); + save_loan(db_connection, loan)?; + } + Err(e) => { + warn!("Failed to parse loan from RPC event: {}", e); + } } - None => { - warn!("Simulation returned None. Loan may not exist (deleted). Skipping."); - return Ok(()); + } + + for event in loan_events.loan_updated_events { + match parse_loan_from_rpc_event(&event.value()) { + Ok(loan) => { + debug!("Successfully parsed updated loan: {:#?}", loan); + save_loan(db_connection, loan)?; + } + Err(e) => { + warn!("Failed to parse updated loan from RPC event: {}", e); + } } } + for event in loan_events.loan_deleted_events { + match parse_loan_id_from_topic(&event.topic()) { + Ok(loan_id) => { + debug!("Successfully parsed deleted loan ID: {:#?}", loan_id); + delete_loan_from_db(&loan_id, db_connection)?; + } + Err(e) => { + warn!("Failed to parse deleted loan ID from topic: {}", e); + } + } + } Ok(()) } pub fn save_loan(db_connection: &mut PgConnection, loan: Loan) -> Result<(), Error> { use crate::schema::loans::dsl::*; - let existing = loans - .filter(borrower.eq(&loan.borrower)) - .first::(db_connection) - .optional()?; - - if let Some(existing_loan) = existing { - diesel::update(loans.filter(id.eq(existing_loan.id))) - .set(( - borrowed_amount.eq(loan.borrowed_amount), - borrowed_from.eq(loan.borrowed_from), - collateral_amount.eq(loan.collateral_amount), - collateral_from.eq(loan.collateral_from), - unpaid_interest.eq(loan.unpaid_interest), - )) - .execute(db_connection)?; - } else { - diesel::insert_into(loans) - .values(( - crate::schema::loans::borrower.eq(&loan.borrower), - crate::schema::loans::borrowed_amount.eq(loan.borrowed_amount), - crate::schema::loans::borrowed_from.eq(&loan.borrowed_from), - crate::schema::loans::collateral_amount.eq(loan.collateral_amount), - crate::schema::loans::collateral_from.eq(&loan.collateral_from), - crate::schema::loans::unpaid_interest.eq(loan.unpaid_interest), - )) - .execute(db_connection)?; - } + diesel::insert_into(loans) + .values(&loan) + .on_conflict((borrower_address, nonce)) + .do_update() + .set(( + borrowed_amount.eq(loan.borrowed_amount), + borrowed_from.eq(loan.borrowed_from.clone()), + collateral_amount.eq(loan.collateral_amount), + collateral_from.eq(loan.collateral_from.clone()), + unpaid_interest.eq(loan.unpaid_interest), + )) + .execute(db_connection)?; Ok(()) } -async fn delete_loan_from_db( - value: Vec, - db_connection: &mut PgConnection, -) -> Result<(), Error> { +fn delete_loan_from_db(loan_id: &LoanId, db_connection: &mut PgConnection) -> Result<(), Error> { use crate::schema::loans::dsl::*; - let loan_owner = &value[1]; - - let deleted_rows = - diesel::delete(loans.filter(borrower.eq(loan_owner))).execute(db_connection)?; + let deleted_rows = diesel::delete( + loans.filter( + borrower_address + .eq(&loan_id.borrower_address) + .and(nonce.eq(loan_id.nonce)), + ), + ) + .execute(db_connection)?; if deleted_rows > 0 { - info!("Deleted loan for borrower: {}", loan_owner); + info!( + "Deleted loan for borrower: {} with nonce: {}", + loan_id.borrower_address, loan_id.nonce + ); } else { - warn!("No loan found to delete for borrower: {}", loan_owner); + warn!( + "No loan found to delete for borrower: {} with nonce: {}", + loan_id.borrower_address, loan_id.nonce + ); } Ok(()) @@ -356,11 +351,7 @@ async fn fetch_prices( Ok(()) } -async fn find_liquidateable( - connection: &mut PgConnection, - server: &Server, - source_account: &Rc>, -) -> Result<(), Error> { +async fn find_liquidateable(connection: &mut PgConnection, server: &Server) -> Result<(), Error> { let all_loans = loans .select(Loan::as_select()) .load(connection) @@ -422,10 +413,10 @@ async fn find_liquidateable( let health_factor_threshold = 10_100_000; if health_factor < health_factor_threshold { info!("Found loan close to liquidation threshold: {:#?}", loan); - if let Err(e) = attempt_liquidating(loan.clone(), server, source_account).await { + if let Err(e) = attempt_liquidating(loan.clone(), server).await { warn!( "Failed to liquidate loan for borrower {}: {}", - loan.borrower, e + loan.borrower_address, e ); // Continue processing other loans instead of crashing the bot } @@ -435,18 +426,16 @@ async fn find_liquidateable( Ok(()) } -async fn attempt_liquidating( - loan: Loan, - server: &Server, - source_account: &Rc>, -) -> Result<(), Error> { - let config = get_config(); +async fn attempt_liquidating(loan: Loan, server: &Server) -> Result<(), Error> { + let BotConfig { + loan_manager_id, + source_keypair, + .. + } = get_config(); info!("Attempting to liquidate loan: {:#?}", loan); // Build operation - let method = "liquidate"; - let loan_owner = &loan.borrower; //TODO: This has to be optimized somehow. Sometimes half of the loan can be too much. Then //again sometimes very small liquidations don't help. @@ -455,22 +444,36 @@ async fn attempt_liquidating( .checked_div(3) .ok_or(Error::msg("OverOrUnderFlow"))? as i128; - let args = vec![ - Address::to_sc_val( - &Address::from_string(&config.source_keypair.public_key()) - .map_err(|e| anyhow::anyhow!("Account::from_string failed: {}", e))?, - ) - .map_err(|e| anyhow::anyhow!("Address::to_sc_val failed: {}", e))?, - Address::to_sc_val( - &Address::from_string(loan_owner) - .map_err(|e| anyhow::anyhow!("Account::from_string failed: {}", e))?, - ) - .map_err(|e| anyhow::anyhow!("Address::to_sc_val failed: {}", e))?, - amount.into(), + // Encode LoanId as ScVal::Map({ borrower_address: Address, nonce: u64 }) to match contracttype + let borrower_addr_scval = Address::to_sc_val( + &Address::from_string(&source_keypair.public_key()) + .map_err(|e| anyhow::anyhow!("Account::from_string failed: {}", e))?, + ) + .map_err(|e| anyhow::anyhow!("Address::to_sc_val failed: {}", e))?; + + let loan_id_entries = vec![ + ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("borrower_address")?)), + val: Address::to_sc_val( + &Address::from_string(&loan.borrower_address) + .map_err(|e| anyhow::anyhow!("Account::from_string failed: {}", e))?, + ) + .map_err(|e| anyhow::anyhow!("Address::to_sc_val failed: {}", e))?, + }, + ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("nonce")?)), + val: ScVal::U64(loan.nonce as u64), + }, ]; + let loan_id_scval = ScVal::Map(Some(ScMap( + VecM::try_from(loan_id_entries) + .map_err(|_| anyhow::anyhow!("Failed to convert Vec to VecM for LoanId map"))?, + ))); + + let args = vec![borrower_addr_scval, loan_id_scval, amount.into()]; let read_loan_op = Operation::new() - .invoke_contract(&config.loan_manager_id, method, args.clone(), None) + .invoke_contract(loan_manager_id, "liquidate", args.clone(), None) .expect("Cannot create invoke_contract operation"); //TODO: response now has data like minimal resource fee and if the liquidation would likely be @@ -483,6 +486,15 @@ async fn attempt_liquidating( #[cfg(not(feature = "local"))] let network = Networks::testnet(); + let account_data = server.get_account(&source_keypair.public_key()).await?; + let source_account = Rc::new(RefCell::new( + Account::new( + &source_keypair.public_key(), + &account_data.sequence_number(), + ) + .map_err(|e| anyhow::anyhow!("Account::new failed: {}", e))?, + )); + let mut builder = TransactionBuilder::new(source_account.clone(), network, None); builder.fee(10000_u32); builder.add_operation(read_loan_op); @@ -495,13 +507,13 @@ async fn attempt_liquidating( Err(e) => { warn!( "Transaction simulation failed for loan {}: {}", - loan.borrower, e + loan.borrower_address, e ); return Err(anyhow::anyhow!("Simulation failed: {}", e)); } }; - tx.sign(std::slice::from_ref(&config.source_keypair)); + tx.sign(std::slice::from_ref(source_keypair)); // Send transaction let response = match server.send_transaction(tx).await { @@ -509,7 +521,7 @@ async fn attempt_liquidating( Err(e) => { warn!( "Transaction sending failed for loan {}: {}", - loan.borrower, e + loan.borrower_address, e ); return Err(anyhow::anyhow!("Transaction sending failed: {}", e)); } @@ -525,10 +537,14 @@ async fn attempt_liquidating( // let max_to_liquidate // let hash = response.hash.clone(); + let loan_id = LoanId { + borrower_address: loan.borrower_address.clone(), + nonce: loan.nonce, + }; if wait_success(server, hash, response).await { - info!("Loan {} liquidated!", loan.borrower); + info!("Loan {} liquidated!", loan_id); } else { - warn!("Failed to liquidate loan for {}", loan.borrower); + warn!("Failed to liquidate loan for {}", loan_id); } Ok(()) } @@ -607,7 +623,7 @@ async fn load_config() -> Result { #[cfg(test)] mod tests { use super::*; - use crate::schema::loans::dsl::{borrower as borrower_col, loans}; + use crate::schema::loans::dsl::{borrower_address as borrower_col, loans}; use crate::schema::prices::dsl::prices; use crate::schema::prices::{ pool_address as pool_address_col, time_weighted_average_price as price_col, @@ -624,8 +640,8 @@ mod tests { fn create_test_loan(borrower: &str) -> Loan { Loan { - id: 0, - borrower: borrower.to_string(), + borrower_address: borrower.to_string(), + nonce: 0, borrowed_amount: 1000000000, // 1000 tokens with 7 decimals borrowed_from: "CCDF2NOJXOW73SXXB6BZRAPGVNJU7VMUURXCVLRHCHHAXHOY2TVRLFFP".to_string(), collateral_amount: 2000000000, // 2000 tokens with 7 decimals @@ -670,8 +686,8 @@ mod tests { clean_test_data(&mut conn, "TEST_BORROWER"); let test_loan = Loan { - id: 0, - borrower: "TEST_BORROWER".into(), + borrower_address: "TEST_BORROWER".into(), + nonce: 0, borrowed_amount: 100, borrowed_from: "SourceA".into(), collateral_amount: 50, @@ -717,7 +733,7 @@ mod tests { .first::(&mut conn) .unwrap(); - assert_eq!(saved.borrower, test_borrower); + assert_eq!(saved.borrower_address, test_borrower); assert_eq!(saved.borrowed_amount, test_loan.borrowed_amount); assert_eq!(saved.collateral_amount, test_loan.collateral_amount); @@ -755,8 +771,11 @@ mod tests { assert_eq!(count_before, 1); // Delete loan - let value = vec!["loan".to_string(), test_borrower.to_string()]; - delete_loan_from_db(value, &mut conn).await.unwrap(); + let loan_id = LoanId { + borrower_address: test_borrower.to_string(), + nonce: 0, + }; + delete_loan_from_db(&loan_id, &mut conn).unwrap(); // Verify loan is deleted let count_after = loans @@ -996,8 +1015,11 @@ mod tests { clean_test_data(&mut conn, test_borrower); // Try to delete non-existent loan - let value = vec!["loan".to_string(), test_borrower.to_string()]; - let result = delete_loan_from_db(value, &mut conn).await; + let loan_id = LoanId { + borrower_address: test_borrower.to_string(), + nonce: 1, + }; + let result = delete_loan_from_db(&loan_id, &mut conn); // Should succeed (no error) even if loan doesn't exist assert!(result.is_ok()); @@ -1053,8 +1075,8 @@ mod tests { // Test with zero amounts let zero_loan = Loan { - id: 0, - borrower: test_borrower.to_string(), + borrower_address: test_borrower.to_string(), + nonce: 0, borrowed_amount: 0, borrowed_from: "POOL1".to_string(), collateral_amount: 0, @@ -1067,8 +1089,8 @@ mod tests { // Test with large values (but not MAX to avoid potential DB constraints) let large_loan = Loan { - id: 0, - borrower: format!("{}_LARGE", test_borrower), + borrower_address: format!("{}_LARGE", test_borrower), + nonce: 0, borrowed_amount: 1_000_000_000_000_000, // 1 quadrillion borrowed_from: "POOL1".to_string(), collateral_amount: 2_000_000_000_000_000, // 2 quadrillion diff --git a/liquidation-bot/src/models.rs b/liquidation-bot/src/models.rs index 6241771e..3efcdf56 100644 --- a/liquidation-bot/src/models.rs +++ b/liquidation-bot/src/models.rs @@ -1,13 +1,16 @@ use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; #[derive(Queryable, Selectable, Insertable, Clone, PartialEq, Debug)] #[diesel(table_name = crate::schema::loans)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Loan { - pub id: i32, + pub borrower_address: String, + pub nonce: i64, pub borrowed_amount: i64, pub borrowed_from: String, - pub borrower: String, pub collateral_amount: i64, pub collateral_from: String, pub unpaid_interest: i64, @@ -21,3 +24,35 @@ pub struct Price { pub pool_address: String, pub time_weighted_average_price: i64, } + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct LoanId { + pub borrower_address: String, + pub nonce: i64, +} + +impl Display for LoanId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.borrower_address, self.nonce) + } +} + +impl FromStr for LoanId { + type Err = String; + + fn from_str(s: &str) -> Result { + let mut parts = s.split(':'); + let borrower_address = parts + .next() + .ok_or_else(|| "missing borrower address".to_string())? + .to_string(); + let nonce_str = parts.next().ok_or_else(|| "missing nonce".to_string())?; + let nonce = nonce_str + .parse::() + .map_err(|_| "invalid nonce".to_string())?; + Ok(LoanId { + borrower_address, + nonce, + }) + } +} diff --git a/liquidation-bot/src/schema.rs b/liquidation-bot/src/schema.rs index 4f9d44e8..236d460c 100644 --- a/liquidation-bot/src/schema.rs +++ b/liquidation-bot/src/schema.rs @@ -1,11 +1,11 @@ // @generated automatically by Diesel CLI. diesel::table! { - loans (id) { - id -> Int4, + loans (borrower_address, nonce) { + borrower_address -> Text, + nonce -> Int8, borrowed_amount -> Int8, borrowed_from -> Text, - borrower -> Text, collateral_amount -> Int8, collateral_from -> Text, unpaid_interest -> Int8, diff --git a/liquidation-bot/src/utils.rs b/liquidation-bot/src/utils.rs index 0e22005c..656e5430 100644 --- a/liquidation-bot/src/utils.rs +++ b/liquidation-bot/src/utils.rs @@ -1,14 +1,15 @@ use std::collections::HashMap; use std::str::FromStr; -use crate::models::Loan; +use crate::models::{Loan, LoanId}; use anyhow::{anyhow, Error, Result}; use base64::engine::general_purpose::STANDARD as base64_engine; use base64::Engine; use soroban_client::address::{Address, AddressTrait}; use soroban_client::xdr::int128_helpers::i128_from_pieces; -use soroban_client::xdr::{Int128Parts, ScSymbol, ScVal, StringM, VecM}; -use stellar_xdr::curr::{Limits, ReadXdr, SorobanAuthorizationEntry}; +use soroban_client::xdr::{ + Int128Parts, Limits, ReadXdr, ScSymbol, ScVal, SorobanAuthorizationEntry, StringM, VecM, +}; pub enum Asset { Stellar(Address), @@ -44,43 +45,116 @@ pub fn asset_to_scval(value: &Asset) -> Result { } } -pub fn decode_loan_from_simulate_response( - result: (ScVal, Vec), -) -> Result { - let map = extract_map(&result.0).unwrap(); - - let borrower_value = - scval_to_address_string(map.get("borrower").ok_or(Error::msg("no key found"))?)?; - let borrowed_from_value = - scval_to_address_string(map.get("borrowed_from").ok_or(Error::msg("no key found"))?)?; - let borrowed_amount_value = scval_to_i128( - map.get("borrowed_amount") - .ok_or(Error::msg("no key found"))?, - )? as i64; - let collateral_amount_value = scval_to_i128( - map.get("collateral_amount") - .ok_or(Error::msg("no key found"))?, +/// Parses loan ID from deleted loan event topic +/// The topic structure is: ["loan_deleted", LoanId] where LoanId is a Map with borrower_address and nonce +pub fn parse_loan_id_from_topic(topic: &[ScVal]) -> Result { + if topic.len() != 2 { + return Err(Error::msg("Expected topic to have exactly 2 elements")); + } + + // First element should be "loan_deleted" + let event_type = &topic[0]; + match event_type { + ScVal::Symbol(symbol) => { + if symbol.to_string() != "loan_deleted" { + return Err(Error::msg( + "Expected first topic element to be 'loan_deleted'", + )); + } + } + _ => return Err(Error::msg("Expected first topic element to be a symbol")), + } + + // Second element should be the LoanId map + let loan_id_map = extract_map(&topic[1])?; + + let borrower_address = scval_to_address_string( + loan_id_map + .get("borrower_address") + .ok_or(Error::msg("borrower_address not found in loan_id"))?, + )?; + + let nonce_val = loan_id_map + .get("nonce") + .ok_or(Error::msg("nonce not found in loan_id"))?; + + let nonce = match nonce_val { + ScVal::U64(n) => *n as i64, + _ => return Err(Error::msg("nonce is not a U64")), + }; + + Ok(crate::models::LoanId { + borrower_address, + nonce, + }) +} + +/// Parses loan data from RPC event response format +/// The data structure is: Map(Some(ScMap(VecM([ScMapEntry { key: Symbol("loan"), val: Map(...) }]))) +pub fn parse_loan_from_rpc_event(event_value: &ScVal) -> Result { + let outer_map = extract_map(event_value)?; + + let loan_map_val = outer_map + .get("loan") + .ok_or(Error::msg("loan key not found in outer map"))?; + + let loan_map = extract_map(loan_map_val)?; + + let loan_id_val = loan_map + .get("loan_id") + .ok_or(Error::msg("loan_id not found in loan map"))?; + let loan_id_map = extract_map(loan_id_val)?; + + let borrower_address = scval_to_address_string( + loan_id_map + .get("borrower_address") + .ok_or(Error::msg("borrower_address not found in loan_id"))?, + )?; + + let nonce = match loan_id_map.get("nonce") { + Some(ScVal::U64(n)) => *n as i64, + _ => return Err(Error::msg("nonce not found or invalid type in loan_id")), + }; + + let borrowed_amount = scval_to_i128( + loan_map + .get("borrowed_amount") + .ok_or(Error::msg("borrowed_amount not found"))?, )? as i64; - let collateral_from_value = scval_to_address_string( - map.get("collateral_from") - .ok_or(Error::msg("no key found"))?, + + let borrowed_from = scval_to_address_string( + loan_map + .get("borrowed_from") + .ok_or(Error::msg("borrowed_from not found"))?, )?; - let unpaid_interest_value = scval_to_i128( - map.get("unpaid_interest") - .ok_or(Error::msg("no key found"))?, + + let collateral_amount = scval_to_i128( + loan_map + .get("collateral_amount") + .ok_or(Error::msg("collateral_amount not found"))?, )? as i64; - let loan = Loan { - borrower: borrower_value, - borrowed_from: borrowed_from_value, - id: 1, - borrowed_amount: borrowed_amount_value as i64, - collateral_amount: collateral_amount_value, - collateral_from: collateral_from_value, - unpaid_interest: unpaid_interest_value, - }; + let collateral_from = scval_to_address_string( + loan_map + .get("collateral_from") + .ok_or(Error::msg("collateral_from not found"))?, + )?; + + let unpaid_interest = scval_to_i128( + loan_map + .get("unpaid_interest") + .ok_or(Error::msg("unpaid_interest not found"))?, + )? as i64; - Ok(loan) + Ok(Loan { + borrower_address, + nonce, + borrowed_amount, + borrowed_from, + collateral_amount, + collateral_from, + unpaid_interest, + }) } pub fn scval_to_i128(val: &ScVal) -> Result { @@ -184,6 +258,34 @@ pub fn decode_value(value: String) -> Result, Error> { Ok(result_parts) } +pub fn decode_loan_event(value: String) -> Result { + let decoded = base64_engine.decode(value)?; + let scval = ScVal::from_xdr( + decoded, + Limits { + depth: 64, + len: 10000, + }, + )?; + + let map = extract_map(&scval)?; + + let borrower_address = scval_to_address_string( + map.get("borrower_address") + .ok_or(Error::msg("borrower_address not found"))?, + )?; + + let nonce = match map.get("nonce") { + Some(ScVal::U64(n)) => *n as i64, + _ => return Err(Error::msg("nonce not found or invalid type")), + }; + + Ok(LoanId { + borrower_address, + nonce, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -383,91 +485,177 @@ mod tests { } #[test] - fn decode_loan_from_simulate_response_missing_keys() { - let mut entries = Vec::new(); - entries.push(ScMapEntry { - key: ScVal::Symbol(ScSymbol(StringM::from_str("borrower").unwrap())), + fn parse_loan_from_rpc_event_success() { + // Create loan_id map + let mut loan_id_entries = Vec::new(); + loan_id_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("borrower_address").unwrap())), val: ScVal::Address( ScAddress::from_str("CCDF2NOJXOW73SXXB6BZRAPGVNJU7VMUURXCVLRHCHHAXHOY2TVRLFFP") .unwrap(), ), }); - // Missing required keys - - let scmap = ScMap(entries.try_into().unwrap()); - let scval = ScVal::Map(Some(scmap)); - let result = (scval, Vec::new()); - - let loan_result = decode_loan_from_simulate_response(result); - assert!(loan_result.is_err()); - } + loan_id_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("nonce").unwrap())), + val: ScVal::U64(3), + }); + let loan_id_map = ScMap(loan_id_entries.try_into().unwrap()); - #[test] - fn decode_loan_from_simulate_response_success() { - let mut entries = Vec::new(); - entries.push(ScMapEntry { - key: ScVal::Symbol(ScSymbol(StringM::from_str("borrower").unwrap())), - val: ScVal::Address( - ScAddress::from_str("CCDF2NOJXOW73SXXB6BZRAPGVNJU7VMUURXCVLRHCHHAXHOY2TVRLFFP") - .unwrap(), - ), + // Create loan map + let mut loan_entries = Vec::new(); + loan_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("borrowed_amount").unwrap())), + val: ScVal::I128(Int128Parts { + hi: 0, + lo: 282333967, + }), }); - entries.push(ScMapEntry { + loan_entries.push(ScMapEntry { key: ScVal::Symbol(ScSymbol(StringM::from_str("borrowed_from").unwrap())), val: ScVal::Address( ScAddress::from_str("CAXTXTUCA6ILFHCPIN34TWWVL4YL2QDDHYI65MVVQCEMDANFZLXVIEIK") .unwrap(), ), }); - entries.push(ScMapEntry { - key: ScVal::Symbol(ScSymbol(StringM::from_str("borrowed_amount").unwrap())), - val: ScVal::I128(Int128Parts { - hi: 0, - lo: 1000000000, - }), - }); - entries.push(ScMapEntry { + loan_entries.push(ScMapEntry { key: ScVal::Symbol(ScSymbol(StringM::from_str("collateral_amount").unwrap())), val: ScVal::I128(Int128Parts { hi: 0, - lo: 2000000000, + lo: 136658653, }), }); - entries.push(ScMapEntry { + loan_entries.push(ScMapEntry { key: ScVal::Symbol(ScSymbol(StringM::from_str("collateral_from").unwrap())), val: ScVal::Address( ScAddress::from_str("CDUFMIS6ZH3JM5MPNTWMDLBXPNQYV5FBPBGCFT2WWG4EXKGEPOCBNGCZ") .unwrap(), ), }); - entries.push(ScMapEntry { - key: ScVal::Symbol(ScSymbol(StringM::from_str("unpaid_interest").unwrap())), + loan_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("health_factor").unwrap())), val: ScVal::I128(Int128Parts { hi: 0, - lo: 50000000, + lo: 11922149, }), }); + loan_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("last_accrual").unwrap())), + val: ScVal::I128(Int128Parts { + hi: 0, + lo: 10003568, + }), + }); + loan_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("loan_id").unwrap())), + val: ScVal::Map(Some(loan_id_map)), + }); + loan_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("unpaid_interest").unwrap())), + val: ScVal::I128(Int128Parts { hi: 0, lo: 0 }), + }); + let loan_map = ScMap(loan_entries.try_into().unwrap()); - let scmap = ScMap(entries.try_into().unwrap()); - let scval = ScVal::Map(Some(scmap)); - let result = (scval, Vec::new()); + // Create outer map + let mut outer_entries = Vec::new(); + outer_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("loan").unwrap())), + val: ScVal::Map(Some(loan_map)), + }); + let outer_map = ScMap(outer_entries.try_into().unwrap()); + let event_value = ScVal::Map(Some(outer_map)); + + let loan = parse_loan_from_rpc_event(&event_value).unwrap(); - let loan = decode_loan_from_simulate_response(result).unwrap(); assert_eq!( - loan.borrower, + loan.borrower_address, "CCDF2NOJXOW73SXXB6BZRAPGVNJU7VMUURXCVLRHCHHAXHOY2TVRLFFP" ); + assert_eq!(loan.nonce, 3); + assert_eq!(loan.borrowed_amount, 282333967); assert_eq!( loan.borrowed_from, "CAXTXTUCA6ILFHCPIN34TWWVL4YL2QDDHYI65MVVQCEMDANFZLXVIEIK" ); - assert_eq!(loan.borrowed_amount, 1000000000); - assert_eq!(loan.collateral_amount, 2000000000); + assert_eq!(loan.collateral_amount, 136658653); assert_eq!( loan.collateral_from, "CDUFMIS6ZH3JM5MPNTWMDLBXPNQYV5FBPBGCFT2WWG4EXKGEPOCBNGCZ" ); - assert_eq!(loan.unpaid_interest, 50000000); - assert_eq!(loan.id, 1); + assert_eq!(loan.unpaid_interest, 0); + } + + #[test] + fn parse_loan_from_rpc_event_missing_loan_key() { + let mut outer_entries = Vec::new(); + outer_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("other_key").unwrap())), + val: ScVal::U32(100), + }); + let outer_map = ScMap(outer_entries.try_into().unwrap()); + let event_value = ScVal::Map(Some(outer_map)); + + let result = parse_loan_from_rpc_event(&event_value); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("loan key not found")); + } + + #[test] + fn parse_loan_from_rpc_event_missing_loan_id() { + let mut loan_entries = Vec::new(); + loan_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("borrowed_amount").unwrap())), + val: ScVal::I128(Int128Parts { hi: 0, lo: 1000 }), + }); + let loan_map = ScMap(loan_entries.try_into().unwrap()); + + let mut outer_entries = Vec::new(); + outer_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("loan").unwrap())), + val: ScVal::Map(Some(loan_map)), + }); + let outer_map = ScMap(outer_entries.try_into().unwrap()); + let event_value = ScVal::Map(Some(outer_map)); + + let result = parse_loan_from_rpc_event(&event_value); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("loan_id not found")); + } + + #[test] + fn parse_loan_from_rpc_event_missing_borrower_address() { + let mut loan_id_entries = Vec::new(); + loan_id_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("nonce").unwrap())), + val: ScVal::U64(3), + }); + let loan_id_map = ScMap(loan_id_entries.try_into().unwrap()); + + let mut loan_entries = Vec::new(); + loan_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("loan_id").unwrap())), + val: ScVal::Map(Some(loan_id_map)), + }); + let loan_map = ScMap(loan_entries.try_into().unwrap()); + + let mut outer_entries = Vec::new(); + outer_entries.push(ScMapEntry { + key: ScVal::Symbol(ScSymbol(StringM::from_str("loan").unwrap())), + val: ScVal::Map(Some(loan_map)), + }); + let outer_map = ScMap(outer_entries.try_into().unwrap()); + let event_value = ScVal::Map(Some(outer_map)); + + let result = parse_loan_from_rpc_event(&event_value); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("borrower_address not found")); } } diff --git a/package-lock.json b/package-lock.json index a548f696..12db5579 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3885,9 +3885,9 @@ "license": "Apache-2.0" }, "node_modules/@stellar/stellar-base": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.0.0.tgz", - "integrity": "sha512-CM84WNbj1GoB4FSWof4In60I6+m5ja0jbUFGKFmpYxabbgiU3Nmf29k9ZM9rkFwdyApgG2kFrB5WEwwoOHSmVA==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.0.1.tgz", + "integrity": "sha512-mI6Kjh9hGWDA1APawQTtCbR7702dNT/8Te1uuRFPqqdoAKBk3WpXOQI3ZSZO+5olW7BSHpmVG5KBPZpIpQxIvw==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.6", @@ -3929,13 +3929,14 @@ } }, "node_modules/@stellar/stellar-sdk": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.1.1.tgz", - "integrity": "sha512-yu9E9fENEOgt26U/YaApQUUn6TRRhnEzzEOey3y43Nf4l08nbUmlzWYLMl9lcEzEilM68D3ENnEWxBuPylKLQQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.2.0.tgz", + "integrity": "sha512-7nh2ogzLRMhfkIC0fGjn1LHUzk3jqVw8tjAuTt5ADWfL9CSGBL18ILucE9igz2L/RU2AZgeAvhujAnW91Ut/oQ==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^14.0.0", - "axios": "^1.8.4", + "@stellar/stellar-base": "^14.0.1", + "axios": "^1.12.2", "bignumber.js": "^9.3.1", "eventsource": "^2.0.2", "feaxios": "^0.0.23", @@ -8687,6 +8688,10 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/loan_manager": { + "resolved": "packages/loan_manager", + "link": true + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -10294,6 +10299,18 @@ "node": ">=10.13.0" } }, + "node_modules/pool_eurc": { + "resolved": "packages/pool_eurc", + "link": true + }, + "node_modules/pool_usdc": { + "resolved": "packages/pool_usdc", + "link": true + }, + "node_modules/pool_xlm": { + "resolved": "packages/pool_xlm", + "link": true + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13992,7 +14009,6 @@ }, "packages/loan_manager": { "version": "0.0.0", - "extraneous": true, "dependencies": { "@stellar/stellar-sdk": "^14.1.1", "buffer": "6.0.3" @@ -14003,7 +14019,6 @@ }, "packages/pool_eurc": { "version": "0.0.0", - "extraneous": true, "dependencies": { "@stellar/stellar-sdk": "^14.1.1", "buffer": "6.0.3" @@ -14014,7 +14029,6 @@ }, "packages/pool_usdc": { "version": "0.0.0", - "extraneous": true, "dependencies": { "@stellar/stellar-sdk": "^14.1.1", "buffer": "6.0.3" @@ -14025,7 +14039,6 @@ }, "packages/pool_xlm": { "version": "0.0.0", - "extraneous": true, "dependencies": { "@stellar/stellar-sdk": "^14.1.1", "buffer": "6.0.3" diff --git a/package.json b/package.json index 0bfd6720..9ee5ea83 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "dev": "astro dev", "dev:local": "STELLAR_NETWORK=local astro dev", "init": "node --import tsx scripts/initialize.ts", + "init:mock-oracle": "node --import tsx scripts/initialize.ts --mock-oracle", "init:local": "docker compose down && docker compose up -d --wait && sleep 10 && DOTENV_CONFIG_PATH=.env.local node --import tsx scripts/initialize_local.ts && DOTENV_CONFIG_PATH=.env.local npm run issue_tokens:local && npm run set-price XLM 17694578912345 1 && npm run set-price USDC 17694578912345 1 && npm run set-price EURC 17694578912345 1", "upgrade": "node --import tsx scripts/upgrade.ts", "preview": "astro preview", diff --git a/scripts/initialize.ts b/scripts/initialize.ts index 8cc7550f..06ffc0f0 100644 --- a/scripts/initialize.ts +++ b/scripts/initialize.ts @@ -10,15 +10,18 @@ import { exe, filenameNoExtension, installContracts, - loanManagerAddress, readTextFile, logDeploymentInfo, + loanManagerAddress, } from './util'; +import { setPrice } from './set-oracle-price'; const account = process.env.SOROBAN_ACCOUNT; -const oracle = process.env.ORACLE_ADDRESS; +const shouldDeployMockOracle = process.argv.includes('--mock-oracle'); + +let oracleAddressEnv = process.env.ORACLE_ADDRESS; -console.log('######################Initializing contracts ########################'); +console.log('###################### Initializing contracts ########################'); const deploy = (wasm: string) => { exe( @@ -26,22 +29,38 @@ const deploy = (wasm: string) => { ); }; +const deployMockOracle = (): string => { + console.log('Deploying mock oracle (reflector_oracle_mock) ...'); + + mkdirSync('./.stellar/contract-ids', { recursive: true }); + + deploy(`./target/wasm32v1-none/release/reflector_oracle_mock.wasm`); + const address = readTextFile('./.stellar/contract-ids/reflector_oracle_mock.txt'); + console.log(`Mock oracle deployed at: ${address}`); + + setPrice('XLM', '17694578912345', 'testnet', '1'); + setPrice('USDC', '17694578912345', 'testnet', '1'); + setPrice('EURC', '17694578912345', 'testnet', '1'); + + return address; +}; + /** Deploy loan_manager contract as there will only be one for all the pools. * Loan_manager is used as a factory for the loan_pools. */ -const deployLoanManager = () => { +const deployLoanManager = (oracleAddress: string) => { const contractsDir = `.stellar/contract-ids`; mkdirSync(contractsDir, { recursive: true }); deploy(`./target/wasm32v1-none/release/loan_manager.wasm`); exe(`stellar contract invoke \ ---id ${loanManagerAddress()} \ +--id ${loanManagerAddress(true)} \ --source-account ${account} \ --network testnet \ -- initialize \ --admin ${account} \ ---oracle_address ${oracle}`); +--oracle_address ${oracleAddress}`); }; /** Deploy liquidity pools using the loan-manager as a factory contract */ @@ -52,7 +71,7 @@ const deployLoanPools = () => { const salt = crypto.randomBytes(32).toString('hex'); exe( `stellar contract invoke \ ---id ${loanManagerAddress()} \ +--id ${loanManagerAddress(true)} \ --source-account ${account} \ --network testnet \ -- deploy_pool \ @@ -69,8 +88,12 @@ const deployLoanPools = () => { // Calling the functions (equivalent to the last part of your bash script) loadAccount(); buildContracts(); -installContracts(); -deployLoanManager(); +installContracts(shouldDeployMockOracle); + +// determine oracle address (deploy mock if requested) +const oracleForInit = shouldDeployMockOracle ? deployMockOracle() : (oracleAddressEnv as string); + +deployLoanManager(oracleForInit); deployLoanPools(); createContractBindings(); createContractImports(); diff --git a/scripts/set-oracle-price.ts b/scripts/set-oracle-price.ts index d3f9c610..b8f50794 100644 --- a/scripts/set-oracle-price.ts +++ b/scripts/set-oracle-price.ts @@ -1,23 +1,42 @@ +import { config } from 'dotenv'; import { readFileSync } from 'fs'; import { execSync } from 'child_process'; +import { pathToFileURL } from 'url'; -const [, , ticker, price, timestamp = '1'] = process.argv; +config(); -if (!ticker || !price) { - console.error('Usage: npm run set-price [timestamp]'); - console.error('Example: npm run set-price XLM 12395743847612 1'); - process.exit(1); -} +const account = process.env.SOROBAN_ACCOUNT; + +export const setPrice = (ticker: string, price: string, network: string, timestamp: string) => { + try { + const oracleContract = readFileSync('.stellar/contract-ids/reflector_oracle_mock.txt', 'utf8').trim(); + + const command = `stellar contract invoke \ + --id ${oracleContract} \ + --network ${network} \ + --source-account ${account} \ + -- update_price \ + --asset '{"Other": "${ticker}"}' \ + --price '{"price": "${price}", "timestamp": ${timestamp}}'`; + + console.log(`Setting ${ticker} price to ${price}...`); + execSync(command, { stdio: 'inherit' }); + console.log(`✅ Set ${ticker} price to ${price} with timestamp ${timestamp}`); + } catch (error) { + console.error('❌ Failed to set price:', error); + process.exit(1); + } +}; -try { - const oracleContract = readFileSync('.stellar/contract-ids/reflector_oracle_mock.txt', 'utf8').trim(); +// ESM-compatible entrypoint check +if (import.meta.url === pathToFileURL(process.argv[1] as string).href) { + const [, , ticker, price, network = 'local', timestamp = '1'] = process.argv; - const command = `stellar contract invoke --id ${oracleContract} --network local --source-account ci_local -- update_price --asset '{"Other":"${ticker}"}' --price '{"price":"${price}","timestamp":${timestamp}}'`; + if (!ticker || !price) { + console.error('Usage: npm run set-price [network] [timestamp]'); + console.error('Example: npm run set-price XLM 12395743847612 1'); + process.exit(1); + } - console.log(`Setting ${ticker} price to ${price}...`); - execSync(command, { stdio: 'inherit' }); - console.log(`✅ Set ${ticker} price to ${price} with timestamp ${timestamp}`); -} catch (error) { - console.error('❌ Failed to set price:', error); - process.exit(1); + setPrice(ticker, price, network, timestamp); } diff --git a/scripts/util.ts b/scripts/util.ts index 2d975a5b..6d68aaf4 100644 --- a/scripts/util.ts +++ b/scripts/util.ts @@ -42,12 +42,16 @@ export const buildContracts = () => { }; /** Install all contracts and save their wasm hashes to .stellar */ -export const installContracts = () => { +export const installContracts = (mockOracle: boolean = false) => { const contractsDir = `./.stellar/contract-wasm-hash`; mkdirSync(contractsDir, { recursive: true }); install('loan_manager'); install('loan_pool'); + // Optionally install mock oracle so it can be deployed with the init flag + if (mockOracle) { + install('reflector_oracle_mock'); + } }; /* Install a contract */ @@ -67,8 +71,14 @@ export const filenameNoExtension = (filename: string) => { export const readTextFile = (path: string): string => readFileSync(path, { encoding: 'utf8' }).trim(); // This is a function so its value can update during init. -export const loanManagerAddress = (): string => - process.env.CONTRACT_ID_LOAN_MANAGER || readTextFile('./.stellar/contract-ids/loan_manager.txt'); +export const loanManagerAddress = (init = false): string => { + if (init) { + // Ignore the env variable if we want to initialize a new manager. + return readTextFile('./.stellar/contract-ids/loan_manager.txt'); + } + + return process.env.CONTRACT_ID_LOAN_MANAGER || readTextFile('./.stellar/contract-ids/loan_manager.txt'); +}; export const createContractBindings = () => { bind('loan_manager', process.env.CONTRACT_ID_LOAN_MANAGER); diff --git a/tsconfig.json b/tsconfig.json index 4d20a6bf..fee015a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "astro/tsconfigs/strictest", - "exclude": ["packages"], + "exclude": ["packages", "node_modules", "dist", "target"], "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react",