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
1 change: 1 addition & 0 deletions config/program.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ coin_ops:
minimum_fee_mojos: 10000000
split_fee_mojos: 0
combine_fee_mojos: 0
combine_input_coin_cap: 5

venues:
dexie:
Expand Down
4 changes: 2 additions & 2 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,8 @@ Monitor `audit_event` records in `~/.greenfloor/db/greenfloor.sqlite`:
- `GREENFLOOR_COINSET_BASE_URL`
- Default behavior: mainnet endpoint when unset; testnet11 endpoint when market/network is `testnet11`.
- For `testnet11`, do not route to mainnet Coinset endpoint unless you explicitly set `GREENFLOOR_ALLOW_MAINNET_COINSET_FOR_TESTNET11=1` for temporary debugging.
- Coin combine input cap (manager/daemon coin-op execution):
- `GREENFLOOR_COIN_OPS_COMBINE_INPUT_COIN_CAP` (default: `5`, min `2`)
- Coin combine input cap (manager/daemon coin-op execution; `~/.greenfloor/config/program.yaml` -> `coin_ops`):
- `combine_input_coin_cap` (default: `5`, min `2`)
- Daemon tx-signal ingestion controls (`~/.greenfloor/config/program.yaml` -> `chain_signals.tx_block_trigger`):
- `mode`: must be `websocket`
- `websocket_url`: Coinset websocket endpoint (defaults by network when blank)
Expand Down
45 changes: 31 additions & 14 deletions greenfloor-engine/src/adapters/dexie/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ pub struct DexieClient {

impl DexieClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_http(base_url, crate::adapters::shared_http_client())
}

#[must_use]
pub fn with_http(base_url: impl Into<String>, http: reqwest::Client) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
http: reqwest::Client::new(),
http,
}
}

Expand Down Expand Up @@ -133,7 +138,7 @@ impl DexieClient {
&self,
path: &str,
timeout_secs: u64,
network_err_tag: &str,
network_err_tag: &'static str,
) -> SignerResult<Value> {
http_json::get_json(
&self.http,
Expand All @@ -150,7 +155,7 @@ impl DexieClient {
path: &str,
body: Value,
timeout_secs: u64,
network_err_tag: &str,
network_err_tag: &'static str,
) -> SignerResult<Value> {
http_json::post_json(
&self.http,
Expand Down Expand Up @@ -196,22 +201,34 @@ mod tests {
}

#[test]
fn parse_response_body_http_error_returns_success_false() {
let payload =
DexieClient::parse_response_body(StatusCode::NOT_FOUND, "missing").expect("parse");
assert_eq!(
payload.get("success").and_then(serde_json::Value::as_bool),
Some(false)
);
assert_eq!(
payload.get("error").and_then(|v| v.as_str()),
Some("dexie_http_error:404:missing")
);
fn parse_response_body_http_error_is_typed_status() {
use crate::error::{SignerError, TransportError};

let err =
DexieClient::parse_response_body(StatusCode::NOT_FOUND, "missing").expect_err("404");
assert!(matches!(
err,
SignerError::Transport(TransportError::HttpStatus {
layer: "dexie_http_error",
status: 404,
..
})
));
assert!(err.is_http_not_found());
}

#[test]
fn parse_response_body_invalid_json_is_err() {
use crate::error::{SignerError, TransportError};

let err = DexieClient::parse_response_body(StatusCode::OK, "not-json").unwrap_err();
assert!(matches!(
err,
SignerError::Transport(TransportError::Http {
layer: "dexie_json_error",
..
})
));
assert!(err.to_string().contains("dexie_json_error"));
}
}
13 changes: 13 additions & 0 deletions greenfloor-engine/src/adapters/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Shared `reqwest::Client` for Dexie/Splash so daemon cycles reuse connections.

use std::sync::LazyLock;

use reqwest::Client;

static SHARED_HTTP: LazyLock<Client> = LazyLock::new(Client::new);

/// Process-wide HTTP client for venue adapters.
#[must_use]
pub fn shared_http_client() -> Client {
SHARED_HTTP.clone()
}
43 changes: 24 additions & 19 deletions greenfloor-engine/src/adapters/http_json.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::time::Duration;

use reqwest::{Client, StatusCode};
use serde_json::{json, Value};
use serde_json::Value;

use crate::error::{SignerError, SignerResult};

Expand All @@ -17,15 +17,15 @@ pub(crate) async fn get_json(
http: &Client,
url: &str,
timeout_secs: u64,
network_err_tag: &str,
network_err_tag: &'static str,
tags: AdapterResponseTags,
) -> SignerResult<Value> {
let response = http
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.map_err(|err| SignerError::Other(format!("{network_err_tag}:{err}")))?;
.map_err(|err| SignerError::from_reqwest(network_err_tag, &err))?;
parse_response(response, tags).await
}

Expand All @@ -34,7 +34,7 @@ pub(crate) async fn post_json(
url: &str,
body: Value,
timeout_secs: u64,
network_err_tag: &str,
network_err_tag: &'static str,
tags: AdapterResponseTags,
) -> SignerResult<Value> {
let response = http
Expand All @@ -43,7 +43,7 @@ pub(crate) async fn post_json(
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.map_err(|err| SignerError::Other(format!("{network_err_tag}:{err}")))?;
.map_err(|err| SignerError::from_reqwest(network_err_tag, &err))?;
parse_response(response, tags).await
}

Expand All @@ -55,7 +55,7 @@ async fn parse_response(
let body = response
.text()
.await
.map_err(|err| SignerError::Other(format!("{}:{err}", tags.read_error_prefix)))?;
.map_err(|err| SignerError::from_reqwest(tags.read_error_prefix, &err))?;
parse_response_body(status, &body, tags)
}

Expand All @@ -66,20 +66,20 @@ pub(crate) fn parse_response_body(
) -> SignerResult<Value> {
if !status.is_success() {
let snippet: String = body.chars().take(500).collect();
let error = if snippet.is_empty() {
format!("{}:{}", tags.http_error_prefix, status.as_u16())
} else {
format!("{}:{}:{snippet}", tags.http_error_prefix, status.as_u16())
};
return Ok(json!({"success": false, "error": error}));
return Err(SignerError::http_status(
tags.http_error_prefix,
status.as_u16(),
snippet,
));
}
serde_json::from_str(body)
.map_err(|err| SignerError::Other(format!("{}:{err}", tags.json_error_prefix)))
.map_err(|err| SignerError::http(tags.json_error_prefix, err.to_string()))
}

#[cfg(test)]
mod tests {
use super::{parse_response_body, AdapterResponseTags};
use crate::error::{SignerError, TransportError};
use reqwest::StatusCode;

const TAGS: AdapterResponseTags = AdapterResponseTags {
Expand All @@ -100,11 +100,16 @@ mod tests {
}

#[test]
fn parse_response_body_http_error_returns_success_false() {
let payload = parse_response_body(StatusCode::NOT_FOUND, "missing", TAGS).expect("parse");
assert_eq!(
payload.get("error").and_then(|v| v.as_str()),
Some("test_http_error:404:missing")
);
fn parse_response_body_http_error_is_typed_status() {
let err = parse_response_body(StatusCode::NOT_FOUND, "missing", TAGS).expect_err("404");
assert!(matches!(
err,
SignerError::Transport(TransportError::HttpStatus {
layer: "test_http_error",
status: 404,
..
})
));
assert!(err.is_http_not_found());
}
}
2 changes: 2 additions & 0 deletions greenfloor-engine/src/adapters/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
mod dexie;
mod http_client;
mod http_json;
mod splash;

pub use dexie::{dexie_offer_view_url, DexieClient, DexieResponse};
pub use http_client::shared_http_client;
pub use splash::{SplashClient, SplashResponse};
7 changes: 6 additions & 1 deletion greenfloor-engine/src/adapters/splash/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ pub struct SplashClient {

impl SplashClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_http(base_url, crate::adapters::shared_http_client())
}

#[must_use]
pub fn with_http(base_url: impl Into<String>, http: reqwest::Client) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
http: reqwest::Client::new(),
http,
}
}

Expand Down
67 changes: 2 additions & 65 deletions greenfloor-engine/src/cli_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,12 @@ use serde_json::{json, Value};

use crate::error::{SignerError, SignerResult};

const RETRYABLE_COINSET_TRANSPORT_MARKERS: &[&str] = &[
"operation timed out",
"connection refused",
"connection reset",
"remote end closed connection",
"error sending request",
"temporary failure",
"temporarily unavailable",
"broken pipe",
"http status server error (502",
"http status server error (503",
"http status server error (504",
"http status client error (429",
"too many requests",
"bad gateway",
"service unavailable",
"error decoding response body",
"ssl",
"handshake",
"cloudflare",
];

#[must_use]
pub fn script_coinset_transport_retryable(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
RETRYABLE_COINSET_TRANSPORT_MARKERS
.iter()
.any(|marker| lower.contains(marker))
}

#[must_use]
pub fn script_engine_error_retryable(err: &SignerError) -> bool {
match err {
SignerError::Coinset(message) => script_coinset_transport_retryable(message),
_ => false,
}
}

pub fn emit_engine_cli_error(err: &SignerError, json_mode: bool) {
if json_mode {
let payload = json!({
"success": false,
"error": err.to_string(),
"retryable": script_engine_error_retryable(err),
"retryable": err.is_retryable_upstream(),
});
eprintln!(
"{}",
Expand Down Expand Up @@ -144,34 +106,9 @@ pub fn print_json_pretty(value: &impl Serialize) -> SignerResult<()> {

#[cfg(test)]
mod tests {
use super::{
format_json, format_json_value, optional_str, optional_trimmed,
script_coinset_transport_retryable, script_engine_error_retryable,
};
use crate::error::SignerError;
use super::{format_json, format_json_value, optional_str, optional_trimmed};
use serde_json::json;

#[test]
fn script_coinset_transport_retryable_matches_decode_and_refused() {
assert!(script_coinset_transport_retryable(
"error decoding response body"
));
assert!(script_coinset_transport_retryable(
"error sending request for url (http://127.0.0.1:1/): connection refused"
));
assert!(!script_coinset_transport_retryable("invalid puzzle hash"));
}

#[test]
fn script_engine_error_retryable_classifies_coinset_and_parse_errors() {
assert!(script_engine_error_retryable(&SignerError::Coinset(
"error decoding response body".to_string()
)));
assert!(!script_engine_error_retryable(&SignerError::Other(
"parse body json: expected value at line 1 column 1".to_string()
)));
}

#[test]
fn optional_str_trims_and_rejects_blank() {
assert_eq!(optional_str(" value "), Some("value"));
Expand Down
12 changes: 12 additions & 0 deletions greenfloor-engine/src/coin_ops/amounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ pub fn total_for_coin_ids(spendable: &[SpendableCoin], coin_ids: &[String]) -> i
.sum()
}

/// Receive inventory plus known unreturned maker amount (saturating).
#[must_use]
pub fn vault_controlled_total(receive_amount: u64, unreturned_amount: u64) -> u64 {
receive_amount.saturating_add(unreturned_amount)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -82,4 +88,10 @@ mod tests {
12
);
}

#[test]
fn vault_controlled_sums_receive_and_unreturned() {
assert_eq!(vault_controlled_total(1_000, 2_000), 3_000);
assert_eq!(vault_controlled_total(u64::MAX, 1), u64::MAX);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
//! Group enabled markets into CAT dust-combine jobs (shared by manager CLI).

use std::collections::{BTreeMap, HashSet};

use serde_json::Value;

use crate::config::MarketsConfig;
use crate::config::{load_cats_catalog, resolve_asset_id_from_catalog, MarketsConfig};
use crate::error::{SignerError, SignerResult};
use crate::hex::{is_hex_id, normalize_hex_id};
use crate::manager_cli::cats_catalog::{load_cats_catalog, resolve_asset_id_from_catalog};

/// One CAT dust-combine job: one signer + asset, possibly spanning several markets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatDustJob {
pub cat_asset_id: String,
Expand All @@ -15,6 +17,8 @@ pub struct CatDustJob {
pub market_ids: Vec<String>,
}

/// Resolve a market base label to a CAT asset id (hex id, or catalog ticker/symbol).
#[must_use]
pub fn resolve_market_base_cat_asset_id(
base_asset: &str,
base_symbol: &str,
Expand All @@ -28,6 +32,12 @@ pub fn resolve_market_base_cat_asset_id(
.or_else(|| resolve_asset_id_from_catalog(catalog, base_symbol))
}

/// Group enabled markets into CAT dust-combine jobs (same signer + asset share a job).
///
/// # Errors
///
/// Returns an error if the cats catalog cannot be loaded, or if markets that share a
/// signer and CAT disagree on `receive_address`.
pub fn build_enabled_cat_jobs(
markets: &MarketsConfig,
cats_config_path: &std::path::Path,
Expand Down
Loading
Loading