Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
# GreenFloor

GreenFloor is a long-running Python application for Chia CAT market making.
GreenFloor is a Chia CAT market-making system with a Python operator surface backed by
the canonical `greenfloor-engine` Rust engine (`greenfloor_engine` PyO3 module).
Python owns CLI/daemon orchestration, config parsing, storage, and network adapters;
Rust owns vault signing, offer construction/validation, coin-op policy, and deterministic
market-cycle policy.

## Components

- `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.
- `greenfloor-engine/`: Rust crate for canonical signing, offer, coin-op, and cycle policy.
- `greenfloor-engine-pyo3/`: PyO3 extension exported as `greenfloor_engine` for in-process Python calls.

## V1 Plan

- The current implementation plan is tracked in `docs/plan.md`.
- Operator deployment/recovery runbook is in `docs/runbook.md`.
- Syncing, signing, and offer-generation baseline: GreenFloor uses `chia-wallet-sdk` (included as a repo submodule) for default blockchain sync/sign and offer-file execution paths.
- Syncing, signing, and offer-generation baseline: GreenFloor uses `chia-wallet-sdk` (included as a repo submodule) through the Rust engine for signing and offer-file execution paths.

## Offer Files

Expand Down Expand Up @@ -111,6 +117,13 @@ python -m pip install -e ".[dev]"
pre-commit run --all-files
```

Rust engine checks:

```bash
cargo test --manifest-path greenfloor-engine/Cargo.toml
cargo test --manifest-path greenfloor-engine-pyo3/Cargo.toml --no-run
```

## Environment Variables

Operator overrides (all optional):
Expand Down
7 changes: 7 additions & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Progress Log

## 2026-05-29 (Offer asset resolution — Rust action boundary)

- **`greenfloor-engine/src/offer/action.rs`:** `try_normalize_resolved_assets` and `resolve_offer_assets_for_action` compose normalize + Coinset fallback; `resolve_offer_assets_via_coinset` is Coinset-only. Signer and BLS action builds both use the composed path when signer config is available.
- **PyO3:** `try_normalize_offer_asset_ids` exposes Rust normalization; `resolve_offer_assets_via_coinset` is the canonical Coinset-only binding (`resolve_offer_asset_ids` remains a deprecated alias).
- **Python bridge:** `offer_assets_bridge.resolve_offer_assets` composes normalize then Coinset once; runtime callers delegate through it.
- **Tests:** Real-engine bridge tests cover normalize, collision, composition, and Coinset fallback; successful MSP lookup is covered by Rust mockito tests.

## 2026-05-29 (ADR 0010 Rust engine rename)

- **Source layout:** Rust crates now live under `greenfloor-engine/` and `greenfloor-engine-pyo3/`.
Expand Down
2 changes: 1 addition & 1 deletion greenfloor-engine-pyo3/src/coin_ops_py.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ fn wallet_coins_from_py_list(coins: &Bound<'_, PyList>) -> PyResult<Vec<serde_js
coins
.iter()
.map(|item| {
let dict = item.downcast::<PyDict>()?;
let dict = item.cast::<PyDict>()?;
request_dict_to_json(dict)
})
.collect()
Expand Down
4 changes: 2 additions & 2 deletions greenfloor-engine-pyo3/src/cycle/cancel_py.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use pyo3::prelude::*;
use pyo3::types::PyList;
use engine_core::{
abs_move_bps, cancel_move_threshold_bps, collect_open_offer_ids_for_cancel,
evaluate_cancel_policy_decision,
};
use pyo3::prelude::*;
use pyo3::types::PyList;

use crate::py_utils::{
cancel_policy_decision_to_py, open_offer_rows_from_py_list, string_list_to_py_list,
Expand Down
4 changes: 2 additions & 2 deletions greenfloor-engine-pyo3/src/cycle/orchestration_py.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ pub fn stale_sweep_progress_from_py(obj: &Bound<'_, PyAny>) -> PyResult<StaleSwe
let checked_offer_count = obj.getattr("checked_offer_count")?.extract::<usize>()?;
let truncated = obj.getattr("truncated")?.extract::<bool>()?;
let requeue_attr = obj.getattr("requeue_market_ids")?;
let requeue_list = requeue_attr.downcast::<PyList>()?;
let requeue_list = requeue_attr.cast::<PyList>()?;
let mut requeue_market_ids = Vec::with_capacity(requeue_list.len());
for item in requeue_list.iter() {
requeue_market_ids.push(item.extract::<String>()?);
}
let hits_attr = obj.getattr("hits")?;
let hits_list = hits_attr.downcast::<PyList>()?;
let hits_list = hits_attr.cast::<PyList>()?;
let mut hits = Vec::with_capacity(hits_list.len());
for item in hits_list.iter() {
hits.push(stale_sweep_hit_from_py(&item)?);
Expand Down
4 changes: 2 additions & 2 deletions greenfloor-engine-pyo3/src/execution_py.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ fn sequential_action_route_py(
#[pyfunction]
#[pyo3(name = "expand_planned_actions")]
fn expand_planned_actions_py(actions: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
let list = actions.downcast::<PyList>()?;
let list = actions.cast::<PyList>()?;
let mut rust_actions = Vec::with_capacity(list.len());
for item in list.iter() {
rust_actions.push(planned_action_from_py(&item)?);
Expand All @@ -97,7 +97,7 @@ fn expand_planned_actions_py(actions: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>>
fn filter_planned_actions_with_positive_repeat_py(
actions: &Bound<'_, PyAny>,
) -> PyResult<Py<PyAny>> {
let list = actions.downcast::<PyList>()?;
let list = actions.cast::<PyList>()?;
let mut rust_actions = Vec::with_capacity(list.len());
for item in list.iter() {
rust_actions.push(planned_action_from_py(&item)?);
Expand Down
2 changes: 1 addition & 1 deletion greenfloor-engine-pyo3/src/hex_py.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use pyo3::prelude::*;
use engine_core::{
default_mojo_multiplier_for_asset, is_canonical_xch_asset, is_hex_id, normalize_hex_id,
};
use pyo3::prelude::*;

#[pyfunction]
#[pyo3(name = "is_hex_id")]
Expand Down
48 changes: 33 additions & 15 deletions greenfloor-engine-pyo3/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
mod coin_ops_py;
mod cycle;
mod execution_py;
mod offer_action_py;
mod hex_py;
mod notifications_py;
mod offer_action_py;
mod offer_bootstrap_py;
mod offer_build_py;
mod offer_request_py;
Expand All @@ -21,8 +21,6 @@ use std::path::Path;
use std::sync::OnceLock;

use chia_bls::SecretKey;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use engine_core::error::{bls_reason, broadcast_reason, BlsOp};
use engine_core::{
broadcast_bls_spend_bundle, build_and_optionally_broadcast_vault_cat_mixed_split,
Expand All @@ -31,13 +29,15 @@ use engine_core::{
encode_offer_from_spend_bundle_bytes, from_input_spend_bundle_bytes,
from_input_spend_bundle_xch_bytes, get_conservative_fee_estimate, get_fee_estimate,
list_cat_coin_summaries, list_cat_coin_summaries_by_ids, load_bls_master_secret_key,
load_signer_config, push_tx_hex, resolve_offer_asset_ids, resolve_vault_context,
load_signer_config, push_tx_hex, resolve_offer_assets_via_coinset, resolve_vault_context,
validate_offer_structure, validate_offer_text, verify_offer_for_dexie, BlsMixedSplitRequest,
BlsOfferRequest, BlsXchCoinOpRequest, CreateOfferRequest, MixedSplitRequest,
};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;

use py_utils::{dict_from_json_value, request_dict_to_json, to_py_err};
use pyo3::types::{PyDict, PyList, PyModule};
use pyo3::types::{PyDict, PyList, PyModule, PyTuple};

fn runtime() -> &'static tokio::runtime::Runtime {
static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
Expand Down Expand Up @@ -266,23 +266,40 @@ fn broadcast_bls_spend_bundle_py(network: &str, spend_bundle_hex: &str) -> PyRes
})
}

#[pyfunction]
#[pyo3(name = "resolve_offer_asset_ids")]
fn resolve_offer_asset_ids_py(
fn resolve_offer_assets_via_coinset_py_impl(
config_path: &str,
base_asset: &str,
quote_asset: &str,
) -> PyResult<Py<PyAny>> {
let config = load_signer_config(Path::new(config_path)).map_err(to_py_err)?;
let (base, quote) = runtime()
.block_on(resolve_offer_asset_ids(config, base_asset, quote_asset))
.block_on(resolve_offer_assets_via_coinset(
config,
base_asset,
quote_asset,
))
.map_err(to_py_err)?;
Python::attach(|py| {
let dict = PyDict::new(py);
dict.set_item("base_asset_id", base)?;
dict.set_item("quote_asset_id", quote)?;
Ok(dict.into())
})
Python::attach(|py| Ok(PyTuple::new(py, [base, quote])?.into()))
}

#[pyfunction]
#[pyo3(name = "resolve_offer_assets_via_coinset")]
fn resolve_offer_assets_via_coinset_py(
config_path: &str,
base_asset: &str,
quote_asset: &str,
) -> PyResult<Py<PyAny>> {
resolve_offer_assets_via_coinset_py_impl(config_path, base_asset, quote_asset)
}

#[pyfunction]
#[pyo3(name = "resolve_offer_asset_ids")]
fn resolve_offer_asset_ids_py(
config_path: &str,
base_asset: &str,
quote_asset: &str,
) -> PyResult<Py<PyAny>> {
resolve_offer_assets_via_coinset_py_impl(config_path, base_asset, quote_asset)
}

/// Full Dexie pre-post validation (structure, expiry, duplicate spends).
Expand Down Expand Up @@ -421,6 +438,7 @@ fn greenfloor_engine(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(list_bls_cat_coins_py, m)?)?;
m.add_function(wrap_pyfunction!(list_bls_cat_coins_by_ids_py, m)?)?;
m.add_function(wrap_pyfunction!(broadcast_bls_spend_bundle_py, m)?)?;
m.add_function(wrap_pyfunction!(resolve_offer_assets_via_coinset_py, m)?)?;
m.add_function(wrap_pyfunction!(resolve_offer_asset_ids_py, m)?)?;
m.add_function(wrap_pyfunction!(validate_offer_py, m)?)?;
m.add_function(wrap_pyfunction!(validate_offer_structure_py, m)?)?;
Expand Down
2 changes: 1 addition & 1 deletion greenfloor-engine-pyo3/src/notifications_py.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use pyo3::prelude::*;
use engine_core::evaluate_low_inventory_alert;
use pyo3::prelude::*;

use crate::py_utils::{low_inventory_evaluation_to_py, low_inventory_input_from_py};

Expand Down
46 changes: 42 additions & 4 deletions greenfloor-engine-pyo3/src/offer_action_py.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyModule};
use std::path::Path;

use engine_core::config::SignerConfig;
use engine_core::offer::action::{
build_bls_offer_for_action, build_signer_offer_for_action, BuildOfferForActionRequest,
build_bls_offer_for_action, build_signer_offer_for_action, try_normalize_resolved_assets,
BuildOfferForActionRequest,
};
use engine_core::{load_bls_master_secret_key, load_signer_config};
use engine_core::{load_bls_master_secret_key, load_signer_config, Error};
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyModule, PyTuple};

use crate::py_utils::{dict_from_json_value, request_dict_to_json, to_py_err};
use crate::{block_on_engine, parse_master_sk_bytes, runtime};

fn asset_pair_to_py_tuple(py: Python<'_>, base: String, quote: String) -> PyResult<Py<PyAny>> {
Ok(PyTuple::new(py, [base, quote])?.into())
}

fn optional_signer_config(config_path: Option<&str>) -> PyResult<Option<SignerConfig>> {
let Some(path) = config_path.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
load_signer_config(Path::new(path))
.map(Some)
.map_err(to_py_err)
}

#[pyfunction]
#[pyo3(name = "try_normalize_offer_asset_ids")]
fn try_normalize_offer_asset_ids_py(base_asset: &str, quote_asset: &str) -> PyResult<Py<PyAny>> {
match try_normalize_resolved_assets(base_asset, quote_asset) {
Ok((base, quote)) => Python::attach(|py| asset_pair_to_py_tuple(py, base, quote)),
Err(Error::ResolvedAssetsCollideForNonXchPair) => {
Err(to_py_err(Error::ResolvedAssetsCollideForNonXchPair))
}
Err(_) => Python::attach(|py| Ok(py.None())),
}
}

#[pyfunction]
#[pyo3(name = "build_signer_offer_for_action")]
fn build_signer_offer_for_action_py(
Expand All @@ -25,19 +54,23 @@ fn build_signer_offer_for_action_py(
}

#[pyfunction]
#[pyo3(signature = (network, key_id, request, *, config_path=None))]
#[pyo3(name = "build_bls_offer_for_action_key")]
fn build_bls_offer_for_action_key_py(
network: &str,
key_id: &str,
request: &Bound<'_, PyDict>,
config_path: Option<&str>,
) -> PyResult<Py<PyAny>> {
let master_sk = load_bls_master_secret_key(key_id.trim()).map_err(to_py_err)?;
let config = optional_signer_config(config_path)?;
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_engine(build_bls_offer_for_action(
network,
&master_sk,
config.as_ref(),
offer_request,
))
.map_err(to_py_err)?;
Expand All @@ -46,26 +79,31 @@ fn build_bls_offer_for_action_key_py(

/// Internal/test entry: build a BLS action offer from raw master secret key bytes.
#[pyfunction]
#[pyo3(signature = (network, master_sk_bytes, request, *, config_path=None))]
#[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>,
config_path: Option<&str>,
) -> PyResult<Py<PyAny>> {
let master_sk = parse_master_sk_bytes(master_sk_bytes)?;
let config = optional_signer_config(config_path)?;
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_engine(build_bls_offer_for_action(
network,
&master_sk,
config.as_ref(),
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!(try_normalize_offer_asset_ids_py, m)?)?;
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)?)?;
Expand Down
32 changes: 16 additions & 16 deletions greenfloor-engine-pyo3/src/py_utils/bootstrap_marshal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use pyo3::types::{PyDict, PyList};
use super::common::cached_class;
use engine_core::{
bootstrap_early_phase, bootstrap_executed_phase, plan_bootstrap_mixed_outputs, BootstrapCoin,
BootstrapPlan, BootstrapPlanOutcome, BootstrapPhaseSnapshot, LadderDeficit, PlannerLadderRow,
BootstrapPhaseSnapshot, BootstrapPlan, BootstrapPlanOutcome, LadderDeficit, PlannerLadderRow,
};

const BOOTSTRAP_MODULE: &str = "greenfloor.offer_bootstrap";
Expand Down Expand Up @@ -91,7 +91,7 @@ fn extract_i64_list(obj: &Bound<'_, PyAny>, name: &str, label: &str) -> PyResult
.getattr(name)
.map_err(|_| PyTypeError::new_err(format!("{label} missing attribute: {name}")))?;
let py_list = list
.downcast::<PyList>()
.cast::<PyList>()
.map_err(|_| PyTypeError::new_err(format!("{label}.{name} must be a list")))?;
let mut values = Vec::with_capacity(py_list.len());
for (index, item) in py_list.iter().enumerate() {
Expand Down Expand Up @@ -142,7 +142,10 @@ fn bootstrap_coins_from_py_list(
Ok(coins)
}

fn ladder_deficit_to_py<'py>(py: Python<'py>, deficit: &LadderDeficit) -> PyResult<Bound<'py, PyAny>> {
fn ladder_deficit_to_py<'py>(
py: Python<'py>,
deficit: &LadderDeficit,
) -> PyResult<Bound<'py, PyAny>> {
let cls = ladder_deficit_class(py)?;
let kwargs = PyDict::new(py);
kwargs.set_item("size_base_units", deficit.size_base_units)?;
Expand All @@ -152,10 +155,7 @@ fn ladder_deficit_to_py<'py>(py: Python<'py>, deficit: &LadderDeficit) -> PyResu
cls.call((), Some(&kwargs))
}

fn bootstrap_plan_to_py<'py>(
py: Python<'py>,
plan: &BootstrapPlan,
) -> PyResult<Bound<'py, PyAny>> {
fn bootstrap_plan_to_py<'py>(py: Python<'py>, plan: &BootstrapPlan) -> PyResult<Bound<'py, PyAny>> {
let cls = bootstrap_plan_class(py)?;
let deficits = PyList::empty(py);
for deficit in &plan.deficits {
Expand Down Expand Up @@ -197,12 +197,12 @@ fn bootstrap_plan_from_py<'py>(
) -> PyResult<BootstrapPlan> {
let cls = bootstrap_plan_class(py)?;
let item = require_instance(plan, &cls, label, "BootstrapPlan")?;
let deficits_attr = item.getattr("deficits").map_err(|_| {
PyTypeError::new_err(format!("{label} missing attribute: deficits"))
})?;
let deficits_list = deficits_attr.downcast::<PyList>().map_err(|_| {
PyTypeError::new_err(format!("{label}.deficits must be a list"))
})?;
let deficits_attr = item
.getattr("deficits")
.map_err(|_| PyTypeError::new_err(format!("{label} missing attribute: deficits")))?;
let deficits_list = deficits_attr
.cast::<PyList>()
.map_err(|_| PyTypeError::new_err(format!("{label}.deficits must be a list")))?;
let mut deficits = Vec::with_capacity(deficits_list.len());
for (index, deficit) in deficits_list.iter().enumerate() {
deficits.push(ladder_deficit_from_py(
Expand Down Expand Up @@ -232,9 +232,9 @@ fn bootstrap_plan_outcome_from_py<'py>(
match kind.trim() {
"ready" => Ok(BootstrapPlanOutcome::Ready),
"needs_split" => {
let plan_attr = item.getattr("plan").map_err(|_| {
PyTypeError::new_err(format!("{label} missing attribute: plan"))
})?;
let plan_attr = item
.getattr("plan")
.map_err(|_| PyTypeError::new_err(format!("{label} missing attribute: plan")))?;
if plan_attr.is_none() {
return Err(PyTypeError::new_err(format!(
"{label}.plan is required for needs_split"
Expand Down
Loading
Loading