diff --git a/.gitignore b/.gitignore index 745c8702..9e0bf5d0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build/ *.egg-info/ greenfloor-native/target/ greenfloor-signer-pyo3/target/ +greenfloor-signer-pyo3/python/ greenfloor-signer/target/ .coverage .coverage.* diff --git a/AGENTS.md b/AGENTS.md index a2557c80..c4d26a4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Severity tags: - `[MUST]` `greenfloor/core/coin_ops/`: coin-op deterministic policy (plan, fee budget, inventory, min-amount guard) shared by CLI and daemon. - `[MUST]` `greenfloor/config`: parse/validate config, resolve paths, resolve quote assets. - `[MUST]` `greenfloor/* adapters`: side effects only (network, filesystem, wallet, notifications). -- `[MUST]` `greenfloor/signing.py`: legacy Python signing entry point during Rust migration (see ADR 0006). +- `[MUST]` Signing/execution path is adapter -> canonical Rust kernel (`greenfloor-signer` / `greenfloor_kernel` PyO3); legacy `greenfloor/signing.py` re-exports adapters only. - `[MUST]` `greenfloor-signer/`: canonical vault KMS signing implementation; new vault spend/offer logic lands here first. - `[MUST]` `greenfloor/cli/manager.py`: operator CLI router (argparse + dispatch). - `[MUST]` `greenfloor/cli/coin_ops_list.py`, `coin_ops_split.py`, `coin_ops_combine.py`: coin list/split/combine CLI commands (`coin_ops.py` re-exports). @@ -57,7 +57,7 @@ Severity tags: ## Design Constraints - `[MUST]` Prefer direct function calls within the package; do not spawn subprocesses for same-env Python calls unless isolation/security is documented in `docs/decisions/`. -- `[MUST]` Signing/execution path is adapter -> canonical signer (`greenfloor-signer` for vault KMS; `greenfloor/signing.py` until cutover). +- `[MUST]` Signing/execution path is adapter -> canonical Rust kernel (`greenfloor-signer` crate, `greenfloor_kernel` PyO3 module). - `[MUST]` Avoid unnecessary indirection layers (`executor`, `worker`, `engine`, etc.). - `[MUST]` Keep one distinct responsibility per file; merge pass-through modules into functions. - `[MUST]` Eliminate duplicated logic blocks (>10 lines) by extracting shared helpers. diff --git a/README.md b/README.md index e2c9102b..a820a4c4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ GreenFloor is a long-running Python application for Chia CAT market making. ## Components -- `greenfloor-manager`: manager CLI for config validation, key onboarding, cloud-wallet coin inventory/reshaping, offer building/posting, and operational checks. +- `greenfloor-manager`: manager CLI for config validation, key onboarding, coin inventory/reshaping, offer building/posting, and operational checks. - `greenfloord`: daemon process that evaluates configured markets, executes offers, and emits low-inventory alerts. ## V1 Plan @@ -63,7 +63,7 @@ greenfloor-manager build-and-post-offer --pair ECO.181.2022:xch --size-base-unit greenfloor-manager build-and-post-offer --pair TDBX:txch --size-base-units 1 --network testnet11 ``` -Cloud Wallet vault operations: +Vault KMS / signer operations: ```bash # List vault inventory (XCH + CAT) @@ -120,12 +120,14 @@ Operator overrides (all optional): - `GREENFLOOR_COINSET_BASE_URL` — custom Coinset API base URL for coin queries and `push_tx`; when unset, `CoinsetAdapter` defaults to mainnet and can be forced to testnet11 by network selection. - `coin_ops.minimum_fee_mojos` (in program config) — fallback minimum fee for coin operations when Coinset advice is unavailable (default `10000000` mojos / `0.00001 XCH`; can be set to `0`). -Cloud Wallet program config contract (`program.yaml`): +Signer program config contract (`program.yaml`): -- `cloud_wallet.base_url` — GraphQL API root URL. -- `cloud_wallet.user_key_id` — user auth key id used in `chia-user-key-id`. -- `cloud_wallet.private_key_pem_path` — PEM private key path for RSA-SHA256 header signatures. -- `cloud_wallet.vault_id` — target wallet/vault ID for coin and offer operations. +- `signer.kms_key_id` — AWS KMS key for vault member signing. +- `signer.kms_region` — AWS region for KMS calls. +- `vault.launcher_id` — vault singleton launcher id (hex). +- `vault.custody_keys` / `vault.recovery_keys` — member public keys for the vault puzzle. + +Legacy `cloud_wallet:` blocks are rejected at config load; use `signer:` + `vault:` instead. CI secret for optional live testnet workflow: diff --git a/docs/decisions/0011-offer-request-python-import-boundaries.md b/docs/decisions/0011-offer-request-python-import-boundaries.md index ecd05ace..37fe953e 100644 --- a/docs/decisions/0011-offer-request-python-import-boundaries.md +++ b/docs/decisions/0011-offer-request-python-import-boundaries.md @@ -19,13 +19,24 @@ without growing `policy_bridge.py` into a flat FFI catalog. ### Python modules (import from here, not `policy_bridge`) -| Module | Use for | -| ---------------------------------------- | ----------------------------------------------------------------------------------------- | -| `greenfloor.core.offer_request_bridge` | Direct kernel access to offer-request symbols (internal bridge). | -| `greenfloor.core.offer_bootstrap_bridge` | **Stable runtime imports** — bootstrap DTOs, planner, and phase kernel wrappers. | -| `greenfloor.core.offer_bootstrap_policy` | Backward-compatible re-export of `offer_bootstrap_bridge` (no logic). | -| `greenfloor.core.offer_policy` | **Stable runtime/daemon/BLS imports** — re-exports leg math + Dexie/publish helpers. | -| `greenfloor.core.signer_offer_request` | `SignerCreateOfferRequest`, `SignerOfferLegAmounts`, `build_signer_create_offer_request`. | +| Module | Use for | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `greenfloor.core.offer_request_bridge` | Direct kernel access to offer-request symbols (internal bridge). | +| `greenfloor.core.offer_bootstrap_bridge` | **Stable runtime imports** — bootstrap DTOs, planner, and phase kernel wrappers. | +| `greenfloor.core.offer_bootstrap_policy` | Backward-compatible re-export of `offer_bootstrap_bridge` (no logic). | +| `greenfloor.core.offer_policy` | **Stable runtime/daemon/BLS imports** — re-exports leg math + Dexie/publish helpers. | +| `greenfloor.core.signer_offer_request` | Low-level `SignerCreateOfferRequest` / `signer_create_offer_request_from_fields` (KMS plan-dict spends only). | +| `greenfloor.core.offer_action` | **Canonical offer create** — typed action request/result, pure shaping, create-phase outcome mapping. | +| `greenfloor.runtime.offer_action_build` | Build action requests from `OfferBuildContext` plus local/signer runtime orchestration (asset resolution + BLS create). | +| `greenfloor.adapters.offer_action` | Kernel IO only (`build_*_offer_for_action`). | + +### Offer-action create path (2026-05) + +- **New market-action offer creation** must use `core/offer_action` + `adapters/offer_action` + (signer) or `runtime/offer_action_build` (local BLS). Do not add call sites to + `rust_signer.build_vault_cat_offer` for that flow. +- Local BLS resolves ticker symbols via `resolve_action_assets_for_build_context` before kernel + dispatch when ids are not already canonical. ### `policy_bridge.py` role diff --git a/greenfloor-signer-pyo3/Cargo.toml b/greenfloor-signer-pyo3/Cargo.toml index a67a5d02..e6a1bd0a 100644 --- a/greenfloor-signer-pyo3/Cargo.toml +++ b/greenfloor-signer-pyo3/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [lib] -name = "greenfloor_signer" +name = "greenfloor_kernel" crate-type = ["cdylib"] [dependencies] diff --git a/greenfloor-signer-pyo3/pyproject.toml b/greenfloor-signer-pyo3/pyproject.toml index eb0634ba..0c7a8933 100644 --- a/greenfloor-signer-pyo3/pyproject.toml +++ b/greenfloor-signer-pyo3/pyproject.toml @@ -3,6 +3,9 @@ requires = ["maturin>=1.8,<2.0"] build-backend = "maturin" [project] -name = "greenfloor_signer" +name = "greenfloor_kernel" version = "0.1.0" requires-python = ">=3.11" + +[tool.maturin] +module-name = "greenfloor_kernel" diff --git a/greenfloor-signer-pyo3/src/lib.rs b/greenfloor-signer-pyo3/src/lib.rs index 339bd831..bcd3e4f2 100644 --- a/greenfloor-signer-pyo3/src/lib.rs +++ b/greenfloor-signer-pyo3/src/lib.rs @@ -1,12 +1,13 @@ //! PyO3 bindings for the GreenFloor Rust kernel (`greenfloor-signer` crate). //! -//! The extension module is still exported as `greenfloor_signer` (ADR 0010). Python +//! The extension module is exported as `greenfloor_kernel` (ADR 0010). Python //! callers should import through `greenfloor.core.kernel_bridge.import_kernel`. extern crate greenfloor_signer as signer_core; mod coin_ops_py; mod cycle; mod execution_py; +mod offer_action_py; mod hex_py; mod notifications_py; mod offer_bootstrap_py; @@ -411,7 +412,7 @@ fn coinset_get_conservative_fee_estimate_py( } #[pymodule] -fn greenfloor_signer(m: &Bound<'_, PyModule>) -> PyResult<()> { +fn greenfloor_kernel(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(resolve_vault_context_py, m)?)?; m.add_function(wrap_pyfunction!(build_vault_cat_offer_py, m)?)?; m.add_function(wrap_pyfunction!(build_mixed_split_py, m)?)?; @@ -435,6 +436,7 @@ fn greenfloor_signer(m: &Bound<'_, PyModule>) -> PyResult<()> { coinset_get_conservative_fee_estimate_py, m )?)?; + offer_action_py::register(m)?; coin_ops_py::register(m)?; cycle::register(m)?; hex_py::register(m)?; diff --git a/greenfloor-signer-pyo3/src/offer_action_py.rs b/greenfloor-signer-pyo3/src/offer_action_py.rs new file mode 100644 index 00000000..439b916a --- /dev/null +++ b/greenfloor-signer-pyo3/src/offer_action_py.rs @@ -0,0 +1,73 @@ +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyModule}; +use signer_core::offer::action::{ + build_bls_offer_for_action, build_signer_offer_for_action, BuildOfferForActionRequest, +}; +use signer_core::{load_bls_master_secret_key, load_signer_config}; + +use crate::py_utils::{dict_from_json_value, request_dict_to_json, to_py_err}; +use crate::{block_on_signer, parse_master_sk_bytes, runtime}; + +#[pyfunction] +#[pyo3(name = "build_signer_offer_for_action")] +fn build_signer_offer_for_action_py( + config_path: &str, + request: &Bound<'_, PyDict>, +) -> PyResult> { + let config = load_signer_config(std::path::Path::new(config_path)).map_err(to_py_err)?; + let payload = request_dict_to_json(request)?; + let offer_request: BuildOfferForActionRequest = + serde_json::from_value(payload).map_err(to_py_err)?; + let result = runtime() + .block_on(build_signer_offer_for_action(config, offer_request)) + .map_err(to_py_err)?; + Python::attach(|py| dict_from_json_value(py, serde_json::to_value(&result).map_err(to_py_err)?)) +} + +#[pyfunction] +#[pyo3(name = "build_bls_offer_for_action_key")] +fn build_bls_offer_for_action_key_py( + network: &str, + key_id: &str, + request: &Bound<'_, PyDict>, +) -> PyResult> { + let master_sk = load_bls_master_secret_key(key_id.trim()).map_err(to_py_err)?; + let payload = request_dict_to_json(request)?; + let offer_request: BuildOfferForActionRequest = + serde_json::from_value(payload).map_err(to_py_err)?; + let result = block_on_signer(build_bls_offer_for_action( + network, + &master_sk, + offer_request, + )) + .map_err(to_py_err)?; + Python::attach(|py| dict_from_json_value(py, serde_json::to_value(&result).map_err(to_py_err)?)) +} + +/// Internal/test entry: build a BLS action offer from raw master secret key bytes. +#[pyfunction] +#[pyo3(name = "build_bls_offer_for_action_sk")] +fn build_bls_offer_for_action_sk_py( + network: &str, + master_sk_bytes: &[u8], + request: &Bound<'_, PyDict>, +) -> PyResult> { + let master_sk = parse_master_sk_bytes(master_sk_bytes)?; + let payload = request_dict_to_json(request)?; + let offer_request: BuildOfferForActionRequest = + serde_json::from_value(payload).map_err(to_py_err)?; + let result = block_on_signer(build_bls_offer_for_action( + network, + &master_sk, + offer_request, + )) + .map_err(to_py_err)?; + Python::attach(|py| dict_from_json_value(py, serde_json::to_value(&result).map_err(to_py_err)?)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(build_signer_offer_for_action_py, m)?)?; + m.add_function(wrap_pyfunction!(build_bls_offer_for_action_key_py, m)?)?; + m.add_function(wrap_pyfunction!(build_bls_offer_for_action_sk_py, m)?)?; + Ok(()) +} diff --git a/greenfloor-signer/src/bls/offer.rs b/greenfloor-signer/src/bls/offer.rs index ea2ff45b..754607bf 100644 --- a/greenfloor-signer/src/bls/offer.rs +++ b/greenfloor-signer/src/bls/offer.rs @@ -26,6 +26,8 @@ pub struct BlsOfferRequest { pub request_amount: u64, #[serde(default)] pub offer_coin_ids: Vec, + #[serde(default)] + pub expires_at: Option, } #[derive(Debug, Clone, Serialize)] @@ -134,7 +136,7 @@ pub async fn build_bls_offer_spend_bundle( offer_amount: request.offer_amount, request_asset_id: request.request_asset_id.trim().to_lowercase(), request_amount: request.request_amount, - expires_at: None, + expires_at: request.expires_at, }; // Assertion nodes for requested payments only (not merged into the driver spend graph). diff --git a/greenfloor-signer/src/coinset/msp.rs b/greenfloor-signer/src/coinset/msp.rs index 54655029..b10be28a 100644 --- a/greenfloor-signer/src/coinset/msp.rs +++ b/greenfloor-signer/src/coinset/msp.rs @@ -134,9 +134,7 @@ pub async fn resolve_offer_asset_ids( && !is_xch_like_asset(&resolved_base) && !is_xch_like_asset(&resolved_quote) { - return Err(SignerError::Other( - "resolved_assets_collide_for_non_xch_pair".to_string(), - )); + return Err(SignerError::ResolvedAssetsCollideForNonXchPair); } Ok((resolved_base, resolved_quote)) } diff --git a/greenfloor-signer/src/error/mod.rs b/greenfloor-signer/src/error/mod.rs index 9938e5b1..7049f97f 100644 --- a/greenfloor-signer/src/error/mod.rs +++ b/greenfloor-signer/src/error/mod.rs @@ -167,6 +167,9 @@ pub enum SignerError { #[error("invalid_offer_amount")] InvalidOfferAmount, + #[error("resolved offer assets collide for non-xch pair")] + ResolvedAssetsCollideForNonXchPair, + #[error("{0}")] Other(String), } diff --git a/greenfloor-signer/src/lib.rs b/greenfloor-signer/src/lib.rs index bcd87219..1cf49777 100644 --- a/greenfloor-signer/src/lib.rs +++ b/greenfloor-signer/src/lib.rs @@ -110,7 +110,11 @@ pub use offer::request::{ compute_signer_offer_leg_amounts, normalize_offer_asset_id, normalize_offer_side, quote_mojos_for_base_size, signer_split_asset_id, SignerOfferLegAmounts, }; -pub use offer::{build_vault_cat_offer, CreateOfferRequest, CreateOfferResult}; +pub use offer::{ + build_bls_offer_for_action, build_signer_offer_for_action, build_vault_cat_offer, + expires_at_unix_from_pricing, BuildOfferForActionRequest, BuildOfferForActionResult, + CreateOfferRequest, CreateOfferResult, +}; pub use vault::{ build_and_optionally_broadcast_vault_cat_mixed_split, MixedSplitRequest, MixedSplitResult, }; diff --git a/greenfloor-signer/src/offer/action.rs b/greenfloor-signer/src/offer/action.rs new file mode 100644 index 00000000..f2a31cef --- /dev/null +++ b/greenfloor-signer/src/offer/action.rs @@ -0,0 +1,248 @@ +//! Unified offer build for market actions (signer vault KMS and local BLS paths). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use chia_bls::SecretKey; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::bls::{build_bls_offer_spend_bundle, BlsOfferRequest}; +use crate::coinset::{self, is_xch_like_asset, normalize_asset_id, resolve_offer_asset_ids, MspCoinset}; +use crate::config::SignerConfig; +use crate::error::{SignerError, SignerResult}; +use crate::offer::build::build_vault_cat_offer; +use crate::offer::build_context::{resolve_offer_expiry_for_pricing, resolve_quote_price_for_pricing}; +use crate::offer::codec::encode_offer_from_spend_bundle_bytes; +use crate::offer::request::{compute_signer_offer_leg_amounts, normalize_offer_side}; +use crate::offer::types::{CreateOfferRequest, CreateOfferResult}; + +#[derive(Debug, Clone, Deserialize)] +pub struct BuildOfferForActionRequest { + pub receive_address: String, + pub base_asset: String, + pub quote_asset: String, + pub size_base_units: u64, + pub action_side: String, + pub pricing: Value, + #[serde(default)] + pub quote_price: Option, + #[serde(default = "default_true")] + pub split_input_coins: bool, + #[serde(default = "default_true")] + pub broadcast_split: bool, + #[serde(default)] + pub offer_coin_ids: Vec, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Serialize)] +pub struct BuildOfferForActionResult { + pub offer_text: String, + pub side: String, + pub expires_at_unix: u64, + pub offer_amount: u64, + pub request_amount: u64, + pub execution_mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_result: Option, +} + +pub fn expires_at_unix_from_pricing(pricing: &Value) -> u64 { + let (_unit, minutes) = resolve_offer_expiry_for_pricing(pricing); + let secs = minutes.saturating_mul(60); + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs().saturating_add(secs as u64)) + .unwrap_or(secs as u64) +} + +fn resolve_quote_price(request: &BuildOfferForActionRequest) -> SignerResult { + if let Some(price) = request.quote_price { + if price > 0.0 { + return Ok(price); + } + } + resolve_quote_price_for_pricing(&request.pricing) +} + +fn resolved_assets_or_collision_error( + resolved_base: String, + resolved_quote: String, +) -> SignerResult<(String, String)> { + if resolved_base == resolved_quote + && !is_xch_like_asset(&resolved_base) + && !is_xch_like_asset(&resolved_quote) + { + return Err(SignerError::ResolvedAssetsCollideForNonXchPair); + } + Ok((resolved_base, resolved_quote)) +} + +fn try_normalize_resolved_assets( + base_asset: &str, + quote_asset: &str, +) -> SignerResult<(String, String)> { + let (resolved_base, resolved_quote) = ( + normalize_asset_id(base_asset)?, + normalize_asset_id(quote_asset)?, + ); + resolved_assets_or_collision_error(resolved_base, resolved_quote) +} + +async fn resolve_signer_assets( + config: &SignerConfig, + base_asset: &str, + quote_asset: &str, +) -> SignerResult<(String, String)> { + match try_normalize_resolved_assets(base_asset, quote_asset) { + Ok(resolved) => Ok(resolved), + Err(SignerError::ResolvedAssetsCollideForNonXchPair) => { + Err(SignerError::ResolvedAssetsCollideForNonXchPair) + } + Err(_) => { + let msp = + MspCoinset::for_network(&config.network, Some(&config.coinset_msp_base_url))?; + resolve_offer_asset_ids(&msp, base_asset, quote_asset).await + } + } +} + +fn leg_amounts_for_request( + request: &BuildOfferForActionRequest, + resolved_base_asset_id: &str, + resolved_quote_asset_id: &str, + quote_price: f64, +) -> SignerResult { + let size = i64::try_from(request.size_base_units).map_err(|_| SignerError::InvalidSizeBaseUnits)?; + compute_signer_offer_leg_amounts( + size, + quote_price, + resolved_base_asset_id, + resolved_quote_asset_id, + &request.action_side, + &request.pricing, + ) +} + +fn create_offer_request_from_leg( + request: &BuildOfferForActionRequest, + leg: &crate::offer::request::SignerOfferLegAmounts, + expires_at_unix: u64, +) -> SignerResult { + Ok(CreateOfferRequest { + receive_address: request.receive_address.clone(), + offer_asset_id: leg.offer_asset_id.clone(), + offer_amount: leg.offer_amount_mojos, + request_asset_id: leg.request_asset_id.clone(), + request_amount: leg.request_amount_mojos, + offer_coin_ids: coinset::parse_coin_ids(&request.offer_coin_ids)?, + presplit_coin_ids: Vec::new(), + split_input_coins: request.split_input_coins, + broadcast_split: request.broadcast_split, + expires_at: Some(expires_at_unix), + }) +} + +pub async fn build_signer_offer_for_action( + config: SignerConfig, + request: BuildOfferForActionRequest, +) -> SignerResult { + let (resolved_base, resolved_quote) = + resolve_signer_assets(&config, &request.base_asset, &request.quote_asset).await?; + let quote_price = resolve_quote_price(&request)?; + let leg = leg_amounts_for_request(&request, &resolved_base, &resolved_quote, quote_price)?; + let expires_at_unix = expires_at_unix_from_pricing(&request.pricing); + let side = normalize_offer_side(&request.action_side).to_string(); + let create_request = create_offer_request_from_leg(&request, &leg, expires_at_unix)?; + let create_result = build_vault_cat_offer(config, create_request).await?; + + Ok(BuildOfferForActionResult { + offer_text: create_result.offer.clone(), + side, + expires_at_unix, + offer_amount: leg.offer_amount_mojos, + request_amount: leg.request_amount_mojos, + execution_mode: create_result.execution_mode.to_string(), + create_result: Some(create_result), + }) +} + +pub async fn build_bls_offer_for_action( + network: &str, + master_sk: &SecretKey, + request: BuildOfferForActionRequest, +) -> SignerResult { + let (resolved_base, resolved_quote) = + try_normalize_resolved_assets(&request.base_asset, &request.quote_asset)?; + let quote_price = resolve_quote_price(&request)?; + let leg = leg_amounts_for_request(&request, &resolved_base, &resolved_quote, quote_price)?; + let expires_at_unix = expires_at_unix_from_pricing(&request.pricing); + let side = normalize_offer_side(&request.action_side).to_string(); + + let bls_request = BlsOfferRequest { + receive_address: request.receive_address.clone(), + offer_asset_id: leg.offer_asset_id.clone(), + offer_amount: leg.offer_amount_mojos, + request_asset_id: leg.request_asset_id.clone(), + request_amount: leg.request_amount_mojos, + offer_coin_ids: request.offer_coin_ids.clone(), + expires_at: Some(expires_at_unix), + }; + let built = build_bls_offer_spend_bundle(network, master_sk, bls_request).await?; + let raw_hex = built + .spend_bundle_hex + .strip_prefix("0x") + .unwrap_or(built.spend_bundle_hex.as_str()); + let spend_bytes = hex::decode(raw_hex) + .map_err(|err| SignerError::Other(format!("invalid spend_bundle_hex: {err}")))?; + let offer_text = encode_offer_from_spend_bundle_bytes(&spend_bytes)?; + + Ok(BuildOfferForActionResult { + offer_text, + side, + expires_at_unix, + offer_amount: leg.offer_amount_mojos, + request_amount: leg.request_amount_mojos, + execution_mode: "bls".to_string(), + create_result: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn expires_at_from_minutes_pricing() { + let before = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_secs(); + let expires = expires_at_unix_from_pricing(&json!({"strategy_offer_expiry_minutes": 5})); + assert!(expires >= before + 300); + assert!(expires <= before + 301); + } + + #[test] + fn try_normalize_accepts_pre_resolved_assets() { + let cat = "a".repeat(64); + let (base, quote) = + try_normalize_resolved_assets(&cat, "xch").expect("normalized assets"); + assert_eq!(base, cat); + assert_eq!(quote, "xch"); + } + + #[test] + fn collision_error_does_not_use_other_variant() { + let cat = "a".repeat(64); + let err = try_normalize_resolved_assets(&cat, &cat).expect_err("collision"); + assert!(matches!( + err, + SignerError::ResolvedAssetsCollideForNonXchPair + )); + } +} diff --git a/greenfloor-signer/src/offer/mod.rs b/greenfloor-signer/src/offer/mod.rs index 678db543..63122718 100644 --- a/greenfloor-signer/src/offer/mod.rs +++ b/greenfloor-signer/src/offer/mod.rs @@ -2,6 +2,7 @@ //! //! See ADR 0010 for the planned crate rename to `greenfloor-kernel`. +pub mod action; pub mod assemble; pub mod bootstrap; pub mod build; @@ -18,6 +19,10 @@ pub use bootstrap::{ bootstrap_early_phase, bootstrap_executed_phase, plan_bootstrap_mixed_outputs, BootstrapCoin, BootstrapPhaseSnapshot, BootstrapPlan, BootstrapPlanOutcome, LadderDeficit, PlannerLadderRow, }; +pub use action::{ + build_bls_offer_for_action, build_signer_offer_for_action, expires_at_unix_from_pricing, + BuildOfferForActionRequest, BuildOfferForActionResult, +}; pub use build::build_vault_cat_offer; pub use build_context::{ mojo_multiplier_for_leg, resolve_offer_expiry_for_pricing, resolve_quote_price_for_pricing, diff --git a/greenfloor/adapters/bls_signing.py b/greenfloor/adapters/bls_signing.py index 6100a957..fefd8c92 100644 --- a/greenfloor/adapters/bls_signing.py +++ b/greenfloor/adapters/bls_signing.py @@ -29,7 +29,7 @@ def _load_master_private_key( kernel = import_kernel() result = kernel.load_bls_master_sk(str(key_id).strip()) except Exception as exc: - return None, f"greenfloor_signer_import_error:{exc}" + return None, f"greenfloor_kernel_import_error:{exc}" if not isinstance(result, dict): return None, "invalid_load_bls_master_sk_response" error = result.get("error") @@ -51,7 +51,7 @@ def _call_signer_build( kernel = import_kernel() build = getattr(kernel, method_name) except Exception as exc: - return None, f"greenfloor_signer_import_error:{exc}" + return None, f"greenfloor_kernel_import_error:{exc}" try: result = build(network, master_sk_bytes, request) except Exception as exc: @@ -136,7 +136,7 @@ def _broadcast_bls_spend_bundle_rust(*, network: str, spend_bundle_hex: str) -> except Exception as exc: return { "status": "skipped", - "reason": f"greenfloor_signer_import_error:{exc}", + "reason": f"greenfloor_kernel_import_error:{exc}", "operation_id": None, } if not isinstance(result, dict): diff --git a/greenfloor/adapters/coinset.py b/greenfloor/adapters/coinset.py index bdf21daa..286dc62f 100644 --- a/greenfloor/adapters/coinset.py +++ b/greenfloor/adapters/coinset.py @@ -132,10 +132,10 @@ def _require_rust_coinset(name: str, *args: Any, **kwargs: Any) -> Any: try: kernel = import_kernel() except ImportError as exc: - raise RuntimeError(f"greenfloor_signer_required_for_coinset_io: {exc}") from exc + raise RuntimeError(f"greenfloor_kernel_required_for_coinset_io: {exc}") from exc fn = getattr(kernel, name, None) if not callable(fn): - raise RuntimeError(f"greenfloor_signer_missing_coinset_fn:{name}") + raise RuntimeError(f"greenfloor_kernel_missing_coinset_fn:{name}") return fn(*args, **kwargs) diff --git a/greenfloor/adapters/offer_action.py b/greenfloor/adapters/offer_action.py new file mode 100644 index 00000000..9a2a6836 --- /dev/null +++ b/greenfloor/adapters/offer_action.py @@ -0,0 +1,35 @@ +"""Rust-kernel IO for unified offer-action build.""" + +from __future__ import annotations + +from greenfloor.core.kernel_bridge import import_kernel +from greenfloor.core.offer_action import ( + OfferActionRequest, + OfferActionResult, + parse_action_result, +) + +__all__ = [ + "build_bls_offer_for_action", + "build_signer_offer_for_action", +] + + +def build_signer_offer_for_action( + config_path: str, + request: OfferActionRequest, +) -> OfferActionResult: + kernel = import_kernel() + result = kernel.build_signer_offer_for_action(str(config_path), dict(request)) + return parse_action_result(result) + + +def build_bls_offer_for_action( + *, + network: str, + key_id: str, + request: OfferActionRequest, +) -> OfferActionResult: + kernel = import_kernel() + result = kernel.build_bls_offer_for_action_key(str(network), str(key_id), dict(request)) + return parse_action_result(result) diff --git a/greenfloor/adapters/rust_signer.py b/greenfloor/adapters/rust_signer.py index 7ecbbd03..d1800a4c 100644 --- a/greenfloor/adapters/rust_signer.py +++ b/greenfloor/adapters/rust_signer.py @@ -1,4 +1,4 @@ -"""Thin wrapper around the Rust kernel vault path (PyO3 module ``greenfloor_signer`` until ADR 0010).""" +"""Thin wrapper around the Rust kernel vault path (PyO3 module ``greenfloor_kernel``).""" from __future__ import annotations @@ -27,7 +27,12 @@ def build_vault_cat_offer( program_path: str, request_dict: SignerCreateOfferPayload | dict[str, Any], ) -> dict[str, Any]: - """Build a vault CAT offer using the canonical Rust kernel vault path.""" + """Build a vault CAT offer using the low-level Rust kernel vault path. + + Deprecated for market-action offer creation: use + ``greenfloor.adapters.offer_action.build_signer_offer_for_action`` instead. + Retained for KMS signing router plan-dict spends and parity tests. + """ kernel = import_kernel() result = kernel.build_vault_cat_offer(str(program_path), request_dict) if not isinstance(result, dict): diff --git a/greenfloor/adapters/wallet.py b/greenfloor/adapters/wallet.py index d9d08215..a3cb23a7 100644 --- a/greenfloor/adapters/wallet.py +++ b/greenfloor/adapters/wallet.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path +from greenfloor.adapters import bls_signing from greenfloor.adapters.coinset import CoinsetAdapter from greenfloor.core.coin_ops import CoinOpPlan from greenfloor.keys.onboarding import load_key_onboarding_selection @@ -185,10 +186,7 @@ def _execute_plan( if signer_fingerprint is not None: payload["key_id_fingerprint_map"] = {str(key_id): str(int(signer_fingerprint))} - # Direct in-process signing + broadcast - from greenfloor.signing import sign_and_broadcast - - result = sign_and_broadcast(payload) + result = bls_signing.sign_and_broadcast(payload) status = str(result.get("status", "skipped")).strip() return CoinOpExecutionItem( op_type=plan.op_type, diff --git a/greenfloor/cli/offer_build_post.py b/greenfloor/cli/offer_build_post.py index 598b2bde..eb7ef9fa 100644 --- a/greenfloor/cli/offer_build_post.py +++ b/greenfloor/cli/offer_build_post.py @@ -14,7 +14,6 @@ ) from greenfloor.config.models import offer_execution_backend from greenfloor.logging_setup import warn_if_log_level_auto_healed -from greenfloor.offer_builder import build_offer from greenfloor.runtime.json_output import format_json_output from greenfloor.runtime.offer_build_context import prepare_offer_build_context from greenfloor.runtime.offer_execution import default_offer_post_deps @@ -66,6 +65,13 @@ def resolve_offer_publish_settings( return venue, dexie_base, splash_base +def build_offer(payload: dict) -> str: + """Legacy test/CLI hook for local BLS offer build (delegates to offer_builder).""" + from greenfloor.offer_builder import build_offer as legacy_build_offer + + return legacy_build_offer(payload) + + def build_and_post_offer_cli( *, program_path: Path, diff --git a/greenfloor/core/kernel_bridge.py b/greenfloor/core/kernel_bridge.py index c378b55f..3e5a4d62 100644 --- a/greenfloor/core/kernel_bridge.py +++ b/greenfloor/core/kernel_bridge.py @@ -1,12 +1,8 @@ """Shared PyO3 bridge for the Rust deterministic policy kernel. -The compiled extension is still named ``greenfloor_signer`` (see ADR 0010). Python +The compiled extension is published as ``greenfloor_kernel`` (ADR 0010). Python callers should use :func:`import_kernel`; ``import_signer`` remains as a migration alias. -:func:`import_kernel` tries :data:`KERNEL_MODULE_LEGACY` first, then -:data:`KERNEL_MODULE_TARGET`, so the ADR 0010 rename can flip module names without -touching bridge call sites. - ``policy_kernel``, ``coin_ops_kernel``, and ``bootstrap_kernel`` are typed views of the same PyO3 module — use the name that matches the ``Protocol`` at the call site. @@ -25,18 +21,14 @@ from greenfloor.core.coin_ops.kernel_protocol import CoinOpsKernelProtocol from greenfloor.core.kernel_protocol import BootstrapKernelProtocol, PolicyKernelProtocol -# ADR 0010 naming map — legacy until the post-migration rename ships. -KERNEL_MODULE_LEGACY = "greenfloor_signer" -KERNEL_MODULE_TARGET = "greenfloor_kernel" -_KERNEL_MODULE_CANDIDATES = (KERNEL_MODULE_LEGACY, KERNEL_MODULE_TARGET) +KERNEL_MODULE = "greenfloor_kernel" _MATURIN_INSTALL = ( "`maturin develop --manifest-path greenfloor-signer-pyo3/Cargo.toml` from the repo root" ) __all__ = [ - "KERNEL_MODULE_LEGACY", - "KERNEL_MODULE_TARGET", + "KERNEL_MODULE", "bootstrap_kernel", "coin_ops_kernel", "import_kernel", @@ -57,17 +49,14 @@ def kernel_rebuild_hint(*, module: str, missing: str = "required kernel") -> str def import_kernel() -> Any: - errors: list[str] = [] - for module_name in _KERNEL_MODULE_CANDIDATES: - try: - return importlib.import_module(module_name) - except ImportError as exc: - errors.append(f"{module_name}: {exc}") - raise ImportError( - "Rust kernel extension is not available " - f"(tried {', '.join(_KERNEL_MODULE_CANDIDATES)}). " - f"Install via {_MATURIN_INSTALL}. " + "; ".join(errors) - ) + try: + return importlib.import_module(KERNEL_MODULE) + except ImportError as exc: + raise ImportError( + "Rust kernel extension is not available " + f"(tried {KERNEL_MODULE}). " + f"Install via {_MATURIN_INSTALL}. {KERNEL_MODULE}: {exc}" + ) from exc def _loaded_kernel_module() -> Any: @@ -95,7 +84,7 @@ def _kernel_module_label(kernel: Any) -> str: name = getattr(kernel, "__name__", None) if isinstance(name, str) and name: return name - return KERNEL_MODULE_LEGACY + return KERNEL_MODULE def require_kernel_method(kernel: Any, method_name: str, *, missing: str) -> Any: diff --git a/greenfloor/core/offer_action.py b/greenfloor/core/offer_action.py new file mode 100644 index 00000000..dbb17424 --- /dev/null +++ b/greenfloor/core/offer_action.py @@ -0,0 +1,195 @@ +"""Typed contracts and pure helpers for unified offer-action build requests.""" + +from __future__ import annotations + +import datetime as dt +import time +from dataclasses import dataclass +from typing import Any, TypedDict + +__all__ = [ + "OfferActionRequest", + "OfferActionResult", + "OfferCreatePhaseOutcome", + "build_action_request", + "expires_at_iso_from_build_context", + "expires_at_iso_from_unix", + "legacy_action_request_from_payload", + "parse_action_result", + "to_create_phase_outcome", + "validate_legacy_offer_payload", +] + + +class OfferActionRequest(TypedDict): + receive_address: str + base_asset: str + quote_asset: str + size_base_units: int + action_side: str + pricing: dict[str, Any] + quote_price: float + split_input_coins: bool + broadcast_split: bool + offer_coin_ids: list[str] + + +class OfferActionResult(TypedDict): + offer_text: str + side: str + expires_at_unix: int + offer_amount: int + request_amount: int + execution_mode: str + create_result: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class OfferCreatePhaseOutcome: + offer_text: str + expires_at: str + side: str + offer_amount: int + request_amount: int + execution_mode: str + create_result: dict[str, Any] + + +def build_action_request( + *, + receive_address: str, + base_asset: str, + quote_asset: str, + pricing: dict[str, Any], + size_base_units: int, + action_side: str, + quote_price: float, + split_input_coins: bool = True, + broadcast_split: bool = True, + offer_coin_ids: list[str] | None = None, +) -> OfferActionRequest: + """Shape a kernel ``BuildOfferForActionRequest`` dict.""" + address = str(receive_address or "").strip() + if not address: + raise ValueError("market.receive_address is required for offer build") + return OfferActionRequest( + receive_address=address, + base_asset=str(base_asset), + quote_asset=str(quote_asset), + size_base_units=int(size_base_units), + action_side=str(action_side), + pricing=dict(pricing or {}), + quote_price=float(quote_price), + split_input_coins=bool(split_input_coins), + broadcast_split=bool(broadcast_split), + offer_coin_ids=list(offer_coin_ids or []), + ) + + +def validate_legacy_offer_payload(payload: dict[str, Any]) -> None: + """Validate legacy offer_builder stdin payloads before action request shaping.""" + receive_address = str(payload.get("receive_address", "")).strip() + key_id = str(payload.get("key_id", "")).strip() + network = str(payload.get("network", "")).strip() + keyring_yaml_path = str(payload.get("keyring_yaml_path", "")).strip() + size_base_units = int(payload.get("size_base_units", 0)) + quote_price_quote_per_base = float(payload.get("quote_price_quote_per_base", 0.0)) + base_unit_mojo_multiplier = int(payload.get("base_unit_mojo_multiplier", 0)) + quote_unit_mojo_multiplier = int(payload.get("quote_unit_mojo_multiplier", 0)) + if not receive_address: + raise ValueError("missing_receive_address") + if size_base_units <= 0: + raise ValueError("invalid_size_base_units") + if not key_id: + raise ValueError("missing_key_id") + if not network: + raise ValueError("missing_network") + if not keyring_yaml_path: + raise ValueError("missing_keyring_yaml_path") + if quote_price_quote_per_base <= 0: + raise ValueError("invalid_quote_price_quote_per_base") + if base_unit_mojo_multiplier <= 0: + raise ValueError("invalid_base_unit_mojo_multiplier") + if quote_unit_mojo_multiplier <= 0: + raise ValueError("invalid_quote_unit_mojo_multiplier") + + quote_asset = str(payload.get("quote_asset", "xch")).strip().lower() or "xch" + if quote_asset not in {"xch", "txch", "1"} and len(quote_asset) != 64: + raise ValueError("invalid_quote_asset_id") + + +def legacy_action_request_from_payload(payload: dict[str, Any]) -> OfferActionRequest: + """Map a validated legacy offer_builder payload to an action request.""" + validate_legacy_offer_payload(payload) + asset_id = str(payload.get("asset_id", "xch")).strip().lower() or "xch" + quote_asset = str(payload.get("quote_asset", "xch")).strip().lower() or "xch" + return build_action_request( + receive_address=str(payload.get("receive_address", "")).strip(), + base_asset=asset_id, + quote_asset=quote_asset, + pricing={ + "base_unit_mojo_multiplier": int(payload.get("base_unit_mojo_multiplier", 0)), + "quote_unit_mojo_multiplier": int(payload.get("quote_unit_mojo_multiplier", 0)), + }, + size_base_units=int(payload.get("size_base_units", 0)), + action_side=str(payload.get("side", "sell")), + quote_price=float(payload.get("quote_price_quote_per_base", 0.0)), + split_input_coins=bool(payload.get("split_input_coins", True)), + broadcast_split=bool(payload.get("broadcast_split", False)), + offer_coin_ids=[ + str(value).strip().lower() + for value in (payload.get("offer_coin_ids") or []) + if str(value).strip() + ], + ) + + +def parse_action_result(payload: object) -> OfferActionResult: + if not isinstance(payload, dict): + raise TypeError("offer action kernel returned non-dict result") + offer_text = str(payload.get("offer_text", "")).strip() + if not offer_text.startswith("offer1"): + raise RuntimeError("offer_action_failed:missing_offer_text") + return OfferActionResult( + offer_text=offer_text, + side=str(payload.get("side", "")), + expires_at_unix=int(payload.get("expires_at_unix", 0)), + offer_amount=int(payload.get("offer_amount", 0)), + request_amount=int(payload.get("request_amount", 0)), + execution_mode=str(payload.get("execution_mode", "")), + create_result=dict(payload["create_result"]) + if isinstance(payload.get("create_result"), dict) + else {}, + ) + + +def expires_at_iso_from_unix(expires_at_unix: int) -> str: + if expires_at_unix <= 0: + return "" + return dt.datetime.fromtimestamp(int(expires_at_unix), tz=dt.UTC).isoformat() + + +def expires_at_iso_from_build_context(*, expiry_unit: str, expiry_value: int) -> str: + """ISO expiry from build-context pricing (minutes-only contract).""" + del expiry_unit + value = int(expiry_value) + if value <= 0: + return "" + return expires_at_iso_from_unix(int(time.time()) + value * 60) + + +def to_create_phase_outcome( + result: OfferActionResult, + *, + action_side: str, +) -> OfferCreatePhaseOutcome: + """Map kernel action result to signer/local create-phase fields.""" + return OfferCreatePhaseOutcome( + offer_text=result["offer_text"], + expires_at=expires_at_iso_from_unix(result["expires_at_unix"]), + side=result["side"] or str(action_side), + offer_amount=result["offer_amount"], + request_amount=result["request_amount"], + execution_mode=result["execution_mode"].strip(), + create_result=dict(result["create_result"]), + ) diff --git a/greenfloor/core/offer_policy.py b/greenfloor/core/offer_policy.py index c9d00e43..fd1c7ac4 100644 --- a/greenfloor/core/offer_policy.py +++ b/greenfloor/core/offer_policy.py @@ -16,14 +16,12 @@ from greenfloor.core.signer_offer_request import ( SignerCreateOfferPayload, SignerCreateOfferRequest, - build_signer_create_offer_request, ) __all__ = [ "SignerCreateOfferPayload", "SignerCreateOfferRequest", "bootstrap_block_error", - "build_signer_create_offer_request", "compute_signer_offer_leg_amounts", "dexie_offer_asset_expectation_error", "expected_publish_asset_fields", diff --git a/greenfloor/core/policy_bridge.py b/greenfloor/core/policy_bridge.py index a2ae72cb..230964c2 100644 --- a/greenfloor/core/policy_bridge.py +++ b/greenfloor/core/policy_bridge.py @@ -89,7 +89,7 @@ def verify_offer_for_dexie(offer_text: str) -> str | None: verify_offer = _require_policy_method("verify_offer_for_dexie") error = verify_offer(offer_text) except ImportError: - return "wallet_sdk_import_error:greenfloor_signer_unavailable" + return "wallet_sdk_import_error:greenfloor_kernel_unavailable" return None if error is None else str(error) diff --git a/greenfloor/core/signer_offer_request.py b/greenfloor/core/signer_offer_request.py index e84708be..dd3b717b 100644 --- a/greenfloor/core/signer_offer_request.py +++ b/greenfloor/core/signer_offer_request.py @@ -9,7 +9,6 @@ from dataclasses import dataclass from typing import Any, TypedDict -from greenfloor.config.models import MarketConfig from greenfloor.core.offer_request_bridge import ( compute_signer_offer_leg_amounts, normalize_offer_asset_id, @@ -23,7 +22,6 @@ "SignerCreateOfferPayload", "SignerCreateOfferRequest", "SignerOfferLegAmounts", - "build_signer_create_offer_request", "compute_signer_offer_leg_amounts", "normalize_offer_asset_id", "quote_mojos_for_base_size", @@ -108,45 +106,6 @@ def resolve_quote_unit_multiplier( ) -def build_signer_create_offer_request( - *, - market: MarketConfig, - size_base_units: int, - quote_price: float, - resolved_base_asset_id: str, - resolved_quote_asset_id: str, - action_side: str = "sell", - split_input_coins: bool = True, - broadcast_split: bool = True, - expires_at_unix: int | None = None, -) -> SignerCreateOfferRequest: - """Build the request passed to ``rust_signer.build_vault_cat_offer``.""" - pricing = dict(market.pricing or {}) - leg = compute_signer_offer_leg_amounts( - size_base_units=size_base_units, - quote_price=quote_price, - resolved_base_asset_id=resolved_base_asset_id, - resolved_quote_asset_id=resolved_quote_asset_id, - action_side=action_side, - pricing=pricing, - ) - - receive_address = str(market.receive_address or "").strip() - if not receive_address: - raise ValueError("market.receive_address is required for signer offer build") - - return SignerCreateOfferRequest( - receive_address=receive_address, - offer_asset_id=leg.offer_asset_id, - offer_amount=int(leg.offer_amount_mojos), - request_asset_id=leg.request_asset_id, - request_amount=int(leg.request_amount_mojos), - split_input_coins=bool(split_input_coins), - broadcast_split=bool(broadcast_split), - expires_at=expires_at_unix, - ) - - def signer_create_offer_request_from_fields( *, receive_address: str, diff --git a/greenfloor/daemon/offer_dispatch/local.py b/greenfloor/daemon/offer_dispatch/local.py index f939b946..07adf68f 100644 --- a/greenfloor/daemon/offer_dispatch/local.py +++ b/greenfloor/daemon/offer_dispatch/local.py @@ -21,11 +21,11 @@ _set_cooldown, ) from greenfloor.daemon.offer_dispatch.items import action_item +from greenfloor.runtime.offer_action_build import build_bls_offer_from_build_context from greenfloor.runtime.offer_build_context import ( default_program_config_path, prepare_offer_build_context, ) -from greenfloor.runtime.offer_execution import build_daemon_action_offer_payload from greenfloor.storage.sqlite import SqliteStore @@ -38,8 +38,7 @@ def build_offer_for_action( program_path: Path | None = None, keyring_yaml_path: str | None = None, ) -> dict[str, Any]: - from greenfloor.offer_builder import build_offer - + del xch_price_usd resolved_program_path = default_program_config_path(program, program_path) try: build_ctx = prepare_offer_build_context( @@ -56,13 +55,13 @@ def build_offer_for_action( "reason": f"offer_builder_failed:{exc}", "offer": None, } - payload = build_daemon_action_offer_payload( - build_ctx, - action=action, - xch_price_usd=xch_price_usd, - ) try: - offer = build_offer(payload) + result = build_bls_offer_from_build_context( + build_ctx, + size_base_units=int(action.size), + action_side=planned_action_side(action), + ) + offer = result["offer_text"] except Exception as exc: return {"status": "skipped", "reason": f"offer_builder_failed:{exc}", "offer": None} return {"status": "executed", "reason": "offer_builder_success", "offer": offer} diff --git a/greenfloor/offer_builder.py b/greenfloor/offer_builder.py index 2d5db91e..33ae0f0b 100644 --- a/greenfloor/offer_builder.py +++ b/greenfloor/offer_builder.py @@ -1,111 +1,28 @@ +"""Legacy offer-builder entry point; ``build_offer`` uses the Rust kernel BLS action path.""" + from __future__ import annotations from typing import Any from greenfloor.adapters.native_offer import encode_offer_from_spend_bundle_hex -from greenfloor.core.offer_policy import compute_signer_offer_leg_amounts - - -def _build_coin_backed_spend_bundle_hex(payload: dict[str, Any]) -> str: - from greenfloor.adapters.bls_signing import build_signed_spend_bundle - - receive_address = str(payload.get("receive_address", "")).strip() - key_id = str(payload.get("key_id", "")).strip() - network = str(payload.get("network", "")).strip() - keyring_yaml_path = str(payload.get("keyring_yaml_path", "")).strip() - size_base_units = int(payload.get("size_base_units", 0)) - quote_price_quote_per_base = float(payload.get("quote_price_quote_per_base", 0.0)) - base_unit_mojo_multiplier = int(payload.get("base_unit_mojo_multiplier", 0)) - quote_unit_mojo_multiplier = int(payload.get("quote_unit_mojo_multiplier", 0)) - if not receive_address: - raise ValueError("missing_receive_address") - if size_base_units <= 0: - raise ValueError("invalid_size_base_units") - if not key_id: - raise ValueError("missing_key_id") - if not network: - raise ValueError("missing_network") - if not keyring_yaml_path: - raise ValueError("missing_keyring_yaml_path") - if quote_price_quote_per_base <= 0: - raise ValueError("invalid_quote_price_quote_per_base") - if base_unit_mojo_multiplier <= 0: - raise ValueError("invalid_base_unit_mojo_multiplier") - if quote_unit_mojo_multiplier <= 0: - raise ValueError("invalid_quote_unit_mojo_multiplier") - - asset_id = str(payload.get("asset_id", "xch")).strip().lower() or "xch" - quote_asset = str(payload.get("quote_asset", "xch")).strip().lower() or "xch" - raw_offer_coin_ids = payload.get("offer_coin_ids", []) - offer_coin_ids = ( - [str(value).strip().lower() for value in raw_offer_coin_ids if str(value).strip()] - if isinstance(raw_offer_coin_ids, list) - else [] - ) - if quote_asset in {"xch", "txch", "1"}: - request_asset_id = quote_asset - else: - if len(quote_asset) != 64: - raise ValueError("invalid_quote_asset_id") - request_asset_id = quote_asset - - leg = compute_signer_offer_leg_amounts( - size_base_units=size_base_units, - quote_price=quote_price_quote_per_base, - resolved_base_asset_id=asset_id, - resolved_quote_asset_id=request_asset_id, - action_side="sell", - pricing={ - "base_unit_mojo_multiplier": base_unit_mojo_multiplier, - "quote_unit_mojo_multiplier": quote_unit_mojo_multiplier, - }, - ) - - program_config_path = str(payload.get("program_config_path", "")).strip() - if not program_config_path: - program_config_path = str(payload.get("program_config", "")).strip() - program_home_dir = str(payload.get("program_home_dir", "")).strip() - result = build_signed_spend_bundle( - { - "key_id": key_id, - "network": network, - "receive_address": receive_address, - "keyring_yaml_path": keyring_yaml_path, - "asset_id": asset_id, - "program_config_path": program_config_path, - "program_home_dir": program_home_dir, - "dry_run": bool(payload.get("dry_run", False)), - "expiry_unit": str(payload.get("expiry_unit", "")).strip(), - "expiry_value": int(payload.get("expiry_value", 0) or 0), - "split_input_coins": bool(payload.get("split_input_coins", True)), - "broadcast_split": bool(payload.get("broadcast_split", False)), - "signing_entrypoint": "build_signed_spend_bundle", - "plan": { - "op_type": "offer", - "offer_asset_id": asset_id, - "offer_amount": int(leg.offer_amount_mojos), - "request_asset_id": request_asset_id, - "request_amount": int(leg.request_amount_mojos), - "offer_coin_ids": offer_coin_ids, - }, - } - ) - if result.get("status") != "executed": - raise RuntimeError(str(result.get("reason", "coin_backed_signing_failed"))) - spend_bundle_hex = str(result.get("spend_bundle_hex", "")).strip() - if not spend_bundle_hex: - raise RuntimeError("missing_spend_bundle_hex") - return spend_bundle_hex +from greenfloor.adapters.offer_action import build_bls_offer_for_action +from greenfloor.core.offer_action import legacy_action_request_from_payload def _build_offer(payload: dict[str, Any]) -> str: spend_bundle_hex = str(payload.get("spend_bundle_hex", "")).strip() - if not spend_bundle_hex: - spend_bundle_hex = _build_coin_backed_spend_bundle_hex(payload) - raw_hex = ( - spend_bundle_hex[2:] if spend_bundle_hex.lower().startswith("0x") else spend_bundle_hex + if spend_bundle_hex: + raw_hex = ( + spend_bundle_hex[2:] if spend_bundle_hex.lower().startswith("0x") else spend_bundle_hex + ) + return encode_offer_from_spend_bundle_hex(raw_hex) + request = legacy_action_request_from_payload(payload) + result = build_bls_offer_for_action( + network=str(payload.get("network", "")).strip(), + key_id=str(payload.get("key_id", "")).strip(), + request=request, ) - return encode_offer_from_spend_bundle_hex(raw_hex) + return result["offer_text"] def build_offer(payload: dict[str, Any]) -> str: diff --git a/greenfloor/runtime/coin_ops/coins.py b/greenfloor/runtime/coin_ops/coins.py index 49607be7..785e2bfa 100644 --- a/greenfloor/runtime/coin_ops/coins.py +++ b/greenfloor/runtime/coin_ops/coins.py @@ -1,4 +1,4 @@ -"""Public coin-state helpers shared by Cloud Wallet runtime and CLI.""" +"""Public coin-state helpers shared by coin-op runtime and CLI.""" from __future__ import annotations @@ -30,11 +30,11 @@ def is_spendable_coin(coin: dict) -> bool: def resolve_coin_global_ids( wallet_coins: list[dict], raw_coin_ids: list[str] ) -> tuple[list[str], list[str]]: - """Map operator hex coin names (or Coin_* global IDs) to Cloud Wallet global IDs. + """Map operator hex coin names to backend coin global IDs. - Returns (resolved_ids, unresolved_ids). Operators usually copy hex coin names - from ``coins-list`` output; Cloud Wallet mutations require the ``Coin_*`` GraphQL - global-ID form. Direct ``Coin_*`` IDs are passed through unchanged for power users. + Returns (resolved_ids, unresolved_ids). Operators usually copy hex coin names + from ``coins-list`` output; some backends require a ``Coin_*`` GraphQL global-ID + form. Direct ``Coin_*`` IDs are passed through unchanged for power users. """ mapping: dict[str, str] = {} for coin in wallet_coins: diff --git a/greenfloor/runtime/local_offer.py b/greenfloor/runtime/local_offer.py index 432abc66..18756177 100644 --- a/greenfloor/runtime/local_offer.py +++ b/greenfloor/runtime/local_offer.py @@ -6,7 +6,9 @@ from pathlib import Path from typing import Any +from greenfloor.core.offer_action import expires_at_iso_from_build_context from greenfloor.core.planned_action import PlannedAction, planned_action_side +from greenfloor.runtime.offer_action_build import build_bls_create_phase_from_build_context from greenfloor.runtime.offer_build_context import OfferBuildContext from greenfloor.runtime.offer_orchestration import OfferCreateFailure, OfferCreateOutcome @@ -82,21 +84,37 @@ def make_local_offer_create_fn( *, dry_run: bool, capture_dir_path: Path | None = None, - build_offer_fn: collections.abc.Callable[[dict[str, Any]], str], + build_offer_fn: collections.abc.Callable[[dict[str, Any]], str] | None = None, ) -> collections.abc.Callable[..., OfferCreateOutcome]: offer_iteration = [0] def create(**kwargs: Any) -> OfferCreateOutcome: index = offer_iteration[0] offer_iteration[0] += 1 - payload = build_local_offer_payload( - build_ctx, - size_base_units=int(kwargs["size_base_units"]), - quote_price=float(kwargs["quote_price"]), - dry_run=dry_run, - ) try: - offer_text = build_offer_fn(payload) + expires_at = expires_at_iso_from_build_context( + expiry_unit=build_ctx.expiry_unit, + expiry_value=int(build_ctx.expiry_value), + ) + if build_offer_fn is not None: + payload = build_local_offer_payload( + build_ctx, + size_base_units=int(kwargs["size_base_units"]), + quote_price=float(kwargs["quote_price"]), + dry_run=dry_run, + ) + offer_text = build_offer_fn(payload) + side = str(kwargs.get("action_side", build_ctx.action_side)) + else: + outcome = build_bls_create_phase_from_build_context( + build_ctx, + size_base_units=int(kwargs["size_base_units"]), + quote_price=float(kwargs["quote_price"]), + action_side=str(kwargs.get("action_side", build_ctx.action_side)), + ) + offer_text = outcome.offer_text + expires_at = outcome.expires_at + side = outcome.side except Exception as exc: raise OfferCreateFailure(f"offer_builder_failed:{exc}") from exc @@ -108,10 +126,15 @@ def create(**kwargs: Any) -> OfferCreateOutcome: capture_file.write_text(offer_text, encoding="utf-8") extra["dry_run_preview"] = {"offer_capture_path": str(capture_file)} - return OfferCreateOutcome( - offer_text=offer_text, - expires_at=f"{int(build_ctx.expiry_value)} {build_ctx.expiry_unit}", - side=str(kwargs.get("action_side", build_ctx.action_side)), + if build_offer_fn is not None: + return OfferCreateOutcome( + offer_text=offer_text, + expires_at=expires_at, + side=side, + extra=extra, + ) + return OfferCreateOutcome.from_create_phase( + outcome, extra=extra, ) diff --git a/greenfloor/runtime/offer_action_build.py b/greenfloor/runtime/offer_action_build.py new file mode 100644 index 00000000..248f2b83 --- /dev/null +++ b/greenfloor/runtime/offer_action_build.py @@ -0,0 +1,124 @@ +"""Runtime orchestration for unified offer-action builds.""" + +from __future__ import annotations + +from greenfloor.adapters import offer_action, rust_signer +from greenfloor.config.models import prepare_signer_runtime +from greenfloor.core.offer_action import ( + OfferActionRequest, + OfferActionResult, + build_action_request, + to_create_phase_outcome, +) +from greenfloor.core.offer_request_bridge import normalize_offer_asset_id +from greenfloor.hex_utils import canonical_is_xch, normalize_hex_id +from greenfloor.runtime.offer_build_context import OfferBuildContext + +__all__ = [ + "action_request_from_context", + "build_bls_create_phase_from_build_context", + "build_bls_offer_from_build_context", + "resolve_action_assets_for_build_context", +] + + +def action_request_from_context( + build_ctx: OfferBuildContext, + *, + size_base_units: int, + action_side: str | None = None, + quote_price: float | None = None, + offer_coin_ids: list[str] | None = None, + split_input_coins: bool = True, + broadcast_split: bool = True, + resolved_base_asset_id: str | None = None, + resolved_quote_asset_id: str | None = None, +) -> OfferActionRequest: + """Build an action request from shared offer build context.""" + market = build_ctx.market + return build_action_request( + receive_address=str(market.receive_address or ""), + base_asset=str(resolved_base_asset_id or market.base_asset), + quote_asset=str(resolved_quote_asset_id or build_ctx.resolved_quote_asset), + pricing=dict(market.pricing or {}), + size_base_units=int(size_base_units), + action_side=str(action_side or build_ctx.action_side), + quote_price=float(quote_price if quote_price is not None else build_ctx.quote_price), + split_input_coins=split_input_coins, + broadcast_split=broadcast_split, + offer_coin_ids=offer_coin_ids, + ) + + +def _is_canonical_offer_asset_id(asset_id: str) -> bool: + normalized = str(asset_id or "").strip() + if canonical_is_xch(normalized): + return True + return bool(normalize_hex_id(normalized)) + + +def resolve_action_assets_for_build_context( + build_ctx: OfferBuildContext, +) -> tuple[str, str]: + """Resolve market symbols to canonical asset ids for local BLS offer-action builds.""" + base = str(build_ctx.market.base_asset) + quote = str(build_ctx.resolved_quote_asset) + if _is_canonical_offer_asset_id(base) and _is_canonical_offer_asset_id(quote): + return normalize_offer_asset_id(base), normalize_offer_asset_id(quote) + config_path = prepare_signer_runtime(build_ctx.program) + payload = rust_signer.resolve_offer_asset_ids(config_path, base, quote) + return payload["base_asset_id"], payload["quote_asset_id"] + + +def build_bls_offer_from_build_context( + build_ctx: OfferBuildContext, + *, + size_base_units: int, + action_side: str | None = None, + quote_price: float | None = None, + offer_coin_ids: list[str] | None = None, +) -> OfferActionResult: + """Build an offer via the Rust kernel BLS path.""" + market = build_ctx.market + key_id = str(market.signer_key_id or "").strip() + if not key_id: + raise ValueError("missing_key_id") + resolved_base, resolved_quote = resolve_action_assets_for_build_context(build_ctx) + request = action_request_from_context( + build_ctx, + size_base_units=size_base_units, + action_side=action_side, + quote_price=quote_price, + offer_coin_ids=offer_coin_ids, + split_input_coins=False, + broadcast_split=False, + resolved_base_asset_id=resolved_base, + resolved_quote_asset_id=resolved_quote, + ) + return offer_action.build_bls_offer_for_action( + network=str(build_ctx.network), + key_id=key_id, + request=request, + ) + + +def build_bls_create_phase_from_build_context( + build_ctx: OfferBuildContext, + *, + size_base_units: int, + action_side: str | None = None, + quote_price: float | None = None, + offer_coin_ids: list[str] | None = None, +): + """Build a BLS offer and map to create-phase outcome fields.""" + result = build_bls_offer_from_build_context( + build_ctx, + size_base_units=size_base_units, + action_side=action_side, + quote_price=quote_price, + offer_coin_ids=offer_coin_ids, + ) + return to_create_phase_outcome( + result, + action_side=str(action_side or build_ctx.action_side), + ) diff --git a/greenfloor/runtime/offer_orchestration.py b/greenfloor/runtime/offer_orchestration.py index 13f48f1e..0fdf392b 100644 --- a/greenfloor/runtime/offer_orchestration.py +++ b/greenfloor/runtime/offer_orchestration.py @@ -12,6 +12,7 @@ from greenfloor.adapters.dexie import DexieAdapter from greenfloor.adapters.splash import SplashAdapter from greenfloor.core import offer_policy +from greenfloor.core.offer_action import OfferCreatePhaseOutcome from greenfloor.core.offer_lifecycle import OfferLifecycleState from greenfloor.offer_bootstrap import ( BootstrapPhaseResult, @@ -37,6 +38,30 @@ class OfferCreateOutcome: create_total_ms: int | None = None extra: dict[str, Any] = field(default_factory=dict) + @classmethod + def from_create_phase( + cls, + phase: OfferCreatePhaseOutcome, + *, + create_phase_ms: int | None = None, + artifact_wait_ms: int | None = None, + create_total_ms: int | None = None, + extra: dict[str, Any] | None = None, + ) -> OfferCreateOutcome: + execution_mode = phase.execution_mode.strip() + merged_extra = dict(extra or {}) + if execution_mode and "execution_mode" not in merged_extra: + merged_extra["execution_mode"] = execution_mode + return cls( + offer_text=phase.offer_text, + expires_at=phase.expires_at, + side=phase.side, + create_phase_ms=create_phase_ms, + artifact_wait_ms=artifact_wait_ms, + create_total_ms=create_total_ms, + extra=merged_extra, + ) + class OfferCreateFailure(Exception): """Create-phase failure with structured fields for offer result payloads.""" diff --git a/greenfloor/runtime/offer_post_request.py b/greenfloor/runtime/offer_post_request.py index bc889dbb..8af9fb46 100644 --- a/greenfloor/runtime/offer_post_request.py +++ b/greenfloor/runtime/offer_post_request.py @@ -156,7 +156,7 @@ def run_local_bls( self, *, capture_dir_path: Path | None, - build_offer_fn: collections.abc.Callable[[dict[str, Any]], str], + build_offer_fn: collections.abc.Callable[[dict[str, Any]], str] | None = None, post_deps: OfferPostDeps, path_label: str = "local", path_extra_fields: dict[str, Any] | None = None, @@ -194,7 +194,7 @@ def run_cli( backend: OfferExecutionBackend, *, capture_dir_path: Path | None, - build_offer_fn: collections.abc.Callable[[dict[str, Any]], str], + build_offer_fn: collections.abc.Callable[[dict[str, Any]], str] | None = None, post_deps: OfferPostDeps, path_extra_fields: dict[str, Any] | None = None, ) -> int: diff --git a/greenfloor/runtime/offer_runtime.py b/greenfloor/runtime/offer_runtime.py index 8affea38..d958f9d8 100644 --- a/greenfloor/runtime/offer_runtime.py +++ b/greenfloor/runtime/offer_runtime.py @@ -1,23 +1,24 @@ from __future__ import annotations import collections.abc -import datetime as dt import logging import time from dataclasses import dataclass from typing import Any -from greenfloor.adapters import rust_signer +from greenfloor.adapters import offer_action, rust_signer from greenfloor.config.models import MarketConfig, ProgramConfig, prepare_signer_runtime +from greenfloor.core.offer_action import ( + OfferCreatePhaseOutcome, + build_action_request, + to_create_phase_outcome, +) from greenfloor.core.offer_bootstrap_bridge import ( BootstrapPhaseResult, plan_bootstrap_mixed_outputs, ) from greenfloor.core.offer_policy import normalize_offer_side -from greenfloor.core.signer_offer_request import ( - build_signer_create_offer_request, - signer_split_asset_id, -) +from greenfloor.core.signer_offer_request import signer_split_asset_id from greenfloor.hex_utils import canonical_is_xch from greenfloor.runtime.bootstrap_fees import resolve_bootstrap_split_fee from greenfloor.runtime.coin_ops.coins import is_spendable_coin @@ -204,39 +205,25 @@ def signer_create_offer_phase( quote_price: float, resolved_base_asset_id: str, resolved_quote_asset_id: str, - expiry_unit: str, - expiry_value: int, action_side: str = "sell", split_input_coins: bool = True, broadcast_split: bool = True, -) -> dict[str, Any]: +) -> OfferCreatePhaseOutcome: side = normalize_offer_side(action_side) - expires_at_dt = dt.datetime.now(dt.UTC) + dt.timedelta(**{expiry_unit: int(expiry_value)}) - request = build_signer_create_offer_request( - market=market, - size_base_units=size_base_units, - quote_price=quote_price, - resolved_base_asset_id=resolved_base_asset_id, - resolved_quote_asset_id=resolved_quote_asset_id, + request = build_action_request( + receive_address=str(market.receive_address or ""), + base_asset=str(resolved_base_asset_id), + quote_asset=str(resolved_quote_asset_id), + pricing=dict(market.pricing or {}), + size_base_units=int(size_base_units), action_side=side, + quote_price=float(quote_price), split_input_coins=split_input_coins, broadcast_split=broadcast_split, - expires_at_unix=int(expires_at_dt.timestamp()), ) config_path = _signer_config_path(program) - result = rust_signer.build_vault_cat_offer(config_path, request.to_payload()) - offer_text = str(result.get("offer", "")).strip() - if not offer_text.startswith("offer1"): - raise RuntimeError("signer_create_offer_failed:missing_offer_text") - return { - "offer_text": offer_text, - "expires_at": expires_at_dt.isoformat(), - "offer_amount": request.offer_amount, - "request_amount": request.request_amount, - "side": side, - "execution_mode": str(result.get("execution_mode", "")).strip(), - "create_result": dict(result) if isinstance(result, dict) else {}, - } + result = offer_action.build_signer_offer_for_action(config_path, request) + return to_create_phase_outcome(result, action_side=side) @dataclass(frozen=True, slots=True) @@ -244,7 +231,7 @@ class SignerOfferDeps: post_deps: OfferPostDeps resolve_signer_offer_asset_ids_fn: collections.abc.Callable[..., tuple[str, str]] signer_bootstrap_phase_fn: collections.abc.Callable[..., BootstrapPhaseResult] - signer_create_offer_phase_fn: collections.abc.Callable[..., dict[str, Any]] + signer_create_offer_phase_fn: collections.abc.Callable[..., OfferCreatePhaseOutcome] def default_signer_offer_deps(*, post_deps: OfferPostDeps | None = None) -> SignerOfferDeps: @@ -283,8 +270,6 @@ def build_and_post_offer_signer( quote_asset_id=str(market.quote_asset), ) ) - expiry_unit = build_ctx.expiry_unit - expiry_value = int(build_ctx.expiry_value) def bootstrap(**kwargs: Any) -> BootstrapPhaseResult: return resolved_deps.signer_bootstrap_phase_fn( @@ -304,8 +289,6 @@ def create(**kwargs: Any) -> OfferCreateOutcome: quote_price=kwargs["quote_price"], resolved_base_asset_id=kwargs["resolved_base_asset_id"], resolved_quote_asset_id=kwargs["resolved_quote_asset_id"], - expiry_unit=expiry_unit, - expiry_value=expiry_value, action_side=kwargs["action_side"], ) except RuntimeError as exc: @@ -314,20 +297,16 @@ def create(**kwargs: Any) -> OfferCreateOutcome: create_phase_ms=int((time.monotonic() - started) * 1000), extra={"signer_path": True}, ) from exc - execution_mode = str(create_phase.get("execution_mode", "")).strip() - offer_text = str(create_phase.get("offer_text", "")).strip() - if not offer_text: + execution_mode = create_phase.execution_mode.strip() + if not create_phase.offer_text.strip(): raise OfferCreateFailure( "signer_create_offer_failed:missing_offer_text", create_phase_ms=int((time.monotonic() - started) * 1000), extra={"signer_path": True, "execution_mode": execution_mode}, ) - return OfferCreateOutcome( - offer_text=str(create_phase.get("offer_text", "")).strip(), - expires_at=str(create_phase.get("expires_at", "")), - side=str(create_phase.get("side", kwargs["action_side"])), + return OfferCreateOutcome.from_create_phase( + create_phase, create_phase_ms=int((time.monotonic() - started) * 1000), - extra={"execution_mode": execution_mode} if execution_mode else {}, ) return build_and_post_offer( diff --git a/greenfloor/runtime/signer_coin_op_backend.py b/greenfloor/runtime/signer_coin_op_backend.py index 02fc9321..8eedcd7c 100644 --- a/greenfloor/runtime/signer_coin_op_backend.py +++ b/greenfloor/runtime/signer_coin_op_backend.py @@ -34,7 +34,7 @@ def _operation_id_from_spend_bundle_hex(spend_bundle_hex: str) -> str | None: @dataclass(slots=True) class SignerCoinOpBackend: - """BLS/signer coin-ops via Rust mixed-split (coinset listing, no Cloud Wallet fees API).""" + """BLS/signer coin-ops via Rust mixed-split (coinset listing).""" program: ProgramConfig market: MarketConfig @@ -183,7 +183,7 @@ def combine_coins( ) -> dict[str, Any]: """Combine via Rust mixed-split. - ``largest_first`` and ``target_amount`` are Cloud Wallet-only; the signer path + ``largest_first`` and ``target_amount`` are legacy wallet-only modes; the signer path always uses explicit ``input_coin_ids`` totals and even output splitting. ``fee_mojos`` is forwarded to the Rust mixed-split builder when non-zero. """ diff --git a/greenfloor/signing.py b/greenfloor/signing.py index dc7e8e13..7d3cacf5 100644 --- a/greenfloor/signing.py +++ b/greenfloor/signing.py @@ -1,4 +1,4 @@ -"""Unified signing entrypoint: vault KMS via Rust signer, BLS via adapters.bls_signing.""" +"""Legacy signing router; prefer ``greenfloor.adapters.bls_signing`` and ``rust_signer``.""" from __future__ import annotations diff --git a/pyproject.toml b/pyproject.toml index d6fc4523..bc1f7a60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,5 +46,5 @@ ignore = ["E501"] testpaths = ["tests"] addopts = "--basetemp=.pytest_tmp" markers = [ - "signer: requires compiled greenfloor_signer (validate_offer_structure)", + "signer: requires compiled greenfloor_kernel (validate_offer_structure)", ] diff --git a/scripts/combine_coinset_direct.py b/scripts/combine_coinset_direct.py index fe11a18b..7df52e1d 100644 --- a/scripts/combine_coinset_direct.py +++ b/scripts/combine_coinset_direct.py @@ -7,12 +7,12 @@ from dataclasses import dataclass from typing import Any +from greenfloor.adapters.bls_signing import sign_and_broadcast_mixed_split from greenfloor.adapters.coinset import CoinsetAdapter from greenfloor.adapters.kms_signer import get_public_key_compressed_hex, sign_digest from greenfloor.config.launcher import launcher_id_from_program_config from greenfloor.constants import MIN_CAT_OUTPUT_MOJOS from greenfloor.hex_utils import normalize_hex_id -from greenfloor.signing import sign_and_broadcast_mixed_split @dataclass(frozen=True, slots=True) diff --git a/tests/helpers/kernel_mock.py b/tests/helpers/kernel_mock.py index 23f30c30..1076423e 100644 --- a/tests/helpers/kernel_mock.py +++ b/tests/helpers/kernel_mock.py @@ -1,10 +1,17 @@ -"""Minimal greenfloor_signer stub helpers for partial kernel mocks in tests.""" +"""Minimal greenfloor kernel stub helpers for partial kernel mocks in tests.""" from __future__ import annotations +from typing import Any + + +def install_kernel_stub(monkeypatch: Any, stub: Any) -> None: + """Register a stub for the ADR 0010 kernel module name.""" + monkeypatch.setitem(__import__("sys").modules, "greenfloor_kernel", stub) + def mock_kernel_normalize_hex_id(value: str) -> str: - """Mirror ``hex_utils.normalize_hex_id`` for tests that stub ``greenfloor_signer``.""" + """Mirror ``hex_utils.normalize_hex_id`` for tests that stub ``greenfloor_kernel``.""" normalized = value.strip().lower() if normalized.startswith("0x"): normalized = normalized[2:] @@ -125,7 +132,7 @@ def mock_kernel_expected_publish_asset_fields( class MinimalSignerKernel: - """Base stub for tests that patch ``sys.modules['greenfloor_signer']``. + """Base stub for tests that patch ``sys.modules['greenfloor_kernel']``. Subclass and override only the symbols your test exercises. Hex helpers, offer-build pricing helpers, and Dexie verification are provided by default diff --git a/tests/test_daemon_strategy_local.py b/tests/test_daemon_strategy_local.py index a24aa150..e4674223 100644 --- a/tests/test_daemon_strategy_local.py +++ b/tests/test_daemon_strategy_local.py @@ -259,13 +259,22 @@ def test_execute_strategy_dispatch_applies_post_cooldown_after_retry_exhaust(mon def test_build_offer_for_action_direct_builder_call(monkeypatch) -> None: - captured = {} + captured_before = {} + + def _capture_build(build_ctx, **kwargs): + captured_before["quote_price"] = float(kwargs.get("quote_price", build_ctx.quote_price)) + captured_before["size_base_units"] = int(kwargs["size_base_units"]) + captured_before["key_id"] = build_ctx.market.signer_key_id + captured_before["network"] = build_ctx.network + captured_before["keyring_yaml_path"] = build_ctx.keyring_yaml_path + captured_before["base_unit_mojo_multiplier"] = build_ctx.base_unit_mojo_multiplier + captured_before["quote_unit_mojo_multiplier"] = build_ctx.quote_unit_mojo_multiplier + return {"offer_text": f"offer1direct-{kwargs['size_base_units']}"} - def _fake_build_offer(payload): - captured["payload"] = payload - return f"offer1direct-{payload['size_base_units']}" - - monkeypatch.setattr("greenfloor.offer_builder.build_offer", _fake_build_offer) + monkeypatch.setattr( + "greenfloor.daemon.offer_dispatch.local.build_bls_offer_from_build_context", + _capture_build, + ) action = PlannedAction( size=10, repeat=1, @@ -287,12 +296,12 @@ def _fake_build_offer(payload): assert built["status"] == "executed" assert built["reason"] == "offer_builder_success" assert built["offer"] == "offer1direct-10" - assert captured["payload"]["quote_price_quote_per_base"] == 0.5 - assert captured["payload"]["base_unit_mojo_multiplier"] == 1000 - assert captured["payload"]["quote_unit_mojo_multiplier"] == 1000 - assert captured["payload"]["key_id"] == "key-main-1" - assert captured["payload"]["network"] == "mainnet" - assert captured["payload"]["keyring_yaml_path"] == "/tmp/keyring.yaml" + assert captured_before["quote_price"] == 0.5 + assert captured_before["base_unit_mojo_multiplier"] == 1000 + assert captured_before["quote_unit_mojo_multiplier"] == 1000 + assert captured_before["key_id"] == "key-main-1" + assert captured_before["network"] == "mainnet" + assert captured_before["keyring_yaml_path"] == "/tmp/keyring.yaml" def test_inject_reseed_action_when_no_active_offers() -> None: diff --git a/tests/test_greenfloor_native_contract.py b/tests/test_greenfloor_native_contract.py index 366ae569..830d8242 100644 --- a/tests/test_greenfloor_native_contract.py +++ b/tests/test_greenfloor_native_contract.py @@ -1,11 +1,10 @@ from __future__ import annotations -import sys - from greenfloor.core.offer_policy import verify_offer_for_dexie +from tests.helpers.kernel_mock import install_kernel_stub -def test_verify_offer_for_dexie_uses_greenfloor_signer_only(monkeypatch) -> None: +def test_verify_offer_for_dexie_uses_greenfloor_kernel_only(monkeypatch) -> None: calls: dict[str, str] = {} class _Signer: @@ -13,7 +12,7 @@ class _Signer: def verify_offer_for_dexie(offer: str) -> None: calls["offer"] = offer - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Signer) + install_kernel_stub(monkeypatch, _Signer) assert verify_offer_for_dexie("offer1contract") is None assert calls["offer"] == "offer1contract" @@ -26,5 +25,5 @@ def test_verify_offer_for_dexie_reports_missing_kernel(monkeypatch) -> None: ) assert verify_offer_for_dexie("offer1contract") == ( - "wallet_sdk_import_error:greenfloor_signer_unavailable" + "wallet_sdk_import_error:greenfloor_kernel_unavailable" ) diff --git a/tests/test_greenfloor_signer_integration.py b/tests/test_greenfloor_signer_integration.py index d71ecd88..21c58dc0 100644 --- a/tests/test_greenfloor_signer_integration.py +++ b/tests/test_greenfloor_signer_integration.py @@ -1,4 +1,4 @@ -"""Integration tests for the greenfloor_signer PyO3 extension.""" +"""Integration tests for the greenfloor_kernel PyO3 extension.""" from __future__ import annotations @@ -20,9 +20,9 @@ def _require_importable_modules(): except Exception: pytest.skip("chia_wallet_sdk import unavailable") try: - import greenfloor_signer as signer # type: ignore + import greenfloor_kernel as signer # type: ignore except Exception: - pytest.skip("greenfloor_signer import unavailable") + pytest.skip("greenfloor_kernel import unavailable") return sdk, signer diff --git a/tests/test_kernel_bridge.py b/tests/test_kernel_bridge.py index fa1e398e..fe525a3c 100644 --- a/tests/test_kernel_bridge.py +++ b/tests/test_kernel_bridge.py @@ -18,59 +18,44 @@ def test_import_signer_is_import_kernel_alias() -> None: def test_kernel_rebuild_hint_uses_module_argument() -> None: hint = kernel_bridge.kernel_rebuild_hint( - module=kernel_bridge.KERNEL_MODULE_LEGACY, + module=kernel_bridge.KERNEL_MODULE, missing="offer-request", ) - assert kernel_bridge.KERNEL_MODULE_LEGACY in hint + assert kernel_bridge.KERNEL_MODULE in hint assert "maturin develop" in hint assert "offer-request" in hint -def test_import_kernel_prefers_legacy_module(monkeypatch) -> None: +def test_import_kernel_loads_target_module(monkeypatch) -> None: calls: list[str] = [] def _fake_import(name: str) -> ModuleType: calls.append(name) - if name == kernel_bridge.KERNEL_MODULE_LEGACY: + if name == kernel_bridge.KERNEL_MODULE: return ModuleType(name) raise ImportError(f"missing {name}") monkeypatch.setattr(importlib, "import_module", _fake_import) mod = kernel_bridge.import_kernel() - assert mod.__name__ == kernel_bridge.KERNEL_MODULE_LEGACY - assert calls == [kernel_bridge.KERNEL_MODULE_LEGACY] + assert mod.__name__ == kernel_bridge.KERNEL_MODULE + assert calls == [kernel_bridge.KERNEL_MODULE] -def test_import_kernel_falls_back_to_target_module(monkeypatch) -> None: - calls: list[str] = [] - - def _fake_import(name: str) -> ModuleType: - calls.append(name) - if name == kernel_bridge.KERNEL_MODULE_TARGET: - return ModuleType(name) - raise ImportError(f"missing {name}") - - monkeypatch.setattr(importlib, "import_module", _fake_import) - mod = kernel_bridge.import_kernel() - assert mod.__name__ == kernel_bridge.KERNEL_MODULE_TARGET - assert calls == [kernel_bridge.KERNEL_MODULE_LEGACY, kernel_bridge.KERNEL_MODULE_TARGET] - - -def test_import_kernel_error_lists_candidates(monkeypatch) -> None: +def test_import_kernel_error_lists_module(monkeypatch) -> None: def _always_fail(name: str) -> ModuleType: raise ImportError(f"missing {name}") monkeypatch.setattr(importlib, "import_module", _always_fail) - with pytest.raises(ImportError, match="greenfloor_signer") as exc_info: + with pytest.raises(ImportError, match="greenfloor_kernel") as exc_info: kernel_bridge.import_kernel() message = str(exc_info.value) - assert kernel_bridge.KERNEL_MODULE_TARGET in message + assert kernel_bridge.KERNEL_MODULE in message assert "maturin develop" in message def test_require_kernel_method_uses_loaded_module_name() -> None: - module = ModuleType(kernel_bridge.KERNEL_MODULE_LEGACY) - with pytest.raises(RuntimeError, match=kernel_bridge.KERNEL_MODULE_LEGACY) as exc_info: + module = ModuleType(kernel_bridge.KERNEL_MODULE) + with pytest.raises(RuntimeError, match=kernel_bridge.KERNEL_MODULE) as exc_info: kernel_bridge.require_kernel_method(module, "missing_symbol", missing="required policy") assert "Missing symbol: missing_symbol" in str(exc_info.value) assert "required policy" in str(exc_info.value) @@ -78,8 +63,8 @@ def test_require_kernel_method_uses_loaded_module_name() -> None: def test_require_kernel_method_with_sys_modules_stub(monkeypatch) -> None: """Regression: policy bridges resolve symbols on test stubs without __spec__.""" - stub = ModuleType(kernel_bridge.KERNEL_MODULE_LEGACY) - monkeypatch.setitem(sys.modules, kernel_bridge.KERNEL_MODULE_LEGACY, stub) + stub = ModuleType(kernel_bridge.KERNEL_MODULE) + monkeypatch.setitem(sys.modules, kernel_bridge.KERNEL_MODULE, stub) with pytest.raises(RuntimeError, match="Missing symbol: bootstrap_block_error"): kernel_bridge.require_kernel_method( stub, @@ -89,7 +74,7 @@ def test_require_kernel_method_with_sys_modules_stub(monkeypatch) -> None: def test_policy_coin_ops_and_bootstrap_kernels_share_import(monkeypatch) -> None: - module = ModuleType(kernel_bridge.KERNEL_MODULE_LEGACY) + module = ModuleType(kernel_bridge.KERNEL_MODULE) monkeypatch.setattr(kernel_bridge, "_loaded_kernel_module", lambda: module) assert kernel_bridge.policy_kernel() is module assert kernel_bridge.coin_ops_kernel() is module @@ -97,7 +82,7 @@ def test_policy_coin_ops_and_bootstrap_kernels_share_import(monkeypatch) -> None def test_kernel_method_getter_delegates_to_require_kernel_method(monkeypatch) -> None: - module = ModuleType(kernel_bridge.KERNEL_MODULE_LEGACY) + module = ModuleType(kernel_bridge.KERNEL_MODULE) calls: list[tuple[Any, str, str]] = [] sentinel = object() diff --git a/tests/test_manager_build_post_offer.py b/tests/test_manager_build_post_offer.py index a0ab6a1f..c85fc586 100644 --- a/tests/test_manager_build_post_offer.py +++ b/tests/test_manager_build_post_offer.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import sys from pathlib import Path import yaml @@ -575,14 +574,14 @@ def post_offer(self, offer: str, *, drop_only: bool, claim_rewards: bool | None) called["post_offer_called"] = True return {"success": True, "id": "should-not-post"} - from tests.helpers.kernel_mock import MinimalSignerKernel + from tests.helpers.kernel_mock import MinimalSignerKernel, install_kernel_stub class _Signer(MinimalSignerKernel): @staticmethod def verify_offer_for_dexie(_offer: str) -> str: return "wallet_sdk_offer_missing_expiration" - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Signer) + install_kernel_stub(monkeypatch, _Signer) monkeypatch.setattr( "greenfloor.cli.offer_build_post.build_offer", lambda _payload: "offer1noexpiry", diff --git a/tests/test_offer_action_build.py b/tests/test_offer_action_build.py new file mode 100644 index 00000000..2bf29ce8 --- /dev/null +++ b/tests/test_offer_action_build.py @@ -0,0 +1,49 @@ +"""Tests for runtime offer-action build helpers.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +from greenfloor.runtime.offer_action_build import resolve_action_assets_for_build_context +from greenfloor.runtime.offer_build_context import prepare_offer_build_context +from tests.helpers.offer_runtime_fixtures import ( + market_config_for_local_offer, + program_config_for_local_offer, +) + + +def test_resolve_action_assets_uses_kernel_for_ticker_symbols(monkeypatch) -> None: + market = replace( + market_config_for_local_offer(), + base_asset="HOA", + pricing={"fixed_quote_per_base": 0.5, "strategy_offer_expiry_minutes": 12}, + ) + build_ctx = prepare_offer_build_context( + program=program_config_for_local_offer(), + market=market, + program_path=Path("/tmp/program.yaml"), + network="mainnet", + keyring_yaml_path="/tmp/keyring.yaml", + ) + captured: dict[str, str] = {} + + def _fake_resolve(_path: str, base: str, quote: str) -> dict[str, str]: + captured["base"] = base + captured["quote"] = quote + return {"base_asset_id": "aa" * 32, "quote_asset_id": "xch"} + + monkeypatch.setattr( + "greenfloor.runtime.offer_action_build.rust_signer.resolve_offer_asset_ids", + _fake_resolve, + ) + monkeypatch.setattr( + "greenfloor.runtime.offer_action_build.prepare_signer_runtime", + lambda _program: "/tmp/signer.yaml", + ) + + base, quote = resolve_action_assets_for_build_context(build_ctx) + + assert captured["base"] == "HOA" + assert base == "aa" * 32 + assert quote == "xch" diff --git a/tests/test_offer_builder_sdk.py b/tests/test_offer_builder_sdk.py index f8ad442d..0d080c0b 100644 --- a/tests/test_offer_builder_sdk.py +++ b/tests/test_offer_builder_sdk.py @@ -38,55 +38,37 @@ def test_build_offer_rejects_missing_coin_backed_inputs() -> None: ("quote_unit_mojo_multiplier", 0, "invalid_quote_unit_mojo_multiplier"), ], ) -def test_build_coin_backed_rejects_invalid_plan_inputs( +def test_legacy_action_request_rejects_invalid_plan_inputs( field: str, value: int | float, message: str, ) -> None: + from greenfloor.core import offer_action as offer_action_core + payload = dict(_BLS_COIN_BACKED_BASE) payload[field] = value with pytest.raises(ValueError, match=message): - offer_builder._build_coin_backed_spend_bundle_hex(payload) - - -def test_build_coin_backed_rejects_rounded_zero_request_amount() -> None: - try: - import greenfloor_signer # type: ignore[import-not-found] # noqa: F401 - except ImportError: - pytest.skip("greenfloor_signer not installed") - - payload = dict(_BLS_COIN_BACKED_BASE) - payload.update( - { - "size_base_units": 1, - "quote_price_quote_per_base": 1e-15, - "base_unit_mojo_multiplier": 1, - "quote_unit_mojo_multiplier": 1, - } - ) - with pytest.raises(ValueError, match="request_amount must be positive"): - offer_builder._build_coin_backed_spend_bundle_hex(payload) - + offer_action_core.validate_legacy_offer_payload(payload) -def test_build_coin_backed_spend_bundle_hex_maps_payload(monkeypatch) -> None: - import greenfloor.adapters.bls_signing as bls_signing_mod - captured = {} +def test_build_offer_calls_kernel_action(monkeypatch) -> None: + captured: dict = {} - def _fake_build(payload): - captured["payload"] = payload - return { - "status": "executed", - "reason": "ok", - "spend_bundle_hex": "deadbeef", - } + def _fake_build(*, network: str, key_id: str, request: dict) -> dict: + captured["network"] = network + captured["key_id"] = key_id + captured["request"] = request + return {"offer_text": "offer1fake"} - monkeypatch.setattr(bls_signing_mod, "build_signed_spend_bundle", _fake_build) - result = offer_builder._build_coin_backed_spend_bundle_hex(dict(_BLS_COIN_BACKED_BASE)) - assert result == "deadbeef" - assert captured["payload"]["key_id"] == "k1" - assert captured["payload"]["plan"]["op_type"] == "offer" - assert captured["payload"]["plan"]["offer_amount"] == 10000 + monkeypatch.setattr( + "greenfloor.offer_builder.build_bls_offer_for_action", + _fake_build, + ) + offer_text = offer_builder.build_offer(dict(_BLS_COIN_BACKED_BASE)) + assert offer_text == "offer1fake" + assert captured["network"] == "mainnet" + assert captured["key_id"] == "k1" + assert captured["request"]["size_base_units"] == 10 def test_main_outputs_executed_json(monkeypatch, capsys) -> None: diff --git a/tests/test_offer_publish.py b/tests/test_offer_publish.py index acd3a041..2df42bbe 100644 --- a/tests/test_offer_publish.py +++ b/tests/test_offer_publish.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from typing import Any, cast import pytest @@ -18,7 +17,7 @@ post_offer_phase, verify_dexie_offer_visible_by_id, ) -from tests.helpers.kernel_mock import MinimalSignerKernel +from tests.helpers.kernel_mock import MinimalSignerKernel, install_kernel_stub def test_verify_offer_for_dexie_success(monkeypatch) -> None: @@ -29,7 +28,7 @@ class _Native(MinimalSignerKernel): def verify_offer_for_dexie(offer: str) -> None: calls.append(offer) - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert verify_offer_for_dexie("offer1ok") is None assert calls == ["offer1ok"] @@ -40,7 +39,7 @@ class _Native(MinimalSignerKernel): def verify_offer_for_dexie(_offer: str) -> str: return "wallet_sdk_offer_duplicate_spent_coin_ids" - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert verify_offer_for_dexie("offer1duplicate") == "wallet_sdk_offer_duplicate_spent_coin_ids" @@ -50,7 +49,7 @@ class _Native(MinimalSignerKernel): def verify_offer_for_dexie(_offer: str) -> str: return "wallet_sdk_offer_missing_expiration" - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert verify_offer_for_dexie("offer1noexpiry") == "wallet_sdk_offer_missing_expiration" @@ -60,7 +59,7 @@ class _Native(MinimalSignerKernel): def verify_offer_for_dexie(_offer: str) -> str: return "wallet_sdk_offer_validate_failed:native_invalid_offer" - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert verify_offer_for_dexie("offer1bad") == ( "wallet_sdk_offer_validate_failed:native_invalid_offer" ) @@ -72,7 +71,7 @@ class _Native(MinimalSignerKernel): def verify_offer_for_dexie(_offer: str) -> str: return "wallet_sdk_offer_validate_failed:malformed_offer" - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert verify_offer_for_dexie("offer1malformed") == ( "wallet_sdk_offer_validate_failed:malformed_offer" ) @@ -81,10 +80,10 @@ def verify_offer_for_dexie(_offer: str) -> str: def test_verify_offer_for_dexie_reports_missing_kernel(monkeypatch) -> None: monkeypatch.setattr( "greenfloor.core.kernel_bridge.import_kernel", - lambda: (_ for _ in ()).throw(ImportError("greenfloor_signer_unavailable")), + lambda: (_ for _ in ()).throw(ImportError("greenfloor_kernel_unavailable")), ) assert verify_offer_for_dexie("offer1contract") == ( - "wallet_sdk_import_error:greenfloor_signer_unavailable" + "wallet_sdk_import_error:greenfloor_kernel_unavailable" ) @@ -99,7 +98,7 @@ def bootstrap_block_error( _ = bootstrap_ready return f"kernel_bootstrap:{bootstrap_status}:{bootstrap_reason}" - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert ( bootstrap_block_error( bootstrap_status="failed", @@ -114,7 +113,7 @@ def test_bootstrap_block_error_requires_kernel_symbol(monkeypatch) -> None: class _Native: pass - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) with pytest.raises(RuntimeError, match="Missing symbol: bootstrap_block_error"): bootstrap_block_error( bootstrap_status="executed", @@ -140,7 +139,7 @@ def expected_publish_asset_fields( "expected_requested_symbol": base_symbol, } - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert expected_publish_asset_fields( side="buy", base_symbol="A1", @@ -159,7 +158,7 @@ def test_expected_publish_asset_fields_requires_kernel_symbol(monkeypatch) -> No class _Native: pass - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) with pytest.raises(RuntimeError, match="Missing symbol: expected_publish_asset_fields"): expected_publish_asset_fields( side="buy", @@ -186,7 +185,7 @@ def expected_publish_asset_fields( "expected_requested_symbol": "A1", } - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) with pytest.raises( TypeError, match="expected_publish_asset_fields missing keys: expected_offered_symbol", @@ -210,7 +209,7 @@ def resolve_offer_expiry_for_pricing(_pricing): def resolve_quote_price_for_pricing(_pricing): return 1.5 - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) pricing = {"strategy_offer_expiry_minutes": 12} assert resolve_offer_expiry_for_pricing(pricing) == ("minutes", 12) assert resolve_quote_price_for_pricing(pricing) == 1.5 @@ -236,7 +235,7 @@ def dexie_offer_asset_expectation_error( f"offered_symbol={expected_offered_symbol}" ) - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) assert dexie_offer_asset_expectation_error( offered=[], requested=[], @@ -254,7 +253,7 @@ def test_dexie_offer_asset_expectation_error_requires_kernel_symbol(monkeypatch) class _Native: pass - monkeypatch.setitem(sys.modules, "greenfloor_signer", _Native) + install_kernel_stub(monkeypatch, _Native) with pytest.raises(RuntimeError, match="Missing symbol: dexie_offer_asset_expectation_error"): dexie_offer_asset_expectation_error( offered=[], diff --git a/tests/test_offer_runtime.py b/tests/test_offer_runtime.py index 9add432b..1b3d17f9 100644 --- a/tests/test_offer_runtime.py +++ b/tests/test_offer_runtime.py @@ -39,10 +39,18 @@ def test_signer_create_offer_phase_calls_signer_and_returns_offer_text(monkeypat def _fake_build(_config_path: str, request: dict) -> dict: captured.update(request) - return {"offer": "offer1test", "execution_mode": "direct"} + return { + "offer_text": "offer1test", + "execution_mode": "direct", + "side": "buy", + "expires_at_unix": 1_700_000_000, + "offer_amount": 10_000, + "request_amount": 20_000, + "create_result": {"execution_mode": "direct"}, + } monkeypatch.setattr( - "greenfloor.adapters.rust_signer.build_vault_cat_offer", + "greenfloor.adapters.offer_action.build_signer_offer_for_action", _fake_build, ) monkeypatch.setattr( @@ -59,31 +67,33 @@ def _fake_build(_config_path: str, request: dict) -> dict: quote_price=2.0, resolved_base_asset_id="basecat", resolved_quote_asset_id="quotecat", - expiry_unit="hours", - expiry_value=1, action_side="buy", ) assert captured assert captured["receive_address"] == market.receive_address - assert captured["expires_at"] is not None - assert result["side"] == "buy" - assert result["offer_text"] == "offer1test" - assert result["execution_mode"] == "direct" - assert result["expires_at"] + assert captured["base_asset"] == "basecat" + assert captured["quote_asset"] == "quotecat" + assert result.side == "buy" + assert result.offer_text == "offer1test" + assert result.execution_mode == "direct" + assert result.expires_at def test_signer_create_offer_phase_requires_offer_text(monkeypatch) -> None: + def _raise_missing(_path: str, _req: dict) -> dict: + raise RuntimeError("offer_action_failed:missing_offer_text") + monkeypatch.setattr( - "greenfloor.adapters.rust_signer.build_vault_cat_offer", - lambda _path, _req: {"offer": "", "execution_mode": "direct"}, + "greenfloor.adapters.offer_action.build_signer_offer_for_action", + _raise_missing, ) monkeypatch.setattr( "greenfloor.runtime.offer_runtime.prepare_signer_runtime", lambda _program: "/tmp/signer.yaml", ) - with pytest.raises(RuntimeError, match="missing_offer_text"): + with pytest.raises(RuntimeError, match="offer_action_failed:missing_offer_text"): signer_create_offer_phase( program=cast(ProgramConfig, SimpleNamespace()), market=_sample_market(), @@ -91,8 +101,6 @@ def test_signer_create_offer_phase_requires_offer_text(monkeypatch) -> None: quote_price=1.0, resolved_base_asset_id="basecat", resolved_quote_asset_id="xch", - expiry_unit="hours", - expiry_value=1, ) diff --git a/tests/test_signer_create_offer_parity.py b/tests/test_signer_create_offer_parity.py index d5d60c81..2d54a413 100644 --- a/tests/test_signer_create_offer_parity.py +++ b/tests/test_signer_create_offer_parity.py @@ -10,11 +10,12 @@ import pytest +from greenfloor.core.offer_action import build_action_request from greenfloor.core.offer_policy import normalize_offer_side from greenfloor.core.planned_action import PlannedAction, planned_action_side from greenfloor.core.signer_offer_request import ( - build_signer_create_offer_request, compute_signer_offer_leg_amounts, + signer_create_offer_request_from_fields, ) from tests.helpers.offer_runtime_fixtures import ( market_config_for_local_offer, @@ -33,13 +34,13 @@ def _require_signer_kernel(): try: - import greenfloor_signer as kernel # type: ignore[import-not-found] + import greenfloor_kernel as kernel # type: ignore[import-not-found] except ImportError: - pytest.skip("greenfloor_signer not installed") + pytest.skip("greenfloor_kernel not installed") if not callable(getattr(kernel, "compute_signer_offer_leg_amounts", None)): - pytest.skip("greenfloor_signer.compute_signer_offer_leg_amounts not available") + pytest.skip("greenfloor_kernel.compute_signer_offer_leg_amounts not available") if not callable(getattr(kernel, "normalize_offer_side", None)): - pytest.skip("greenfloor_signer.normalize_offer_side not available") + pytest.skip("greenfloor_kernel.normalize_offer_side not available") return kernel @@ -103,16 +104,34 @@ def test_signer_golden_fixture_contract(fixture_path: Path) -> None: assert str(payload["offer"]).startswith("offer1") market = market_config_from_fixture(create_offer_request=fixture_req, parity=parity) - runtime_req = build_signer_create_offer_request( - market=market, + action = build_action_request( + receive_address=str(market.receive_address or ""), + base_asset=parity["resolved_base_asset_id"], + quote_asset=parity["resolved_quote_asset_id"], + pricing=dict(market.pricing or {}), size_base_units=parity["size_base_units"], - quote_price=parity["quote_price"], - resolved_base_asset_id=parity["resolved_base_asset_id"], - resolved_quote_asset_id=parity["resolved_quote_asset_id"], action_side=parity["action_side"], + quote_price=parity["quote_price"], split_input_coins=fixture_req["split_input_coins"], broadcast_split=fixture_req["broadcast_split"], - expires_at_unix=expires_at_unix, + ) + leg = compute_signer_offer_leg_amounts( + size_base_units=action["size_base_units"], + quote_price=action["quote_price"], + resolved_base_asset_id=action["base_asset"], + resolved_quote_asset_id=action["quote_asset"], + action_side=action["action_side"], + pricing=action["pricing"], + ) + runtime_req = signer_create_offer_request_from_fields( + receive_address=action["receive_address"], + offer_asset_id=leg.offer_asset_id, + offer_amount=int(leg.offer_amount_mojos), + request_asset_id=leg.request_asset_id, + request_amount=int(leg.request_amount_mojos), + split_input_coins=action["split_input_coins"], + broadcast_split=action["broadcast_split"], + expires_at=expires_at_unix, ) runtime_payload = runtime_req.to_payload() assert int(runtime_payload["offer_amount"]) == int(fixture_req["offer_amount"]) @@ -160,7 +179,7 @@ def test_signer_golden_offer_validates(fixture_path: Path) -> None: kernel = _require_signer_kernel() validate = getattr(kernel, "validate_offer_structure", None) if not callable(validate): - pytest.skip("greenfloor_signer.validate_offer_structure not available") + pytest.skip("greenfloor_kernel.validate_offer_structure not available") payload = json.loads(fixture_path.read_text(encoding="utf-8")) offer = str(payload.get("offer", "")).strip() diff --git a/tests/test_signing.py b/tests/test_signing.py index 2635c91e..a938ef95 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -158,7 +158,7 @@ def test_build_signed_spend_bundle_invalid_plan(monkeypatch) -> None: def test_build_signed_spend_bundle_signer_import_error(monkeypatch) -> None: def _fail_import(): - raise ImportError("no greenfloor_signer") + raise ImportError("no greenfloor_kernel") monkeypatch.setattr( signing_mod, @@ -177,7 +177,7 @@ def _fail_import(): } ) assert result["status"] == "skipped" - assert result["reason"] == "signing_failed:greenfloor_signer_import_error:no greenfloor_signer" + assert result["reason"] == "signing_failed:greenfloor_kernel_import_error:no greenfloor_kernel" def test_build_signed_spend_bundle_no_coins(monkeypatch) -> None: diff --git a/tests/test_wallet_adapter.py b/tests/test_wallet_adapter.py index 49f4b846..9afc2cf7 100644 --- a/tests/test_wallet_adapter.py +++ b/tests/test_wallet_adapter.py @@ -99,7 +99,7 @@ def test_wallet_adapter_non_dry_run_requires_signer_selection(tmp_path: Path) -> def test_wallet_adapter_non_dry_run_direct_signing(tmp_path: Path, monkeypatch) -> None: - """Uses signing.sign_and_broadcast directly for non-dry-run execution.""" + """Uses bls_signing.sign_and_broadcast directly for non-dry-run execution.""" from greenfloor.core.coin_ops import CoinOpPlan from greenfloor.keys.onboarding import KeyOnboardingSelection, save_key_onboarding_selection @@ -115,7 +115,7 @@ def test_wallet_adapter_non_dry_run_direct_signing(tmp_path: Path, monkeypatch) ), ) - import greenfloor.signing as signing_mod + import greenfloor.adapters.bls_signing as signing_mod captured: dict = {}