diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67b1186..2a10160 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,9 +73,16 @@ 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 \ + target/debug/greenfloor-engine \ + "$GITHUB_WORKSPACE/.venv/bin/greenfloor-engine" + - uses: ./.github/actions/greenfloor-maturin-wheels with: operation: ensure-and-install @@ -96,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 diff --git a/.github/workflows/live-testnet-e2e.yml b/.github/workflows/live-testnet-e2e.yml index 78b9923..a3fad50 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 --bin greenfloor-engine + echo "$GITHUB_WORKSPACE/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/decisions/0012-manager-cli-rust-orchestration-cutover.md b/docs/decisions/0012-manager-cli-rust-orchestration-cutover.md new file mode 100644 index 0000000..11c791a --- /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 112f473..defac78 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,5 +1,36 @@ # 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). +- **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`:** 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. + ## 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 f7aa228..d3e1510 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,9 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tracing", + "tracing-subscriber", + "urlencoding", ] [[package]] @@ -1844,6 +1893,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 +1921,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 +2176,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 +2446,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" @@ -2413,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" @@ -2465,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" @@ -3205,6 +3319,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" @@ -3519,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" @@ -3730,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" @@ -4006,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]] @@ -4105,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" @@ -4298,12 +4480,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 +4842,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 8014ed4..90b6c2d 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,16 @@ 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"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +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 0000000..d80f8a1 --- /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 0000000..608f880 --- /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 0000000..93f37b8 --- /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 0000000..cab40bc --- /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 0000000..688ca66 --- /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 0000000..485b383 --- /dev/null +++ b/greenfloor-engine/src/config/program.rs @@ -0,0 +1,316 @@ +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 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, + 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, + log_level: 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, + 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()) + .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, + app_log_level, + app_log_level_was_missing, + 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 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") { + 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"); + } + + #[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/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 3bc1426..279a54c 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 a080c8c..9e824d1 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,41 @@ 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, + #[arg(long)] + no_persist_results: bool, + }, } #[tokio::main] @@ -173,12 +208,64 @@ 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, + no_persist_results, + } => { + 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: !no_persist_results, + }) + .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 0000000..2a0a2e6 --- /dev/null +++ b/greenfloor-engine/src/manager/bootstrap.rs @@ -0,0 +1,409 @@ +use std::collections::HashSet; + +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::{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::{ + 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 { + is_spendable_wallet_coin(&json!({ + "state": coin.state, + })) +} + +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 = timeout_seconds.max(10) as i64; + let initial_sleep = 2.0f64; + let max_sleep = 20.0f64; + let mut sleep_seconds = 0.0f64; + loop { + 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() { + 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(next_sleep)).await; + sleep_seconds = poll_exponential_advance_sleep(sleep_seconds, initial_sleep, max_sleep, 1.5); + } +} + +pub async fn signer_bootstrap_phase( + program: &ManagerProgramConfig, + market: &MarketConfig, + signer_config: &SignerConfig, + 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 split_result = match build_and_optionally_broadcast_vault_cat_mixed_split( + signer_config.clone(), + MixedSplitRequest { + receive_address: receive_address.to_string(), + asset_id: crate::vault::members::hex_to_bytes32(&split_asset_id)?, + 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, + }, + 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 0000000..2c6f483 --- /dev/null +++ b/greenfloor-engine/src/manager/build_and_post.rs @@ -0,0 +1,778 @@ +use std::path::{Path, PathBuf}; +use std::time::Instant; + +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, 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::storage::{ + persist_offer_post_records, state_db_path_for_home, OfferPostPersistRecord, SqliteStore, +}; + +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 { + 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, +} + +#[derive(Debug, Clone)] +pub(crate) 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()) + } 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())); + } + + let ctx = resolve_build_and_post_context(&request).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 && ctx.publish_venue == "dexie" { + Some(DexieClient::new(ctx.dexie_base_url.clone())) + } else { + None + }; + 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 (bootstrap_action, iteration) = run_post_iteration( + &request, + &ctx, + dexie.as_ref(), + splash.as_ref(), + ) + .await?; + 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(success) => { + if !success.success { + publish_failures += 1; + } + let venue_result = success.to_venue_result(); + if let Some(record) = success.persist_record { + persist_records.push(record); + } + post_results.push(venue_result); + } + } + } + + persist_post_records_if_enabled( + &ctx.program.home_dir, + request.persist_results, + request.dry_run, + &persist_records, + )?; + + let payload = json!({ + "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": 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, + "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": ctx.offer_fee_mojos, + "offer_fee_source": ctx.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, + }) +} + +enum PostIterationOutcome { + Preview(Value), + Failure(PostFailure), + 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 { + 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)?; + 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(), + )?; + 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, + &ctx.signer_config, + &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 publish_success = publish.success; + 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(PostAttemptSuccess { + publish_venue: ctx.publish_venue.clone(), + result: result_payload, + success: publish_success, + persist_record, + }), + )) +} + +async fn create_offer( + signer_config: &SignerConfig, + market: &MarketConfig, + size_base_units: u64, + quote_price: f64, + action_side: &str, +) -> SignerResult { + 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(), + }; + build_signer_offer_for_action(signer_config.clone(), request).await +} + +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 { + let body = 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 => { + 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, + } + } +} + +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()), + Ok(None) => (0, "coinset_fee_unavailable".to_string()), + Err(_) => (0, "coinset_fee_unavailable".to_string()), + } +} + +fn timing_payload( + started: 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), + }) +} + +#[cfg(test)] +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] + 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}"# + ); + } + + #[test] + fn offer_post_persist_record_requires_success_and_offer_id() { + let ctx = sample_resolved_build_and_post_context(); + 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 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"); + 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/logging.rs b/greenfloor-engine/src/manager/logging.rs new file mode 100644 index 0000000..f372ac3 --- /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 new file mode 100644 index 0000000..cb93133 --- /dev/null +++ b/greenfloor-engine/src/manager/mod.rs @@ -0,0 +1,11 @@ +mod bootstrap; +mod build_and_post; +mod logging; + +#[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 0000000..5d86364 --- /dev/null +++ b/greenfloor-engine/src/manager/tests.rs @@ -0,0 +1,193 @@ +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, + resolve_offer_publish_settings, ManagerProgramConfig, +}; + +#[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"); +} + +#[test] +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(), + 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 new file mode 100644 index 0000000..7ee2426 --- /dev/null +++ b/greenfloor-engine/src/storage/mod.rs @@ -0,0 +1,11 @@ +//! 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; + +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 0000000..086442d --- /dev/null +++ b/greenfloor-engine/src/storage/sqlite.rs @@ -0,0 +1,344 @@ +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(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", + params![event_type, market_id], + |row| row.get(0), + ) + .map_err(|err| SignerError::Other(format!("failed to count audit events: {err}"))) + } + + #[cfg(test)] + 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 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 0000000..a5ccee7 --- /dev/null +++ b/greenfloor/cli/engine_binary.py @@ -0,0 +1,157 @@ +"""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 +from pathlib import Path + + +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("target/release/greenfloor-engine"), + Path("target/debug/greenfloor-engine"), + 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 | 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), + "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)), + ] + 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 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: + argv.append("--claim-rewards") + if dry_run: + argv.append("--dry-run") + if compact_json: + argv.append("--json") + if not persist_results: + argv.append("--no-persist-results") + 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 | None, + dexie_base_url: str | None, + splash_base_url: str | None, + drop_only: bool, + claim_rewards: bool, + dry_run: bool, + compact_json: bool = False, + persist_results: bool = True, + run_fn: Callable[..., object] | 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, + persist_results=persist_results, + ) + 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 diff --git a/greenfloor/cli/manager.py b/greenfloor/cli/manager.py index bd8dba2..ae0a762 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 adbc3d8..964fe52 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.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.offer_build_context import prepare_offer_build_context -from greenfloor.runtime.offer_post_request import OfferPostRequest -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 +from greenfloor.cli.engine_binary import run_build_and_post_offer_via_engine +from greenfloor.runtime.json_output import json_output_compact 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,34 +30,13 @@ 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, - ) - market = resolve_market_for_build( - markets, - market_id=market_id, - pair=pair, - network=network, - ) - build_ctx = prepare_offer_build_context( - program=program, - market=market, + return run_build_and_post_offer_via_engine( program_path=program_path, + markets_path=markets_path, + testnet_markets_path=testnet_markets_path, 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, - ) - - request = OfferPostRequest( - build_ctx=build_ctx, + market_id=market_id, + pair=pair, size_base_units=size_base_units, repeat=repeat, publish_venue=publish_venue, @@ -119,7 +44,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 1f2a857..c083a89 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 0000000..251b179 --- /dev/null +++ b/tests/helpers/engine_binary_fixtures.py @@ -0,0 +1,67 @@ +"""Shared helpers for mocking greenfloor-engine CLI delegation in tests.""" + +from __future__ import annotations + +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 4ed5d9a..70a1085 100644 --- a/tests/test_manager_build_post_offer.py +++ b/tests/test_manager_build_post_offer.py @@ -3,444 +3,114 @@ 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.offer_runtime_fixtures import ( - patch_signer_create_offer_phase, - write_manager_program_with_signer, - write_markets, - write_markets_with_duplicate_pair, +from tests.helpers.engine_binary_fixtures import ( + default_build_post_success_payload, + patch_engine_build_and_post, ) -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 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) - 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, - ) - 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 +def test_resolve_greenfloor_engine_binary_from_env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> 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) + 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 - 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, - ) - 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, - ) - 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" - - -def test_build_and_post_offer_uses_market_configured_expiry_override( - monkeypatch, tmp_path: Path, capsys +def test_resolve_greenfloor_engine_binary_missing_env( + monkeypatch: pytest.MonkeyPatch, ) -> 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.delenv("GREENFLOOR_ENGINE_BIN", raising=False) monkeypatch.setattr( - "greenfloor.runtime.offer_runtime.signer_create_offer_phase", - _capture_create, + "greenfloor.cli.engine_binary.shutil.which", + lambda _name: None, ) - 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, - 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, - ) - assert code == 0 - market = captured_kwargs["market"] - from greenfloor.config.models import MarketConfig - - 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" - - -def test_build_and_post_offer_dry_run_builds_but_does_not_post( - 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) - - 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) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, - network="mainnet", - 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", - drop_only=True, - claim_rewards=False, - dry_run=True, - ) - 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) - - 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, + "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=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]["venue"] == "dexie" - 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) - - 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, - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, +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, + 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-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["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: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - - try: - 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, - ) - 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: - 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: - 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, - ) - 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: - program = tmp_path / "program.yaml" - markets = tmp_path / "markets.yaml" - write_manager_program_with_signer(program, tmp_path=tmp_path) - write_markets(markets) - try: - 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, - ) - 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( - 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) - - 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, - ) - - code = build_and_post_offer_cli( - program_path=program, - markets_path=markets, + drop_only=False, + claim_rewards=True, + dry_run=True, + compact_json=True, + persist_results=False, + ) + 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 "--no-persist-results" in argv + assert "--pair" in argv + assert "A1:txch" in argv + + +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="splash", - 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, + compact_json=False, + persist_results=True, ) - assert code == 0 - payload = json.loads(capsys.readouterr().out.strip()) - assert payload["results"][0]["venue"] == "splash" - assert payload["results"][0]["result"]["id"] == "splash-1" + 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_returns_nonzero_when_offer_verification_fails( - monkeypatch, tmp_path: Path, capsys -) -> None: +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) - - patch_signer_create_offer_phase(monkeypatch) - monkeypatch.setattr( - "greenfloor.core.policy_bridge.verify_offer_for_dexie", - lambda _offer: "wallet_sdk_offer_verify_false", + program.write_text("app: {}\n", encoding="utf-8") + markets.write_text("markets: []\n", encoding="utf-8") + captured: dict = {} + patch_engine_build_and_post( + monkeypatch, + capture=captured, + payload=default_build_post_success_payload(), ) code = build_and_post_offer_cli( @@ -449,93 +119,41 @@ def test_build_and_post_offer_returns_nonzero_when_offer_verification_fails( network="mainnet", market_id="m1", pair=None, - size_base_units=1, + 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 == 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) + assert code == 0 + assert captured["market_id"] == "m1" + assert captured["publish_venue"] is None + assert captured["dexie_base_url"] is None - 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"] - ) + assert payload["results"][0]["venue"] == "dexie" -def test_build_and_post_offer_blocks_publish_when_offer_has_no_expiry( - 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) - 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) + 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( + dry_run=True, + publish_attempts=0, + publish_failures=0, + results=[], + built_offers_preview=[ + {"offer_prefix": "offer1abc", "offer_length": "120"}, + ], + ), + ) code = build_and_post_offer_cli( program_path=program, @@ -545,21 +163,17 @@ def verify_offer_for_dexie(_offer: str) -> str: pair=None, size_base_units=1, 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, + dry_run=True, ) - - assert code == 2 + assert code == 0 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 + assert payload["dry_run"] is True + assert payload["results"] == [] def test_build_and_post_offer_returns_nonzero_when_publish_fails( @@ -567,22 +181,20 @@ def test_build_and_post_offer_returns_nonzero_when_publish_fails( ) -> None: program = tmp_path / "program.yaml" 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, + 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, + 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( @@ -595,49 +207,28 @@ def post_offer(self, offer: str, *, drop_only: bool, claim_rewards: bool | None) 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_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:") +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 1be3d25..4417924 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_assets_bridge.py b/tests/test_offer_assets_bridge.py index 544df28..ee93932 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( diff --git a/tests/test_offer_cli_dispatch.py b/tests/test_offer_cli_dispatch.py index da31cef..6a37cf7 100644 --- a/tests/test_offer_cli_dispatch.py +++ b/tests/test_offer_cli_dispatch.py @@ -2,60 +2,22 @@ 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_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, @@ -65,12 +27,14 @@ def _fake_signer(**kwargs): 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 signer_dispatched[0] is True + assert captured["program_path"] == program + assert captured["markets_path"] == markets + assert captured["market_id"] == "m1"