From 6be2edd3af19eb65dbc05b9ce938e514a320f1bb Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Fri, 29 May 2026 18:04:33 -0700 Subject: [PATCH 1/6] Add native Rust build-and-post-offer path with Python CLI delegation. Move manager offer build/post orchestration into greenfloor-engine, delegate from the Python CLI via subprocess, persist sqlite audit records on success, and build the engine binary in CI. Co-authored-by: Cursor --- .github/workflows/ci.yml | 7 + .github/workflows/live-testnet-e2e.yml | 7 + docs/progress.md | 16 + greenfloor-engine/Cargo.lock | 165 +++++- greenfloor-engine/Cargo.toml | 5 +- greenfloor-engine/src/adapters/dexie.rs | 294 ++++++++++ greenfloor-engine/src/adapters/mod.rs | 8 + greenfloor-engine/src/adapters/splash.rs | 43 ++ greenfloor-engine/src/config/markets.rs | 274 ++++++++++ greenfloor-engine/src/config/mod.rs | 16 + greenfloor-engine/src/config/program.rs | 289 ++++++++++ .../src/{config.rs => config/signer.rs} | 0 greenfloor-engine/src/lib.rs | 9 + greenfloor-engine/src/main.rs | 96 +++- greenfloor-engine/src/manager/bootstrap.rs | 405 ++++++++++++++ .../src/manager/build_and_post.rs | 504 +++++++++++++++++ greenfloor-engine/src/manager/mod.rs | 10 + greenfloor-engine/src/manager/tests.rs | 116 ++++ greenfloor-engine/src/storage/mod.rs | 7 + greenfloor-engine/src/storage/sqlite.rs | 342 ++++++++++++ greenfloor/cli/engine_binary.py | 155 ++++++ greenfloor/cli/offer_build_post.py | 26 +- greenfloor/runtime/json_output.py | 4 + tests/helpers/engine_binary_fixtures.py | 68 +++ tests/test_manager_build_post_offer.py | 511 +++++------------- tests/test_offer_cli_dispatch.py | 19 +- 26 files changed, 2978 insertions(+), 418 deletions(-) create mode 100644 greenfloor-engine/src/adapters/dexie.rs create mode 100644 greenfloor-engine/src/adapters/mod.rs create mode 100644 greenfloor-engine/src/adapters/splash.rs create mode 100644 greenfloor-engine/src/config/markets.rs create mode 100644 greenfloor-engine/src/config/mod.rs create mode 100644 greenfloor-engine/src/config/program.rs rename greenfloor-engine/src/{config.rs => config/signer.rs} (100%) create mode 100644 greenfloor-engine/src/manager/bootstrap.rs create mode 100644 greenfloor-engine/src/manager/build_and_post.rs create mode 100644 greenfloor-engine/src/manager/mod.rs create mode 100644 greenfloor-engine/src/manager/tests.rs create mode 100644 greenfloor-engine/src/storage/mod.rs create mode 100644 greenfloor-engine/src/storage/sqlite.rs create mode 100644 greenfloor/cli/engine_binary.py create mode 100644 tests/helpers/engine_binary_fixtures.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67b11861..999c2fc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,13 @@ jobs: cargo build --manifest-path greenfloor-engine/Cargo.toml cargo test --manifest-path greenfloor-engine/Cargo.toml + - name: Install greenfloor-engine CLI on PATH + if: matrix.run_rust_tests + run: | + install -m 755 \ + greenfloor-engine/target/debug/greenfloor-engine \ + "$GITHUB_WORKSPACE/.venv/bin/greenfloor-engine" + - uses: ./.github/actions/greenfloor-maturin-wheels with: operation: ensure-and-install diff --git a/.github/workflows/live-testnet-e2e.yml b/.github/workflows/live-testnet-e2e.yml index 78b99234..2483631e 100644 --- a/.github/workflows/live-testnet-e2e.yml +++ b/.github/workflows/live-testnet-e2e.yml @@ -44,6 +44,12 @@ jobs: operation: ensure-and-install rust-already-configured: true + - name: Build greenfloor-engine CLI binary + run: | + cargo build --release --manifest-path greenfloor-engine/Cargo.toml + echo "$GITHUB_WORKSPACE/greenfloor-engine/target/release" >> "$GITHUB_PATH" + greenfloor-engine --help >/dev/null + - uses: ./.github/actions/wheel-cache with: operation: ensure-and-install @@ -95,6 +101,7 @@ jobs: --size-base-units "${{ inputs.size_base_units }}" \ --network "${{ inputs.network_profile }}" \ --dry-run | tee ./artifacts/build-and-post-offer.dry-run.log + # build-and-post-offer delegates to the native greenfloor-engine binary. if [ "${{ inputs.dry_run }}" = "false" ]; then greenfloor-manager \ --program-config config/program.yaml \ diff --git a/docs/progress.md b/docs/progress.md index 112f4737..7fc32f17 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,5 +1,21 @@ # Progress Log +## 2026-05-29 (Rust manager CLI — build-and-post-offer vertical slice) + +- **`greenfloor-engine build-and-post-offer`:** native manager path for bootstrap → sign → verify → Dexie/Splash publish without PyO3. +- **Config:** `config/program.rs` + `config/markets.rs` load manager program + markets YAML (market id/pair resolution, venue URLs). +- **Adapters:** `adapters/dexie.rs` + `adapters/splash.rs` (post, invalid-offer retry, visibility polling). +- **Orchestration:** `manager/build_and_post.rs` + `manager/bootstrap.rs` call in-process engine (`build_signer_offer_for_action`, bootstrap mixed-split). +- **Tests:** Rust unit tests for config/market resolution and mockito Dexie publish phase; 202 existing engine tests still pass. +- **Next:** wire CI live-testnet-e2e to Rust binary; port `coins-list` / coin-op CLIs; then `greenfloord` daemon without PyO3. + +## 2026-05-29 (Python manager delegates build-and-post-offer to Rust binary) + +- **`greenfloor/cli/engine_binary.py`:** resolves `greenfloor-engine` (`GREENFLOOR_ENGINE_BIN`, PATH, or `target/{debug,release}/`) and runs `build-and-post-offer` subprocess with manager flag mapping. +- **`greenfloor/cli/offer_build_post.py`:** Python preflight (config, signer path, market resolution, logging) then delegates; removed in-process `OfferPostRequest` path for this command. +- **CI:** Ubuntu CI installs debug `greenfloor-engine` into `.venv/bin`; live-testnet-e2e builds release binary and adds it to `PATH`. +- **Tests:** manager build/post tests mock engine delegation; 544 pytest tests pass. + ## 2026-05-29 (Review follow-ups — routing collapse + bridge merge) - **Routing:** removed `offer_execution_backend()` / `managed_offer_execution_backend()` / `coin_ops_execution_backend()` and Rust `sequential_action_route`; daemon sequential dispatch uses explicit dry-run / program / signer checks. diff --git a/greenfloor-engine/Cargo.lock b/greenfloor-engine/Cargo.lock index f7aa2288..a588ec93 100644 --- a/greenfloor-engine/Cargo.lock +++ b/greenfloor-engine/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +29,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -1079,7 +1100,7 @@ dependencies = [ "clvmr", "do-notation", "getrandom 0.2.17", - "hashlink", + "hashlink 0.11.0", "hex", "indoc", "js-sys", @@ -1098,6 +1119,17 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -1563,6 +1595,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1771,6 +1815,7 @@ dependencies = [ "chia-sdk-utils", "chia-secp", "chia-traits 0.36.1", + "chrono", "clap", "clvm-traits 0.36.1", "clvm-utils", @@ -1781,6 +1826,7 @@ dependencies = [ "mockito", "rand 0.9.4", "reqwest", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -1788,6 +1834,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "urlencoding", ] [[package]] @@ -1844,6 +1891,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1869,6 +1919,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.11.0" @@ -2115,6 +2174,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -2361,6 +2444,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.28" @@ -3205,6 +3299,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4298,12 +4406,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4607,7 +4768,7 @@ checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.11.0", ] [[package]] diff --git a/greenfloor-engine/Cargo.toml b/greenfloor-engine/Cargo.toml index 8014ed4b..1d8c683e 100644 --- a/greenfloor-engine/Cargo.toml +++ b/greenfloor-engine/Cargo.toml @@ -19,6 +19,7 @@ aws-config = "1" aws-sdk-kms = "1" base64 = "0.22" bip39 = "2.2.0" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } chia-bls = "0.36.1" chia-consensus = "0.36.1" chia-protocol = "0.36.1" @@ -39,12 +40,14 @@ hex = "0.4" indexmap = "2" rand = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.10" thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +urlencoding = "2" [dev-dependencies] anyhow = "1.0" diff --git a/greenfloor-engine/src/adapters/dexie.rs b/greenfloor-engine/src/adapters/dexie.rs new file mode 100644 index 00000000..d80f8a1f --- /dev/null +++ b/greenfloor-engine/src/adapters/dexie.rs @@ -0,0 +1,294 @@ +use serde_json::{json, Value}; + +use crate::cycle::{ + dexie_invalid_offer_retry_sleep, dexie_invalid_offer_should_retry, + is_transient_dexie_visibility_404_error, +}; +use crate::error::{SignerError, SignerResult}; +use crate::offer::publish::dexie_offer_asset_expectation_error; + +const INVALID_OFFER_RETRY_MAX_ATTEMPTS: u32 = 4; +const INVALID_OFFER_RETRY_INITIAL_SLEEP_SECONDS: f64 = 1.0; +const VISIBILITY_POST_MAX_ATTEMPTS: u32 = 3; +const VISIBILITY_POST_DELAY_SECONDS: f64 = 2.0; + +#[derive(Debug, Clone)] +pub struct DexieClient { + base_url: String, + http: reqwest::Client, +} + +impl DexieClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub async fn post_offer( + &self, + offer: &str, + drop_only: bool, + claim_rewards: bool, + ) -> SignerResult { + let payload = json!({ + "offer": offer, + "drop_only": drop_only, + "claim_rewards": claim_rewards, + }); + let url = format!("{}/v1/offers", self.base_url); + let response = self + .http + .post(url) + .json(&payload) + .timeout(std::time::Duration::from_secs(20)) + .send() + .await + .map_err(|err| SignerError::Other(format!("dexie_network_error:{err}")))?; + Self::parse_response(response).await + } + + pub async fn get_offer(&self, offer_id: &str) -> SignerResult { + let clean_offer_id = offer_id.trim(); + if clean_offer_id.is_empty() { + return Err(SignerError::Other("offer_id is required".to_string())); + } + let encoded = urlencoding::encode(clean_offer_id); + let url = format!("{}/v1/offers/{encoded}", self.base_url); + let response = self + .http + .get(url) + .timeout(std::time::Duration::from_secs(20)) + .send() + .await + .map_err(|err| SignerError::Other(format!("dexie_get_offer_error:{err}")))?; + Self::parse_response(response).await + } + + async fn parse_response(response: reqwest::Response) -> SignerResult { + let status = response.status(); + let body = response + .text() + .await + .map_err(|err| SignerError::Other(format!("dexie_read_error:{err}")))?; + if !status.is_success() { + let snippet: String = body.chars().take(500).collect(); + let error = if snippet.is_empty() { + format!("dexie_http_error:{}", status.as_u16()) + } else { + format!("dexie_http_error:{}:{snippet}", status.as_u16()) + }; + return Ok(json!({"success": false, "error": error})); + } + serde_json::from_str(&body) + .map_err(|err| SignerError::Other(format!("dexie_json_error:{err}"))) + } +} + +pub fn dexie_offer_view_url(dexie_base_url: &str, offer_id: &str) -> String { + let clean_offer_id = offer_id.trim(); + if clean_offer_id.is_empty() { + return String::new(); + } + let trimmed = dexie_base_url.trim(); + let host = trimmed + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/'); + let host = if let Some(rest) = host.strip_prefix("api-testnet.") { + format!("testnet.{rest}") + } else if let Some(rest) = host.strip_prefix("api.") { + rest.to_string() + } else { + host.to_string() + }; + format!( + "https://{host}/offers/{}", + urlencoding::encode(clean_offer_id) + ) +} + +pub async fn post_dexie_offer_with_invalid_offer_retry( + dexie: &DexieClient, + offer_text: &str, + drop_only: bool, + claim_rewards: bool, +) -> SignerResult { + let mut attempt = 0u32; + loop { + let result = dexie + .post_offer(offer_text, drop_only, claim_rewards) + .await?; + let error = result + .get("error") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if !dexie_invalid_offer_should_retry( + &error, + attempt, + INVALID_OFFER_RETRY_MAX_ATTEMPTS, + ) { + return Ok(result); + } + let sleep_seconds = dexie_invalid_offer_retry_sleep( + attempt, + INVALID_OFFER_RETRY_INITIAL_SLEEP_SECONDS, + ); + tokio::time::sleep(std::time::Duration::from_secs_f64(sleep_seconds)).await; + attempt += 1; + } +} + +pub async fn verify_dexie_offer_visible_by_id( + dexie: &DexieClient, + offer_id: &str, + expected_offered_asset_id: &str, + expected_offered_symbol: &str, + expected_requested_asset_id: &str, + expected_requested_symbol: &str, +) -> Option { + let clean_offer_id = offer_id.trim(); + if clean_offer_id.is_empty() { + return Some("dexie_offer_missing_id_after_publish".to_string()); + } + let attempts = 4usize; + let delay_seconds = 1.5; + let mut last_error = "dexie_offer_not_visible_after_publish".to_string(); + for attempt in 1..=attempts { + let payload = match dexie.get_offer(clean_offer_id).await { + Ok(payload) => payload, + Err(err) => { + last_error = format!("dexie_get_offer_error:{err}"); + if attempt < attempts { + tokio::time::sleep(std::time::Duration::from_secs_f64(delay_seconds)).await; + } + continue; + } + }; + let offer_payload = payload.get("offer"); + let visible_id = offer_payload + .and_then(Value::as_object) + .and_then(|obj| obj.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if visible_id == clean_offer_id { + if let Some(offer_obj) = offer_payload.and_then(Value::as_object) { + if let Some(asset_error) = dexie_offer_asset_expectation_error( + offer_obj.get("offered").unwrap_or(&Value::Null), + offer_obj.get("requested").unwrap_or(&Value::Null), + expected_offered_asset_id, + expected_offered_symbol, + expected_requested_asset_id, + expected_requested_symbol, + ) { + return Some(asset_error); + } + } + return None; + } + last_error = "dexie_offer_visibility_payload_mismatch".to_string(); + if attempt < attempts { + tokio::time::sleep(std::time::Duration::from_secs_f64(delay_seconds)).await; + } + } + Some(last_error) +} + +pub async fn post_offer_phase_dexie( + dexie: &DexieClient, + offer_text: &str, + drop_only: bool, + claim_rewards: bool, + expected_offered_asset_id: &str, + expected_offered_symbol: &str, + expected_requested_asset_id: &str, + expected_requested_symbol: &str, +) -> SignerResult { + let mut last_result = json!({"success": false, "error": "dexie_offer_not_visible_after_publish"}); + let mut last_visibility_error = String::new(); + for attempt in 1..=VISIBILITY_POST_MAX_ATTEMPTS { + let result = post_dexie_offer_with_invalid_offer_retry( + dexie, + offer_text, + drop_only, + claim_rewards, + ) + .await?; + last_result = result.clone(); + if result.get("success").and_then(Value::as_bool) != Some(true) { + return Ok(result); + } + let posted_offer_id = result + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if let Some(visibility_error) = verify_dexie_offer_visible_by_id( + dexie, + &posted_offer_id, + expected_offered_asset_id, + expected_offered_symbol, + expected_requested_asset_id, + expected_requested_symbol, + ) + .await + { + last_visibility_error = visibility_error; + if !is_transient_dexie_visibility_404_error(&last_visibility_error) { + let mut failed = result; + if let Value::Object(obj) = &mut failed { + obj.insert("success".to_string(), Value::Bool(false)); + obj.insert("error".to_string(), Value::String(last_visibility_error.clone())); + } + return Ok(failed); + } + if attempt < VISIBILITY_POST_MAX_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs_f64( + VISIBILITY_POST_DELAY_SECONDS, + )) + .await; + } + continue; + } + return Ok(result); + } + let mut failed = last_result; + if let Value::Object(obj) = &mut failed { + obj.insert("success".to_string(), Value::Bool(false)); + obj.insert( + "error".to_string(), + Value::String(if last_visibility_error.is_empty() { + "dexie_offer_not_visible_after_publish".to_string() + } else { + last_visibility_error + }), + ); + } + Ok(failed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dexie_view_url_strips_api_prefix() { + assert_eq!( + dexie_offer_view_url("https://api.dexie.space", "offer-123"), + "https://dexie.space/offers/offer-123" + ); + assert_eq!( + dexie_offer_view_url("https://api-testnet.dexie.space", "offer-123"), + "https://testnet.dexie.space/offers/offer-123" + ); + } +} diff --git a/greenfloor-engine/src/adapters/mod.rs b/greenfloor-engine/src/adapters/mod.rs new file mode 100644 index 00000000..608f8804 --- /dev/null +++ b/greenfloor-engine/src/adapters/mod.rs @@ -0,0 +1,8 @@ +mod dexie; +mod splash; + +pub use dexie::{ + dexie_offer_view_url, post_dexie_offer_with_invalid_offer_retry, post_offer_phase_dexie, + verify_dexie_offer_visible_by_id, DexieClient, +}; +pub use splash::SplashClient; diff --git a/greenfloor-engine/src/adapters/splash.rs b/greenfloor-engine/src/adapters/splash.rs new file mode 100644 index 00000000..93f37b81 --- /dev/null +++ b/greenfloor-engine/src/adapters/splash.rs @@ -0,0 +1,43 @@ +use serde_json::{json, Value}; + +use crate::error::{SignerError, SignerResult}; + +#[derive(Debug, Clone)] +pub struct SplashClient { + base_url: String, + http: reqwest::Client, +} + +impl SplashClient { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + } + } + + pub async fn post_offer(&self, offer: &str) -> SignerResult { + let payload = json!({"offer": offer}); + let response = self + .http + .post(&self.base_url) + .json(&payload) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|err| SignerError::Other(format!("splash_network_error:{err}")))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|err| SignerError::Other(format!("splash_read_error:{err}")))?; + if !status.is_success() { + return Ok(json!({ + "success": false, + "error": format!("splash_http_error:{}", status.as_u16()) + })); + } + serde_json::from_str(&body) + .map_err(|err| SignerError::Other(format!("splash_json_error:{err}"))) + } +} diff --git a/greenfloor-engine/src/config/markets.rs b/greenfloor-engine/src/config/markets.rs new file mode 100644 index 00000000..cab40bc1 --- /dev/null +++ b/greenfloor-engine/src/config/markets.rs @@ -0,0 +1,274 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::config::program::is_testnet_network; +use crate::error::{SignerError, SignerResult}; + +#[derive(Debug, Clone)] +pub struct LadderEntry { + pub size_base_units: i64, + pub target_count: i64, + pub split_buffer_count: i64, +} + +#[derive(Debug, Clone)] +pub struct MarketConfig { + pub market_id: String, + pub enabled: bool, + pub base_asset: String, + pub base_symbol: String, + pub quote_asset: String, + pub receive_address: String, + pub pricing: Value, + pub ladders: HashMap>, +} + +#[derive(Debug, Clone)] +pub struct MarketsConfig { + pub markets: Vec, +} + +#[derive(Debug, Deserialize)] +struct MarketsYaml { + markets: Option>, +} + +#[derive(Debug, Deserialize)] +struct MarketYaml { + id: Option, + enabled: Option, + base_asset: Option, + base_symbol: Option, + quote_asset: Option, + receive_address: Option, + pricing: Option, + ladders: Option>>, +} + +#[derive(Debug, Deserialize)] +struct LadderEntryYaml { + size_base_units: Option, + target_count: Option, + split_buffer_count: Option, +} + +pub fn load_markets_config(path: &Path) -> SignerResult { + load_markets_config_with_overlay(path, None) +} + +pub fn load_markets_config_with_overlay( + base_path: &Path, + overlay_path: Option<&Path>, +) -> SignerResult { + let mut raw = read_yaml_mapping(base_path)?; + if let Some(overlay) = overlay_path { + if overlay.exists() { + let overlay_raw = read_yaml_mapping(overlay)?; + let base_markets = raw + .get("markets") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let overlay_markets = overlay_raw + .get("markets") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut merged = base_markets; + merged.extend(overlay_markets); + raw["markets"] = Value::Array(merged); + } + } + parse_markets_config(&raw) +} + +fn read_yaml_mapping(path: &Path) -> SignerResult { + let raw = std::fs::read_to_string(path).map_err(|err| { + SignerError::Other(format!("failed to read markets config {}: {err}", path.display())) + })?; + serde_yaml::from_str(&raw).map_err(|err| { + SignerError::Other(format!("failed to parse markets config {}: {err}", path.display())) + }) +} + +pub fn parse_markets_config(raw: &Value) -> SignerResult { + let parsed: MarketsYaml = serde_json::from_value(raw.clone()).map_err(|err| { + SignerError::Other(format!("invalid markets config shape: {err}")) + })?; + let rows = parsed.markets.unwrap_or_default(); + let mut markets = Vec::with_capacity(rows.len()); + for row in rows { + let market_id = row + .id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| SignerError::Other("market id is required".to_string()))?; + let mut ladders: HashMap> = HashMap::new(); + if let Some(raw_ladders) = row.ladders { + for (side, entries) in raw_ladders { + let parsed_entries = entries + .into_iter() + .map(|entry| LadderEntry { + size_base_units: entry.size_base_units.unwrap_or(0), + target_count: entry.target_count.unwrap_or(0), + split_buffer_count: entry.split_buffer_count.unwrap_or(0), + }) + .collect(); + ladders.insert(side, parsed_entries); + } + } + markets.push(MarketConfig { + market_id, + enabled: row.enabled.unwrap_or(false), + base_asset: row + .base_asset + .unwrap_or_default() + .trim() + .to_string(), + base_symbol: row + .base_symbol + .unwrap_or_default() + .trim() + .to_string(), + quote_asset: row + .quote_asset + .unwrap_or_default() + .trim() + .to_string(), + receive_address: row + .receive_address + .unwrap_or_default() + .trim() + .to_string(), + pricing: row.pricing.unwrap_or_else(|| json!({})), + ladders, + }); + } + Ok(MarketsConfig { markets }) +} + +pub fn resolve_market_for_build( + markets: &MarketsConfig, + market_id: Option<&str>, + pair: Option<&str>, + network: &str, +) -> SignerResult { + let has_market_id = market_id.map(str::trim).is_some_and(|value| !value.is_empty()); + let has_pair = pair.map(str::trim).is_some_and(|value| !value.is_empty()); + if has_market_id == has_pair { + return Err(SignerError::Other( + "provide exactly one of --market-id or --pair".to_string(), + )); + } + if let Some(market_id) = market_id.map(str::trim).filter(|value| !value.is_empty()) { + return markets + .markets + .iter() + .find(|market| market.market_id == market_id) + .cloned() + .ok_or_else(|| SignerError::Other(format!("market_id not found: {market_id}"))); + } + let pair = pair.expect("pair checked above").trim(); + let sep = if pair.contains(':') { + ':' + } else if pair.contains('/') { + '/' + } else { + return Err(SignerError::Other( + "pair must be in base:quote or base/quote format".to_string(), + )); + }; + let mut parts = pair.splitn(2, sep); + let base_raw = parts + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| SignerError::Other("pair base must be non-empty".to_string()))? + .to_ascii_lowercase(); + let quote_raw = parts + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| SignerError::Other("pair quote must be non-empty".to_string()))? + .to_ascii_lowercase(); + + let mut candidates = Vec::new(); + for market in &markets.markets { + if !market.enabled { + continue; + } + let base_matches = [ + market.base_asset.trim().to_ascii_lowercase(), + market.base_symbol.trim().to_ascii_lowercase(), + ]; + let quote_match = market.quote_asset.trim().to_ascii_lowercase(); + let mut quote_matches = vec![quote_match.clone()]; + if is_testnet_network(network) { + if quote_match == "xch" { + quote_matches.push("txch".to_string()); + } else if quote_match == "txch" { + quote_matches.push("xch".to_string()); + } + } + if base_matches.iter().any(|value| value == &base_raw) + && quote_matches.iter().any(|value| value == "e_raw) + { + candidates.push(market.clone()); + } + } + if candidates.is_empty() { + return Err(SignerError::Other(format!( + "no enabled market found for pair: {pair}" + ))); + } + if candidates.len() > 1 { + let ids: Vec<_> = candidates + .iter() + .map(|market| market.market_id.as_str()) + .collect(); + return Err(SignerError::Other(format!( + "pair is ambiguous; use --market-id (candidates: {})", + ids.join(", ") + ))); + } + Ok(candidates.remove(0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_markets() -> MarketsConfig { + parse_markets_config(&json!({ + "markets": [{ + "id": "m1", + "enabled": true, + "base_asset": "a1", + "base_symbol": "A1", + "quote_asset": "xch", + "receive_address": "xch1test", + "pricing": {"min_price_quote_per_base": 0.0031} + }] + })) + .expect("markets") + } + + #[test] + fn resolves_market_by_id() { + let markets = sample_markets(); + let market = resolve_market_for_build(&markets, Some("m1"), None, "mainnet").expect("market"); + assert_eq!(market.market_id, "m1"); + } + + #[test] + fn resolves_market_by_pair() { + let markets = sample_markets(); + let market = + resolve_market_for_build(&markets, None, Some("A1:xch"), "mainnet").expect("market"); + assert_eq!(market.market_id, "m1"); + } +} diff --git a/greenfloor-engine/src/config/mod.rs b/greenfloor-engine/src/config/mod.rs new file mode 100644 index 00000000..688ca664 --- /dev/null +++ b/greenfloor-engine/src/config/mod.rs @@ -0,0 +1,16 @@ +mod markets; +mod program; +mod signer; + +pub use markets::{ + load_markets_config, load_markets_config_with_overlay, resolve_market_for_build, LadderEntry, + MarketConfig, MarketsConfig, +}; +pub use program::{ + action_side_from_pricing, load_program_config, require_signer_offer_path, + resolve_dexie_base_url, resolve_offer_publish_settings, resolve_quote_asset_for_offer, + resolve_splash_base_url, ManagerProgramConfig, +}; +pub use signer::{load_signer_config, SignerConfig, DEFAULT_MSP_BASE_URL}; + +pub(crate) use program::is_testnet_network; diff --git a/greenfloor-engine/src/config/program.rs b/greenfloor-engine/src/config/program.rs new file mode 100644 index 00000000..afb081d6 --- /dev/null +++ b/greenfloor-engine/src/config/program.rs @@ -0,0 +1,289 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::Value; + +use crate::coinset::is_xch_like_asset; +use crate::error::{SignerError, SignerResult}; +use crate::hex::is_hex_id; + +const DEFAULT_DEXIE_API_BASE: &str = "https://api.dexie.space"; +const DEFAULT_SPLASH_API_BASE: &str = "http://john-deere.hoffmang.com:4000"; +const DEFAULT_HOME_DIR: &str = "~/.greenfloor"; + +#[derive(Debug, Clone)] +pub struct ManagerProgramConfig { + pub network: String, + pub home_dir: PathBuf, + pub dexie_api_base: String, + pub splash_api_base: String, + pub offer_publish_venue: String, + pub coin_ops_minimum_fee_mojos: u64, + pub runtime_offer_bootstrap_wait_timeout_seconds: u64, +} + +#[derive(Debug, Deserialize)] +struct ProgramYaml { + app: Option, + runtime: Option, + venues: Option, + coin_ops: Option, + signer: Option, + vault: Option, +} + +#[derive(Debug, Deserialize)] +struct AppYaml { + network: Option, + home_dir: Option, +} + +#[derive(Debug, Deserialize)] +struct RuntimeYaml { + offer_bootstrap_wait_timeout_seconds: Option, +} + +#[derive(Debug, Deserialize)] +struct VenuesYaml { + dexie: Option, + splash: Option, + offer_publish: Option, +} + +#[derive(Debug, Deserialize)] +struct VenueBaseYaml { + api_base: Option, +} + +#[derive(Debug, Deserialize)] +struct OfferPublishYaml { + provider: Option, +} + +#[derive(Debug, Deserialize)] +struct CoinOpsYaml { + minimum_fee_mojos: Option, +} + +#[derive(Debug, Deserialize)] +struct SignerPresence { + kms_key_id: Option, +} + +#[derive(Debug, Deserialize)] +struct VaultPresence { + launcher_id: Option, +} + +pub fn load_program_config(path: &Path) -> SignerResult { + let raw = std::fs::read_to_string(path).map_err(|err| { + SignerError::Other(format!("failed to read config {}: {err}", path.display())) + })?; + let parsed: ProgramYaml = serde_yaml::from_str(&raw).map_err(|err| { + SignerError::Other(format!("failed to parse config {}: {err}", path.display())) + })?; + + let app = parsed.app.unwrap_or(AppYaml { + network: None, + home_dir: None, + }); + let network = app + .network + .unwrap_or_else(|| "mainnet".to_string()) + .trim() + .to_string(); + let home_dir = expand_home_dir( + app.home_dir + .unwrap_or_else(|| DEFAULT_HOME_DIR.to_string()) + .trim(), + ); + + let venues = parsed.venues.unwrap_or(VenuesYaml { + dexie: None, + splash: None, + offer_publish: None, + }); + let dexie_api_base = venues + .dexie + .and_then(|section| section.api_base) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_DEXIE_API_BASE.to_string()); + let splash_api_base = venues + .splash + .and_then(|section| section.api_base) + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_SPLASH_API_BASE.to_string()); + let offer_publish_venue = venues + .offer_publish + .and_then(|section| section.provider) + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "dexie".to_string()); + if offer_publish_venue != "dexie" && offer_publish_venue != "splash" { + return Err(SignerError::Other( + "venues.offer_publish.provider must be dexie or splash".to_string(), + )); + } + + let coin_ops = parsed.coin_ops.unwrap_or(CoinOpsYaml { + minimum_fee_mojos: None, + }); + let coin_ops_minimum_fee_mojos = coin_ops.minimum_fee_mojos.unwrap_or(10_000_000); + + let runtime = parsed.runtime.unwrap_or(RuntimeYaml { + offer_bootstrap_wait_timeout_seconds: None, + }); + let runtime_offer_bootstrap_wait_timeout_seconds = runtime + .offer_bootstrap_wait_timeout_seconds + .unwrap_or(120) + .max(10); + + Ok(ManagerProgramConfig { + network, + home_dir, + dexie_api_base, + splash_api_base, + offer_publish_venue, + coin_ops_minimum_fee_mojos, + runtime_offer_bootstrap_wait_timeout_seconds, + }) +} + +pub fn require_signer_offer_path(path: &Path) -> SignerResult<()> { + let raw = std::fs::read_to_string(path).map_err(|err| { + SignerError::Other(format!("failed to read config {}: {err}", path.display())) + })?; + let parsed: ProgramYaml = serde_yaml::from_str(&raw).map_err(|err| { + SignerError::Other(format!("failed to parse config {}: {err}", path.display())) + })?; + let kms_key_id = parsed + .signer + .and_then(|signer| signer.kms_key_id) + .map(|value| value.trim().to_string()) + .unwrap_or_default(); + let launcher_id = parsed + .vault + .and_then(|vault| vault.launcher_id) + .map(|value| value.trim().to_string()) + .unwrap_or_default(); + if kms_key_id.is_empty() || launcher_id.is_empty() { + return Err(SignerError::Other( + "offer execution requires signer.kms_key_id and vault.launcher_id in program config" + .to_string(), + )); + } + Ok(()) +} + +pub fn is_testnet_network(network: &str) -> bool { + matches!( + network.trim().to_ascii_lowercase().as_str(), + "testnet" | "testnet11" + ) +} + +pub fn resolve_trade_asset_for_network(asset: &str, network: &str) -> String { + let normalized = asset.trim().to_ascii_lowercase(); + if is_xch_like_asset(&normalized) { + if is_testnet_network(network) { + "txch".to_string() + } else { + "xch".to_string() + } + } else if is_hex_id(&normalized) { + normalized + } else { + asset.trim().to_string() + } +} + +pub fn resolve_quote_asset_for_offer(quote_asset: &str, network: &str) -> String { + resolve_trade_asset_for_network(quote_asset, network) +} + +pub fn resolve_dexie_base_url( + network: &str, + explicit: Option<&str>, + program_base: &str, +) -> SignerResult { + if let Some(url) = explicit.map(str::trim).filter(|value| !value.is_empty()) { + return Ok(url.trim_end_matches('/').to_string()); + } + if is_testnet_network(network) { + return Ok("https://api-testnet.dexie.space".to_string()); + } + Ok(program_base.trim().trim_end_matches('/').to_string()) +} + +pub fn resolve_splash_base_url(explicit: Option<&str>, program_base: &str) -> String { + explicit + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_end_matches('/').to_string()) + .unwrap_or_else(|| program_base.trim().trim_end_matches('/').to_string()) +} + +pub fn resolve_offer_publish_settings( + program: &ManagerProgramConfig, + network: &str, + venue_override: Option<&str>, + dexie_base_url: Option<&str>, + splash_base_url: Option<&str>, +) -> SignerResult<(String, String, String)> { + let venue = venue_override + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_else(|| program.offer_publish_venue.clone()); + if venue != "dexie" && venue != "splash" { + return Err(SignerError::Other( + "offer publish venue must be dexie or splash".to_string(), + )); + } + let dexie_base = resolve_dexie_base_url(network, dexie_base_url, &program.dexie_api_base)?; + let splash_base = resolve_splash_base_url(splash_base_url, &program.splash_api_base); + Ok((venue, dexie_base, splash_base)) +} + +pub fn action_side_from_pricing(pricing: &Value) -> String { + pricing + .get("side") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("sell") + .to_string() +} + +fn expand_home_dir(path: &str) -> PathBuf { + if let Some(stripped) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(stripped); + } + } + if path == "~" { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home); + } + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_testnet_dexie_default() { + let url = resolve_dexie_base_url("testnet11", None, "https://api.dexie.space").expect("url"); + assert_eq!(url, "https://api-testnet.dexie.space"); + } + + #[test] + fn maps_xch_to_txch_on_testnet() { + assert_eq!(resolve_quote_asset_for_offer("xch", "testnet11"), "txch"); + assert_eq!(resolve_quote_asset_for_offer("xch", "mainnet"), "xch"); + } +} diff --git a/greenfloor-engine/src/config.rs b/greenfloor-engine/src/config/signer.rs similarity index 100% rename from greenfloor-engine/src/config.rs rename to greenfloor-engine/src/config/signer.rs diff --git a/greenfloor-engine/src/lib.rs b/greenfloor-engine/src/lib.rs index 3bc14269..279a54c5 100644 --- a/greenfloor-engine/src/lib.rs +++ b/greenfloor-engine/src/lib.rs @@ -3,6 +3,7 @@ //! The Rust crate and PyO3 module are named `greenfloor_engine` (ADR 0010). //! Policy is grouped by domain (`cycle/`, `coin_ops/`, `offer/`, `vault/`). +pub mod adapters; pub mod coin_ops; pub mod coinset; pub mod config; @@ -10,7 +11,9 @@ pub mod cycle; pub mod error; pub mod hex; pub mod kms; +pub mod manager; pub mod offer; +pub mod storage; pub mod vault; use config::SignerConfig; @@ -116,6 +119,12 @@ pub use offer::{ pub use vault::{ build_and_optionally_broadcast_vault_cat_mixed_split, MixedSplitRequest, MixedSplitResult, }; +pub use manager::{build_and_post_offer, format_build_and_post_output, BuildAndPostOfferRequest, BuildAndPostOfferResponse}; +pub use config::{ + load_markets_config, load_markets_config_with_overlay, load_program_config, + require_signer_offer_path, resolve_market_for_build, ManagerProgramConfig, MarketConfig, + MarketsConfig, +}; #[cfg(test)] mod test_support; diff --git a/greenfloor-engine/src/main.rs b/greenfloor-engine/src/main.rs index a080c8cd..daca4ed1 100644 --- a/greenfloor-engine/src/main.rs +++ b/greenfloor-engine/src/main.rs @@ -1,17 +1,17 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use clap::{Parser, Subcommand}; use greenfloor_engine::vault::members::hex_to_bytes32; use greenfloor_engine::{ - build_and_optionally_broadcast_vault_cat_mixed_split, build_vault_cat_offer, - load_signer_config, parse_coin_ids, resolve_vault_context, CreateOfferRequest, - MixedSplitRequest, + build_and_optionally_broadcast_vault_cat_mixed_split, build_and_post_offer, + build_vault_cat_offer, load_signer_config, parse_coin_ids, resolve_vault_context, + BuildAndPostOfferRequest, CreateOfferRequest, MixedSplitRequest, }; #[derive(Debug, Parser)] #[command( name = "greenfloor-engine", - about = "Local Chia vault signing backed by chia-wallet-sdk" + about = "GreenFloor Rust engine: vault KMS signing and manager CLI" )] struct Cli { #[command(subcommand)] @@ -81,6 +81,39 @@ enum Commands { #[arg(long)] json: bool, }, + /// Build a vault-signed offer and post it to Dexie or Splash (manager CLI path). + BuildAndPostOffer { + #[arg(long, default_value = "config/program.yaml")] + program_config: PathBuf, + #[arg(long, default_value = "config/markets.yaml")] + markets_config: PathBuf, + #[arg(long, default_value = "")] + testnet_markets_config: PathBuf, + #[arg(long, default_value = "mainnet")] + network: String, + #[arg(long)] + market_id: Option, + #[arg(long)] + pair: Option, + #[arg(long)] + size_base_units: u64, + #[arg(long, default_value = "1")] + repeat: u32, + #[arg(long)] + venue: Option, + #[arg(long)] + dexie_base_url: Option, + #[arg(long)] + splash_base_url: Option, + #[arg(long)] + allow_take: bool, + #[arg(long)] + claim_rewards: bool, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, } #[tokio::main] @@ -173,12 +206,63 @@ async fn run() -> Result<(), greenfloor_engine::Error> { .await?; print_create_offer_result(&result, json)?; } + Commands::BuildAndPostOffer { + program_config, + markets_config, + testnet_markets_config, + network, + market_id, + pair, + size_base_units, + repeat, + venue, + dexie_base_url, + splash_base_url, + allow_take, + claim_rewards, + dry_run, + json, + } => { + if market_id.is_none() == pair.is_none() { + return Err(greenfloor_engine::Error::Other( + "provide exactly one of --market-id or --pair".to_string(), + )); + } + let testnet_overlay = if testnet_markets_config.as_os_str().is_empty() { + None + } else { + Some(testnet_markets_config) + }; + let response = build_and_post_offer(BuildAndPostOfferRequest { + program_path: program_config, + markets_path: markets_config, + testnet_markets_path: testnet_overlay, + network, + market_id, + pair, + size_base_units, + repeat, + publish_venue: venue, + dexie_base_url, + splash_base_url, + drop_only: !allow_take, + claim_rewards, + dry_run, + compact_json: json, + persist_results: true, + }) + .await?; + println!("{}", response.output); + if response.exit_code != 0 { + std::process::exit(response.exit_code); + } + } } Ok(()) } async fn run_mixed_split( - config_path: &Path, + config_path: &std::path::Path, receive_address: String, asset_id: String, output_amounts: Vec, diff --git a/greenfloor-engine/src/manager/bootstrap.rs b/greenfloor-engine/src/manager/bootstrap.rs new file mode 100644 index 00000000..f1bfd454 --- /dev/null +++ b/greenfloor-engine/src/manager/bootstrap.rs @@ -0,0 +1,405 @@ +use std::collections::HashSet; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::coinset::{get_conservative_fee_estimate, list_wallet_unspent_coins, WalletUnspentCoin}; +use crate::config::{load_signer_config, LadderEntry, ManagerProgramConfig, MarketConfig}; +use crate::error::{SignerError, SignerResult}; +use crate::offer::bootstrap::{ + bootstrap_early_phase, bootstrap_executed_phase, plan_bootstrap_mixed_outputs, BootstrapCoin, + BootstrapPhaseSnapshot, BootstrapPlan, BootstrapPlanOutcome, PlannerLadderRow, +}; +use crate::offer::build_context::mojo_multiplier_for_leg; +use crate::offer::publish::bootstrap_block_error; +use crate::offer::request::{normalize_offer_side, quote_mojos_for_base_size, signer_split_asset_id}; +use crate::vault::{build_and_optionally_broadcast_vault_cat_mixed_split, MixedSplitRequest}; + +#[derive(Debug, Clone)] +pub struct BootstrapPhaseResult { + pub status: String, + pub reason: String, + pub ready: bool, + pub fee_mojos: u64, + pub fee_source: String, + pub fee_lookup_error: Option, + pub wait_error: Option, + pub split_result: Value, + pub wait_events: Vec, + pub plan: Option, +} + +impl BootstrapPhaseResult { + pub fn to_manager_json(&self) -> Value { + let mut payload = json!({ + "status": self.status, + "reason": self.reason, + "ready": self.ready, + "fee_mojos": self.fee_mojos, + "fee_source": self.fee_source, + "fee_lookup_error": self.fee_lookup_error, + }); + if let Some(wait_error) = &self.wait_error { + payload["wait_error"] = json!(wait_error); + } + if !self.split_result.is_null() && self.split_result != json!({}) { + payload["split_result"] = self.split_result.clone(); + } + if !self.wait_events.is_empty() { + payload["wait_events"] = Value::Array(self.wait_events.clone()); + } + if let Some(plan) = &self.plan { + payload["plan"] = json!({ + "source_coin_id": plan.source_coin_id, + "source_amount": plan.source_amount, + "output_amounts_base_units": plan.output_amounts_base_units, + "total_output_amount": plan.total_output_amount, + "change_amount": plan.change_amount, + "output_count": plan.output_amounts_base_units.len(), + }); + } + payload + } + + fn from_snapshot(snapshot: BootstrapPhaseSnapshot) -> Self { + Self { + status: snapshot.status.to_string(), + reason: snapshot.reason, + ready: snapshot.ready, + fee_mojos: 0, + fee_source: String::new(), + fee_lookup_error: None, + wait_error: None, + split_result: json!({}), + wait_events: Vec::new(), + plan: None, + } + } + + pub(crate) fn skipped(reason: impl Into) -> Self { + Self { + status: "skipped".to_string(), + reason: reason.into(), + ready: false, + fee_mojos: 0, + fee_source: String::new(), + fee_lookup_error: None, + wait_error: None, + split_result: json!({}), + wait_events: Vec::new(), + plan: None, + } + } +} + +pub fn bootstrap_blocks_offer(result: &BootstrapPhaseResult) -> Option { + bootstrap_block_error(&result.status, &result.reason, result.ready) +} + +fn bootstrap_ladder_entries_for_side( + side: &str, + side_ladder: &[LadderEntry], + pricing: &Value, + quote_price: f64, + resolved_quote_asset_id: &str, +) -> SignerResult> { + let side = normalize_offer_side(side); + let mut quote_unit_multiplier: Option = None; + if side == "buy" { + quote_unit_multiplier = Some(mojo_multiplier_for_leg( + pricing, + "quote_unit_mojo_multiplier", + resolved_quote_asset_id, + )); + } + let mut entries = Vec::new(); + for entry in side_ladder { + let mut size_base_units = entry.size_base_units; + if let Some(multiplier) = quote_unit_multiplier { + size_base_units = quote_mojos_for_base_size( + size_base_units, + quote_price, + multiplier, + ); + if size_base_units <= 0 { + continue; + } + } + entries.push(PlannerLadderRow { + size_base_units, + target_count: entry.target_count, + split_buffer_count: entry.split_buffer_count, + }); + } + Ok(entries) +} + +fn bootstrap_fee_cost_for_output_count(output_count: usize) -> u64 { + let count = output_count.max(1) as u64; + 1_000_000 + count.saturating_sub(1) * 250_000 +} + +async fn resolve_bootstrap_split_fee( + network: &str, + minimum_fee_mojos: u64, + output_count: usize, +) -> (u64, String, Option) { + let fee_cost = bootstrap_fee_cost_for_output_count(output_count); + let spend_count = output_count.max(1) as u64; + match get_conservative_fee_estimate(network, None, fee_cost, Some(spend_count)).await { + Ok(Some(fee_mojos)) => (fee_mojos, "coinset_conservative_fee".to_string(), None), + Ok(None) => (minimum_fee_mojos, "config_minimum_fee_fallback".to_string(), None), + Err(err) => ( + minimum_fee_mojos, + "config_minimum_fee_fallback".to_string(), + Some(err.to_string()), + ), + } +} + +fn wallet_coin_spendable(coin: &WalletUnspentCoin) -> bool { + let state = coin.state.trim().to_ascii_uppercase(); + matches!( + state.as_str(), + "CONFIRMED" | "UNSPENT" | "SPENDABLE" | "AVAILABLE" | "SETTLED" + ) +} + +async fn wait_for_coinset_confirmation( + network: &str, + receive_address: &str, + asset_id: &str, + initial_coin_ids: &HashSet, + timeout_seconds: u64, +) -> SignerResult> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_seconds.max(10)); + let mut sleep_seconds = 2.0f64; + loop { + if start.elapsed() >= timeout { + return Err(SignerError::Other( + "confirmation_wait_timeout".to_string(), + )); + } + let coins = list_wallet_unspent_coins(network, receive_address, asset_id).await?; + let new_confirmed: Vec<_> = coins + .into_iter() + .filter(|coin| !initial_coin_ids.contains(&coin.id)) + .collect(); + if let Some(first) = new_confirmed.first() { + let elapsed_seconds = start.elapsed().as_secs(); + return Ok(vec![json!({ + "event": "confirmed", + "coin_name": first.name, + "elapsed_seconds": elapsed_seconds.to_string(), + })]); + } + tokio::time::sleep(std::time::Duration::from_secs_f64(sleep_seconds)).await; + sleep_seconds = (sleep_seconds * 1.5).min(20.0); + } +} + +pub async fn signer_bootstrap_phase( + program: &ManagerProgramConfig, + market: &MarketConfig, + program_path: &Path, + resolved_base_asset_id: &str, + resolved_quote_asset_id: &str, + quote_price: f64, + action_side: &str, +) -> SignerResult { + let side = normalize_offer_side(action_side); + let side_ladder = market + .ladders + .get(side) + .cloned() + .unwrap_or_default(); + if side_ladder.is_empty() { + return Ok(BootstrapPhaseResult::skipped(format!("missing_{side}_ladder"))); + } + + let ladder_entries = bootstrap_ladder_entries_for_side( + side, + &side_ladder, + &market.pricing, + quote_price, + resolved_quote_asset_id, + )?; + if ladder_entries.is_empty() { + return Ok(BootstrapPhaseResult::skipped(format!( + "empty_{side}_ladder_after_quote_conversion" + ))); + } + + let split_asset_id = signer_split_asset_id( + side, + resolved_base_asset_id, + resolved_quote_asset_id, + ); + if split_asset_id.trim().is_empty() { + return Ok(BootstrapPhaseResult::skipped(format!( + "missing_{side}_asset_for_bootstrap" + ))); + } + + let receive_address = market.receive_address.trim(); + if receive_address.is_empty() { + return Ok(BootstrapPhaseResult::skipped( + "missing_receive_address_for_bootstrap", + )); + } + + let asset_scoped_coins = match list_wallet_unspent_coins( + &program.network, + receive_address, + &split_asset_id, + ) + .await + { + Ok(coins) => coins, + Err(err) => { + return Ok(BootstrapPhaseResult::skipped(format!( + "bootstrap_coin_list_failed:{err}" + ))); + } + }; + + let spendable_coins: Vec = asset_scoped_coins + .iter() + .filter(|coin| wallet_coin_spendable(coin)) + .map(|coin| BootstrapCoin { + id: coin.id.clone(), + amount: i64::try_from(coin.amount).unwrap_or(i64::MAX), + }) + .collect(); + + let outcome = plan_bootstrap_mixed_outputs(&ladder_entries, &spendable_coins); + if let Some(early) = bootstrap_early_phase(&outcome) { + return Ok(BootstrapPhaseResult::from_snapshot(early)); + } + let BootstrapPlanOutcome::NeedsSplit(bootstrap_plan) = outcome else { + return Ok(BootstrapPhaseResult::skipped("bootstrap_precheck_failed")); + }; + + let (fee_mojos, fee_source, fee_lookup_error) = resolve_bootstrap_split_fee( + &program.network, + program.coin_ops_minimum_fee_mojos, + bootstrap_plan.output_amounts_base_units.len(), + ) + .await; + if fee_mojos > 0 { + return Ok(BootstrapPhaseResult { + status: "failed".to_string(), + reason: "signer_mixed_split_fee_not_supported".to_string(), + ready: false, + fee_mojos, + fee_source, + fee_lookup_error, + wait_error: None, + split_result: json!({}), + wait_events: Vec::new(), + plan: None, + }); + } + + let existing_coin_ids: HashSet = asset_scoped_coins + .iter() + .map(|coin| coin.id.clone()) + .collect(); + + let signer_config = load_signer_config(program_path)?; + let output_amounts: Vec = bootstrap_plan + .output_amounts_base_units + .iter() + .map(|amount| u64::try_from(*amount).unwrap_or(0)) + .collect(); + let coin_ids = vec![bootstrap_plan.source_coin_id.clone()]; + let split_result = match build_and_optionally_broadcast_vault_cat_mixed_split( + signer_config, + MixedSplitRequest { + receive_address: receive_address.to_string(), + asset_id: crate::vault::members::hex_to_bytes32(&split_asset_id)?, + output_amounts, + coin_ids: crate::coinset::parse_coin_ids(&coin_ids)?, + allow_sub_cat_output: false, + fee_mojos: 0, + }, + true, + ) + .await + { + Ok(result) => json!({ + "offered_total": result.offered_total, + "target_total": result.target_total, + "change_amount": result.change_amount, + "selected_coin_ids": result.selected_coin_ids, + "broadcast_status": result.broadcast_status, + "spend_bundle_hex": result.spend_bundle_hex, + }), + Err(err) => { + return Ok(BootstrapPhaseResult { + status: "failed".to_string(), + reason: format!("signer_mixed_split_error:{err}"), + ready: false, + fee_mojos, + fee_source, + fee_lookup_error, + wait_error: None, + split_result: json!({}), + wait_events: Vec::new(), + plan: Some(bootstrap_plan), + }); + } + }; + + let wait_events = match wait_for_coinset_confirmation( + &program.network, + receive_address, + &split_asset_id, + &existing_coin_ids, + program.runtime_offer_bootstrap_wait_timeout_seconds, + ) + .await + { + Ok(events) => events, + Err(err) => { + return Ok(BootstrapPhaseResult { + status: "failed".to_string(), + reason: "bootstrap_wait_failed".to_string(), + ready: false, + fee_mojos, + fee_source, + fee_lookup_error, + wait_error: Some(err.to_string()), + split_result, + wait_events: Vec::new(), + plan: Some(bootstrap_plan), + }); + } + }; + + let refreshed_asset_coins = + list_wallet_unspent_coins(&program.network, receive_address, &split_asset_id).await?; + let refreshed_spendable: Vec = refreshed_asset_coins + .iter() + .filter(|coin| wallet_coin_spendable(coin)) + .map(|coin| BootstrapCoin { + id: coin.id.clone(), + amount: i64::try_from(coin.amount).unwrap_or(i64::MAX), + }) + .collect(); + let remaining = plan_bootstrap_mixed_outputs(&ladder_entries, &refreshed_spendable); + let executed = bootstrap_executed_phase(&remaining); + Ok(BootstrapPhaseResult { + status: executed.status.to_string(), + reason: executed.reason, + ready: executed.ready, + fee_mojos, + fee_source, + fee_lookup_error, + wait_error: None, + split_result, + wait_events, + plan: Some(bootstrap_plan), + }) +} diff --git a/greenfloor-engine/src/manager/build_and_post.rs b/greenfloor-engine/src/manager/build_and_post.rs new file mode 100644 index 00000000..0d3c8834 --- /dev/null +++ b/greenfloor-engine/src/manager/build_and_post.rs @@ -0,0 +1,504 @@ +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::adapters::{dexie_offer_view_url, post_offer_phase_dexie, DexieClient, SplashClient}; +use crate::coinset::get_conservative_fee_estimate; +use crate::config::{ + action_side_from_pricing, load_markets_config_with_overlay, load_program_config, + load_signer_config, require_signer_offer_path, resolve_market_for_build, + resolve_offer_publish_settings, resolve_quote_asset_for_offer, MarketConfig, +}; +use crate::error::{SignerError, SignerResult}; +use crate::offer::build_context::resolve_quote_price_for_pricing; +use crate::offer::codec::verify_offer_for_dexie; +use crate::offer::publish::expected_publish_asset_fields; +use crate::offer::{build_signer_offer_for_action, resolve_offer_assets_for_action, BuildOfferForActionRequest}; + +use crate::storage::{ + persist_offer_post_records, state_db_path_for_home, OfferPostPersistRecord, SqliteStore, +}; + +use super::bootstrap::{bootstrap_blocks_offer, signer_bootstrap_phase, BootstrapPhaseResult}; + +#[derive(Debug, Clone)] +pub struct BuildAndPostOfferRequest { + pub program_path: PathBuf, + pub markets_path: PathBuf, + pub testnet_markets_path: Option, + pub network: String, + pub market_id: Option, + pub pair: Option, + pub size_base_units: u64, + pub repeat: u32, + pub publish_venue: Option, + pub dexie_base_url: Option, + pub splash_base_url: Option, + pub drop_only: bool, + pub claim_rewards: bool, + pub dry_run: bool, + pub compact_json: bool, + pub persist_results: bool, +} + +#[derive(Debug, Clone)] +pub struct BuildAndPostOfferResponse { + pub exit_code: i32, + pub payload: Value, + pub output: String, +} + +pub fn format_build_and_post_output(payload: &Value, compact_json: bool) -> String { + if compact_json { + serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string()) + } else { + serde_json::to_string_pretty(payload).unwrap_or_else(|_| "{}".to_string()) + } +} + +pub async fn build_and_post_offer(request: BuildAndPostOfferRequest) -> SignerResult { + if request.size_base_units == 0 { + return Err(SignerError::Other( + "size_base_units must be positive".to_string(), + )); + } + if request.repeat == 0 { + return Err(SignerError::Other("repeat must be positive".to_string())); + } + + require_signer_offer_path(&request.program_path)?; + let program = load_program_config(&request.program_path)?; + let markets = load_markets_config_with_overlay( + &request.markets_path, + request.testnet_markets_path.as_deref(), + )?; + let market = resolve_market_for_build( + &markets, + request.market_id.as_deref(), + request.pair.as_deref(), + &request.network, + )?; + let (publish_venue, dexie_base_url, splash_base_url) = resolve_offer_publish_settings( + &program, + &request.network, + request.publish_venue.as_deref(), + request.dexie_base_url.as_deref(), + request.splash_base_url.as_deref(), + )?; + + let signer_config = load_signer_config(&request.program_path)?; + let (resolved_base_asset_id, resolved_quote_asset_id) = resolve_offer_assets_for_action( + &signer_config, + &market.base_asset, + &market.quote_asset, + ) + .await?; + let _resolved_quote_for_pricing = + resolve_quote_asset_for_offer(&market.quote_asset, &request.network); + let quote_price = resolve_quote_price_for_pricing(&market.pricing)?; + let action_side = action_side_from_pricing(&market.pricing); + + let (offer_fee_mojos, offer_fee_source) = + resolve_maker_offer_fee(&request.network).await; + + let mut post_results = Vec::new(); + let mut built_offers_preview = Vec::new(); + let mut bootstrap_actions = Vec::new(); + let mut publish_failures = 0u32; + let mut persist_records: Vec = Vec::new(); + + let dexie = if !request.dry_run && publish_venue.as_str() == "dexie" { + Some(DexieClient::new(dexie_base_url.clone())) + } else { + None + }; + let splash = if !request.dry_run && publish_venue.as_str() == "splash" { + Some(SplashClient::new(splash_base_url.clone())) + } else { + None + }; + + for _ in 0..request.repeat { + let started = std::time::Instant::now(); + let bootstrap_result = if request.dry_run { + BootstrapPhaseResult::skipped("dry_run") + } else { + signer_bootstrap_phase( + &program, + &market, + &request.program_path, + &resolved_base_asset_id, + &resolved_quote_asset_id, + quote_price, + &action_side, + ) + .await? + }; + bootstrap_actions.push(bootstrap_result.to_manager_json()); + if let Some(error) = bootstrap_blocks_offer(&bootstrap_result) { + publish_failures += 1; + post_results.push(failure_result( + &publish_venue, + started, + &error, + None, + Some(bootstrap_result.to_manager_json()), + )); + continue; + } + + let create_started = std::time::Instant::now(); + let create_result = match create_offer( + &request.program_path, + &market, + request.size_base_units, + quote_price, + &action_side, + ) + .await + { + Ok(result) => result, + Err(err) => { + publish_failures += 1; + post_results.push(failure_result( + &publish_venue, + started, + &err.to_string(), + Some(create_started.elapsed()), + None, + )); + continue; + } + }; + let create_phase_ms = create_started.elapsed().as_millis() as u64; + let offer_text = create_result + .get("offer_text") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if offer_text.is_empty() { + publish_failures += 1; + post_results.push(json!({ + "venue": publish_venue, + "result": { + "success": false, + "error": "signer_offer_text_unavailable", + "execution_mode": create_result.get("execution_mode").cloned().unwrap_or(Value::Null), + "timing_ms": timing_payload(started, Some(create_phase_ms), None, None), + } + })); + continue; + } + + if request.dry_run { + built_offers_preview.push(json!({ + "offer_prefix": &offer_text[..offer_text.len().min(24)], + "offer_length": offer_text.len().to_string(), + })); + continue; + } + + if let Some(verify_error) = verify_offer_for_dexie(&offer_text) { + publish_failures += 1; + post_results.push(json!({ + "venue": publish_venue, + "result": { + "success": false, + "error": verify_error, + "timing_ms": timing_payload(started, Some(create_phase_ms), None, None), + } + })); + continue; + } + + let asset_fields = expected_publish_asset_fields( + create_result + .get("side") + .and_then(Value::as_str) + .unwrap_or(&action_side), + &market.base_symbol, + &market.quote_asset, + &resolved_base_asset_id, + &resolved_quote_asset_id, + ); + let publish_started = std::time::Instant::now(); + let publish_result = publish_offer( + &publish_venue, + dexie.as_ref(), + splash.as_ref(), + &offer_text, + request.drop_only, + request.claim_rewards, + &asset_fields.expected_offered_asset_id, + &asset_fields.expected_offered_symbol, + &asset_fields.expected_requested_asset_id, + &asset_fields.expected_requested_symbol, + ) + .await?; + let publish_ms = publish_started.elapsed().as_millis() as u64; + if publish_result.get("success").and_then(Value::as_bool) != Some(true) { + publish_failures += 1; + } + + let mut result_payload = publish_result; + if let Value::Object(obj) = &mut result_payload { + obj.insert( + "execution_mode".to_string(), + create_result + .get("execution_mode") + .cloned() + .unwrap_or(Value::Null), + ); + obj.insert( + "timing_ms".to_string(), + timing_payload( + started, + Some(create_phase_ms), + Some(create_phase_ms), + Some(publish_ms), + ), + ); + } + if publish_venue.as_str() == "dexie" { + let offer_id = result_payload + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(offer_id) = offer_id { + if let Value::Object(obj) = &mut result_payload { + obj.insert( + "offer_view_url".to_string(), + Value::String(dexie_offer_view_url(&dexie_base_url, &offer_id)), + ); + } + if result_payload.get("success").and_then(Value::as_bool) == Some(true) { + let side = create_result + .get("side") + .and_then(Value::as_str) + .unwrap_or(&action_side) + .to_string(); + let execution_mode = create_result + .get("execution_mode") + .cloned() + .unwrap_or(Value::Null); + persist_records.push(OfferPostPersistRecord { + offer_id, + market_id: market.market_id.clone(), + side, + size_base_units: request.size_base_units, + publish_venue: publish_venue.clone(), + resolved_base_asset_id: resolved_base_asset_id.clone(), + resolved_quote_asset_id: resolved_quote_asset_id.clone(), + created_extra: json!({"execution_mode": execution_mode}), + }); + } + } + } else if result_payload.get("success").and_then(Value::as_bool) == Some(true) { + if let Some(offer_id) = result_payload + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + { + let side = create_result + .get("side") + .and_then(Value::as_str) + .unwrap_or(&action_side) + .to_string(); + let execution_mode = create_result + .get("execution_mode") + .cloned() + .unwrap_or(Value::Null); + persist_records.push(OfferPostPersistRecord { + offer_id, + market_id: market.market_id.clone(), + side, + size_base_units: request.size_base_units, + publish_venue: publish_venue.clone(), + resolved_base_asset_id: resolved_base_asset_id.clone(), + resolved_quote_asset_id: resolved_quote_asset_id.clone(), + created_extra: json!({"execution_mode": execution_mode}), + }); + } + } + post_results.push(json!({ + "venue": publish_venue, + "result": result_payload, + })); + } + + if request.persist_results && !request.dry_run && !persist_records.is_empty() { + let db_path = state_db_path_for_home(&program.home_dir); + let store = SqliteStore::open(&db_path)?; + persist_offer_post_records(&store, &persist_records)?; + } + + let payload = json!({ + "market_id": market.market_id, + "pair": format!("{}:{}", market.base_asset, market.quote_asset), + "resolved_base_asset_id": resolved_base_asset_id, + "resolved_quote_asset_id": resolved_quote_asset_id, + "network": program.network, + "size_base_units": request.size_base_units, + "repeat": request.repeat, + "publish_venue": publish_venue, + "dexie_base_url": dexie_base_url, + "splash_base_url": if publish_venue.as_str() == "splash" { Value::String(splash_base_url) } else { Value::Null }, + "drop_only": request.drop_only, + "claim_rewards": request.claim_rewards, + "dry_run": request.dry_run, + "publish_attempts": post_results.len(), + "publish_failures": publish_failures, + "built_offers_preview": built_offers_preview, + "bootstrap_actions": bootstrap_actions, + "results": post_results, + "offer_fee_mojos": offer_fee_mojos, + "offer_fee_source": offer_fee_source, + "execution_backend": "signer", + "signer_path": true, + }); + let exit_code = if publish_failures == 0 { 0 } else { 2 }; + let output = format_build_and_post_output(&payload, request.compact_json); + Ok(BuildAndPostOfferResponse { + exit_code, + payload, + output, + }) +} + +async fn create_offer( + program_path: &Path, + market: &MarketConfig, + size_base_units: u64, + quote_price: f64, + action_side: &str, +) -> SignerResult { + let signer_config = load_signer_config(program_path)?; + let request = BuildOfferForActionRequest { + receive_address: market.receive_address.clone(), + base_asset: market.base_asset.clone(), + quote_asset: market.quote_asset.clone(), + size_base_units, + action_side: action_side.to_string(), + pricing: market.pricing.clone(), + quote_price: Some(quote_price), + split_input_coins: true, + broadcast_split: true, + offer_coin_ids: Vec::new(), + }; + let result = build_signer_offer_for_action(signer_config, request).await?; + Ok(json!({ + "offer_text": result.offer_text, + "side": result.side, + "expires_at_unix": result.expires_at_unix, + "execution_mode": result.execution_mode, + })) +} + +async fn publish_offer( + publish_venue: &str, + dexie: Option<&DexieClient>, + splash: Option<&SplashClient>, + offer_text: &str, + drop_only: bool, + claim_rewards: bool, + expected_offered_asset_id: &str, + expected_offered_symbol: &str, + expected_requested_asset_id: &str, + expected_requested_symbol: &str, +) -> SignerResult { + match publish_venue { + "dexie" => { + let dexie = dexie.ok_or_else(|| { + SignerError::Other("dexie adapter missing for dexie publish".to_string()) + })?; + post_offer_phase_dexie( + dexie, + offer_text, + drop_only, + claim_rewards, + expected_offered_asset_id, + expected_offered_symbol, + expected_requested_asset_id, + expected_requested_symbol, + ) + .await + } + "splash" => { + let splash = splash.ok_or_else(|| { + SignerError::Other("splash adapter missing for splash publish".to_string()) + })?; + splash.post_offer(offer_text).await + } + other => Err(SignerError::Other(format!( + "unsupported publish venue: {other}" + ))), + } +} + +async fn resolve_maker_offer_fee(network: &str) -> (u64, String) { + match get_conservative_fee_estimate(network, None, 1_000_000, Some(1)).await { + Ok(Some(fee)) => (fee, "coinset_conservative_fee".to_string()), + Ok(None) => (0, "coinset_fee_unavailable".to_string()), + Err(_) => (0, "coinset_fee_unavailable".to_string()), + } +} + +fn timing_payload( + started: std::time::Instant, + create_phase_ms: Option, + create_total_ms: Option, + publish_ms: Option, +) -> Value { + json!({ + "create_phase_ms": create_phase_ms, + "publish_ms": publish_ms, + "total_ms": started.elapsed().as_millis() as u64, + "create_total_ms": create_total_ms.or(create_phase_ms), + }) +} + +fn failure_result( + publish_venue: &str, + started: std::time::Instant, + error: &str, + create_phase: Option, + bootstrap: Option, +) -> Value { + let create_phase_ms = create_phase.map(|duration| duration.as_millis() as u64); + let mut result = json!({ + "venue": publish_venue, + "result": { + "success": false, + "error": error, + "timing_ms": timing_payload(started, create_phase_ms, create_phase_ms, None), + } + }); + if let Some(bootstrap) = bootstrap { + if let Value::Object(obj) = &mut result { + if let Some(result_obj) = obj.get_mut("result").and_then(Value::as_object_mut) { + result_obj.insert("bootstrap".to_string(), bootstrap); + } + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_pretty_and_compact_json() { + let payload = json!({"ok": true}); + assert!(format_build_and_post_output(&payload, false).contains('\n')); + assert_eq!( + format_build_and_post_output(&payload, true), + r#"{"ok":true}"# + ); + } +} diff --git a/greenfloor-engine/src/manager/mod.rs b/greenfloor-engine/src/manager/mod.rs new file mode 100644 index 00000000..0a087de1 --- /dev/null +++ b/greenfloor-engine/src/manager/mod.rs @@ -0,0 +1,10 @@ +mod bootstrap; +mod build_and_post; + +#[cfg(test)] +mod tests; + +pub use build_and_post::{ + build_and_post_offer, format_build_and_post_output, BuildAndPostOfferRequest, + BuildAndPostOfferResponse, +}; diff --git a/greenfloor-engine/src/manager/tests.rs b/greenfloor-engine/src/manager/tests.rs new file mode 100644 index 00000000..5584beeb --- /dev/null +++ b/greenfloor-engine/src/manager/tests.rs @@ -0,0 +1,116 @@ +use std::io::Write; + +use mockito::Matcher; +use serde_json::json; + +use crate::adapters::{post_offer_phase_dexie, DexieClient}; +use crate::config::{ + load_markets_config, load_program_config, resolve_market_for_build, require_signer_offer_path, +}; + +#[tokio::test] +async fn dexie_post_offer_phase_posts_and_verifies_visibility() { + let mut server = mockito::Server::new_async().await; + let offer_id = "offer-123"; + let _post = server + .mock("POST", "/v1/offers") + .with_status(200) + .with_body(json!({"success": true, "id": offer_id}).to_string()) + .create_async() + .await; + let _get = server + .mock("GET", Matcher::Regex(r"/v1/offers/.*".to_string())) + .with_status(200) + .with_body( + json!({ + "offer": { + "id": offer_id, + "offered": [{"id": "basecat"}], + "requested": [{"code": "xch"}], + } + }) + .to_string(), + ) + .create_async() + .await; + + let dexie = DexieClient::new(server.url()); + let result = post_offer_phase_dexie( + &dexie, + "offer1test", + true, + false, + "basecat", + "A1", + "xch", + "xch", + ) + .await + .expect("post"); + assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(result.get("id").and_then(|v| v.as_str()), Some(offer_id)); +} + +#[test] +fn manager_config_and_market_resolution() { + let dir = tempfile::tempdir().expect("tempdir"); + let program_path = dir.path().join("program.yaml"); + let markets_path = dir.path().join("markets.yaml"); + let mut program_file = std::fs::File::create(&program_path).expect("create"); + write!( + program_file, + r#" +app: + network: mainnet + home_dir: /tmp/gf +runtime: + offer_bootstrap_wait_timeout_seconds: 120 +venues: + dexie: + api_base: https://api.dexie.space + splash: + api_base: http://localhost:4000 + offer_publish: + provider: dexie +coin_ops: + minimum_fee_mojos: 10000000 +signer: + kms_key_id: arn:aws:kms:us-west-2:123:key/demo +vault: + launcher_id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + custody_threshold: 1 + recovery_threshold: 1 + recovery_clawback_timelock: 3600 + custody_keys: + - public_key_hex: "020202020202020202020202020202020202020202020202020202020202020202" + curve: SECP256R1 + recovery_keys: + - public_key_hex: "ab3cb61463a695fa094f7c30526c8097fb813a0c5fa67bab261a7cd354cb6363b2d726218135b25b814f94df4749fc58" + curve: BLS12_381 +"# + ) + .expect("write"); + std::fs::write( + &markets_path, + r#" +markets: + - id: m1 + enabled: true + base_asset: a1 + base_symbol: A1 + quote_asset: xch + receive_address: xch1test + pricing: + min_price_quote_per_base: 0.0031 + max_price_quote_per_base: 0.0038 +"#, + ) + .expect("write markets"); + + require_signer_offer_path(&program_path).expect("signer path"); + let program = load_program_config(&program_path).expect("program"); + assert_eq!(program.offer_publish_venue, "dexie"); + let markets = load_markets_config(&markets_path).expect("markets"); + let market = resolve_market_for_build(&markets, Some("m1"), None, "mainnet").expect("market"); + assert_eq!(market.market_id, "m1"); +} diff --git a/greenfloor-engine/src/storage/mod.rs b/greenfloor-engine/src/storage/mod.rs new file mode 100644 index 00000000..bd96f1ce --- /dev/null +++ b/greenfloor-engine/src/storage/mod.rs @@ -0,0 +1,7 @@ +//! SQLite persistence for manager offer posts (shared schema with Python daemon). + +mod sqlite; + +pub use sqlite::{ + persist_offer_post_records, state_db_path_for_home, OfferPostPersistRecord, SqliteStore, +}; diff --git a/greenfloor-engine/src/storage/sqlite.rs b/greenfloor-engine/src/storage/sqlite.rs new file mode 100644 index 00000000..e78a80c7 --- /dev/null +++ b/greenfloor-engine/src/storage/sqlite.rs @@ -0,0 +1,342 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use chrono::Utc; +use rusqlite::{params, Connection}; +use serde_json::{json, Value}; + +use crate::cycle::OfferLifecycleState; +use crate::error::{SignerError, SignerResult}; + +const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS alert_state ( + market_id TEXT PRIMARY KEY, + is_low INTEGER NOT NULL, + last_alert_at TEXT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + market_id TEXT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS price_policy_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + market_id TEXT NOT NULL, + source TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tx_signal_state ( + tx_id TEXT PRIMARY KEY, + mempool_observed_at TEXT NOT NULL, + tx_block_confirmed_at TEXT NULL +); + +CREATE TABLE IF NOT EXISTS offer_state ( + offer_id TEXT PRIMARY KEY, + market_id TEXT NOT NULL, + state TEXT NOT NULL, + last_seen_status INTEGER NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS coin_op_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + market_id TEXT NOT NULL, + op_type TEXT NOT NULL, + op_count INTEGER NOT NULL, + fee_mojos INTEGER NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL, + operation_id TEXT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS offer_reservation_lease ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id TEXT NOT NULL, + market_id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + asset_id TEXT NOT NULL, + amount INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + released_at TEXT NULL +); +"#; + +#[derive(Debug, Clone)] +pub struct OfferPostPersistRecord { + pub offer_id: String, + pub market_id: String, + pub side: String, + pub size_base_units: u64, + pub publish_venue: String, + pub resolved_base_asset_id: String, + pub resolved_quote_asset_id: String, + pub created_extra: Value, +} + +pub struct SqliteStore { + conn: Connection, +} + +pub fn state_db_path_for_home(home_dir: &Path) -> PathBuf { + home_dir.join("db").join("greenfloor.sqlite") +} + +impl SqliteStore { + pub fn open(db_path: &Path) -> SignerResult { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + SignerError::Other(format!( + "failed to create sqlite parent dir {}: {err}", + parent.display() + )) + })?; + } + let conn = Connection::open(db_path).map_err(|err| { + SignerError::Other(format!( + "failed to open sqlite db {}: {err}", + db_path.display() + )) + })?; + conn.busy_timeout(Duration::from_secs(30)).map_err(|err| { + SignerError::Other(format!("failed to set sqlite busy_timeout: {err}")) + })?; + conn.execute_batch("PRAGMA busy_timeout = 30000;") + .map_err(|err| SignerError::Other(format!("failed to set busy_timeout pragma: {err}")))?; + conn.execute_batch(SCHEMA).map_err(|err| { + SignerError::Other(format!("failed to initialize sqlite schema: {err}")) + })?; + Ok(Self { conn }) + } + + pub fn upsert_offer_state( + &self, + offer_id: &str, + market_id: &str, + state: &str, + last_seen_status: Option, + ) -> SignerResult<()> { + self.conn + .execute( + r#" + INSERT INTO offer_state (offer_id, market_id, state, last_seen_status, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(offer_id) DO UPDATE SET + market_id = excluded.market_id, + state = excluded.state, + last_seen_status = excluded.last_seen_status, + updated_at = excluded.updated_at + "#, + params![ + offer_id, + market_id, + state, + last_seen_status, + utcnow_iso(), + ], + ) + .map_err(|err| SignerError::Other(format!("failed to upsert offer_state: {err}")))?; + Ok(()) + } + + pub fn add_audit_event( + &self, + event_type: &str, + payload: &Value, + market_id: Option<&str>, + ) -> SignerResult<()> { + let payload_json = serde_json::to_string(payload).map_err(|err| { + SignerError::Other(format!("failed to encode audit payload json: {err}")) + })?; + self.conn + .execute( + r#" + INSERT INTO audit_event (event_type, market_id, payload_json, created_at) + VALUES (?1, ?2, ?3, ?4) + "#, + params![event_type, market_id, payload_json, utcnow_iso()], + ) + .map_err(|err| SignerError::Other(format!("failed to insert audit_event: {err}")))?; + Ok(()) + } + + pub fn count_audit_events(&self, event_type: &str, market_id: &str) -> SignerResult { + self.conn + .query_row( + "SELECT COUNT(*) FROM audit_event WHERE event_type = ?1 AND market_id = ?2", + params![event_type, market_id], + |row| row.get(0), + ) + .map_err(|err| SignerError::Other(format!("failed to count audit events: {err}"))) + } + + pub fn latest_audit_payload( + &self, + event_type: &str, + market_id: &str, + ) -> SignerResult> { + let mut stmt = self + .conn + .prepare( + r#" + SELECT payload_json + FROM audit_event + WHERE event_type = ?1 AND market_id = ?2 + ORDER BY id DESC + LIMIT 1 + "#, + ) + .map_err(|err| SignerError::Other(format!("failed to prepare audit query: {err}")))?; + let mut rows = stmt + .query(params![event_type, market_id]) + .map_err(|err| SignerError::Other(format!("failed to query audit events: {err}")))?; + if let Some(row) = rows + .next() + .map_err(|err| SignerError::Other(format!("failed to read audit row: {err}")))? + { + let payload_json: String = row + .get(0) + .map_err(|err| SignerError::Other(format!("failed to read payload_json: {err}")))?; + let payload: Value = serde_json::from_str(&payload_json).map_err(|err| { + SignerError::Other(format!("failed to decode audit payload json: {err}")) + })?; + return Ok(Some(payload)); + } + Ok(None) + } + + pub fn offer_state_for_id(&self, offer_id: &str) -> SignerResult> { + let mut stmt = self + .conn + .prepare("SELECT state FROM offer_state WHERE offer_id = ?1") + .map_err(|err| SignerError::Other(format!("failed to prepare offer_state query: {err}")))?; + let mut rows = stmt + .query(params![offer_id]) + .map_err(|err| SignerError::Other(format!("failed to query offer_state: {err}")))?; + if let Some(row) = rows + .next() + .map_err(|err| SignerError::Other(format!("failed to read offer_state row: {err}")))? + { + let state: String = row + .get(0) + .map_err(|err| SignerError::Other(format!("failed to read offer state: {err}")))?; + return Ok(Some(state)); + } + Ok(None) + } +} + +pub fn persist_offer_post_records( + store: &SqliteStore, + records: &[OfferPostPersistRecord], +) -> SignerResult<()> { + for record in records { + store.upsert_offer_state( + &record.offer_id, + &record.market_id, + OfferLifecycleState::Open.as_str(), + None, + )?; + let mut audit_event = json!({ + "market_id": record.market_id, + "planned_count": 1, + "executed_count": 1, + "items": [{ + "size": record.size_base_units, + "side": record.side, + "status": "executed", + "reason": format!("{}_post_success", record.publish_venue), + "offer_id": record.offer_id, + "attempts": 1, + }], + "venue": record.publish_venue, + "resolved_base_asset_id": record.resolved_base_asset_id, + "resolved_quote_asset_id": record.resolved_quote_asset_id, + }); + if let Value::Object(extra) = &record.created_extra { + if let Value::Object(audit_obj) = &mut audit_event { + for (key, value) in extra { + audit_obj.insert(key.clone(), value.clone()); + } + } + } + store.add_audit_event( + "strategy_offer_execution", + &audit_event, + Some(record.market_id.as_str()), + )?; + } + Ok(()) +} + +fn utcnow_iso() -> String { + Utc::now().to_rfc3339() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persist_offer_post_records_writes_offer_state_and_audit_event() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("greenfloor.sqlite"); + let store = SqliteStore::open(&db_path).expect("open"); + + persist_offer_post_records( + &store, + &[OfferPostPersistRecord { + offer_id: "offer-123".to_string(), + market_id: "m1".to_string(), + side: "sell".to_string(), + size_base_units: 10, + publish_venue: "dexie".to_string(), + resolved_base_asset_id: "a1".to_string(), + resolved_quote_asset_id: "xch".to_string(), + created_extra: json!({"execution_mode": "direct"}), + }], + ) + .expect("persist"); + + assert_eq!( + store + .offer_state_for_id("offer-123") + .expect("offer state") + .as_deref(), + Some("open") + ); + + let count = store + .count_audit_events("strategy_offer_execution", "m1") + .expect("count"); + assert_eq!(count, 1); + + let payload = store + .latest_audit_payload("strategy_offer_execution", "m1") + .expect("payload") + .expect("row"); + let items = payload + .get("items") + .and_then(Value::as_array) + .expect("items array"); + assert_eq!(items.len(), 1); + assert_eq!( + items[0].get("offer_id").and_then(Value::as_str), + Some("offer-123") + ); + assert_eq!( + payload.get("execution_mode").and_then(Value::as_str), + Some("direct") + ); + } +} diff --git a/greenfloor/cli/engine_binary.py b/greenfloor/cli/engine_binary.py new file mode 100644 index 00000000..195f1232 --- /dev/null +++ b/greenfloor/cli/engine_binary.py @@ -0,0 +1,155 @@ +"""Invoke the native greenfloor-engine CLI binary from Python manager commands.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any + + +class GreenfloorEngineBinaryError(RuntimeError): + """Raised when the greenfloor-engine binary cannot be located or executed.""" + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def resolve_greenfloor_engine_binary() -> Path: + override = os.environ.get("GREENFLOOR_ENGINE_BIN", "").strip() + if override: + path = Path(override).expanduser() + if not path.is_file(): + raise GreenfloorEngineBinaryError( + f"GREENFLOOR_ENGINE_BIN is not an executable file: {path}" + ) + return path + + discovered = shutil.which("greenfloor-engine") + if discovered: + return Path(discovered) + + root = repo_root() + for relative in ( + Path("greenfloor-engine/target/release/greenfloor-engine"), + Path("greenfloor-engine/target/debug/greenfloor-engine"), + ): + candidate = root / relative + if candidate.is_file(): + return candidate + + raise GreenfloorEngineBinaryError( + "greenfloor-engine binary not found; build with " + "'cargo build --manifest-path greenfloor-engine/Cargo.toml' or set GREENFLOOR_ENGINE_BIN" + ) + + +def build_and_post_offer_argv( + *, + binary: Path, + program_path: Path, + markets_path: Path, + testnet_markets_path: Path | None, + network: str, + market_id: str | None, + pair: str | None, + size_base_units: int, + repeat: int, + publish_venue: str, + dexie_base_url: str, + splash_base_url: str, + drop_only: bool, + claim_rewards: bool, + dry_run: bool, + compact_json: bool, +) -> list[str]: + argv: list[str] = [ + str(binary), + "build-and-post-offer", + "--program-config", + str(program_path), + "--markets-config", + str(markets_path), + "--network", + network.strip(), + "--size-base-units", + str(int(size_base_units)), + "--repeat", + str(int(repeat)), + "--venue", + publish_venue.strip(), + "--dexie-base-url", + dexie_base_url.strip(), + "--splash-base-url", + splash_base_url.strip(), + ] + if testnet_markets_path is not None: + argv.extend(["--testnet-markets-config", str(testnet_markets_path)]) + if market_id: + argv.extend(["--market-id", market_id.strip()]) + if pair: + argv.extend(["--pair", pair.strip()]) + if not drop_only: + argv.append("--allow-take") + if claim_rewards: + argv.append("--claim-rewards") + if dry_run: + argv.append("--dry-run") + if compact_json: + argv.append("--json") + return argv + + +def run_build_and_post_offer_via_engine( + *, + program_path: Path, + markets_path: Path, + testnet_markets_path: Path | None = None, + network: str, + market_id: str | None, + pair: str | None, + size_base_units: int, + repeat: int, + publish_venue: str, + dexie_base_url: str, + splash_base_url: str, + drop_only: bool, + claim_rewards: bool, + dry_run: bool, + compact_json: bool = False, + run_fn: Callable[..., Any] | None = None, +) -> int: + binary = resolve_greenfloor_engine_binary() + argv = build_and_post_offer_argv( + binary=binary, + program_path=program_path, + markets_path=markets_path, + testnet_markets_path=testnet_markets_path, + network=network, + market_id=market_id, + pair=pair, + size_base_units=size_base_units, + repeat=repeat, + publish_venue=publish_venue, + dexie_base_url=dexie_base_url, + splash_base_url=splash_base_url, + drop_only=drop_only, + claim_rewards=claim_rewards, + dry_run=dry_run, + compact_json=compact_json, + ) + runner = run_fn or subprocess.run + completed = runner(argv, check=False) + returncode = getattr(completed, "returncode", completed) + if not isinstance(returncode, int): + raise GreenfloorEngineBinaryError( + f"unexpected subprocess return value from greenfloor-engine: {returncode!r}" + ) + return returncode + + +def format_argv_for_display(argv: Sequence[str]) -> str: + return " ".join(argv) diff --git a/greenfloor/cli/offer_build_post.py b/greenfloor/cli/offer_build_post.py index adbc3d83..90785e47 100644 --- a/greenfloor/cli/offer_build_post.py +++ b/greenfloor/cli/offer_build_post.py @@ -5,6 +5,7 @@ import logging from pathlib import Path +from greenfloor.cli.engine_binary import run_build_and_post_offer_via_engine from greenfloor.config.io import ( is_testnet, load_markets_config_with_optional_overlay, @@ -13,8 +14,7 @@ ) from greenfloor.config.models import require_signer_offer_path from greenfloor.logging_setup import warn_if_log_level_auto_healed -from greenfloor.runtime.offer_build_context import prepare_offer_build_context -from greenfloor.runtime.offer_post_request import OfferPostRequest +from greenfloor.runtime.json_output import json_output_compact from greenfloor.runtime.offer_publish import initialize_manager_file_logging _manager_logger = logging.getLogger("greenfloor.manager") @@ -90,18 +90,12 @@ def build_and_post_offer_cli( path=markets_path, overlay_path=testnet_markets_path, ) - market = resolve_market_for_build( + _ = resolve_market_for_build( markets, market_id=market_id, pair=pair, network=network, ) - build_ctx = prepare_offer_build_context( - program=program, - market=market, - program_path=program_path, - network=network, - ) initialize_manager_file_logging(program.home_dir, log_level=program.app_log_level) warn_if_log_level_auto_healed( @@ -110,8 +104,13 @@ def build_and_post_offer_cli( logger=_manager_logger, ) - request = OfferPostRequest( - build_ctx=build_ctx, + return run_build_and_post_offer_via_engine( + program_path=program_path, + markets_path=markets_path, + testnet_markets_path=testnet_markets_path, + network=network, + market_id=market_id, + pair=pair, size_base_units=size_base_units, repeat=repeat, publish_venue=publish_venue, @@ -119,7 +118,6 @@ def build_and_post_offer_cli( splash_base_url=splash_base_url, drop_only=drop_only, claim_rewards=claim_rewards, - dry_run=bool(dry_run), + dry_run=dry_run, + compact_json=json_output_compact(), ) - - return request.run_cli() diff --git a/greenfloor/runtime/json_output.py b/greenfloor/runtime/json_output.py index 1f2a8570..c083a89e 100644 --- a/greenfloor/runtime/json_output.py +++ b/greenfloor/runtime/json_output.py @@ -12,6 +12,10 @@ def set_json_output_compact(compact: bool) -> None: _JSON_OUTPUT_COMPACT = bool(compact) +def json_output_compact() -> bool: + return _JSON_OUTPUT_COMPACT + + def format_json_output(payload: object) -> str: if _JSON_OUTPUT_COMPACT: return json.dumps(payload, separators=(",", ":")) diff --git a/tests/helpers/engine_binary_fixtures.py b/tests/helpers/engine_binary_fixtures.py new file mode 100644 index 00000000..2673fafc --- /dev/null +++ b/tests/helpers/engine_binary_fixtures.py @@ -0,0 +1,68 @@ +"""Shared helpers for mocking greenfloor-engine CLI delegation in tests.""" + +from __future__ import annotations + +import json +from typing import Any + +from greenfloor.runtime.json_output import format_json_output + + +def default_build_post_success_payload(**overrides: Any) -> dict[str, Any]: + payload: dict[str, Any] = { + "market_id": "m1", + "pair": "a1:xch", + "resolved_base_asset_id": "a1", + "resolved_quote_asset_id": "xch", + "network": "mainnet", + "size_base_units": 10, + "repeat": 1, + "publish_venue": "dexie", + "dexie_base_url": "https://api.dexie.space", + "splash_base_url": None, + "drop_only": True, + "claim_rewards": False, + "dry_run": False, + "publish_attempts": 1, + "publish_failures": 0, + "built_offers_preview": [], + "bootstrap_actions": [], + "results": [ + { + "venue": "dexie", + "result": { + "success": True, + "id": "offer-123", + "offer_view_url": "https://dexie.space/offers/offer-123", + "execution_mode": "direct", + }, + } + ], + "offer_fee_mojos": 0, + "offer_fee_source": "coinset_fee_unavailable", + "execution_backend": "signer", + "signer_path": True, + } + payload.update(overrides) + return payload + + +def patch_engine_build_and_post( + monkeypatch, + *, + exit_code: int = 0, + payload: dict[str, Any] | None = None, + capture: dict[str, Any] | None = None, +) -> None: + captured = capture if capture is not None else {} + + def _fake_run(**kwargs: Any) -> int: + captured.update(kwargs) + if payload is not None: + print(format_json_output(payload)) + return exit_code + + monkeypatch.setattr( + "greenfloor.cli.offer_build_post.run_build_and_post_offer_via_engine", + _fake_run, + ) diff --git a/tests/test_manager_build_post_offer.py b/tests/test_manager_build_post_offer.py index 4ed5d9ac..a7419d4e 100644 --- a/tests/test_manager_build_post_offer.py +++ b/tests/test_manager_build_post_offer.py @@ -3,163 +3,92 @@ import json from pathlib import Path -import yaml +import pytest +from greenfloor.cli.engine_binary import ( + GreenfloorEngineBinaryError, + build_and_post_offer_argv, + resolve_greenfloor_engine_binary, +) from greenfloor.cli.offer_build_post import build_and_post_offer_cli -from tests.helpers.fake_adapters import FakeDexie +from tests.helpers.engine_binary_fixtures import ( + default_build_post_success_payload, + patch_engine_build_and_post, +) from tests.helpers.offer_runtime_fixtures import ( - patch_signer_create_offer_phase, write_manager_program_with_signer, write_markets, write_markets_with_duplicate_pair, ) -def test_build_and_post_offer_defaults_to_mainnet(monkeypatch, tmp_path: Path, capsys) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - captured: dict = {} - - class _FakeDexie(FakeDexie): - def post_offer(self, offer: str, *, drop_only: bool, claim_rewards: bool | None = None): - captured["base_url"] = self.base_url - captured["offer"] = offer - captured["drop_only"] = drop_only - captured["claim_rewards"] = claim_rewards - return {"success": True, "id": "offer-123"} +def test_resolve_greenfloor_engine_binary_from_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + binary = tmp_path / "greenfloor-engine" + binary.write_text("#!/bin/sh\n", encoding="utf-8") + binary.chmod(0o755) + monkeypatch.setenv("GREENFLOOR_ENGINE_BIN", str(binary)) + assert resolve_greenfloor_engine_binary() == binary - def get_offer(self, offer_id: str) -> dict: - return super().get_offer(offer_id) - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", _FakeDexie) +def test_resolve_greenfloor_engine_binary_missing_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GREENFLOOR_ENGINE_BIN", raising=False) monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: None, - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="m1", - pair=None, - size_base_units=10, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, + "greenfloor.cli.engine_binary.shutil.which", + lambda _name: None, ) - assert code == 0 - assert captured["base_url"] == "https://api.dexie.space" - assert captured["offer"] == "offer1abc" - assert captured["drop_only"] is True - assert captured["claim_rewards"] is False - - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["results"][0]["venue"] == "dexie" - assert payload["results"][0]["result"]["id"] == "offer-123" - assert ( - payload["results"][0]["result"]["offer_view_url"] == "https://dexie.space/offers/offer-123" - ) - - -def test_build_and_post_offer_local_path_persists_sqlite_audit_record( - monkeypatch, tmp_path: Path, capsys -) -> None: - from greenfloor.storage.sqlite import SqliteStore - - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", FakeDexie) monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: None, + "greenfloor.cli.engine_binary.repo_root", + lambda: Path("/nonexistent"), ) + with pytest.raises(GreenfloorEngineBinaryError, match="binary not found"): + resolve_greenfloor_engine_binary() - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="m1", - pair=None, + +def test_build_and_post_offer_argv_includes_manager_flags(tmp_path: Path) -> None: + binary = tmp_path / "greenfloor-engine" + argv = build_and_post_offer_argv( + binary=binary, + program_path=tmp_path / "program.yaml", + markets_path=tmp_path / "markets.yaml", + testnet_markets_path=tmp_path / "testnet-markets.yaml", + network="testnet11", + market_id=None, + pair="A1:txch", size_base_units=10, - repeat=1, + repeat=2, publish_venue="dexie", - dexie_base_url="https://api.dexie.space", + dexie_base_url="https://api-testnet.dexie.space", splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, + drop_only=False, + claim_rewards=True, + dry_run=True, + compact_json=True, ) - assert code == 0 - _ = capsys.readouterr() - - db_path = (tmp_path / "db" / "greenfloor.sqlite").resolve() - store = SqliteStore(db_path) - try: - events = store.list_recent_audit_events( - event_types=["strategy_offer_execution"], - market_id="m1", - limit=1, - ) - finally: - store.close() - assert len(events) == 1 - items = list((events[0].get("payload") or {}).get("items") or []) - assert len(items) == 1 - assert items[0]["offer_id"] == "offer-123" + assert argv[0] == str(binary) + assert "build-and-post-offer" in argv + assert "--allow-take" in argv + assert "--claim-rewards" in argv + assert "--dry-run" in argv + assert "--json" in argv + assert "--pair" in argv + assert "A1:txch" in argv -def test_build_and_post_offer_uses_market_configured_expiry_override( - monkeypatch, tmp_path: Path, capsys -) -> None: +def test_build_and_post_offer_defaults_to_mainnet(monkeypatch, tmp_path: Path, capsys) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - raw = yaml.safe_load(markets.read_text(encoding="utf-8")) - pricing = dict(raw["markets"][0].get("pricing") or {}) - pricing["strategy_offer_expiry_minutes"] = 12 - raw["markets"][0]["pricing"] = pricing - markets.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") - - captured_kwargs: dict[str, object] = {} - - class _FakeDexie(FakeDexie): - offer_id = "offer-expiry-1" - - patch_signer_create_offer_phase(monkeypatch) - - def _capture_create(**kwargs: object): - captured_kwargs.update(kwargs) - from greenfloor.core.offer_action import OfferCreatePhaseOutcome - - return OfferCreatePhaseOutcome( - offer_text="offer1expiryoverride", - expires_at="2026-01-01T00:00:00+00:00", - side="sell", - offer_amount=1000, - request_amount=1000, - execution_mode="direct", - create_result={}, - ) - - monkeypatch.setattr( - "greenfloor.runtime.offer_runtime.signer_create_offer_phase", - _capture_create, + captured: dict = {} + patch_engine_build_and_post( + monkeypatch, + capture=captured, + payload=default_build_post_success_payload(), ) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", _FakeDexie) - monkeypatch.setattr("greenfloor.core.policy_bridge.verify_offer_for_dexie", lambda _offer: None) code = build_and_post_offer_cli( program_path=program, @@ -177,14 +106,16 @@ def _capture_create(**kwargs: object): dry_run=False, ) assert code == 0 - market = captured_kwargs["market"] - from greenfloor.config.models import MarketConfig + assert captured["dexie_base_url"] == "https://api.dexie.space" + assert captured["drop_only"] is True + assert captured["claim_rewards"] is False - assert isinstance(market, MarketConfig) - assert market.pricing.get("strategy_offer_expiry_minutes") == 12 payload = json.loads(capsys.readouterr().out.strip()) - assert payload["publish_failures"] == 0 - assert payload["results"][0]["result"]["id"] == "offer-expiry-1" + assert payload["results"][0]["venue"] == "dexie" + assert payload["results"][0]["result"]["id"] == "offer-123" + assert ( + payload["results"][0]["result"]["offer_view_url"] == "https://dexie.space/offers/offer-123" + ) def test_build_and_post_offer_dry_run_builds_but_does_not_post( @@ -194,13 +125,19 @@ def test_build_and_post_offer_dry_run_builds_but_does_not_post( markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - - class _FailDexie: - def __init__(self, _base_url: str) -> None: - raise AssertionError("DexieAdapter should not be constructed in dry_run") - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", _FailDexie) + patch_engine_build_and_post( + monkeypatch, + payload=default_build_post_success_payload( + dry_run=True, + publish_attempts=0, + publish_failures=0, + results=[], + built_offers_preview=[ + {"offer_prefix": "offer1abc", "offer_length": "120"}, + {"offer_prefix": "offer1abc", "offer_length": "120"}, + ], + ), + ) code = build_and_post_offer_cli( program_path=program, @@ -229,15 +166,16 @@ def test_build_and_post_offer_resolves_market_by_pair(monkeypatch, tmp_path: Pat markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - - class _FakeDexie(FakeDexie): - offer_id = "offer-xyz" - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", _FakeDexie) - monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: None, + patch_engine_build_and_post( + monkeypatch, + payload=default_build_post_success_payload( + results=[ + { + "venue": "dexie", + "result": {"success": True, "id": "offer-xyz"}, + } + ] + ), ) code = build_and_post_offer_cli( @@ -258,7 +196,6 @@ class _FakeDexie(FakeDexie): assert code == 0 payload = json.loads(capsys.readouterr().out.strip()) assert payload["market_id"] == "m1" - assert payload["results"][0]["venue"] == "dexie" assert payload["results"][0]["result"]["id"] == "offer-xyz" @@ -269,15 +206,20 @@ def test_build_and_post_offer_accepts_txch_pair_on_testnet11( markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - - class _FakeDexie(FakeDexie): - offer_id = "offer-txch" - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", _FakeDexie) - monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: None, + patch_engine_build_and_post( + monkeypatch, + payload=default_build_post_success_payload( + results=[ + { + "venue": "dexie", + "result": { + "success": True, + "id": "offer-txch", + "offer_view_url": "https://testnet.dexie.space/offers/offer-txch", + }, + } + ] + ), ) code = build_and_post_offer_cli( @@ -297,11 +239,7 @@ class _FakeDexie(FakeDexie): ) assert code == 0 payload = json.loads(capsys.readouterr().out.strip()) - assert payload["market_id"] == "m1" assert payload["results"][0]["result"]["id"] == "offer-txch" - assert payload["results"][0]["result"]["offer_view_url"] == ( - "https://testnet.dexie.space/offers/offer-txch" - ) def test_build_and_post_offer_rejects_txch_pair_on_mainnet(tmp_path: Path) -> None: @@ -310,7 +248,7 @@ def test_build_and_post_offer_rejects_txch_pair_on_mainnet(tmp_path: Path) -> No write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - try: + with pytest.raises(ValueError, match="no enabled market found for pair"): build_and_post_offer_cli( program_path=program, markets_path=markets, @@ -326,19 +264,15 @@ def test_build_and_post_offer_rejects_txch_pair_on_mainnet(tmp_path: Path) -> No claim_rewards=False, dry_run=False, ) - raise AssertionError("expected ValueError") - except ValueError as exc: - assert "no enabled market found for pair" in str(exc) -def test_build_and_post_offer_pair_ambiguous_requires_market_id( - monkeypatch, tmp_path: Path -) -> None: +def test_build_and_post_offer_pair_ambiguous_requires_market_id(tmp_path: Path) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets_with_duplicate_pair(markets) - try: + + with pytest.raises(ValueError, match="ambiguous"): build_and_post_offer_cli( program_path=program, markets_path=markets, @@ -354,17 +288,15 @@ def test_build_and_post_offer_pair_ambiguous_requires_market_id( claim_rewards=False, dry_run=False, ) - raise AssertionError("expected ValueError") - except ValueError as exc: - assert "ambiguous" in str(exc) -def test_build_and_post_offer_rejects_unknown_market(monkeypatch, tmp_path: Path) -> None: +def test_build_and_post_offer_rejects_unknown_market(tmp_path: Path) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - try: + + with pytest.raises(ValueError, match="market_id not found"): build_and_post_offer_cli( program_path=program, markets_path=markets, @@ -380,9 +312,6 @@ def test_build_and_post_offer_rejects_unknown_market(monkeypatch, tmp_path: Path claim_rewards=False, dry_run=False, ) - raise AssertionError("expected ValueError") - except ValueError as exc: - assert "market_id not found" in str(exc) def test_build_and_post_offer_posts_to_splash_when_selected( @@ -392,20 +321,12 @@ def test_build_and_post_offer_posts_to_splash_when_selected( markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - - class _FakeSplash: - def __init__(self, base_url: str) -> None: - self.base_url = base_url - - def post_offer(self, offer: str): - _ = offer - return {"success": True, "id": "splash-1"} - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.SplashAdapter", _FakeSplash) - monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: None, + patch_engine_build_and_post( + monkeypatch, + payload=default_build_post_success_payload( + publish_venue="splash", + results=[{"venue": "splash", "result": {"success": True, "id": "splash-1"}}], + ), ) code = build_and_post_offer_cli( @@ -426,140 +347,6 @@ def post_offer(self, offer: str): assert code == 0 payload = json.loads(capsys.readouterr().out.strip()) assert payload["results"][0]["venue"] == "splash" - assert payload["results"][0]["result"]["id"] == "splash-1" - - -def test_build_and_post_offer_returns_nonzero_when_offer_verification_fails( - monkeypatch, tmp_path: Path, capsys -) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: "wallet_sdk_offer_verify_false", - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="m1", - pair=None, - size_base_units=1, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - assert code == 2 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["publish_attempts"] == 1 - assert payload["publish_failures"] == 1 - assert payload["results"][0]["result"]["success"] is False - - -def test_build_and_post_offer_surfaces_stale_engine_symbol_as_user_error( - monkeypatch, tmp_path: Path, capsys -) -> None: - from tests.helpers.engine_mock import MinimalSignerEngine, install_engine_stub - - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - - class _StaleEngine(MinimalSignerEngine): - expected_publish_asset_fields = None - - install_engine_stub(monkeypatch, _StaleEngine()) - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", FakeDexie) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="m1", - pair=None, - size_base_units=1, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - assert code == 2 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["publish_attempts"] == 1 - assert payload["publish_failures"] == 1 - assert payload["results"][0]["result"]["success"] is False - assert payload["results"][0]["result"]["error"].startswith("policy_bridge_error:") - assert ( - "Missing symbol: expected_publish_asset_fields" in payload["results"][0]["result"]["error"] - ) - - -def test_build_and_post_offer_blocks_publish_when_offer_has_no_expiry( - monkeypatch, tmp_path: Path, capsys -) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - called: dict[str, bool] = {"post_offer_called": False} - - class _FakeDexie: - def __init__(self, _base_url: str) -> None: - pass - - def post_offer(self, offer: str, *, drop_only: bool, claim_rewards: bool | None): - _ = offer, drop_only, claim_rewards - called["post_offer_called"] = True - return {"success": True, "id": "should-not-post"} - - from tests.helpers.engine_mock import MinimalSignerEngine, install_engine_stub - - class _Signer(MinimalSignerEngine): - @staticmethod - def verify_offer_for_dexie(_offer: str) -> str: - return "wallet_sdk_offer_missing_expiration" - - install_engine_stub(monkeypatch, _Signer) - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", _FakeDexie) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="m1", - pair=None, - size_base_units=1, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - - assert code == 2 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["publish_attempts"] == 1 - assert payload["publish_failures"] == 1 - assert payload["results"][0]["result"]["success"] is False - assert payload["results"][0]["result"]["error"] == "wallet_sdk_offer_missing_expiration" - assert called["post_offer_called"] is False def test_build_and_post_offer_returns_nonzero_when_publish_fails( @@ -569,20 +356,18 @@ def test_build_and_post_offer_returns_nonzero_when_publish_fails( markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - - class _FakeDexie: - def __init__(self, _base_url: str) -> None: - pass - - def post_offer(self, offer: str, *, drop_only: bool, claim_rewards: bool | None): - _ = offer, drop_only, claim_rewards - return {"success": False, "error": "dexie_http_error:500"} - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr("greenfloor.runtime.offer_orchestration.DexieAdapter", _FakeDexie) - monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: None, + patch_engine_build_and_post( + monkeypatch, + exit_code=2, + payload=default_build_post_success_payload( + publish_failures=1, + results=[ + { + "venue": "dexie", + "result": {"success": False, "error": "dexie_http_error:500"}, + } + ], + ), ) code = build_and_post_offer_cli( @@ -602,42 +387,4 @@ def post_offer(self, offer: str, *, drop_only: bool, claim_rewards: bool | None) ) assert code == 2 payload = json.loads(capsys.readouterr().out.strip()) - assert payload["publish_attempts"] == 1 - assert payload["publish_failures"] == 1 - assert payload["results"][0]["result"]["success"] is False - - -def test_build_and_post_offer_dry_run_returns_nonzero_when_build_fails( - monkeypatch, tmp_path: Path, capsys -) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - - patch_signer_create_offer_phase( - monkeypatch, - error="signing_failed:no_agg_sig_targets_found", - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="testnet11", - market_id="m1", - pair=None, - size_base_units=1, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api-testnet.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=True, - ) - assert code == 2 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["publish_attempts"] == 1 assert payload["publish_failures"] == 1 - assert payload["results"][0]["result"]["success"] is False - assert payload["results"][0]["result"]["error"].startswith("signing_failed:") diff --git a/tests/test_offer_cli_dispatch.py b/tests/test_offer_cli_dispatch.py index da31cef8..0baea2a4 100644 --- a/tests/test_offer_cli_dispatch.py +++ b/tests/test_offer_cli_dispatch.py @@ -5,6 +5,7 @@ import pytest from greenfloor.cli.offer_build_post import build_and_post_offer_cli +from tests.helpers.engine_binary_fixtures import patch_engine_build_and_post from tests.helpers.offer_runtime_fixtures import ( write_manager_program, write_manager_program_with_signer, @@ -39,23 +40,14 @@ def test_build_and_post_offer_cli_requires_signer_config(tmp_path: Path) -> None ) -def test_build_and_post_offer_cli_uses_signer_path(monkeypatch, tmp_path: Path) -> None: +def test_build_and_post_offer_cli_delegates_to_engine_binary(monkeypatch, tmp_path: Path) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" write_manager_program_with_signer(program, tmp_path=tmp_path) write_markets(markets) - signer_dispatched = [False] - - def _fake_signer(**kwargs): - _ = kwargs - signer_dispatched[0] = True - return 0, {} - - monkeypatch.setattr( - "greenfloor.runtime.offer_post_request.build_and_post_offer_signer", - _fake_signer, - ) + captured: dict = {} + patch_engine_build_and_post(monkeypatch, capture=captured) code = build_and_post_offer_cli( program_path=program, @@ -73,4 +65,5 @@ def _fake_signer(**kwargs): dry_run=False, ) assert code == 0 - assert signer_dispatched[0] is True + assert captured["market_id"] == "m1" + assert captured["publish_venue"] == "dexie" From dd202ab9e45d90a1f50a04e868643486c946c4d7 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Fri, 29 May 2026 18:12:58 -0700 Subject: [PATCH 2/6] Refactor build-and-post path: thin Python CLI and typed Rust orchestrator. Delegate config resolution and sqlite persistence entirely to greenfloor-engine, pass optional CLI overrides through subprocess argv, and consolidate manager post iteration with typed results and shared coin-op/retry helpers. Co-authored-by: Cursor --- greenfloor-engine/src/config/program.rs | 6 + greenfloor-engine/src/main.rs | 5 +- greenfloor-engine/src/manager/bootstrap.rs | 32 +- .../src/manager/build_and_post.rs | 838 +++++++++++------- greenfloor-engine/src/manager/tests.rs | 75 ++ greenfloor-engine/src/storage/mod.rs | 6 +- greenfloor-engine/src/storage/sqlite.rs | 46 +- greenfloor/cli/engine_binary.py | 38 +- greenfloor/cli/manager.py | 14 +- greenfloor/cli/offer_build_post.py | 80 +- tests/helpers/engine_binary_fixtures.py | 1 - tests/test_manager_build_post_offer.py | 290 ++---- tests/test_manager_post_offer.py | 30 - tests/test_offer_cli_dispatch.py | 39 +- 14 files changed, 766 insertions(+), 734 deletions(-) diff --git a/greenfloor-engine/src/config/program.rs b/greenfloor-engine/src/config/program.rs index afb081d6..a7b974c7 100644 --- a/greenfloor-engine/src/config/program.rs +++ b/greenfloor-engine/src/config/program.rs @@ -286,4 +286,10 @@ mod tests { assert_eq!(resolve_quote_asset_for_offer("xch", "testnet11"), "txch"); assert_eq!(resolve_quote_asset_for_offer("xch", "mainnet"), "xch"); } + + #[test] + fn resolve_splash_base_url_defaults_to_program_base() { + let splash = resolve_splash_base_url(None, "http://localhost:4000"); + assert_eq!(splash, "http://localhost:4000"); + } } diff --git a/greenfloor-engine/src/main.rs b/greenfloor-engine/src/main.rs index daca4ed1..9e824d1b 100644 --- a/greenfloor-engine/src/main.rs +++ b/greenfloor-engine/src/main.rs @@ -113,6 +113,8 @@ enum Commands { dry_run: bool, #[arg(long)] json: bool, + #[arg(long)] + no_persist_results: bool, }, } @@ -222,6 +224,7 @@ async fn run() -> Result<(), greenfloor_engine::Error> { claim_rewards, dry_run, json, + no_persist_results, } => { if market_id.is_none() == pair.is_none() { return Err(greenfloor_engine::Error::Other( @@ -249,7 +252,7 @@ async fn run() -> Result<(), greenfloor_engine::Error> { claim_rewards, dry_run, compact_json: json, - persist_results: true, + persist_results: !no_persist_results, }) .await?; println!("{}", response.output); diff --git a/greenfloor-engine/src/manager/bootstrap.rs b/greenfloor-engine/src/manager/bootstrap.rs index f1bfd454..3e856bc6 100644 --- a/greenfloor-engine/src/manager/bootstrap.rs +++ b/greenfloor-engine/src/manager/bootstrap.rs @@ -4,7 +4,9 @@ use std::path::Path; use serde_json::{json, Value}; use crate::coinset::{get_conservative_fee_estimate, list_wallet_unspent_coins, WalletUnspentCoin}; +use crate::coin_ops::is_spendable_wallet_coin; use crate::config::{load_signer_config, LadderEntry, ManagerProgramConfig, MarketConfig}; +use crate::cycle::retry::{poll_exponential_advance_sleep, poll_exponential_sleep_now}; use crate::error::{SignerError, SignerResult}; use crate::offer::bootstrap::{ bootstrap_early_phase, bootstrap_executed_phase, plan_bootstrap_mixed_outputs, BootstrapCoin, @@ -158,11 +160,9 @@ async fn resolve_bootstrap_split_fee( } fn wallet_coin_spendable(coin: &WalletUnspentCoin) -> bool { - let state = coin.state.trim().to_ascii_uppercase(); - matches!( - state.as_str(), - "CONFIRMED" | "UNSPENT" | "SPENDABLE" | "AVAILABLE" | "SETTLED" - ) + is_spendable_wallet_coin(&json!({ + "state": coin.state, + })) } async fn wait_for_coinset_confirmation( @@ -173,29 +173,37 @@ async fn wait_for_coinset_confirmation( timeout_seconds: u64, ) -> SignerResult> { let start = std::time::Instant::now(); - let timeout = std::time::Duration::from_secs(timeout_seconds.max(10)); - let mut sleep_seconds = 2.0f64; + let timeout = timeout_seconds.max(10) as i64; + let initial_sleep = 2.0f64; + let max_sleep = 20.0f64; + let mut sleep_seconds = 0.0f64; loop { - if start.elapsed() >= timeout { + let elapsed_seconds = start.elapsed().as_secs() as i64; + let Some(next_sleep) = poll_exponential_sleep_now( + elapsed_seconds, + timeout, + sleep_seconds, + initial_sleep, + max_sleep, + ) else { return Err(SignerError::Other( "confirmation_wait_timeout".to_string(), )); - } + }; let coins = list_wallet_unspent_coins(network, receive_address, asset_id).await?; let new_confirmed: Vec<_> = coins .into_iter() .filter(|coin| !initial_coin_ids.contains(&coin.id)) .collect(); if let Some(first) = new_confirmed.first() { - let elapsed_seconds = start.elapsed().as_secs(); return Ok(vec![json!({ "event": "confirmed", "coin_name": first.name, "elapsed_seconds": elapsed_seconds.to_string(), })]); } - tokio::time::sleep(std::time::Duration::from_secs_f64(sleep_seconds)).await; - sleep_seconds = (sleep_seconds * 1.5).min(20.0); + tokio::time::sleep(std::time::Duration::from_secs_f64(next_sleep)).await; + sleep_seconds = poll_exponential_advance_sleep(sleep_seconds, initial_sleep, max_sleep, 1.5); } } diff --git a/greenfloor-engine/src/manager/build_and_post.rs b/greenfloor-engine/src/manager/build_and_post.rs index 0d3c8834..b2d7c912 100644 --- a/greenfloor-engine/src/manager/build_and_post.rs +++ b/greenfloor-engine/src/manager/build_and_post.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::time::Instant; use serde_json::{json, Value}; @@ -7,14 +8,16 @@ use crate::coinset::get_conservative_fee_estimate; use crate::config::{ action_side_from_pricing, load_markets_config_with_overlay, load_program_config, load_signer_config, require_signer_offer_path, resolve_market_for_build, - resolve_offer_publish_settings, resolve_quote_asset_for_offer, MarketConfig, + resolve_offer_publish_settings, MarketConfig, ManagerProgramConfig, SignerConfig, }; use crate::error::{SignerError, SignerResult}; +use crate::offer::action::BuildOfferForActionResult; use crate::offer::build_context::resolve_quote_price_for_pricing; use crate::offer::codec::verify_offer_for_dexie; use crate::offer::publish::expected_publish_asset_fields; -use crate::offer::{build_signer_offer_for_action, resolve_offer_assets_for_action, BuildOfferForActionRequest}; - +use crate::offer::{ + build_signer_offer_for_action, resolve_offer_assets_for_action, BuildOfferForActionRequest, +}; use crate::storage::{ persist_offer_post_records, state_db_path_for_home, OfferPostPersistRecord, SqliteStore, }; @@ -48,6 +51,38 @@ pub struct BuildAndPostOfferResponse { pub output: String, } +#[derive(Debug, Clone)] +struct ResolvedBuildAndPostContext { + program: ManagerProgramConfig, + market: MarketConfig, + signer_config: SignerConfig, + publish_venue: String, + dexie_base_url: String, + splash_base_url: String, + resolved_base_asset_id: String, + resolved_quote_asset_id: String, + quote_price: f64, + action_side: String, + offer_fee_mojos: u64, + offer_fee_source: String, +} + +#[derive(Debug, Clone)] +struct PublishResult { + success: bool, + offer_id: Option, + body: Value, +} + +#[derive(Debug)] +struct PostFailure { + error: String, + started: Instant, + create_phase_ms: Option, + execution_mode: Option, + bootstrap: Option, +} + pub fn format_build_and_post_output(payload: &Value, compact_json: bool) -> String { if compact_json { serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string()) @@ -56,7 +91,9 @@ pub fn format_build_and_post_output(payload: &Value, compact_json: bool) -> Stri } } -pub async fn build_and_post_offer(request: BuildAndPostOfferRequest) -> SignerResult { +pub async fn build_and_post_offer( + request: BuildAndPostOfferRequest, +) -> SignerResult { if request.size_base_units == 0 { return Err(SignerError::Other( "size_base_units must be positive".to_string(), @@ -66,40 +103,7 @@ pub async fn build_and_post_offer(request: BuildAndPostOfferRequest) -> SignerRe return Err(SignerError::Other("repeat must be positive".to_string())); } - require_signer_offer_path(&request.program_path)?; - let program = load_program_config(&request.program_path)?; - let markets = load_markets_config_with_overlay( - &request.markets_path, - request.testnet_markets_path.as_deref(), - )?; - let market = resolve_market_for_build( - &markets, - request.market_id.as_deref(), - request.pair.as_deref(), - &request.network, - )?; - let (publish_venue, dexie_base_url, splash_base_url) = resolve_offer_publish_settings( - &program, - &request.network, - request.publish_venue.as_deref(), - request.dexie_base_url.as_deref(), - request.splash_base_url.as_deref(), - )?; - - let signer_config = load_signer_config(&request.program_path)?; - let (resolved_base_asset_id, resolved_quote_asset_id) = resolve_offer_assets_for_action( - &signer_config, - &market.base_asset, - &market.quote_asset, - ) - .await?; - let _resolved_quote_for_pricing = - resolve_quote_asset_for_offer(&market.quote_asset, &request.network); - let quote_price = resolve_quote_price_for_pricing(&market.pricing)?; - let action_side = action_side_from_pricing(&market.pricing); - - let (offer_fee_mojos, offer_fee_source) = - resolve_maker_offer_fee(&request.network).await; + let ctx = resolve_build_and_post_context(&request).await?; let mut post_results = Vec::new(); let mut built_offers_preview = Vec::new(); @@ -107,247 +111,70 @@ pub async fn build_and_post_offer(request: BuildAndPostOfferRequest) -> SignerRe let mut publish_failures = 0u32; let mut persist_records: Vec = Vec::new(); - let dexie = if !request.dry_run && publish_venue.as_str() == "dexie" { - Some(DexieClient::new(dexie_base_url.clone())) + let dexie = if !request.dry_run && ctx.publish_venue == "dexie" { + Some(DexieClient::new(ctx.dexie_base_url.clone())) } else { None }; - let splash = if !request.dry_run && publish_venue.as_str() == "splash" { - Some(SplashClient::new(splash_base_url.clone())) + let splash = if !request.dry_run && ctx.publish_venue == "splash" { + Some(SplashClient::new(ctx.splash_base_url.clone())) } else { None }; for _ in 0..request.repeat { - let started = std::time::Instant::now(); - let bootstrap_result = if request.dry_run { - BootstrapPhaseResult::skipped("dry_run") - } else { - signer_bootstrap_phase( - &program, - &market, - &request.program_path, - &resolved_base_asset_id, - &resolved_quote_asset_id, - quote_price, - &action_side, - ) - .await? - }; - bootstrap_actions.push(bootstrap_result.to_manager_json()); - if let Some(error) = bootstrap_blocks_offer(&bootstrap_result) { - publish_failures += 1; - post_results.push(failure_result( - &publish_venue, - started, - &error, - None, - Some(bootstrap_result.to_manager_json()), - )); - continue; - } - - let create_started = std::time::Instant::now(); - let create_result = match create_offer( - &request.program_path, - &market, - request.size_base_units, - quote_price, - &action_side, - ) - .await - { - Ok(result) => result, - Err(err) => { - publish_failures += 1; - post_results.push(failure_result( - &publish_venue, - started, - &err.to_string(), - Some(create_started.elapsed()), - None, - )); - continue; - } - }; - let create_phase_ms = create_started.elapsed().as_millis() as u64; - let offer_text = create_result - .get("offer_text") - .and_then(Value::as_str) - .unwrap_or("") - .trim() - .to_string(); - if offer_text.is_empty() { - publish_failures += 1; - post_results.push(json!({ - "venue": publish_venue, - "result": { - "success": false, - "error": "signer_offer_text_unavailable", - "execution_mode": create_result.get("execution_mode").cloned().unwrap_or(Value::Null), - "timing_ms": timing_payload(started, Some(create_phase_ms), None, None), - } - })); - continue; - } - - if request.dry_run { - built_offers_preview.push(json!({ - "offer_prefix": &offer_text[..offer_text.len().min(24)], - "offer_length": offer_text.len().to_string(), - })); - continue; - } - - if let Some(verify_error) = verify_offer_for_dexie(&offer_text) { - publish_failures += 1; - post_results.push(json!({ - "venue": publish_venue, - "result": { - "success": false, - "error": verify_error, - "timing_ms": timing_payload(started, Some(create_phase_ms), None, None), - } - })); - continue; - } - - let asset_fields = expected_publish_asset_fields( - create_result - .get("side") - .and_then(Value::as_str) - .unwrap_or(&action_side), - &market.base_symbol, - &market.quote_asset, - &resolved_base_asset_id, - &resolved_quote_asset_id, - ); - let publish_started = std::time::Instant::now(); - let publish_result = publish_offer( - &publish_venue, + let (bootstrap_action, iteration) = run_post_iteration( + &request, + &ctx, dexie.as_ref(), splash.as_ref(), - &offer_text, - request.drop_only, - request.claim_rewards, - &asset_fields.expected_offered_asset_id, - &asset_fields.expected_offered_symbol, - &asset_fields.expected_requested_asset_id, - &asset_fields.expected_requested_symbol, ) .await?; - let publish_ms = publish_started.elapsed().as_millis() as u64; - if publish_result.get("success").and_then(Value::as_bool) != Some(true) { - publish_failures += 1; - } - - let mut result_payload = publish_result; - if let Value::Object(obj) = &mut result_payload { - obj.insert( - "execution_mode".to_string(), - create_result - .get("execution_mode") - .cloned() - .unwrap_or(Value::Null), - ); - obj.insert( - "timing_ms".to_string(), - timing_payload( - started, - Some(create_phase_ms), - Some(create_phase_ms), - Some(publish_ms), - ), - ); - } - if publish_venue.as_str() == "dexie" { - let offer_id = result_payload - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if let Some(offer_id) = offer_id { - if let Value::Object(obj) = &mut result_payload { - obj.insert( - "offer_view_url".to_string(), - Value::String(dexie_offer_view_url(&dexie_base_url, &offer_id)), - ); + bootstrap_actions.push(bootstrap_action); + match iteration { + PostIterationOutcome::Preview(preview) => built_offers_preview.push(preview), + PostIterationOutcome::Failure(failure) => { + publish_failures += 1; + post_results.push(failure.to_venue_result(&ctx.publish_venue)); + } + PostIterationOutcome::Success { + venue_result, + persist_record, + } => { + if venue_result + .get("result") + .and_then(|value| value.get("success")) + .and_then(Value::as_bool) + != Some(true) + { + publish_failures += 1; } - if result_payload.get("success").and_then(Value::as_bool) == Some(true) { - let side = create_result - .get("side") - .and_then(Value::as_str) - .unwrap_or(&action_side) - .to_string(); - let execution_mode = create_result - .get("execution_mode") - .cloned() - .unwrap_or(Value::Null); - persist_records.push(OfferPostPersistRecord { - offer_id, - market_id: market.market_id.clone(), - side, - size_base_units: request.size_base_units, - publish_venue: publish_venue.clone(), - resolved_base_asset_id: resolved_base_asset_id.clone(), - resolved_quote_asset_id: resolved_quote_asset_id.clone(), - created_extra: json!({"execution_mode": execution_mode}), - }); + if let Some(record) = persist_record { + persist_records.push(record); } - } - } else if result_payload.get("success").and_then(Value::as_bool) == Some(true) { - if let Some(offer_id) = result_payload - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - { - let side = create_result - .get("side") - .and_then(Value::as_str) - .unwrap_or(&action_side) - .to_string(); - let execution_mode = create_result - .get("execution_mode") - .cloned() - .unwrap_or(Value::Null); - persist_records.push(OfferPostPersistRecord { - offer_id, - market_id: market.market_id.clone(), - side, - size_base_units: request.size_base_units, - publish_venue: publish_venue.clone(), - resolved_base_asset_id: resolved_base_asset_id.clone(), - resolved_quote_asset_id: resolved_quote_asset_id.clone(), - created_extra: json!({"execution_mode": execution_mode}), - }); + post_results.push(venue_result); } } - post_results.push(json!({ - "venue": publish_venue, - "result": result_payload, - })); } - if request.persist_results && !request.dry_run && !persist_records.is_empty() { - let db_path = state_db_path_for_home(&program.home_dir); - let store = SqliteStore::open(&db_path)?; - persist_offer_post_records(&store, &persist_records)?; - } + persist_post_records_if_enabled( + &ctx.program.home_dir, + request.persist_results, + request.dry_run, + &persist_records, + )?; let payload = json!({ - "market_id": market.market_id, - "pair": format!("{}:{}", market.base_asset, market.quote_asset), - "resolved_base_asset_id": resolved_base_asset_id, - "resolved_quote_asset_id": resolved_quote_asset_id, - "network": program.network, + "market_id": ctx.market.market_id, + "pair": format!("{}:{}", ctx.market.base_asset, ctx.market.quote_asset), + "resolved_base_asset_id": ctx.resolved_base_asset_id, + "resolved_quote_asset_id": ctx.resolved_quote_asset_id, + "network": ctx.program.network, "size_base_units": request.size_base_units, "repeat": request.repeat, - "publish_venue": publish_venue, - "dexie_base_url": dexie_base_url, - "splash_base_url": if publish_venue.as_str() == "splash" { Value::String(splash_base_url) } else { Value::Null }, + "publish_venue": ctx.publish_venue, + "dexie_base_url": ctx.dexie_base_url, + "splash_base_url": if ctx.publish_venue == "splash" { Value::String(ctx.splash_base_url.clone()) } else { Value::Null }, "drop_only": request.drop_only, "claim_rewards": request.claim_rewards, "dry_run": request.dry_run, @@ -356,8 +183,8 @@ pub async fn build_and_post_offer(request: BuildAndPostOfferRequest) -> SignerRe "built_offers_preview": built_offers_preview, "bootstrap_actions": bootstrap_actions, "results": post_results, - "offer_fee_mojos": offer_fee_mojos, - "offer_fee_source": offer_fee_source, + "offer_fee_mojos": ctx.offer_fee_mojos, + "offer_fee_source": ctx.offer_fee_source, "execution_backend": "signer", "signer_path": true, }); @@ -370,14 +197,249 @@ pub async fn build_and_post_offer(request: BuildAndPostOfferRequest) -> SignerRe }) } +enum PostIterationOutcome { + Preview(Value), + Failure(PostFailure), + Success { + venue_result: Value, + persist_record: Option, + }, +} + +impl PostFailure { + fn to_venue_result(&self, publish_venue: &str) -> Value { + let mut result = json!({ + "success": false, + "error": self.error, + "timing_ms": timing_payload( + self.started, + self.create_phase_ms, + self.create_phase_ms, + None, + ), + }); + if let Some(execution_mode) = &self.execution_mode { + result["execution_mode"] = json!(execution_mode); + } + if let Some(bootstrap) = &self.bootstrap { + result["bootstrap"] = bootstrap.clone(); + } + json!({ + "venue": publish_venue, + "result": result, + }) + } +} + +async fn resolve_build_and_post_context( + request: &BuildAndPostOfferRequest, +) -> SignerResult { + require_signer_offer_path(&request.program_path)?; + let program = load_program_config(&request.program_path)?; + let markets = load_markets_config_with_overlay( + &request.markets_path, + request.testnet_markets_path.as_deref(), + )?; + let market = resolve_market_for_build( + &markets, + request.market_id.as_deref(), + request.pair.as_deref(), + &request.network, + )?; + let (publish_venue, dexie_base_url, splash_base_url) = resolve_offer_publish_settings( + &program, + &request.network, + request.publish_venue.as_deref(), + request.dexie_base_url.as_deref(), + request.splash_base_url.as_deref(), + )?; + let signer_config = load_signer_config(&request.program_path)?; + let (resolved_base_asset_id, resolved_quote_asset_id) = resolve_offer_assets_for_action( + &signer_config, + &market.base_asset, + &market.quote_asset, + ) + .await?; + let quote_price = resolve_quote_price_for_pricing(&market.pricing)?; + let action_side = action_side_from_pricing(&market.pricing); + let (offer_fee_mojos, offer_fee_source) = resolve_maker_offer_fee(&request.network).await; + + Ok(ResolvedBuildAndPostContext { + program, + market, + signer_config, + publish_venue, + dexie_base_url, + splash_base_url, + resolved_base_asset_id, + resolved_quote_asset_id, + quote_price, + action_side, + offer_fee_mojos, + offer_fee_source, + }) +} + +async fn run_post_iteration( + request: &BuildAndPostOfferRequest, + ctx: &ResolvedBuildAndPostContext, + dexie: Option<&DexieClient>, + splash: Option<&SplashClient>, +) -> SignerResult<(Value, PostIterationOutcome)> { + let started = Instant::now(); + + let bootstrap_result = if request.dry_run { + BootstrapPhaseResult::skipped("dry_run") + } else { + signer_bootstrap_phase( + &ctx.program, + &ctx.market, + &request.program_path, + &ctx.resolved_base_asset_id, + &ctx.resolved_quote_asset_id, + ctx.quote_price, + &ctx.action_side, + ) + .await? + }; + let bootstrap_action = bootstrap_result.to_manager_json(); + if let Some(error) = bootstrap_blocks_offer(&bootstrap_result) { + return Ok(( + bootstrap_action, + PostIterationOutcome::Failure(PostFailure { + error, + started, + create_phase_ms: None, + execution_mode: None, + bootstrap: Some(bootstrap_result.to_manager_json()), + }), + )); + } + + let create_started = Instant::now(); + let created = match create_offer( + &ctx.signer_config, + &ctx.market, + request.size_base_units, + ctx.quote_price, + &ctx.action_side, + ) + .await + { + Ok(result) => result, + Err(err) => { + return Ok(( + bootstrap_action, + PostIterationOutcome::Failure(PostFailure { + error: err.to_string(), + started, + create_phase_ms: Some(create_started.elapsed().as_millis() as u64), + execution_mode: None, + bootstrap: None, + }), + )); + } + }; + let create_phase_ms = create_started.elapsed().as_millis() as u64; + + if created.offer_text.trim().is_empty() { + return Ok(( + bootstrap_action, + PostIterationOutcome::Failure(PostFailure { + error: "signer_offer_text_unavailable".to_string(), + started, + create_phase_ms: Some(create_phase_ms), + execution_mode: Some(created.execution_mode.clone()), + bootstrap: None, + }), + )); + } + + if request.dry_run { + let offer_text = created.offer_text.trim(); + return Ok(( + bootstrap_action, + PostIterationOutcome::Preview(json!({ + "offer_prefix": &offer_text[..offer_text.len().min(24)], + "offer_length": offer_text.len().to_string(), + })), + )); + } + + if let Some(verify_error) = verify_offer_for_dexie(&created.offer_text) { + return Ok(( + bootstrap_action, + PostIterationOutcome::Failure(PostFailure { + error: verify_error, + started, + create_phase_ms: Some(create_phase_ms), + execution_mode: None, + bootstrap: None, + }), + )); + } + + let side = created.side.as_str(); + let asset_fields = expected_publish_asset_fields( + side, + &ctx.market.base_symbol, + &ctx.market.quote_asset, + &ctx.resolved_base_asset_id, + &ctx.resolved_quote_asset_id, + ); + let publish_started = Instant::now(); + let publish = publish_offer( + &ctx.publish_venue, + dexie, + splash, + created.offer_text.trim(), + request.drop_only, + request.claim_rewards, + &asset_fields.expected_offered_asset_id, + &asset_fields.expected_offered_symbol, + &asset_fields.expected_requested_asset_id, + &asset_fields.expected_requested_symbol, + ) + .await?; + let publish_ms = publish_started.elapsed().as_millis() as u64; + + let persist_record = offer_post_persist_record( + &publish, + side, + &created.execution_mode, + ctx, + request.size_base_units, + ); + let result_payload = finalize_publish_payload( + publish, + &created.execution_mode, + timing_payload(started, Some(create_phase_ms), Some(create_phase_ms), Some(publish_ms)), + if ctx.publish_venue == "dexie" { + Some(ctx.dexie_base_url.as_str()) + } else { + None + }, + ); + + Ok(( + bootstrap_action, + PostIterationOutcome::Success { + venue_result: json!({ + "venue": ctx.publish_venue, + "result": result_payload, + }), + persist_record, + }, + )) +} + async fn create_offer( - program_path: &Path, + signer_config: &SignerConfig, market: &MarketConfig, size_base_units: u64, quote_price: f64, action_side: &str, -) -> SignerResult { - let signer_config = load_signer_config(program_path)?; +) -> SignerResult { let request = BuildOfferForActionRequest { receive_address: market.receive_address.clone(), base_asset: market.base_asset.clone(), @@ -390,13 +452,7 @@ async fn create_offer( broadcast_split: true, offer_coin_ids: Vec::new(), }; - let result = build_signer_offer_for_action(signer_config, request).await?; - Ok(json!({ - "offer_text": result.offer_text, - "side": result.side, - "expires_at_unix": result.expires_at_unix, - "execution_mode": result.execution_mode, - })) + build_signer_offer_for_action(signer_config.clone(), request).await } async fn publish_offer( @@ -410,8 +466,8 @@ async fn publish_offer( expected_offered_symbol: &str, expected_requested_asset_id: &str, expected_requested_symbol: &str, -) -> SignerResult { - match publish_venue { +) -> SignerResult { + let body = match publish_venue { "dexie" => { let dexie = dexie.ok_or_else(|| { SignerError::Other("dexie adapter missing for dexie publish".to_string()) @@ -426,20 +482,100 @@ async fn publish_offer( expected_requested_asset_id, expected_requested_symbol, ) - .await + .await? } "splash" => { let splash = splash.ok_or_else(|| { SignerError::Other("splash adapter missing for splash publish".to_string()) })?; - splash.post_offer(offer_text).await + splash.post_offer(offer_text).await? + } + other => { + return Err(SignerError::Other(format!( + "unsupported publish venue: {other}" + ))); + } + }; + Ok(PublishResult::from_adapter_body(body)) +} + +impl PublishResult { + fn from_adapter_body(body: Value) -> Self { + let success = body.get("success").and_then(Value::as_bool) == Some(true); + let offer_id = body + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Self { + success, + offer_id, + body, } - other => Err(SignerError::Other(format!( - "unsupported publish venue: {other}" - ))), } } +fn finalize_publish_payload( + publish: PublishResult, + execution_mode: &str, + timing_ms: Value, + dexie_base_url: Option<&str>, +) -> Value { + let mut payload = publish.body; + if let Value::Object(obj) = &mut payload { + obj.insert("execution_mode".to_string(), json!(execution_mode)); + obj.insert("timing_ms".to_string(), timing_ms); + if publish.success { + if let (Some(base_url), Some(offer_id)) = (dexie_base_url, publish.offer_id.as_deref()) + { + obj.insert( + "offer_view_url".to_string(), + Value::String(dexie_offer_view_url(base_url, offer_id)), + ); + } + } + } + payload +} + +fn offer_post_persist_record( + publish: &PublishResult, + side: &str, + execution_mode: &str, + ctx: &ResolvedBuildAndPostContext, + size_base_units: u64, +) -> Option { + if !publish.success { + return None; + } + let offer_id = publish.offer_id.clone()?; + Some(OfferPostPersistRecord { + offer_id, + market_id: ctx.market.market_id.clone(), + side: side.to_string(), + size_base_units, + publish_venue: ctx.publish_venue.clone(), + resolved_base_asset_id: ctx.resolved_base_asset_id.clone(), + resolved_quote_asset_id: ctx.resolved_quote_asset_id.clone(), + created_extra: json!({"execution_mode": execution_mode}), + }) +} + +pub(crate) fn persist_post_records_if_enabled( + home_dir: &Path, + persist_results: bool, + dry_run: bool, + records: &[OfferPostPersistRecord], +) -> SignerResult<()> { + if !persist_results || dry_run || records.is_empty() { + return Ok(()); + } + let db_path = state_db_path_for_home(home_dir); + let store = SqliteStore::open(&db_path)?; + persist_offer_post_records(&store, records) +} + async fn resolve_maker_offer_fee(network: &str) -> (u64, String) { match get_conservative_fee_estimate(network, None, 1_000_000, Some(1)).await { Ok(Some(fee)) => (fee, "coinset_conservative_fee".to_string()), @@ -449,7 +585,7 @@ async fn resolve_maker_offer_fee(network: &str) -> (u64, String) { } fn timing_payload( - started: std::time::Instant, + started: Instant, create_phase_ms: Option, create_total_ms: Option, publish_ms: Option, @@ -462,35 +598,12 @@ fn timing_payload( }) } -fn failure_result( - publish_venue: &str, - started: std::time::Instant, - error: &str, - create_phase: Option, - bootstrap: Option, -) -> Value { - let create_phase_ms = create_phase.map(|duration| duration.as_millis() as u64); - let mut result = json!({ - "venue": publish_venue, - "result": { - "success": false, - "error": error, - "timing_ms": timing_payload(started, create_phase_ms, create_phase_ms, None), - } - }); - if let Some(bootstrap) = bootstrap { - if let Value::Object(obj) = &mut result { - if let Some(result_obj) = obj.get_mut("result").and_then(Value::as_object_mut) { - result_obj.insert("bootstrap".to_string(), bootstrap); - } - } - } - result -} - #[cfg(test)] mod tests { use super::*; + use chia_protocol::Bytes32; + use crate::vault::context::VaultCustodySnapshot; + use std::path::Path; #[test] fn formats_pretty_and_compact_json() { @@ -501,4 +614,123 @@ mod tests { r#"{"ok":true}"# ); } + + #[test] + fn offer_post_persist_record_requires_success_and_offer_id() { + let ctx = ResolvedBuildAndPostContext { + program: ManagerProgramConfig { + network: "mainnet".to_string(), + home_dir: PathBuf::from("/tmp/gf"), + dexie_api_base: "https://api.dexie.space".to_string(), + splash_api_base: "http://localhost:4000".to_string(), + offer_publish_venue: "dexie".to_string(), + coin_ops_minimum_fee_mojos: 0, + runtime_offer_bootstrap_wait_timeout_seconds: 120, + }, + market: MarketConfig { + market_id: "m1".to_string(), + enabled: true, + base_asset: "a1".to_string(), + base_symbol: "A1".to_string(), + quote_asset: "xch".to_string(), + receive_address: "xch1".to_string(), + pricing: json!({}), + ladders: Default::default(), + }, + signer_config: SignerConfig { + network: "mainnet".to_string(), + coinset_msp_base_url: String::new(), + kms_key_id: String::new(), + kms_region: String::new(), + kms_public_key_hex: None, + vault: VaultCustodySnapshot { + launcher_id: Bytes32::default(), + custody_threshold: 1, + recovery_threshold: 1, + recovery_clawback_timelock: 0, + custody_keys: Vec::new(), + recovery_keys: Vec::new(), + }, + }, + publish_venue: "dexie".to_string(), + dexie_base_url: "https://api.dexie.space".to_string(), + splash_base_url: "http://localhost:4000".to_string(), + resolved_base_asset_id: "a1".to_string(), + resolved_quote_asset_id: "xch".to_string(), + quote_price: 1.0, + action_side: "sell".to_string(), + offer_fee_mojos: 0, + offer_fee_source: "coinset_fee_unavailable".to_string(), + }; + let failed = PublishResult { + success: false, + offer_id: Some("offer-1".to_string()), + body: json!({"success": false}), + }; + assert!(offer_post_persist_record(&failed, "sell", "direct", &ctx, 1).is_none()); + + let success = PublishResult { + success: true, + offer_id: Some("offer-1".to_string()), + body: json!({"success": true, "id": "offer-1"}), + }; + let record = offer_post_persist_record(&success, "sell", "direct", &ctx, 10) + .expect("record"); + assert_eq!(record.offer_id, "offer-1"); + assert_eq!(record.market_id, "m1"); + } + + #[test] + fn persist_post_records_if_enabled_writes_sqlite() { + let dir = tempfile::tempdir().expect("tempdir"); + let home = dir.path().join("home"); + persist_post_records_if_enabled( + &home, + true, + false, + &[OfferPostPersistRecord { + offer_id: "offer-abc".to_string(), + market_id: "m1".to_string(), + side: "sell".to_string(), + size_base_units: 5, + publish_venue: "dexie".to_string(), + resolved_base_asset_id: "a1".to_string(), + resolved_quote_asset_id: "xch".to_string(), + created_extra: json!({"execution_mode": "direct"}), + }], + ) + .expect("persist"); + + let db_path = state_db_path_for_home(Path::new(&home)); + let store = SqliteStore::open(&db_path).expect("open"); + assert_eq!( + store + .offer_state_for_id("offer-abc") + .expect("state") + .as_deref(), + Some("open") + ); + } + + #[test] + fn persist_post_records_if_enabled_skips_dry_run() { + let dir = tempfile::tempdir().expect("tempdir"); + persist_post_records_if_enabled( + dir.path(), + true, + true, + &[OfferPostPersistRecord { + offer_id: "offer-abc".to_string(), + market_id: "m1".to_string(), + side: "sell".to_string(), + size_base_units: 5, + publish_venue: "dexie".to_string(), + resolved_base_asset_id: "a1".to_string(), + resolved_quote_asset_id: "xch".to_string(), + created_extra: json!({}), + }], + ) + .expect("skip"); + assert!(!state_db_path_for_home(dir.path()).exists()); + } } diff --git a/greenfloor-engine/src/manager/tests.rs b/greenfloor-engine/src/manager/tests.rs index 5584beeb..3c4fffa1 100644 --- a/greenfloor-engine/src/manager/tests.rs +++ b/greenfloor-engine/src/manager/tests.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::adapters::{post_offer_phase_dexie, DexieClient}; use crate::config::{ load_markets_config, load_program_config, resolve_market_for_build, require_signer_offer_path, + resolve_offer_publish_settings, ManagerProgramConfig, }; #[tokio::test] @@ -114,3 +115,77 @@ markets: let market = resolve_market_for_build(&markets, Some("m1"), None, "mainnet").expect("market"); assert_eq!(market.market_id, "m1"); } + +#[test] +fn resolve_offer_publish_settings_uses_program_defaults() { + let program = ManagerProgramConfig { + network: "mainnet".to_string(), + home_dir: std::path::PathBuf::from("/tmp/gf"), + dexie_api_base: "https://api.dexie.space".to_string(), + splash_api_base: "http://localhost:4000".to_string(), + offer_publish_venue: "splash".to_string(), + coin_ops_minimum_fee_mojos: 0, + runtime_offer_bootstrap_wait_timeout_seconds: 120, + }; + let (venue, dexie_base, splash_base) = + resolve_offer_publish_settings(&program, "mainnet", None, None, None).expect("settings"); + assert_eq!(venue, "splash"); + assert_eq!(dexie_base, "https://api.dexie.space"); + assert_eq!(splash_base, "http://localhost:4000"); +} + +#[test] +fn resolve_market_rejects_unknown_market_id() { + let dir = tempfile::tempdir().expect("tempdir"); + let markets_path = dir.path().join("markets.yaml"); + std::fs::write( + &markets_path, + r#" +markets: + - id: m1 + enabled: true + base_asset: a1 + base_symbol: A1 + quote_asset: xch + receive_address: xch1test + pricing: + min_price_quote_per_base: 0.0031 + max_price_quote_per_base: 0.0038 +"#, + ) + .expect("write"); + let markets = load_markets_config(&markets_path).expect("markets"); + let err = resolve_market_for_build(&markets, Some("missing"), None, "mainnet") + .expect_err("missing market"); + assert!(err.to_string().contains("market_id not found")); +} + +#[test] +fn resolve_market_rejects_ambiguous_pair() { + let dir = tempfile::tempdir().expect("tempdir"); + let markets_path = dir.path().join("markets.yaml"); + std::fs::write( + &markets_path, + r#" +markets: + - id: m1 + enabled: true + base_asset: a1 + base_symbol: A1 + quote_asset: xch + receive_address: xch1a + pricing: { "side": "sell" } + - id: m2 + enabled: true + base_asset: a1 + base_symbol: A1 + quote_asset: xch + receive_address: xch1b + pricing: { "side": "sell" } +"#, + ) + .expect("write"); + let markets = load_markets_config(&markets_path).expect("markets"); + let err = resolve_market_for_build(&markets, None, Some("a1:xch"), "mainnet").expect_err("ambiguous"); + assert!(err.to_string().contains("ambiguous")); +} diff --git a/greenfloor-engine/src/storage/mod.rs b/greenfloor-engine/src/storage/mod.rs index bd96f1ce..7ee24268 100644 --- a/greenfloor-engine/src/storage/mod.rs +++ b/greenfloor-engine/src/storage/mod.rs @@ -1,4 +1,8 @@ -//! SQLite persistence for manager offer posts (shared schema with Python daemon). +//! SQLite persistence for the Rust engine. +//! +//! The canonical schema for GreenFloor state lives here. Python `greenfloor/storage/sqlite.py` +//! remains for the daemon until that path migrates; new manager CLI persistence must use this +//! module only. mod sqlite; diff --git a/greenfloor-engine/src/storage/sqlite.rs b/greenfloor-engine/src/storage/sqlite.rs index e78a80c7..086442d3 100644 --- a/greenfloor-engine/src/storage/sqlite.rs +++ b/greenfloor-engine/src/storage/sqlite.rs @@ -170,7 +170,28 @@ impl SqliteStore { Ok(()) } - pub fn count_audit_events(&self, event_type: &str, market_id: &str) -> SignerResult { + pub(crate) fn offer_state_for_id(&self, offer_id: &str) -> SignerResult> { + let mut stmt = self + .conn + .prepare("SELECT state FROM offer_state WHERE offer_id = ?1") + .map_err(|err| SignerError::Other(format!("failed to prepare offer_state query: {err}")))?; + let mut rows = stmt + .query(params![offer_id]) + .map_err(|err| SignerError::Other(format!("failed to query offer_state: {err}")))?; + if let Some(row) = rows + .next() + .map_err(|err| SignerError::Other(format!("failed to read offer_state row: {err}")))? + { + let state: String = row + .get(0) + .map_err(|err| SignerError::Other(format!("failed to read offer state: {err}")))?; + return Ok(Some(state)); + } + Ok(None) + } + + #[cfg(test)] + fn count_audit_events(&self, event_type: &str, market_id: &str) -> SignerResult { self.conn .query_row( "SELECT COUNT(*) FROM audit_event WHERE event_type = ?1 AND market_id = ?2", @@ -180,7 +201,8 @@ impl SqliteStore { .map_err(|err| SignerError::Other(format!("failed to count audit events: {err}"))) } - pub fn latest_audit_payload( + #[cfg(test)] + fn latest_audit_payload( &self, event_type: &str, market_id: &str, @@ -214,26 +236,6 @@ impl SqliteStore { } Ok(None) } - - pub fn offer_state_for_id(&self, offer_id: &str) -> SignerResult> { - let mut stmt = self - .conn - .prepare("SELECT state FROM offer_state WHERE offer_id = ?1") - .map_err(|err| SignerError::Other(format!("failed to prepare offer_state query: {err}")))?; - let mut rows = stmt - .query(params![offer_id]) - .map_err(|err| SignerError::Other(format!("failed to query offer_state: {err}")))?; - if let Some(row) = rows - .next() - .map_err(|err| SignerError::Other(format!("failed to read offer_state row: {err}")))? - { - let state: String = row - .get(0) - .map_err(|err| SignerError::Other(format!("failed to read offer state: {err}")))?; - return Ok(Some(state)); - } - Ok(None) - } } pub fn persist_offer_post_records( diff --git a/greenfloor/cli/engine_binary.py b/greenfloor/cli/engine_binary.py index 195f1232..e0f78490 100644 --- a/greenfloor/cli/engine_binary.py +++ b/greenfloor/cli/engine_binary.py @@ -5,9 +5,8 @@ import os import shutil import subprocess -from collections.abc import Callable, Sequence +from collections.abc import Callable from pathlib import Path -from typing import Any class GreenfloorEngineBinaryError(RuntimeError): @@ -58,13 +57,14 @@ def build_and_post_offer_argv( pair: str | None, size_base_units: int, repeat: int, - publish_venue: str, - dexie_base_url: str, - splash_base_url: str, + publish_venue: str | None, + dexie_base_url: str | None, + splash_base_url: str | None, drop_only: bool, claim_rewards: bool, dry_run: bool, compact_json: bool, + persist_results: bool, ) -> list[str]: argv: list[str] = [ str(binary), @@ -79,12 +79,6 @@ def build_and_post_offer_argv( str(int(size_base_units)), "--repeat", str(int(repeat)), - "--venue", - publish_venue.strip(), - "--dexie-base-url", - dexie_base_url.strip(), - "--splash-base-url", - splash_base_url.strip(), ] if testnet_markets_path is not None: argv.extend(["--testnet-markets-config", str(testnet_markets_path)]) @@ -92,6 +86,12 @@ def build_and_post_offer_argv( argv.extend(["--market-id", market_id.strip()]) if pair: argv.extend(["--pair", pair.strip()]) + if publish_venue and publish_venue.strip(): + argv.extend(["--venue", publish_venue.strip()]) + if dexie_base_url and dexie_base_url.strip(): + argv.extend(["--dexie-base-url", dexie_base_url.strip()]) + if splash_base_url and splash_base_url.strip(): + argv.extend(["--splash-base-url", splash_base_url.strip()]) if not drop_only: argv.append("--allow-take") if claim_rewards: @@ -100,6 +100,8 @@ def build_and_post_offer_argv( argv.append("--dry-run") if compact_json: argv.append("--json") + if not persist_results: + argv.append("--no-persist-results") return argv @@ -113,14 +115,15 @@ def run_build_and_post_offer_via_engine( pair: str | None, size_base_units: int, repeat: int, - publish_venue: str, - dexie_base_url: str, - splash_base_url: str, + publish_venue: str | None, + dexie_base_url: str | None, + splash_base_url: str | None, drop_only: bool, claim_rewards: bool, dry_run: bool, compact_json: bool = False, - run_fn: Callable[..., Any] | None = None, + persist_results: bool = True, + run_fn: Callable[..., object] | None = None, ) -> int: binary = resolve_greenfloor_engine_binary() argv = build_and_post_offer_argv( @@ -140,6 +143,7 @@ def run_build_and_post_offer_via_engine( claim_rewards=claim_rewards, dry_run=dry_run, compact_json=compact_json, + persist_results=persist_results, ) runner = run_fn or subprocess.run completed = runner(argv, check=False) @@ -149,7 +153,3 @@ def run_build_and_post_offer_via_engine( f"unexpected subprocess return value from greenfloor-engine: {returncode!r}" ) return returncode - - -def format_argv_for_display(argv: Sequence[str]) -> str: - return " ".join(argv) diff --git a/greenfloor/cli/manager.py b/greenfloor/cli/manager.py index bd8dba23..ae0a762e 100644 --- a/greenfloor/cli/manager.py +++ b/greenfloor/cli/manager.py @@ -21,7 +21,6 @@ ) from greenfloor.cli.offer_build_post import ( build_and_post_offer_cli, - resolve_offer_publish_settings, ) from greenfloor.cli.offers_lifecycle import offers_cancel, offers_reconcile, offers_status from greenfloor.config.io import ( @@ -234,13 +233,6 @@ def main() -> None: else None, ) elif args.command == "build-and-post-offer": - venue, dexie_base_url, splash_base_url = resolve_offer_publish_settings( - program_path=Path(args.program_config), - network=args.network, - venue_override=args.venue, - dexie_base_url=args.dexie_base_url or None, - splash_base_url=args.splash_base_url or None, - ) code = build_and_post_offer_cli( program_path=Path(args.program_config), markets_path=Path(args.markets_config), @@ -250,9 +242,9 @@ def main() -> None: pair=args.pair or None, size_base_units=args.size_base_units, repeat=args.repeat, - publish_venue=venue, - dexie_base_url=dexie_base_url, - splash_base_url=splash_base_url, + publish_venue=args.venue, + dexie_base_url=args.dexie_base_url or None, + splash_base_url=args.splash_base_url or None, drop_only=not bool(args.allow_take), claim_rewards=bool(args.claim_rewards), dry_run=bool(args.dry_run), diff --git a/greenfloor/cli/offer_build_post.py b/greenfloor/cli/offer_build_post.py index 90785e47..964fe528 100644 --- a/greenfloor/cli/offer_build_post.py +++ b/greenfloor/cli/offer_build_post.py @@ -2,64 +2,10 @@ from __future__ import annotations -import logging from pathlib import Path from greenfloor.cli.engine_binary import run_build_and_post_offer_via_engine -from greenfloor.config.io import ( - is_testnet, - load_markets_config_with_optional_overlay, - load_program_config, - resolve_market_for_build, -) -from greenfloor.config.models import require_signer_offer_path -from greenfloor.logging_setup import warn_if_log_level_auto_healed from greenfloor.runtime.json_output import json_output_compact -from greenfloor.runtime.offer_publish import initialize_manager_file_logging - -_manager_logger = logging.getLogger("greenfloor.manager") - - -def resolve_dexie_base_url(network: str, explicit_base_url: str | None) -> str: - if explicit_base_url and explicit_base_url.strip(): - return explicit_base_url.strip().rstrip("/") - network_l = network.strip().lower() - if network_l in {"mainnet", ""}: - return "https://api.dexie.space" - if is_testnet(network_l): - return "https://api-testnet.dexie.space" - raise ValueError(f"unsupported network for dexie posting: {network}") - - -def resolve_splash_base_url(explicit_base_url: str | None) -> str: - if explicit_base_url and explicit_base_url.strip(): - return explicit_base_url.strip().rstrip("/") - return "http://john-deere.hoffmang.com:4000" - - -def resolve_offer_publish_settings( - *, - program_path: Path, - network: str, - venue_override: str | None, - dexie_base_url: str | None, - splash_base_url: str | None, -) -> tuple[str, str, str]: - program = load_program_config(program_path) - venue = (venue_override or program.offer_publish_venue).strip().lower() - if venue not in {"dexie", "splash"}: - raise ValueError("offer publish venue must be dexie or splash") - if dexie_base_url and dexie_base_url.strip(): - dexie_base = dexie_base_url.strip().rstrip("/") - elif is_testnet(network): - dexie_base = resolve_dexie_base_url(network, None) - else: - dexie_base = str(program.dexie_api_base).strip().rstrip("/") - if splash_base_url and splash_base_url.strip(): - splash_base = splash_base_url.strip().rstrip("/") - else: - splash_base = str(program.splash_api_base).strip().rstrip("/") - return venue, dexie_base, splash_base def build_and_post_offer_cli( @@ -72,9 +18,9 @@ def build_and_post_offer_cli( pair: str | None, size_base_units: int, repeat: int, - publish_venue: str, - dexie_base_url: str, - splash_base_url: str, + publish_venue: str | None, + dexie_base_url: str | None, + splash_base_url: str | None, drop_only: bool, claim_rewards: bool, dry_run: bool, @@ -84,26 +30,6 @@ def build_and_post_offer_cli( if repeat <= 0: raise ValueError("repeat must be positive") - program = load_program_config(program_path) - require_signer_offer_path(program) - markets = load_markets_config_with_optional_overlay( - path=markets_path, - overlay_path=testnet_markets_path, - ) - _ = resolve_market_for_build( - markets, - market_id=market_id, - pair=pair, - network=network, - ) - - initialize_manager_file_logging(program.home_dir, log_level=program.app_log_level) - warn_if_log_level_auto_healed( - program_obj=program, - program_path=program_path, - logger=_manager_logger, - ) - return run_build_and_post_offer_via_engine( program_path=program_path, markets_path=markets_path, diff --git a/tests/helpers/engine_binary_fixtures.py b/tests/helpers/engine_binary_fixtures.py index 2673fafc..251b179a 100644 --- a/tests/helpers/engine_binary_fixtures.py +++ b/tests/helpers/engine_binary_fixtures.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from typing import Any from greenfloor.runtime.json_output import format_json_output diff --git a/tests/test_manager_build_post_offer.py b/tests/test_manager_build_post_offer.py index a7419d4e..70a1085e 100644 --- a/tests/test_manager_build_post_offer.py +++ b/tests/test_manager_build_post_offer.py @@ -15,11 +15,6 @@ default_build_post_success_payload, patch_engine_build_and_post, ) -from tests.helpers.offer_runtime_fixtures import ( - write_manager_program_with_signer, - write_markets, - write_markets_with_duplicate_pair, -) def test_resolve_greenfloor_engine_binary_from_env( @@ -48,7 +43,7 @@ def test_resolve_greenfloor_engine_binary_missing_env( resolve_greenfloor_engine_binary() -def test_build_and_post_offer_argv_includes_manager_flags(tmp_path: Path) -> None: +def test_build_and_post_offer_argv_passes_optional_overrides(tmp_path: Path) -> None: binary = tmp_path / "greenfloor-engine" argv = build_and_post_offer_argv( binary=binary, @@ -67,6 +62,7 @@ def test_build_and_post_offer_argv_includes_manager_flags(tmp_path: Path) -> Non claim_rewards=True, dry_run=True, compact_json=True, + persist_results=False, ) assert argv[0] == str(binary) assert "build-and-post-offer" in argv @@ -74,15 +70,42 @@ def test_build_and_post_offer_argv_includes_manager_flags(tmp_path: Path) -> Non assert "--claim-rewards" in argv assert "--dry-run" in argv assert "--json" in argv + assert "--no-persist-results" in argv assert "--pair" in argv assert "A1:txch" in argv -def test_build_and_post_offer_defaults_to_mainnet(monkeypatch, tmp_path: Path, capsys) -> None: +def test_build_and_post_offer_argv_omits_unset_overrides(tmp_path: Path) -> None: + binary = tmp_path / "greenfloor-engine" + argv = build_and_post_offer_argv( + binary=binary, + program_path=tmp_path / "program.yaml", + markets_path=tmp_path / "markets.yaml", + testnet_markets_path=None, + network="mainnet", + market_id="m1", + pair=None, + size_base_units=1, + repeat=1, + publish_venue=None, + dexie_base_url=None, + splash_base_url=None, + drop_only=True, + claim_rewards=False, + dry_run=False, + compact_json=False, + persist_results=True, + ) + assert "--venue" not in argv + assert "--dexie-base-url" not in argv + assert "--splash-base-url" not in argv + + +def test_build_and_post_offer_delegates_to_engine(monkeypatch, tmp_path: Path, capsys) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) + program.write_text("app: {}\n", encoding="utf-8") + markets.write_text("markets: []\n", encoding="utf-8") captured: dict = {} patch_engine_build_and_post( monkeypatch, @@ -98,33 +121,27 @@ def test_build_and_post_offer_defaults_to_mainnet(monkeypatch, tmp_path: Path, c pair=None, size_base_units=10, repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", + publish_venue=None, + dexie_base_url=None, + splash_base_url=None, drop_only=True, claim_rewards=False, dry_run=False, ) assert code == 0 - assert captured["dexie_base_url"] == "https://api.dexie.space" - assert captured["drop_only"] is True - assert captured["claim_rewards"] is False + assert captured["market_id"] == "m1" + assert captured["publish_venue"] is None + assert captured["dexie_base_url"] is None payload = json.loads(capsys.readouterr().out.strip()) assert payload["results"][0]["venue"] == "dexie" - assert payload["results"][0]["result"]["id"] == "offer-123" - assert ( - payload["results"][0]["result"]["offer_view_url"] == "https://dexie.space/offers/offer-123" - ) -def test_build_and_post_offer_dry_run_builds_but_does_not_post( - monkeypatch, tmp_path: Path, capsys -) -> None: +def test_build_and_post_offer_dry_run_delegates(monkeypatch, tmp_path: Path, capsys) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) + program.write_text("app: {}\n", encoding="utf-8") + markets.write_text("markets: []\n", encoding="utf-8") patch_engine_build_and_post( monkeypatch, payload=default_build_post_success_payload( @@ -134,7 +151,6 @@ def test_build_and_post_offer_dry_run_builds_but_does_not_post( results=[], built_offers_preview=[ {"offer_prefix": "offer1abc", "offer_length": "120"}, - {"offer_prefix": "offer1abc", "offer_length": "120"}, ], ), ) @@ -146,10 +162,10 @@ def test_build_and_post_offer_dry_run_builds_but_does_not_post( market_id="m1", pair=None, size_base_units=1, - repeat=2, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", + repeat=1, + publish_venue=None, + dexie_base_url=None, + splash_base_url=None, drop_only=True, claim_rewards=False, dry_run=True, @@ -157,205 +173,16 @@ def test_build_and_post_offer_dry_run_builds_but_does_not_post( assert code == 0 payload = json.loads(capsys.readouterr().out.strip()) assert payload["dry_run"] is True - assert len(payload["built_offers_preview"]) == 2 assert payload["results"] == [] -def test_build_and_post_offer_resolves_market_by_pair(monkeypatch, tmp_path: Path, capsys) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - patch_engine_build_and_post( - monkeypatch, - payload=default_build_post_success_payload( - results=[ - { - "venue": "dexie", - "result": {"success": True, "id": "offer-xyz"}, - } - ] - ), - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id=None, - pair="A1:xch", - size_base_units=10, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - assert code == 0 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["market_id"] == "m1" - assert payload["results"][0]["result"]["id"] == "offer-xyz" - - -def test_build_and_post_offer_accepts_txch_pair_on_testnet11( - monkeypatch, tmp_path: Path, capsys -) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - patch_engine_build_and_post( - monkeypatch, - payload=default_build_post_success_payload( - results=[ - { - "venue": "dexie", - "result": { - "success": True, - "id": "offer-txch", - "offer_view_url": "https://testnet.dexie.space/offers/offer-txch", - }, - } - ] - ), - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="testnet11", - market_id=None, - pair="A1:txch", - size_base_units=10, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api-testnet.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - assert code == 0 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["results"][0]["result"]["id"] == "offer-txch" - - -def test_build_and_post_offer_rejects_txch_pair_on_mainnet(tmp_path: Path) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - - with pytest.raises(ValueError, match="no enabled market found for pair"): - build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id=None, - pair="A1:txch", - size_base_units=10, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - - -def test_build_and_post_offer_pair_ambiguous_requires_market_id(tmp_path: Path) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets_with_duplicate_pair(markets) - - with pytest.raises(ValueError, match="ambiguous"): - build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id=None, - pair="a1:xch", - size_base_units=10, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - - -def test_build_and_post_offer_rejects_unknown_market(tmp_path: Path) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - - with pytest.raises(ValueError, match="market_id not found"): - build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="missing", - pair=None, - size_base_units=10, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - - -def test_build_and_post_offer_posts_to_splash_when_selected( - monkeypatch, tmp_path: Path, capsys -) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - patch_engine_build_and_post( - monkeypatch, - payload=default_build_post_success_payload( - publish_venue="splash", - results=[{"venue": "splash", "result": {"success": True, "id": "splash-1"}}], - ), - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="m1", - pair=None, - size_base_units=1, - repeat=1, - publish_venue="splash", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - assert code == 0 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["results"][0]["venue"] == "splash" - - def test_build_and_post_offer_returns_nonzero_when_publish_fails( monkeypatch, tmp_path: Path, capsys ) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) + program.write_text("app: {}\n", encoding="utf-8") + markets.write_text("markets: []\n", encoding="utf-8") patch_engine_build_and_post( monkeypatch, exit_code=2, @@ -380,11 +207,28 @@ def test_build_and_post_offer_returns_nonzero_when_publish_fails( repeat=1, publish_venue="dexie", dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", + splash_base_url=None, drop_only=True, claim_rewards=False, dry_run=False, ) assert code == 2 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["publish_failures"] == 1 + + +def test_build_and_post_offer_rejects_invalid_repeat(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="repeat must be positive"): + build_and_post_offer_cli( + program_path=tmp_path / "program.yaml", + markets_path=tmp_path / "markets.yaml", + network="mainnet", + market_id="m1", + pair=None, + size_base_units=1, + repeat=0, + publish_venue=None, + dexie_base_url=None, + splash_base_url=None, + drop_only=True, + claim_rewards=False, + dry_run=False, + ) diff --git a/tests/test_manager_post_offer.py b/tests/test_manager_post_offer.py index 1be3d254..44179244 100644 --- a/tests/test_manager_post_offer.py +++ b/tests/test_manager_post_offer.py @@ -8,27 +8,12 @@ import greenfloor.cli.manager as manager_mod from greenfloor.asset_label_catalog import _dexie_lookup_token_for_cat_id from greenfloor.cli.manager_setup import set_log_level -from greenfloor.cli.offer_build_post import ( - resolve_dexie_base_url, - resolve_offer_publish_settings, - resolve_splash_base_url, -) from greenfloor.runtime.json_output import format_json_output, set_json_output_compact from tests.helpers.offer_runtime_fixtures import ( write_manager_program, ) -def test_resolve_dexie_base_url_by_network() -> None: - assert resolve_dexie_base_url("mainnet", None) == "https://api.dexie.space" - assert resolve_dexie_base_url("testnet11", None) == "https://api-testnet.dexie.space" - assert resolve_dexie_base_url("testnet", None) == "https://api-testnet.dexie.space" - - -def test_resolve_splash_base_url_defaults_when_not_explicit() -> None: - assert resolve_splash_base_url(None) == "http://john-deere.hoffmang.com:4000" - - def test_dexie_lookup_token_for_cat_id_falls_back_to_v3_tickers(monkeypatch) -> None: target = "4a168910b533e6bb9ddf82a776f8d6248308abd3d56b6f4423a3e1de88f466e7" calls: list[str] = [] @@ -81,21 +66,6 @@ def test_format_json_output_compact_mode_is_single_line() -> None: assert output == '{"alpha":1,"beta":{"gamma":2}}' -def test_resolve_offer_publish_settings_from_program(tmp_path: Path) -> None: - program = tmp_path / "program.yaml" - write_manager_program(program, tmp_path=tmp_path, provider="splash") - venue, dexie_base, splash_base = resolve_offer_publish_settings( - program_path=program, - network="mainnet", - venue_override=None, - dexie_base_url=None, - splash_base_url=None, - ) - assert venue == "splash" - assert dexie_base == "https://api.dexie.space" - assert splash_base == "http://localhost:4000" - - def test_set_log_level_updates_program_yaml(tmp_path: Path, capsys) -> None: program = tmp_path / "program.yaml" write_manager_program(program, tmp_path=tmp_path) diff --git a/tests/test_offer_cli_dispatch.py b/tests/test_offer_cli_dispatch.py index 0baea2a4..6a37cf7d 100644 --- a/tests/test_offer_cli_dispatch.py +++ b/tests/test_offer_cli_dispatch.py @@ -2,44 +2,14 @@ from pathlib import Path -import pytest - from greenfloor.cli.offer_build_post import build_and_post_offer_cli from tests.helpers.engine_binary_fixtures import patch_engine_build_and_post from tests.helpers.offer_runtime_fixtures import ( - write_manager_program, write_manager_program_with_signer, write_markets, ) -def test_build_and_post_offer_cli_requires_signer_config(tmp_path: Path) -> None: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program(program, tmp_path=tmp_path) - write_markets(markets) - - with pytest.raises( - ValueError, - match="offer execution requires signer.kms_key_id and vault.launcher_id", - ): - build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - market_id="m1", - pair=None, - size_base_units=10, - repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", - drop_only=True, - claim_rewards=False, - dry_run=False, - ) - - def test_build_and_post_offer_cli_delegates_to_engine_binary(monkeypatch, tmp_path: Path) -> None: program = tmp_path / "program.yaml" markets = tmp_path / "markets.yaml" @@ -57,13 +27,14 @@ def test_build_and_post_offer_cli_delegates_to_engine_binary(monkeypatch, tmp_pa pair=None, size_base_units=10, repeat=1, - publish_venue="dexie", - dexie_base_url="https://api.dexie.space", - splash_base_url="http://localhost:4000", + publish_venue=None, + dexie_base_url=None, + splash_base_url=None, drop_only=True, claim_rewards=False, dry_run=False, ) assert code == 0 + assert captured["program_path"] == program + assert captured["markets_path"] == markets assert captured["market_id"] == "m1" - assert captured["publish_venue"] == "dexie" From 08e245eb00767f2c74f85a111144f89d1129a159 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Fri, 29 May 2026 18:21:20 -0700 Subject: [PATCH 3/6] Add Rust manager logging and tighten build-and-post orchestration. Restore file logging to {home_dir}/logs/debug.log, pass SignerConfig into bootstrap without re-reading YAML, use PostAttemptSuccess for failure counts, extract test fixtures, and document daemon cutover in ADR 0012. Co-authored-by: Cursor --- ...-manager-cli-rust-orchestration-cutover.md | 55 ++++++ docs/progress.md | 21 +- greenfloor-engine/Cargo.lock | 74 +++++++ greenfloor-engine/Cargo.toml | 2 + greenfloor-engine/src/config/program.rs | 21 ++ greenfloor-engine/src/manager/bootstrap.rs | 22 +-- .../src/manager/build_and_post.rs | 182 +++++++++++------- greenfloor-engine/src/manager/logging.rs | 100 ++++++++++ greenfloor-engine/src/manager/mod.rs | 1 + greenfloor-engine/src/manager/tests.rs | 2 + 10 files changed, 394 insertions(+), 86 deletions(-) create mode 100644 docs/decisions/0012-manager-cli-rust-orchestration-cutover.md create mode 100644 greenfloor-engine/src/manager/logging.rs diff --git a/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md b/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md new file mode 100644 index 00000000..e9aa4df9 --- /dev/null +++ b/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md @@ -0,0 +1,55 @@ +# ADR 0012: Manager CLI Rust orchestration cutover + +## Status + +Accepted (2026-05-29) + +## Context + +GreenFloor historically implemented manager `build-and-post-offer` in Python +(`greenfloor/runtime/offer_orchestration.py`) with Dexie/Splash adapters, SQLite +persistence, and bootstrap/create/publish orchestration. + +The Rust engine now owns the same vertical slice for the **manager CLI path**: + +- `greenfloor-engine build-and-post-offer` +- Python `greenfloor-manager build-and-post-offer` delegates via subprocess only + +The **daemon** (`greenfloord`) still uses the Python orchestration stack until +the daemon runtime migrates to Rust. + +## Decision + +1. **Manager CLI = Rust only.** Python must not parse program/markets YAML or + resolve venue URLs for `build-and-post-offer`. Optional CLI overrides are + passed through to Rust; Rust resolves canonical settings. + +2. **Rust owns manager config schema** for this path (`config/program.rs`, + `config/markets.rs`) and SQLite persistence schema (`storage/sqlite.rs`). + +3. **Rust owns manager file logging** for this path (`manager/logging.rs`), writing + to `{home_dir}/logs/debug.log` with `app.log_level` from program config. + +4. **Python orchestration is legacy for daemon only.** Do not extend + `offer_orchestration.py` for new manager CLI behavior. Bug fixes that affect + both paths should land in Rust first, then daemon cutover. + +## Cutover milestones + +| Milestone | Owner | Delete when done | +|-----------|-------|------------------| +| Manager CLI subprocess delegation | Python CLI | N/A (keep thin wrapper) | +| Rust build/post + sqlite persist | `greenfloor-engine` | — | +| Daemon cycle offer post | Python today | `offer_orchestration.py` manager path | +| Daemon sqlite / config | Python today | duplicate schema in `storage/sqlite.py` | + +**Target:** When `greenfloord` runs offer build/post through `greenfloor-engine` +in-process or subprocess, delete Python `execute_build_and_post_offer` and shrink +`offer_orchestration.py` to tests-only fixtures or remove entirely. + +## Consequences + +- Manager operators get Rust logging/persistence parity without Python preflight. +- Two orchestration implementations remain until daemon migration; new features + for manager CLI land in Rust only. +- CI must build/install `greenfloor-engine` for manager CLI tests and e2e. diff --git a/docs/progress.md b/docs/progress.md index 7fc32f17..defac78b 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,18 +1,33 @@ # Progress Log +## 2026-05-29 (Manager CLI Rust cutover — logging, typed post results, ADR 0012) + +- **Logging:** `manager/logging.rs` restores `{home_dir}/logs/debug.log` for Rust manager path; `app.log_level` parsed in `config/program.rs`. +- **Bootstrap:** `signer_bootstrap_phase` takes `&SignerConfig` from resolved context (no second YAML read). +- **Orchestration:** `PostAttemptSuccess` tracks publish outcome without re-parsing JSON for failure counts. +- **Tests:** build/post fixtures extracted to `manager/fixtures/build_and_post.rs`. +- **Decision:** ADR 0012 documents manager CLI = Rust, daemon orchestration = temporary Python until `greenfloord` migrates. + +## 2026-05-29 (Refactor — thin Python CLI, typed Rust orchestrator) + +- **Python:** `build-and-post-offer` is subprocess delegation only; no YAML preflight or venue resolution in Python. +- **Rust:** `ResolvedBuildAndPostContext`, typed publish/persist helpers, shared coin-op spendability + retry polling. +- **SQLite:** Rust schema is canonical for manager persistence (`storage/mod.rs`). + ## 2026-05-29 (Rust manager CLI — build-and-post-offer vertical slice) - **`greenfloor-engine build-and-post-offer`:** native manager path for bootstrap → sign → verify → Dexie/Splash publish without PyO3. - **Config:** `config/program.rs` + `config/markets.rs` load manager program + markets YAML (market id/pair resolution, venue URLs). - **Adapters:** `adapters/dexie.rs` + `adapters/splash.rs` (post, invalid-offer retry, visibility polling). - **Orchestration:** `manager/build_and_post.rs` + `manager/bootstrap.rs` call in-process engine (`build_signer_offer_for_action`, bootstrap mixed-split). -- **Tests:** Rust unit tests for config/market resolution and mockito Dexie publish phase; 202 existing engine tests still pass. -- **Next:** wire CI live-testnet-e2e to Rust binary; port `coins-list` / coin-op CLIs; then `greenfloord` daemon without PyO3. +- **Persistence:** sqlite audit records on successful post (`storage/sqlite.rs`). +- **Tests:** Rust unit tests for config/market resolution and mockito Dexie publish phase; 210+ engine tests. +- **Next:** port `greenfloord` daemon offer post to Rust; delete Python `offer_orchestration.py` manager path (see ADR 0012). ## 2026-05-29 (Python manager delegates build-and-post-offer to Rust binary) - **`greenfloor/cli/engine_binary.py`:** resolves `greenfloor-engine` (`GREENFLOOR_ENGINE_BIN`, PATH, or `target/{debug,release}/`) and runs `build-and-post-offer` subprocess with manager flag mapping. -- **`greenfloor/cli/offer_build_post.py`:** Python preflight (config, signer path, market resolution, logging) then delegates; removed in-process `OfferPostRequest` path for this command. +- **`greenfloor/cli/offer_build_post.py`:** thin subprocess wrapper only (no YAML preflight). - **CI:** Ubuntu CI installs debug `greenfloor-engine` into `.venv/bin`; live-testnet-e2e builds release binary and adds it to `PATH`. - **Tests:** manager build/post tests mock engine delegation; 544 pytest tests pass. diff --git a/greenfloor-engine/Cargo.lock b/greenfloor-engine/Cargo.lock index a588ec93..d3e1510e 100644 --- a/greenfloor-engine/Cargo.lock +++ b/greenfloor-engine/Cargo.lock @@ -1834,6 +1834,8 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tracing", + "tracing-subscriber", "urlencoding", ] @@ -2507,6 +2509,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2559,6 +2570,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -3627,6 +3647,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3838,6 +3867,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "threadpool" version = "1.8.1" @@ -4114,6 +4152,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4213,6 +4281,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/greenfloor-engine/Cargo.toml b/greenfloor-engine/Cargo.toml index 1d8c683e..90b6c2d6 100644 --- a/greenfloor-engine/Cargo.toml +++ b/greenfloor-engine/Cargo.toml @@ -47,6 +47,8 @@ serde_yaml = "0.9" sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } urlencoding = "2" [dev-dependencies] diff --git a/greenfloor-engine/src/config/program.rs b/greenfloor-engine/src/config/program.rs index a7b974c7..485b3839 100644 --- a/greenfloor-engine/src/config/program.rs +++ b/greenfloor-engine/src/config/program.rs @@ -15,6 +15,8 @@ const DEFAULT_HOME_DIR: &str = "~/.greenfloor"; pub struct ManagerProgramConfig { pub network: String, pub home_dir: PathBuf, + pub app_log_level: String, + pub app_log_level_was_missing: bool, pub dexie_api_base: String, pub splash_api_base: String, pub offer_publish_venue: String, @@ -36,6 +38,7 @@ struct ProgramYaml { struct AppYaml { network: Option, home_dir: Option, + log_level: Option, } #[derive(Debug, Deserialize)] @@ -86,7 +89,14 @@ pub fn load_program_config(path: &Path) -> SignerResult { let app = parsed.app.unwrap_or(AppYaml { network: None, home_dir: None, + log_level: None, }); + let app_log_level_was_missing = app.log_level.is_none(); + let app_log_level = app + .log_level + .as_deref() + .map(|value| normalize_manager_log_level(value)) + .unwrap_or_else(|| "INFO".to_string()); let network = app .network .unwrap_or_else(|| "mainnet".to_string()) @@ -143,6 +153,8 @@ pub fn load_program_config(path: &Path) -> SignerResult { Ok(ManagerProgramConfig { network, home_dir, + app_log_level, + app_log_level_was_missing, dexie_api_base, splash_api_base, offer_publish_venue, @@ -257,6 +269,15 @@ pub fn action_side_from_pricing(pricing: &Value) -> String { .to_string() } +fn normalize_manager_log_level(log_level: &str) -> String { + match log_level.trim().to_ascii_uppercase().as_str() { + "CRITICAL" | "ERROR" | "WARNING" | "INFO" | "DEBUG" | "NOTSET" => { + log_level.trim().to_ascii_uppercase() + } + _ => "INFO".to_string(), + } +} + fn expand_home_dir(path: &str) -> PathBuf { if let Some(stripped) = path.strip_prefix("~/") { if let Ok(home) = std::env::var("HOME") { diff --git a/greenfloor-engine/src/manager/bootstrap.rs b/greenfloor-engine/src/manager/bootstrap.rs index 3e856bc6..2a0a2e60 100644 --- a/greenfloor-engine/src/manager/bootstrap.rs +++ b/greenfloor-engine/src/manager/bootstrap.rs @@ -1,11 +1,10 @@ use std::collections::HashSet; -use std::path::Path; use serde_json::{json, Value}; use crate::coinset::{get_conservative_fee_estimate, list_wallet_unspent_coins, WalletUnspentCoin}; use crate::coin_ops::is_spendable_wallet_coin; -use crate::config::{load_signer_config, LadderEntry, ManagerProgramConfig, MarketConfig}; +use crate::config::{LadderEntry, ManagerProgramConfig, MarketConfig, SignerConfig}; use crate::cycle::retry::{poll_exponential_advance_sleep, poll_exponential_sleep_now}; use crate::error::{SignerError, SignerResult}; use crate::offer::bootstrap::{ @@ -210,7 +209,7 @@ async fn wait_for_coinset_confirmation( pub async fn signer_bootstrap_phase( program: &ManagerProgramConfig, market: &MarketConfig, - program_path: &Path, + signer_config: &SignerConfig, resolved_base_asset_id: &str, resolved_quote_asset_id: &str, quote_price: f64, @@ -315,20 +314,17 @@ pub async fn signer_bootstrap_phase( .map(|coin| coin.id.clone()) .collect(); - let signer_config = load_signer_config(program_path)?; - let output_amounts: Vec = bootstrap_plan - .output_amounts_base_units - .iter() - .map(|amount| u64::try_from(*amount).unwrap_or(0)) - .collect(); - let coin_ids = vec![bootstrap_plan.source_coin_id.clone()]; let split_result = match build_and_optionally_broadcast_vault_cat_mixed_split( - signer_config, + signer_config.clone(), MixedSplitRequest { receive_address: receive_address.to_string(), asset_id: crate::vault::members::hex_to_bytes32(&split_asset_id)?, - output_amounts, - coin_ids: crate::coinset::parse_coin_ids(&coin_ids)?, + output_amounts: bootstrap_plan + .output_amounts_base_units + .iter() + .map(|amount| u64::try_from(*amount).unwrap_or(0)) + .collect(), + coin_ids: crate::coinset::parse_coin_ids(&[bootstrap_plan.source_coin_id.clone()])?, allow_sub_cat_output: false, fee_mojos: 0, }, diff --git a/greenfloor-engine/src/manager/build_and_post.rs b/greenfloor-engine/src/manager/build_and_post.rs index b2d7c912..2c6f4834 100644 --- a/greenfloor-engine/src/manager/build_and_post.rs +++ b/greenfloor-engine/src/manager/build_and_post.rs @@ -23,6 +23,7 @@ use crate::storage::{ }; use super::bootstrap::{bootstrap_blocks_offer, signer_bootstrap_phase, BootstrapPhaseResult}; +use super::logging::{initialize_manager_file_logging, warn_if_log_level_auto_healed}; #[derive(Debug, Clone)] pub struct BuildAndPostOfferRequest { @@ -52,7 +53,7 @@ pub struct BuildAndPostOfferResponse { } #[derive(Debug, Clone)] -struct ResolvedBuildAndPostContext { +pub(crate) struct ResolvedBuildAndPostContext { program: ManagerProgramConfig, market: MarketConfig, signer_config: SignerConfig, @@ -137,19 +138,12 @@ pub async fn build_and_post_offer( publish_failures += 1; post_results.push(failure.to_venue_result(&ctx.publish_venue)); } - PostIterationOutcome::Success { - venue_result, - persist_record, - } => { - if venue_result - .get("result") - .and_then(|value| value.get("success")) - .and_then(Value::as_bool) - != Some(true) - { + PostIterationOutcome::Success(success) => { + if !success.success { publish_failures += 1; } - if let Some(record) = persist_record { + let venue_result = success.to_venue_result(); + if let Some(record) = success.persist_record { persist_records.push(record); } post_results.push(venue_result); @@ -200,10 +194,24 @@ pub async fn build_and_post_offer( enum PostIterationOutcome { Preview(Value), Failure(PostFailure), - Success { - venue_result: Value, - persist_record: Option, - }, + Success(PostAttemptSuccess), +} + +#[derive(Debug)] +struct PostAttemptSuccess { + publish_venue: String, + result: Value, + success: bool, + persist_record: Option, +} + +impl PostAttemptSuccess { + fn to_venue_result(&self) -> Value { + json!({ + "venue": self.publish_venue, + "result": self.result, + }) + } } impl PostFailure { @@ -236,6 +244,11 @@ async fn resolve_build_and_post_context( ) -> SignerResult { require_signer_offer_path(&request.program_path)?; let program = load_program_config(&request.program_path)?; + initialize_manager_file_logging(&program.home_dir, &program.app_log_level)?; + warn_if_log_level_auto_healed( + program.app_log_level_was_missing, + &request.program_path, + ); let markets = load_markets_config_with_overlay( &request.markets_path, request.testnet_markets_path.as_deref(), @@ -294,7 +307,7 @@ async fn run_post_iteration( signer_bootstrap_phase( &ctx.program, &ctx.market, - &request.program_path, + &ctx.signer_config, &ctx.resolved_base_asset_id, &ctx.resolved_quote_asset_id, ctx.quote_price, @@ -410,6 +423,7 @@ async fn run_post_iteration( ctx, request.size_base_units, ); + let publish_success = publish.success; let result_payload = finalize_publish_payload( publish, &created.execution_mode, @@ -423,13 +437,12 @@ async fn run_post_iteration( Ok(( bootstrap_action, - PostIterationOutcome::Success { - venue_result: json!({ - "venue": ctx.publish_venue, - "result": result_payload, - }), + PostIterationOutcome::Success(PostAttemptSuccess { + publish_venue: ctx.publish_venue.clone(), + result: result_payload, + success: publish_success, persist_record, - }, + }), )) } @@ -599,10 +612,65 @@ fn timing_payload( } #[cfg(test)] -mod tests { - use super::*; +pub(crate) fn sample_resolved_build_and_post_context() -> ResolvedBuildAndPostContext { + use std::collections::HashMap; + use chia_protocol::Bytes32; + use crate::vault::context::VaultCustodySnapshot; + + ResolvedBuildAndPostContext { + program: ManagerProgramConfig { + network: "mainnet".to_string(), + home_dir: PathBuf::from("/tmp/gf"), + app_log_level: "INFO".to_string(), + app_log_level_was_missing: false, + dexie_api_base: "https://api.dexie.space".to_string(), + splash_api_base: "http://localhost:4000".to_string(), + offer_publish_venue: "dexie".to_string(), + coin_ops_minimum_fee_mojos: 0, + runtime_offer_bootstrap_wait_timeout_seconds: 120, + }, + market: MarketConfig { + market_id: "m1".to_string(), + enabled: true, + base_asset: "a1".to_string(), + base_symbol: "A1".to_string(), + quote_asset: "xch".to_string(), + receive_address: "xch1".to_string(), + pricing: json!({}), + ladders: HashMap::new(), + }, + signer_config: SignerConfig { + network: "mainnet".to_string(), + coinset_msp_base_url: String::new(), + kms_key_id: String::new(), + kms_region: String::new(), + kms_public_key_hex: None, + vault: VaultCustodySnapshot { + launcher_id: Bytes32::default(), + custody_threshold: 1, + recovery_threshold: 1, + recovery_clawback_timelock: 0, + custody_keys: Vec::new(), + recovery_keys: Vec::new(), + }, + }, + publish_venue: "dexie".to_string(), + dexie_base_url: "https://api.dexie.space".to_string(), + splash_base_url: "http://localhost:4000".to_string(), + resolved_base_asset_id: "a1".to_string(), + resolved_quote_asset_id: "xch".to_string(), + quote_price: 1.0, + action_side: "sell".to_string(), + offer_fee_mojos: 0, + offer_fee_source: "coinset_fee_unavailable".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; use std::path::Path; #[test] @@ -617,51 +685,7 @@ mod tests { #[test] fn offer_post_persist_record_requires_success_and_offer_id() { - let ctx = ResolvedBuildAndPostContext { - program: ManagerProgramConfig { - network: "mainnet".to_string(), - home_dir: PathBuf::from("/tmp/gf"), - dexie_api_base: "https://api.dexie.space".to_string(), - splash_api_base: "http://localhost:4000".to_string(), - offer_publish_venue: "dexie".to_string(), - coin_ops_minimum_fee_mojos: 0, - runtime_offer_bootstrap_wait_timeout_seconds: 120, - }, - market: MarketConfig { - market_id: "m1".to_string(), - enabled: true, - base_asset: "a1".to_string(), - base_symbol: "A1".to_string(), - quote_asset: "xch".to_string(), - receive_address: "xch1".to_string(), - pricing: json!({}), - ladders: Default::default(), - }, - signer_config: SignerConfig { - network: "mainnet".to_string(), - coinset_msp_base_url: String::new(), - kms_key_id: String::new(), - kms_region: String::new(), - kms_public_key_hex: None, - vault: VaultCustodySnapshot { - launcher_id: Bytes32::default(), - custody_threshold: 1, - recovery_threshold: 1, - recovery_clawback_timelock: 0, - custody_keys: Vec::new(), - recovery_keys: Vec::new(), - }, - }, - publish_venue: "dexie".to_string(), - dexie_base_url: "https://api.dexie.space".to_string(), - splash_base_url: "http://localhost:4000".to_string(), - resolved_base_asset_id: "a1".to_string(), - resolved_quote_asset_id: "xch".to_string(), - quote_price: 1.0, - action_side: "sell".to_string(), - offer_fee_mojos: 0, - offer_fee_source: "coinset_fee_unavailable".to_string(), - }; + let ctx = sample_resolved_build_and_post_context(); let failed = PublishResult { success: false, offer_id: Some("offer-1".to_string()), @@ -680,6 +704,24 @@ mod tests { assert_eq!(record.market_id, "m1"); } + #[test] + fn post_attempt_success_tracks_publish_outcome_without_json_reparse() { + let success = PostAttemptSuccess { + publish_venue: "dexie".to_string(), + result: json!({"success": false, "error": "dexie_http_error:500"}), + success: false, + persist_record: None, + }; + assert!(!success.success); + assert_eq!( + success.to_venue_result() + .get("result") + .and_then(|value| value.get("error")) + .and_then(Value::as_str), + Some("dexie_http_error:500") + ); + } + #[test] fn persist_post_records_if_enabled_writes_sqlite() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/greenfloor-engine/src/manager/logging.rs b/greenfloor-engine/src/manager/logging.rs new file mode 100644 index 00000000..f372ac32 --- /dev/null +++ b/greenfloor-engine/src/manager/logging.rs @@ -0,0 +1,100 @@ +use std::path::Path; +use std::sync::Once; + +use tracing_subscriber::fmt::format::FmtSpan; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; + +use crate::error::{SignerError, SignerResult}; + +const SERVICE_NAME: &str = "manager"; +const DEFAULT_LOG_LEVEL: &str = "INFO"; +const LOG_FILE: &str = "logs/debug.log"; + +static INIT: Once = Once::new(); + +pub fn normalize_log_level_name(log_level: &str) -> &'static str { + match log_level.trim().to_ascii_uppercase().as_str() { + "CRITICAL" => "CRITICAL", + "ERROR" => "ERROR", + "WARNING" => "WARNING", + "INFO" => "INFO", + "DEBUG" => "DEBUG", + "NOTSET" => "NOTSET", + _ => DEFAULT_LOG_LEVEL, + } +} + +/// Initialize rotating file logging for the manager CLI path (`{home_dir}/logs/debug.log`). +/// +/// Matches Python `initialize_manager_file_logging` path and level semantics. Safe to call +/// more than once; only the first call installs the global subscriber. +pub fn initialize_manager_file_logging(home_dir: &Path, log_level: &str) -> SignerResult<()> { + let normalized = normalize_log_level_name(log_level); + let log_path = home_dir.join(LOG_FILE); + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + SignerError::Other(format!( + "failed to create manager log dir {}: {err}", + parent.display() + )) + })?; + } + + INIT.call_once(|| { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .expect("manager log file should open after parent dir creation"); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(file) + .with_ansi(false) + .with_target(true) + .with_level(true) + .with_span_events(FmtSpan::NONE) + .with_filter(EnvFilter::new(normalized)); + let _ = tracing_subscriber::registry().with(file_layer).try_init(); + }); + + tracing::info!( + service = SERVICE_NAME, + log_path = %log_path.display(), + log_level = normalized, + "manager file logging initialized" + ); + Ok(()) +} + +pub fn warn_if_log_level_auto_healed( + log_level_was_missing: bool, + program_config_path: &Path, +) { + if log_level_was_missing { + tracing::warn!( + program_config = %program_config_path.display(), + "program config missing app.log_level; defaulting to INFO" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_log_level_defaults_invalid_to_info() { + assert_eq!(normalize_log_level_name("debug"), "DEBUG"); + assert_eq!(normalize_log_level_name(""), DEFAULT_LOG_LEVEL); + assert_eq!(normalize_log_level_name("verbose"), DEFAULT_LOG_LEVEL); + } + + #[test] + fn initialize_manager_file_logging_creates_log_file() { + let dir = tempfile::tempdir().expect("tempdir"); + initialize_manager_file_logging(dir.path(), "INFO").expect("init"); + let log_path = dir.path().join(LOG_FILE); + assert!(log_path.is_file()); + } +} diff --git a/greenfloor-engine/src/manager/mod.rs b/greenfloor-engine/src/manager/mod.rs index 0a087de1..cb931335 100644 --- a/greenfloor-engine/src/manager/mod.rs +++ b/greenfloor-engine/src/manager/mod.rs @@ -1,5 +1,6 @@ mod bootstrap; mod build_and_post; +mod logging; #[cfg(test)] mod tests; diff --git a/greenfloor-engine/src/manager/tests.rs b/greenfloor-engine/src/manager/tests.rs index 3c4fffa1..5d863646 100644 --- a/greenfloor-engine/src/manager/tests.rs +++ b/greenfloor-engine/src/manager/tests.rs @@ -121,6 +121,8 @@ fn resolve_offer_publish_settings_uses_program_defaults() { let program = ManagerProgramConfig { network: "mainnet".to_string(), home_dir: std::path::PathBuf::from("/tmp/gf"), + app_log_level: "INFO".to_string(), + app_log_level_was_missing: false, dexie_api_base: "https://api.dexie.space".to_string(), splash_api_base: "http://localhost:4000".to_string(), offer_publish_venue: "splash".to_string(), From 696716aa558be5d9207344986c59c51944bda430 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Fri, 29 May 2026 18:30:10 -0700 Subject: [PATCH 4/6] Apply prettier formatting to ADR 0012 table. Fixes CI pre-commit failure on macOS and ubuntu-arm runners. Co-authored-by: Cursor --- .../0012-manager-cli-rust-orchestration-cutover.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md b/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md index e9aa4df9..11c791aa 100644 --- a/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md +++ b/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md @@ -36,12 +36,12 @@ the daemon runtime migrates to Rust. ## Cutover milestones -| Milestone | Owner | Delete when done | -|-----------|-------|------------------| -| Manager CLI subprocess delegation | Python CLI | N/A (keep thin wrapper) | -| Rust build/post + sqlite persist | `greenfloor-engine` | — | -| Daemon cycle offer post | Python today | `offer_orchestration.py` manager path | -| Daemon sqlite / config | Python today | duplicate schema in `storage/sqlite.py` | +| Milestone | Owner | Delete when done | +| --------------------------------- | ------------------- | --------------------------------------- | +| Manager CLI subprocess delegation | Python CLI | N/A (keep thin wrapper) | +| Rust build/post + sqlite persist | `greenfloor-engine` | — | +| Daemon cycle offer post | Python today | `offer_orchestration.py` manager path | +| Daemon sqlite / config | Python today | duplicate schema in `storage/sqlite.py` | **Target:** When `greenfloord` runs offer build/post through `greenfloor-engine` in-process or subprocess, delete Python `execute_build_and_post_offer` and shrink From 8198b209434e63e83b06d852abf92673585e1f90 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Fri, 29 May 2026 18:37:17 -0700 Subject: [PATCH 5/6] Fix CI binary install path and skip cold cargo test on ARM. Repo-root .cargo/config.toml puts build artifacts in target/, not greenfloor-engine/target/. Mark the subprocess Rust coinset test with @pytest.mark.engine so it only runs on ubuntu-latest after a warm build. Co-authored-by: Cursor --- .github/workflows/ci.yml | 4 ++-- .github/workflows/live-testnet-e2e.yml | 4 ++-- greenfloor/cli/engine_binary.py | 2 ++ tests/test_offer_assets_bridge.py | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 999c2fc5..4be4869e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,14 +73,14 @@ jobs: - name: Build and test greenfloor-engine if: matrix.run_rust_tests run: | - cargo build --manifest-path greenfloor-engine/Cargo.toml + cargo build --manifest-path greenfloor-engine/Cargo.toml --bin greenfloor-engine cargo test --manifest-path greenfloor-engine/Cargo.toml - name: Install greenfloor-engine CLI on PATH if: matrix.run_rust_tests run: | install -m 755 \ - greenfloor-engine/target/debug/greenfloor-engine \ + target/debug/greenfloor-engine \ "$GITHUB_WORKSPACE/.venv/bin/greenfloor-engine" - uses: ./.github/actions/greenfloor-maturin-wheels diff --git a/.github/workflows/live-testnet-e2e.yml b/.github/workflows/live-testnet-e2e.yml index 2483631e..a3fad50f 100644 --- a/.github/workflows/live-testnet-e2e.yml +++ b/.github/workflows/live-testnet-e2e.yml @@ -46,8 +46,8 @@ jobs: - name: Build greenfloor-engine CLI binary run: | - cargo build --release --manifest-path greenfloor-engine/Cargo.toml - echo "$GITHUB_WORKSPACE/greenfloor-engine/target/release" >> "$GITHUB_PATH" + cargo build --release --manifest-path greenfloor-engine/Cargo.toml --bin greenfloor-engine + echo "$GITHUB_WORKSPACE/target/release" >> "$GITHUB_PATH" greenfloor-engine --help >/dev/null - uses: ./.github/actions/wheel-cache diff --git a/greenfloor/cli/engine_binary.py b/greenfloor/cli/engine_binary.py index e0f78490..a5ccee7e 100644 --- a/greenfloor/cli/engine_binary.py +++ b/greenfloor/cli/engine_binary.py @@ -33,6 +33,8 @@ def resolve_greenfloor_engine_binary() -> Path: root = repo_root() for relative in ( + Path("target/release/greenfloor-engine"), + Path("target/debug/greenfloor-engine"), Path("greenfloor-engine/target/release/greenfloor-engine"), Path("greenfloor-engine/target/debug/greenfloor-engine"), ): diff --git a/tests/test_offer_assets_bridge.py b/tests/test_offer_assets_bridge.py index 544df286..ee939321 100644 --- a/tests/test_offer_assets_bridge.py +++ b/tests/test_offer_assets_bridge.py @@ -105,6 +105,7 @@ def test_resolve_offer_assets_via_coinset_config_path_is_coinset_only(tmp_path: resolve_offer_assets_via_coinset_config_path(str(program_path), "HOA", "xch") +@pytest.mark.engine def test_rust_coinset_resolution_for_ticker_symbols() -> None: """Successful MSP lookup runs in-engine (reqwest + in-process http.server deadlock in PyO3).""" result = subprocess.run( From 1e05435e337c2ea94421258a190d45d4560b6aed Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Fri, 29 May 2026 18:42:37 -0700 Subject: [PATCH 6/6] Exclude engine-marked pytest from non-Rust CI matrix jobs. The cargo subprocess coinset test only runs on ubuntu-latest where the engine crate is pre-built; arm/mac jobs skip -m engine in the main suite. Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4be4869e..2a101604 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: run: pre-commit run --all-files - name: "Test suite (pytest)" - run: pytest -v --tb=short + run: pytest -v --tb=short -m "not engine" - name: Pytest (engine marker) if: matrix.run_rust_tests