diff --git a/.github/workflows/testnet11-asset-bootstrap-helper.yml b/.github/workflows/testnet11-asset-bootstrap-helper.yml index 6dcd25b..166745a 100644 --- a/.github/workflows/testnet11-asset-bootstrap-helper.yml +++ b/.github/workflows/testnet11-asset-bootstrap-helper.yml @@ -277,8 +277,6 @@ jobs: snippet_lines.append(f" signer_key_id: {yaml_quote(signer_key_id)}") snippet_lines.append(f" receive_address: {yaml_quote(receive_address)}") snippet_lines.append(" pricing:") - snippet_lines.append(' reference_source: "coingecko"') - snippet_lines.append(' reference_pair: "txch_usd"') snippet_lines.append(' side: "sell"') snippet_lines.append(f" fixed_quote_per_base: {fixed_quote_per_base}") snippet_lines.append(" quote_unit_mojo_multiplier: 1000000000000") @@ -286,6 +284,7 @@ jobs: snippet_lines.append(" strategy_target_spread_bps: 140") snippet_lines.append(" strategy_min_xch_price_usd: 20.0") snippet_lines.append(" strategy_max_xch_price_usd: 60.0") + snippet_lines.append(" strategy_offer_expiry_minutes: 10") snippet_lines.append(" cancel_policy_stable_vs_unstable: true") snippet_lines.append(" inventory:") snippet_lines.append(" low_watermark_base_units: 0") diff --git a/.tmp-fix-xch-pricing.bundle b/.tmp-fix-xch-pricing.bundle new file mode 100644 index 0000000..2d7919f Binary files /dev/null and b/.tmp-fix-xch-pricing.bundle differ diff --git a/config/markets.yaml b/config/markets.yaml index 63aaf3d..3841e83 100644 --- a/config/markets.yaml +++ b/config/markets.yaml @@ -14,8 +14,6 @@ markets: cloud_wallet_base_global_id: "Asset_ymgm3ygl5om7ia4u9llk3iu7" cloud_wallet_quote_global_id: "Asset_huun64oh7dbt9f1f9ie8khuw" pricing: - reference_source: "coingecko" - reference_pair: "xch_usd" side: "sell" min_price_quote_per_base: 0.0031 max_price_quote_per_base: 0.0038 @@ -23,6 +21,7 @@ markets: strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 + strategy_offer_expiry_minutes: 10 cancel_policy_stable_vs_unstable: true inventory: low_watermark_base_units: 500 @@ -62,8 +61,7 @@ markets: side: "sell" fixed_quote_per_base: 7.75 slippage_bps: 25 - strategy_offer_expiry_unit: "hours" - strategy_offer_expiry_value: 2 + strategy_offer_expiry_minutes: 120 inventory: low_watermark_base_units: 500 low_inventory_alert_threshold_base_units: null @@ -99,8 +97,6 @@ markets: cloud_wallet_base_global_id: "Asset_wjfkwih7s6c1y6rlswnznr6p" cloud_wallet_quote_global_id: "Asset_huun64oh7dbt9f1f9ie8khuw" pricing: - reference_source: "coingecko" - reference_pair: "xch_usd" side: "sell" min_price_quote_per_base: 0.0027 max_price_quote_per_base: 0.0033 @@ -108,6 +104,7 @@ markets: strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 + strategy_offer_expiry_minutes: 10 cancel_policy_stable_vs_unstable: true inventory: low_watermark_base_units: 500 @@ -147,8 +144,7 @@ markets: side: "sell" fixed_quote_per_base: 6.75 slippage_bps: 25 - strategy_offer_expiry_unit: "hours" - strategy_offer_expiry_value: 2 + strategy_offer_expiry_minutes: 120 inventory: low_watermark_base_units: 500 low_inventory_alert_threshold_base_units: null @@ -173,7 +169,7 @@ markets: combine_when_excess_factor: 2.0 - id: eco1812020_sell_xch - enabled: false + enabled: true mode: sell_only base_asset: "e257aca547a83020e537e87f8c83e9332d2c3adb729c052e6f04971317084327" base_symbol: "ECO.181.2020" @@ -184,8 +180,6 @@ markets: cloud_wallet_base_global_id: "Asset_s1ifxnpgnf8n3hsd6wlb04q5" cloud_wallet_quote_global_id: "Asset_huun64oh7dbt9f1f9ie8khuw" pricing: - reference_source: "coingecko" - reference_pair: "xch_usd" side: "sell" min_price_quote_per_base: 0.0030 max_price_quote_per_base: 0.0037 @@ -193,6 +187,8 @@ markets: strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 + strategy_offer_expiry_minutes: 10 + cancel_move_threshold_bps: 300 cancel_policy_stable_vs_unstable: true inventory: low_watermark_base_units: 500 @@ -232,8 +228,7 @@ markets: side: "sell" fixed_quote_per_base: 7.50 slippage_bps: 25 - strategy_offer_expiry_unit: "hours" - strategy_offer_expiry_value: 2 + strategy_offer_expiry_minutes: 120 inventory: low_watermark_base_units: 500 low_inventory_alert_threshold_base_units: null @@ -269,8 +264,6 @@ markets: cloud_wallet_base_global_id: "Asset_e3jexei5nswyy916lg77vabz" cloud_wallet_quote_global_id: "Asset_huun64oh7dbt9f1f9ie8khuw" pricing: - reference_source: "coingecko" - reference_pair: "xch_usd" side: "sell" min_price_quote_per_base: 0.0027 max_price_quote_per_base: 0.0033 @@ -278,6 +271,7 @@ markets: strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 + strategy_offer_expiry_minutes: 10 cancel_policy_stable_vs_unstable: true inventory: low_watermark_base_units: 500 @@ -317,8 +311,46 @@ markets: side: "sell" fixed_quote_per_base: 6.75 slippage_bps: 25 - strategy_offer_expiry_unit: "hours" - strategy_offer_expiry_value: 2 + strategy_offer_expiry_minutes: 120 + inventory: + low_watermark_base_units: 500 + low_inventory_alert_threshold_base_units: null + current_available_base_units: 500 + bucket_counts: + 1: 0 + 10: 0 + 100: 0 + ladders: + sell: + - size_base_units: 1 + target_count: 5 + split_buffer_count: 1 + combine_when_excess_factor: 2.0 + - size_base_units: 10 + target_count: 2 + split_buffer_count: 1 + combine_when_excess_factor: 2.0 + - size_base_units: 100 + target_count: 1 + split_buffer_count: 0 + combine_when_excess_factor: 2.0 + + - id: eco292021_sell_wusdbc + enabled: true + mode: sell_only + base_asset: "5af8db0b15e0de99ad1eff02486bb1998602053c56dfb22dc04e0f5e17ccec8d" + base_symbol: "ECO.29.2021" + quote_asset: "wUSDC.b" + quote_asset_type: stable + signer_key_id: "key-main-1" + receive_address: "xch1u3tytpv45sj0h4lpwmtkyzh2ggvw4x7jccyxzu995p2aj40wzcxqvymyn3" + cloud_wallet_base_global_id: "Asset_aihyg9ncps0bqdkyw9j7i8x2" + cloud_wallet_quote_global_id: "Asset_cxc7mql006dp2w3kigqlj58t" + pricing: + side: "sell" + fixed_quote_per_base: 6.5 + slippage_bps: 25 + strategy_offer_expiry_minutes: 120 inventory: low_watermark_base_units: 500 low_inventory_alert_threshold_base_units: null @@ -353,6 +385,7 @@ markets: receive_address: "xch1u3tytpv45sj0h4lpwmtkyzh2ggvw4x7jccyxzu995p2aj40wzcxqvymyn3" pricing: fixed_quote_per_base: 0.999 + strategy_offer_expiry_minutes: 30 inventory: low_watermark_base_units: 200 low_inventory_alert_threshold_base_units: 200 diff --git a/config/testnet-markets.yaml b/config/testnet-markets.yaml index f951732..9ae1df4 100644 --- a/config/testnet-markets.yaml +++ b/config/testnet-markets.yaml @@ -10,8 +10,6 @@ markets: signer_key_id: "key-main-1" receive_address: "txch1t37dk4kxmptw9eceyjvxn55cfrh827yf5f0nnnm2t6r882nkl66qknnt9k" pricing: - reference_source: "coingecko" - reference_pair: "txch_usd" side: "sell" fixed_quote_per_base: 0.004714285714285714 quote_unit_mojo_multiplier: 1000000000000 @@ -19,6 +17,7 @@ markets: strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 + strategy_offer_expiry_minutes: 10 cancel_policy_stable_vs_unstable: true inventory: low_watermark_base_units: 500 @@ -53,8 +52,6 @@ markets: signer_key_id: "key-main-1" receive_address: "txch1t37dk4kxmptw9eceyjvxn55cfrh827yf5f0nnnm2t6r882nkl66qknnt9k" pricing: - reference_source: "coingecko" - reference_pair: "txch_usd" side: "sell" fixed_quote_per_base: 0.002420604183 quote_unit_mojo_multiplier: 1000000000000 @@ -62,6 +59,7 @@ markets: strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 + strategy_offer_expiry_minutes: 10 cancel_policy_stable_vs_unstable: true inventory: low_watermark_base_units: 0 diff --git a/docs/progress.md b/docs/progress.md index b884d00..ceed73c 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,5 +1,108 @@ # Progress Log +## 2026-03-11 (parallel reservation drain timing + John-Deere worker cap set to 3) + +- Diagnosed the remaining `eco1812020_sell_xch` underfill behavior on `John-Deere` as refill-drain latency in the daemon reservation queue rather than duplicate offer artifact assignment: + - strategy planning repeatedly produced full refill batches, + - reservation leases for queued items stayed active for long periods before worker pickup/release. +- Refactored parallel Cloud Wallet strategy execution in `greenfloor/daemon/main.py` so reservation acquisition happens at worker execution time instead of pre-acquiring every queued submission. +- Added `market_decision` timing instrumentation (same logger path used by `debug.log`) for: + - `parallel_offer_dispatch` (planned/queued/worker counts), + - `parallel_offer_queue_wait` (per-submission queue delay), + - `parallel_offer_reservation_acquired` / `parallel_offer_reservation_released` (acquire and hold timings). +- Updated live `John-Deere` runtime config to `runtime.offer_parallelism_max_workers: 3`, deployed the patched daemon file, restarted, and verified new debug evidence: + - `parallel_offer_dispatch ... workers=3` for `eco1812020_sell_xch`, + - queue/lease timing lines now visible in `debug.log` for active refill cycles. + +## 2026-03-11 (removed ladder cadence throttling after live ECO/XCH watch) + +- Re-ran live `John-Deere` monitoring for `eco1812020_sell_xch` while treating Dexie as authoritative apart from normal latency: + - repeated Dexie / SQLite snapshots showed the market oscillating below target (`1` rung dropping from `2` to `1`) while the daemon still held recently-open local rows that had not reconciled out yet, + - current underfill was therefore not a Dexie-visibility bug; the daemon was simply refilling too slowly once multiple short-TTL offers expired near each other. +- Removed the strategy/reseed cadence throttle in `greenfloor/daemon/main.py`: + - `_apply_action_cadence_gate(...)` now passes planned actions through unchanged instead of suppressing or shrinking repost batches, + - removed the now-dead recent-post cadence bookkeeping tied to `strategy_offer_execution` history. +- Updated deterministic coverage in `tests/test_daemon_offer_execution.py` to assert the new passthrough behavior instead of the old cadence-limited cases. +- Enabled `runtime.offer_parallelism_enabled` on `John-Deere` and re-watched the live market: + - the daemon now does plan the full missing burst immediately, and Cloud Wallet begins draining the burst in parallel, + - but the current submission ordering still prioritizes larger sizes first (`10` before `1`), so Dexie can remain underfilled on the critical small rung while the worker spends several minutes posting larger replacements, + - `greenfloor/cloud_wallet_offer_runtime.py` also writes per-offer `strategy_offer_execution` audit rows during posting, which explains the staggered DB evidence seen during live monitoring. +- Fixed the refill ordering in `greenfloor/daemon/main.py`: + - `_expand_strategy_actions(...)` no longer re-sorts planned actions by descending size before submission, + - strategy/reseed action order now follows the planner's natural ascending ladder order so missing `1` offers are submitted before missing `10` offers. +- Added deterministic regression coverage in `tests/test_daemon_offer_execution.py` for preserving strategy action order during expansion. + +## 2026-03-10 (final-gap cadence bypass + BYC quote-balance confirmation) + +- Tightened the generalized short-TTL cadence gate in `greenfloor/daemon/main.py` after reviewing the live `eco1812020_sell_xch` underfill: + - the prior generalized gate could still suppress the last missing `1` when the market sat at `4/5` and the latest same-size post was inside the spacing window, + - the gate now bypasses cadence suppression for the final missing offer (`target_count - active_count <= 1`) so the daemon can still restore the rung to target. +- Added deterministic coverage in `tests/test_daemon_offer_execution.py` for the exact `4/5 -> allow 1 more` case. +- Rechecked the live `byc_two_sided_wusdbc` buy-side failure on `John-Deere` with direct wallet evidence: + - the daemon does plan the configured buy-side offer, + - current `strategy_offer_execution` rows show the buy action is skipped by Cloud Wallet with `cloud_wallet_offer_insufficient_spendable_balance:side=buy:required=9990:available=50`, + - direct `wallet.list_coins(asset_id=Asset_cxc7mql006dp2w3kigqlj58t, include_pending={False,True})` returns exactly one unlocked settled `wUSDC.b` coin of `50`, + - so the present blocker is insufficient spendable quote inventory in the vault, not Dexie visibility. + +## 2026-03-10 (daemon general strategy cadence gate + BYC buy-side finding) + +- Followed up on live `John-Deere` monitoring after the first short-TTL cadence patch: + - the initial gate only affected reseed injection, + - ordinary `strategy_actions_present` planning still emitted repeated `1`-offer posts for `eco1812020_sell_xch`, + - live Dexie samples continued to oscillate (`5 -> 4 -> 3 -> 2`) and remote audit events still showed grouped `1` executions from the main strategy path. +- Generalized cadence limiting in `greenfloor/daemon/main.py`: + - the action gate now keys by `(side, size)` using recent successful `strategy_offer_execution` events, + - ordinary `below_target` actions are cadence-limited before execution, not only reseed-only actions, + - existing reseed gating now reuses the same helper so the behavior is consistent across both planning paths. +- Added deterministic coverage in `tests/test_daemon_offer_execution.py` for: + - suppressing general strategy actions when the most recent same-side/same-size post is still inside the cadence window, + - reducing ordinary repeated `below_target` actions to a single post once the cadence window has elapsed. +- Separate live finding on `byc_two_sided_wusdbc`: + - the daemon repeatedly observed `buy: 0 / sell: 3`, planned one buy-side action, and still finished cycles with `strategy_executed=0`, + - current local builder path explicitly skips buy-side offers with `offer_builder_failed:buy_side_requires_cloud_wallet_path`, + - this is a distinct two-sided BYC defect and is not fixed by the ECO/XCH cadence change. + +## 2026-03-10 (daemon small-offer reseed cadence gating) + +- Hardened sell-only reseed behavior in `greenfloor/daemon/main.py` to reduce bursty `1`-offer reposting on short-TTL markets: + - repeated same-size reseed actions now consult recent successful `strategy_offer_execution` timestamps for the market, + - small repeated ladders (`target_count >= 3`) are cadence-limited to approximately one spacing interval at a time instead of reposting the full deficit in a single cycle, + - cold-start / fully empty ladders still permit a small bootstrap burst (`2`) so markets do not stay empty for the full spacing window. +- Goal: keep `10`-minute `1`-offer ladders, especially `eco1812020_sell_xch`, from expiring in synchronized bursts that temporarily drop live Dexie coverage below target even though the daemon later refills the rung. +- Added deterministic coverage in `tests/test_daemon_offer_execution.py` for: + - empty-market bootstrap reseed limiting, + - suppression when the most recent successful same-size post is still inside the cadence window, + - resuming a single small-offer refill after the cadence window elapses. + +## 2026-03-10 (Dexie 404 reconciliation fix for stale ECO 50 offers) + +- Root cause confirmed on `John-Deere` for `eco1812020_sell_xch` not reposting its `50` rung: + - Dexie no longer returned several watched `50` offer ids, + - direct `DexieAdapter.get_offer(...)` on those ids returned `HTTP Error 404: Not Found`, + - daemon reconciliation left the prior `offer_state` rows at `open`, so strategy kept counting `50: 1` and suppressed replacement posting. +- Fixed `greenfloor/daemon/main.py` reconciliation for watched offers that vanish from Dexie: + - direct watched-offer fetches that 404 now transition the local offer state to `expired`, + - ordinary direct-fetch network failures still remain non-terminal and do not clear active state. +- Added deterministic regression coverage in `tests/test_daemon_offer_execution.py` for the exact stale-watch scenario: + - watched offer present in local state, + - Dexie list omits it, + - direct `get_offer()` 404 forces the row out of the active set. +- Live remediation on `John-Deere`: + - identified stale open `50` offer rows for `eco1812020_sell_xch`, + - prepared cleanup so the market can resume posting replacement `50` offers immediately instead of waiting for manual DB intervention. + +## 2026-03-10 (upstream ent-wallet duplicate vault CAT input report + workaround analysis) + +- Documented a new upstream Cloud Wallet / `ent-wallet` issue draft in `docs/ent-wallet-upstream-duplicate-vault-cat-offer-inputs.md`: + - live `John-Deere` evidence shows malformed `50`-unit ECO vault offers already contain duplicate CAT inputs before `greenfloor` validates the returned `offer1...`, + - Cloud Wallet `CREATE_OFFER` transactions themselves report the same CAT coin id multiple times in `inputs`, + - likely root cause is the row-multiplying `transactionCoinRecords` join in `getUnspentVaultCoinsByWalletId(...)`. +- Captured temporary workaround options while waiting for the upstream fix: + - safest immediate mitigation is pausing the `50` rung, + - more targeted operational stopgap is combining explicit clean ECO inputs into a fresh exact `50000`-mojo CAT coin, + - off-chain cancellation of malformed internal wallet offers may free locked polluted inputs, + - a possible future `greenfloor` mitigation is a cooldown and/or exact-input-combine path when `wallet_sdk_offer_duplicate_spent_coin_ids` recurs. + ## 2026-03-05 (BYC scoped-query leak fail-closed mitigation + John-Deere validation) - Root cause for the remaining BYC coin-op failure on John-Deere was narrowed further: diff --git a/greenfloor/adapters/cloud_wallet.py b/greenfloor/adapters/cloud_wallet.py index dad8c10..40e9cd4 100644 --- a/greenfloor/adapters/cloud_wallet.py +++ b/greenfloor/adapters/cloud_wallet.py @@ -140,6 +140,33 @@ def list_coins( break return coins + def get_chia_usd_quote(self) -> float: + query = """ +query quote($asset: String!) { + quote(asset: $asset) { + price + baseAsset + currency + source + createdAt + } +} +""" + payload = self._graphql(query=query, variables={"asset": "chia"}) + quote_payload = payload.get("quote") + if not isinstance(quote_payload, dict): + raise RuntimeError("cloud_wallet_missing_quote") + raw_price = quote_payload.get("price") + if not isinstance(raw_price, str | int | float): + raise RuntimeError("cloud_wallet_invalid_quote_price") + try: + price = float(raw_price) + except (TypeError, ValueError) as exc: + raise RuntimeError("cloud_wallet_invalid_quote_price") from exc + if price <= 0: + raise RuntimeError("cloud_wallet_invalid_quote_price") + return price + def split_coins( self, *, @@ -414,6 +441,9 @@ def get_wallet( states: list[str] | None = None, first: int = 100, ) -> dict[str, Any]: + # Cloud Wallet currently rejects wallet.offers limits above 100. + # Revisit this guard if Cloud Wallet pagination defaults/maxima change. + first_limit = min(100, max(0, int(first))) query = """ query getWallet($walletId: ID, $isCreator: Boolean, $states: [OfferState!], $first: Int) { wallet(id: $walletId) { @@ -439,7 +469,7 @@ def get_wallet( "walletId": self._vault_id, "isCreator": is_creator, "states": states, - "first": int(first), + "first": first_limit, }, ) wallet = payload.get("wallet") or {} diff --git a/greenfloor/adapters/dexie.py b/greenfloor/adapters/dexie.py index d5e7abf..7c417b9 100644 --- a/greenfloor/adapters/dexie.py +++ b/greenfloor/adapters/dexie.py @@ -1,15 +1,51 @@ from __future__ import annotations import json +import time import urllib.error import urllib.parse import urllib.request +from dataclasses import dataclass, field from typing import Any +@dataclass +class _RowCache: + """Minimal TTL cache for a list of dict rows.""" + + ttl: int + _rows: list[dict] | None = field(default=None, init=False, repr=False) + _cached_at: float | None = field(default=None, init=False, repr=False) + + def get_if_fresh(self, now: float) -> list[dict] | None: + if ( + self._rows is not None + and self._cached_at is not None + and (now - self._cached_at) <= self.ttl + ): + return list(self._rows) + return None + + def store(self, rows: list[dict], now: float) -> list[dict]: + self._rows = list(rows) + self._cached_at = now + return list(rows) + + def stale(self) -> list[dict]: + return list(self._rows) if self._rows is not None else [] + + class DexieAdapter: - def __init__(self, base_url: str) -> None: + def __init__( + self, + base_url: str, + *, + cache_ttl_seconds: int = 900, + ) -> None: self.base_url = base_url.rstrip("/") + ttl = max(1, int(cache_ttl_seconds)) + self._token_cache = _RowCache(ttl=ttl) + self._ticker_cache = _RowCache(ttl=ttl) def get_tokens(self) -> list[dict]: url = f"{self.base_url}/v1/swap/tokens" @@ -125,26 +161,35 @@ def lookup_token_by_symbol( return row return None - def _fetch_token_rows(self) -> list[dict]: + def _cached_fetch(self, cache: _RowCache, fetcher: Any) -> list[dict]: + """Fetch rows through *cache*, falling back to stale rows on error.""" + now = time.time() + fresh = cache.get_if_fresh(now) + if fresh is not None: + return fresh try: - return self.get_tokens() + rows = fetcher() except Exception: - return [] + return cache.stale() + return cache.store(rows, now) + + def _fetch_token_rows(self) -> list[dict]: + return self._cached_fetch(self._token_cache, self.get_tokens) def _fetch_ticker_rows(self) -> list[dict]: - url = f"{self.base_url}/v3/prices/tickers" - try: + def _fetch() -> list[dict]: + url = f"{self.base_url}/v3/prices/tickers" with urllib.request.urlopen(url, timeout=20) as resp: payload = json.loads(resp.read().decode("utf-8")) - except Exception: + if isinstance(payload, list): + return [r for r in payload if isinstance(r, dict)] + if isinstance(payload, dict): + tickers = payload.get("tickers") + if isinstance(tickers, list): + return [r for r in tickers if isinstance(r, dict)] return [] - if isinstance(payload, list): - return [r for r in payload if isinstance(r, dict)] - if isinstance(payload, dict): - tickers = payload.get("tickers") - if isinstance(tickers, list): - return [r for r in tickers if isinstance(r, dict)] - return [] + + return self._cached_fetch(self._ticker_cache, _fetch) def _row_matches_cat_target(row: dict, target: str, *, include_ticker_split: bool = False) -> bool: diff --git a/greenfloor/adapters/price.py b/greenfloor/adapters/price.py index b626a5b..3d42bb8 100644 --- a/greenfloor/adapters/price.py +++ b/greenfloor/adapters/price.py @@ -61,3 +61,57 @@ async def _fetch_xch_price(self) -> float: return float(payload[0]["current_price"]) raise ValueError("coincodex_response_missing_price") + + +class XchPriceProvider: + """Unified XCH/USD provider with Cloud Wallet primary and CoinCodex fallback.""" + + def __init__( + self, + *, + cloud_wallet_price_fn: Callable[[], float] | None = None, + cloud_wallet_ttl_seconds: int = 120, + fallback_price_adapter: PriceAdapter | None = None, + now_fn: Callable[[], float] | None = None, + ) -> None: + self._cloud_wallet_price_fn = cloud_wallet_price_fn + self._cloud_wallet_ttl_seconds = max(1, int(cloud_wallet_ttl_seconds)) + self._fallback_price_adapter = fallback_price_adapter or PriceAdapter() + self._now_fn = now_fn or time.time + self._cloud_wallet_cached_price_usd: float | None = None + self._cloud_wallet_cached_at_epoch_s: float | None = None + self._last_good_price_usd: float | None = None + + async def get_xch_price(self) -> float: + if self._cloud_wallet_price_fn is not None: + try: + price = self._get_cloud_wallet_price() + self._last_good_price_usd = price + return price + except Exception: + pass + try: + price = await self._fallback_price_adapter.get_xch_price() + self._last_good_price_usd = price + return price + except Exception: + if self._last_good_price_usd is not None: + return self._last_good_price_usd + raise + + def _get_cloud_wallet_price(self) -> float: + now = float(self._now_fn()) + if ( + self._cloud_wallet_cached_price_usd is not None + and self._cloud_wallet_cached_at_epoch_s is not None + and (now - self._cloud_wallet_cached_at_epoch_s) <= self._cloud_wallet_ttl_seconds + ): + return self._cloud_wallet_cached_price_usd + if self._cloud_wallet_price_fn is None: + raise RuntimeError("cloud_wallet_price_not_configured") + value = float(self._cloud_wallet_price_fn()) + if value <= 0: + raise ValueError("cloud_wallet_quote_price_must_be_positive") + self._cloud_wallet_cached_price_usd = value + self._cloud_wallet_cached_at_epoch_s = now + return value diff --git a/greenfloor/cli/manager.py b/greenfloor/cli/manager.py index cb27f66..2166d8b 100644 --- a/greenfloor/cli/manager.py +++ b/greenfloor/cli/manager.py @@ -245,6 +245,10 @@ def _canonical_is_xch(asset_id: str) -> bool: return value in {"xch", "txch"} +def _default_mojo_multiplier_for_asset(asset_id: str) -> int: + return 1_000_000_000_000 if _canonical_is_xch(asset_id) else 1000 + + def _canonical_is_cloud_global_id(asset_id: str) -> bool: return asset_id.strip().startswith("Asset_") @@ -1613,6 +1617,40 @@ def _coin_meets_coin_op_min_amount(coin: dict, *, canonical_asset_id: str) -> bo ) +def _coin_matches_direct_spendable_lookup( + *, + wallet: Any, + coin: dict, + scoped_asset_id: str, + cache: dict[str, bool] | None = None, +) -> bool: + get_coin_record = getattr(wallet, "get_coin_record", None) + if not callable(get_coin_record): + return True + coin_id = str(coin.get("id", "")).strip() + if not coin_id: + return False + if cache is not None and coin_id in cache: + return bool(cache[coin_id]) + try: + coin_record = get_coin_record(coin_id=coin_id) + except Exception: + result = False + else: + if not isinstance(coin_record, dict): + result = False + else: + result = ( + _is_spendable_coin(coin_record) + and not bool(coin_record.get("isLinkedToOpenOffer")) + and _coin_asset_id(coin_record).strip().lower() + == str(scoped_asset_id).strip().lower() + ) + if cache is not None: + cache[coin_id] = result + return result + + def _evaluate_denomination_readiness( *, wallet: CloudWalletAdapter, @@ -1687,6 +1725,37 @@ def _resolve_coin_global_ids( return resolved, unresolved +def _coin_id_asset_lookup(wallet_coins: list[dict]) -> dict[str, str]: + lookup: dict[str, str] = {} + for coin in wallet_coins: + coin_id = str(coin.get("id", "")).strip() + if not coin_id: + continue + lookup[coin_id] = _coin_asset_id(coin).strip().lower() + return lookup + + +def _classify_resolved_coin_ids_by_asset( + *, + wallet_coins: list[dict], + resolved_coin_ids: list[str], + expected_asset_id: str, +) -> tuple[list[str], list[dict[str, str]]]: + lookup = _coin_id_asset_lookup(wallet_coins) + expected = str(expected_asset_id).strip().lower() + unknown: list[str] = [] + mismatched: list[dict[str, str]] = [] + for coin_id in resolved_coin_ids: + normalized_coin_id = str(coin_id).strip() + actual_asset = lookup.get(normalized_coin_id) + if actual_asset is None: + unknown.append(normalized_coin_id) + continue + if actual_asset != expected: + mismatched.append({"coin_id": normalized_coin_id, "coin_asset_id": actual_asset}) + return unknown, mismatched + + # --------------------------------------------------------------------------- # Shared coin-operation helpers # --------------------------------------------------------------------------- @@ -2429,6 +2498,7 @@ def _cloud_wallet_post_offer_phase( expected_offered_symbol: str, expected_requested_asset_id: str, expected_requested_symbol: str, + sleep_fn: collections.abc.Callable[[float], None] | None = None, ) -> dict[str, Any]: return _shared_cloud_wallet_post_offer_phase( publish_venue=publish_venue, @@ -2444,6 +2514,7 @@ def _cloud_wallet_post_offer_phase( expected_requested_symbol=expected_requested_symbol, post_dexie_offer_with_invalid_offer_retry_fn=_post_dexie_offer_with_invalid_offer_retry, verify_dexie_offer_visible_by_id_fn=_verify_dexie_offer_visible_by_id, + sleep_fn=sleep_fn, ) @@ -2537,8 +2608,22 @@ def _build_and_post_offer( # (pyright and runtime callers) that treat this as a non-optional value. keyring_yaml_path = str(signer_key.keyring_yaml_path or "") if signer_key is not None else "" pricing = dict(getattr(market, "pricing", {}) or {}) - base_unit_mojo_multiplier = int(pricing.get("base_unit_mojo_multiplier", 1000)) - quote_unit_mojo_multiplier = int(pricing.get("quote_unit_mojo_multiplier", 1000)) + default_quote_asset = _resolve_quote_asset_for_local_offer_build( + quote_asset=str(market.quote_asset), + network=network, + ) + base_unit_mojo_multiplier = int( + pricing.get( + "base_unit_mojo_multiplier", + _default_mojo_multiplier_for_asset(str(market.base_asset)), + ) + ) + quote_unit_mojo_multiplier = int( + pricing.get( + "quote_unit_mojo_multiplier", + _default_mojo_multiplier_for_asset(str(default_quote_asset)), + ) + ) expiry_unit, expiry_value = _resolve_offer_expiry_for_market(market) quote_price = pricing.get("fixed_quote_per_base") if quote_price is None: @@ -3373,13 +3458,51 @@ def _coin_combine( raise ValueError( "when --coin-id is provided, --input-coin-count must match the number of --coin-id values" ) + unresolved_coin_ids, mismatched_coin_ids = _classify_resolved_coin_ids_by_asset( + wallet_coins=wallet_coins, + resolved_coin_ids=resolved_input_coin_ids, + expected_asset_id=resolved_asset_id, + ) + if unresolved_coin_ids: + break + if mismatched_coin_ids: + print( + _format_json_output( + { + **_coin_op_base_payload(market, selected_venue, wallet), + "waited": False, + "success": False, + "error": "coin_id_asset_mismatch", + "resolved_asset_id": resolved_asset_id, + "mismatched_coin_ids": [ + str(entry.get("coin_id", "")).strip() + for entry in mismatched_coin_ids + if str(entry.get("coin_id", "")).strip() + ], + "mismatched_coin_assets": mismatched_coin_ids, + "operator_guidance": ( + "all explicit --coin-id values must resolve to the same asset " + "as --asset-id; re-run coins-list scoped to the target asset " + "and retry with only those coin ids" + ), + } + ) + ) + return 2 elif min_coin_amount_mojos > 0: asset_scoped_coins = wallet.list_coins(asset_id=resolved_asset_id, include_pending=True) + direct_lookup_cache: dict[str, bool] = {} eligible_asset_coins = [ c for c in asset_scoped_coins if _is_spendable_coin(c) and _coin_meets_coin_op_min_amount(c, canonical_asset_id=combine_canonical_asset_id) + and _coin_matches_direct_spendable_lookup( + wallet=wallet, + coin=c, + scoped_asset_id=resolved_asset_id, + cache=direct_lookup_cache, + ) and str(c.get("id", "")).strip() ] if len(eligible_asset_coins) < number_of_coins: diff --git a/greenfloor/cloud_wallet_offer_runtime.py b/greenfloor/cloud_wallet_offer_runtime.py index 4552213..24e7017 100644 --- a/greenfloor/cloud_wallet_offer_runtime.py +++ b/greenfloor/cloud_wallet_offer_runtime.py @@ -20,16 +20,22 @@ from greenfloor.adapters.splash import SplashAdapter from greenfloor.config.io import is_testnet, load_yaml from greenfloor.core.offer_lifecycle import OfferLifecycleState -from greenfloor.hex_utils import is_hex_id, normalize_hex_id +from greenfloor.hex_utils import ( + canonical_is_xch, + default_mojo_multiplier_for_asset, + is_hex_id, + normalize_hex_id, +) from greenfloor.logging_setup import initialize_service_file_logging from greenfloor.offer_bootstrap import plan_bootstrap_mixed_outputs from greenfloor.signing import sign_and_broadcast_mixed_split from greenfloor.storage.sqlite import SqliteStore -_TEST_PHASE_OFFER_EXPIRY_MINUTES = 5 _MANAGER_SERVICE_NAME = "manager" _DEXIE_INVALID_OFFER_RETRY_MAX_ATTEMPTS = 4 _DEXIE_INVALID_OFFER_RETRY_INITIAL_DELAY_SECONDS = 1.0 +_DEXIE_VISIBILITY_POST_MAX_ATTEMPTS = 3 +_DEXIE_VISIBILITY_POST_DELAY_SECONDS = 2.0 _runtime_logger = logging.getLogger("greenfloor.manager") _JSON_OUTPUT_COMPACT = False @@ -100,18 +106,85 @@ def _offer_has_expiration_condition(sdk: object, offer_text: str) -> bool: spend_bundle = decode_offer(offer_text) coin_spends = getattr(spend_bundle, "coin_spends", None) or [] for coin_spend in coin_spends: - conditions_fn = getattr(coin_spend, "conditions", None) - if not callable(conditions_fn): - continue - conditions = conditions_fn() or [] - if not isinstance(conditions, list): - continue - for condition in conditions: + for condition in _extract_offer_conditions_from_coin_spend(sdk, coin_spend): if _condition_has_offer_expiration(condition): return True return False +def _extract_offer_conditions_from_coin_spend(sdk: object, coin_spend: object) -> list[object]: + # Derive conditions from CLVM execution of puzzle reveal + solution. + clvm_cls = getattr(sdk, "Clvm", None) + if not callable(clvm_cls): + return [] + puzzle_reveal = getattr(coin_spend, "puzzle_reveal", None) + solution = getattr(coin_spend, "solution", None) + if not isinstance(puzzle_reveal, bytes | bytearray | memoryview) or not isinstance( + solution, bytes | bytearray | memoryview + ): + return [] + + try: + clvm = clvm_cls() + deserialize_fn = getattr(clvm, "deserialize", None) + if not callable(deserialize_fn): + return [] + puzzle_program = deserialize_fn(bytes(puzzle_reveal)) + solution_program = deserialize_fn(bytes(solution)) + run_fn = getattr(puzzle_program, "run", None) + if not callable(run_fn): + return [] + run_output = run_fn(solution_program, 1_000_000_000_000, True) + value = getattr(run_output, "value", None) + if value is None: + return [] + to_list_fn = getattr(value, "to_list", None) + if callable(to_list_fn): + parsed = to_list_fn() or [] + if isinstance(parsed, collections.abc.Iterable) and not isinstance( + parsed, bytes | bytearray | str + ): + return list(parsed) + if isinstance(value, collections.abc.Iterable) and not isinstance( + value, bytes | bytearray | str + ): + return list(value) + except Exception: + return [] + return [] + + +def _offer_has_duplicate_spent_coin_ids(sdk: object, offer_text: str) -> bool: + decode_offer = getattr(sdk, "decode_offer", None) + to_hex = getattr(sdk, "to_hex", None) + if not callable(decode_offer) or not callable(to_hex): + return False + try: + spend_bundle = decode_offer(offer_text) + except Exception: + return False + coin_spends = getattr(spend_bundle, "coin_spends", None) or [] + seen: set[str] = set() + for coin_spend in coin_spends: + coin = getattr(coin_spend, "coin", None) + if coin is None: + continue + coin_id_fn = getattr(coin, "coin_id", None) + if not callable(coin_id_fn): + continue + try: + coin_id_hex = str(to_hex(coin_id_fn())).strip().lower() + except Exception: + continue + normalized = normalize_hex_id(coin_id_hex) + if not normalized: + continue + if normalized in seen: + return True + seen.add(normalized) + return False + + def _extract_coin_id_hints_from_offer_text(offer_text: str) -> list[str]: try: sdk = importlib.import_module("chia_wallet_sdk") @@ -169,6 +242,7 @@ def log_signed_offer_artifact( def verify_offer_text_for_dexie(offer_text: str) -> str | None: + native_validated = False try: native = importlib.import_module("greenfloor_native") except Exception: @@ -176,23 +250,32 @@ def verify_offer_text_for_dexie(offer_text: str) -> str | None: else: try: native.validate_offer(offer_text) - return None + native_validated = True except Exception as exc: return f"wallet_sdk_offer_validate_failed:{exc}" try: import chia_wallet_sdk as sdk # type: ignore except Exception as exc: + if native_validated: + return None return f"wallet_sdk_import_error:{exc}" try: - validate_offer = getattr(sdk, "validate_offer", None) - if callable(validate_offer): - validate_offer(offer_text) - else: - verify_offer = getattr(sdk, "verify_offer", None) - if not callable(verify_offer): - return "wallet_sdk_validate_offer_unavailable" - if not bool(verify_offer(offer_text)): - return "wallet_sdk_offer_verify_false" + decode_offer = getattr(sdk, "decode_offer", None) + decode_available = callable(decode_offer) + if not native_validated: + validate_offer = getattr(sdk, "validate_offer", None) + if callable(validate_offer): + validate_offer(offer_text) + else: + verify_offer = getattr(sdk, "verify_offer", None) + if not callable(verify_offer): + return "wallet_sdk_validate_offer_unavailable" + if not bool(verify_offer(offer_text)): + return "wallet_sdk_offer_verify_false" + if native_validated and not decode_available: + return None + if _offer_has_duplicate_spent_coin_ids(sdk, offer_text): + return "wallet_sdk_offer_duplicate_spent_coin_ids" if not _offer_has_expiration_condition(sdk, offer_text): return "wallet_sdk_offer_missing_expiration" except Exception as exc: @@ -214,11 +297,6 @@ def __init__( super().__init__(f"{failure_kind}:{detail}") -def _canonical_is_xch(asset_id: str) -> bool: - value = asset_id.strip().lower() - return value in {"xch", "txch"} - - def _canonical_is_cloud_global_id(asset_id: str) -> bool: return asset_id.strip().startswith("Asset_") @@ -450,7 +528,7 @@ def resolve_cloud_wallet_asset_id( "symbol": symbol, } ) - if _canonical_is_xch(raw): + if canonical_is_xch(raw): hinted = str(global_id_hint or "").strip() if hinted and hinted in set(crypto_asset_ids): return hinted @@ -565,8 +643,8 @@ def resolve_cloud_wallet_offer_asset_ids( ) if ( resolved_base == resolved_quote - and not _canonical_is_xch(base_asset_id) - and not _canonical_is_xch(quote_asset_id) + and not canonical_is_xch(base_asset_id) + and not canonical_is_xch(quote_asset_id) and not _canonical_is_cloud_global_id(base_asset_id) and not _canonical_is_cloud_global_id(quote_asset_id) ): @@ -854,6 +932,13 @@ def verify_dexie_offer_visible_by_id( return last_error +def is_transient_dexie_visibility_404_error(error: str) -> bool: + normalized = str(error).strip().lower() + return ( + "dexie_get_offer_error" in normalized and "404" in normalized + ) or "dexie_http_error:404" in normalized + + def _coinset_coin_url(*, coin_name: str, network: str = "mainnet") -> str: base = "https://testnet11.coinset.org" if is_testnet(network) else "https://coinset.org" return f"{base}/coin/{coin_name.strip()}" @@ -1464,16 +1549,14 @@ def dexie_offer_view_url(*, dexie_base_url: str, offer_id: str) -> str: def resolve_offer_expiry_for_market(market: Any) -> tuple[str, int]: pricing = dict(getattr(market, "pricing", {}) or {}) - unit = str(pricing.get("strategy_offer_expiry_unit", "")).strip().lower() - value_raw = pricing.get("strategy_offer_expiry_value") - if unit in {"minutes", "hours"}: - try: - value = int(value_raw or 0) - except (TypeError, ValueError): - value = 0 - if value > 0: - return unit, value - return "minutes", _TEST_PHASE_OFFER_EXPIRY_MINUTES + value_raw = pricing.get("strategy_offer_expiry_minutes") + try: + value = int(value_raw or 0) + except (TypeError, ValueError): + value = 0 + if value > 0: + return "minutes", value + return "minutes", 10 def _bootstrap_fee_cost_for_output_count(output_count: int) -> int: @@ -1545,7 +1628,12 @@ def ensure_offer_bootstrap_denominations( if not side_ladder: return {"status": "skipped", "reason": f"missing_{side}_ladder"} pricing = dict(getattr(market, "pricing", {}) or {}) - quote_unit_multiplier = int(pricing.get("quote_unit_mojo_multiplier", 1000)) + quote_unit_multiplier = int( + pricing.get( + "quote_unit_mojo_multiplier", + default_mojo_multiplier_for_asset(str(resolved_quote_asset_id)), + ) + ) if side == "buy": ladder_for_split = [] for entry in side_ladder: @@ -1748,13 +1836,24 @@ def cloud_wallet_create_offer_phase( known_offer_markers = offer_markers(prior_offers if isinstance(prior_offers, list) else []) offer_request_started_at = dt.datetime.now(dt.UTC) offer_amount = int( - size_base_units * int((market.pricing or {}).get("base_unit_mojo_multiplier", 1000)) + size_base_units + * int( + (market.pricing or {}).get( + "base_unit_mojo_multiplier", + default_mojo_multiplier_for_asset(str(resolved_base_asset_id)), + ) + ) ) request_amount = int( round( float(size_base_units) * float(quote_price) - * int((market.pricing or {}).get("quote_unit_mojo_multiplier", 1000)) + * int( + (market.pricing or {}).get( + "quote_unit_mojo_multiplier", + default_mojo_multiplier_for_asset(str(resolved_quote_asset_id)), + ) + ) ) ) if request_amount <= 0: @@ -1762,9 +1861,26 @@ def cloud_wallet_create_offer_phase( if side == "buy": offered = [{"assetId": resolved_quote_asset_id, "amount": request_amount}] requested = [{"assetId": resolved_base_asset_id, "amount": offer_amount}] + spend_asset_id = str(resolved_quote_asset_id).strip() + required_spendable_amount = int(request_amount) else: offered = [{"assetId": resolved_base_asset_id, "amount": offer_amount}] requested = [{"assetId": resolved_quote_asset_id, "amount": request_amount}] + spend_asset_id = str(resolved_base_asset_id).strip() + required_spendable_amount = int(offer_amount) + if hasattr(wallet, "list_coins") and spend_asset_id: + asset_scoped_coins = wallet.list_coins(asset_id=spend_asset_id, include_pending=True) + spendable_amount = sum( + int(coin.get("amount", 0)) + for coin in asset_scoped_coins + if isinstance(coin, dict) and _is_spendable_coin(coin) + ) + if spendable_amount < required_spendable_amount: + raise RuntimeError( + "cloud_wallet_offer_insufficient_spendable_balance:" + f"side={side}:required={required_spendable_amount}:" + f"available={spendable_amount}:asset_id={spend_asset_id}" + ) expires_at = ( dt.datetime.now(dt.UTC) + dt.timedelta(**{expiry_unit: int(expiry_value)}) ).isoformat() @@ -1815,19 +1931,34 @@ def cloud_wallet_wait_offer_artifact_phase( if poll_offer_artifact_by_signature_request_fn is None: poll_offer_artifact_by_signature_request_fn = poll_offer_artifact_by_signature_request strict_timeout = max(15, int(timeout_seconds)) - try: - return poll_offer_artifact_until_available_fn( - wallet=wallet, - known_markers=known_markers, - timeout_seconds=strict_timeout, - min_created_at=offer_request_started_at, - require_open_state=False, - states=("OPEN", "PENDING"), - prefer_newest=True, - ) - except RuntimeError as exc: - if str(exc) != "cloud_wallet_offer_artifact_timeout": - raise + if signature_request_id: + try: + return poll_offer_artifact_by_signature_request_fn( + wallet=wallet, + signature_request_id=signature_request_id, + known_markers=known_markers, + timeout_seconds=strict_timeout, + min_created_at=offer_request_started_at, + ) + except RuntimeError: + # Signature-request scoped lookup is preferred when supported, but + # not all test stubs or adapter variants implement this path. + # Fall back to generic wallet offer polling in those cases. + pass + else: + try: + return poll_offer_artifact_until_available_fn( + wallet=wallet, + known_markers=known_markers, + timeout_seconds=strict_timeout, + min_created_at=offer_request_started_at, + require_open_state=False, + states=("OPEN", "PENDING"), + prefer_newest=True, + ) + except RuntimeError as exc: + if str(exc) != "cloud_wallet_offer_artifact_timeout": + raise extended_timeout = max(45, strict_timeout * 3) if signature_request_id: try: @@ -1880,21 +2011,29 @@ def cloud_wallet_post_offer_phase( post_dexie_offer_with_invalid_offer_retry_fn: collections.abc.Callable[..., dict[str, Any]] | None = None, verify_dexie_offer_visible_by_id_fn: collections.abc.Callable[..., str | None] | None = None, + sleep_fn: collections.abc.Callable[[float], None] | None = None, ) -> dict[str, Any]: _ = market if post_dexie_offer_with_invalid_offer_retry_fn is None: post_dexie_offer_with_invalid_offer_retry_fn = post_dexie_offer_with_invalid_offer_retry if verify_dexie_offer_visible_by_id_fn is None: verify_dexie_offer_visible_by_id_fn = verify_dexie_offer_visible_by_id + if sleep_fn is None: + sleep_fn = time.sleep if publish_venue == "dexie": assert dexie is not None - result = post_dexie_offer_with_invalid_offer_retry_fn( - dexie=dexie, - offer_text=offer_text, - drop_only=drop_only, - claim_rewards=claim_rewards, - ) - if bool(result.get("success", False)): + last_result: dict[str, Any] = {} + last_visibility_error = "" + for attempt in range(1, _DEXIE_VISIBILITY_POST_MAX_ATTEMPTS + 1): + result = post_dexie_offer_with_invalid_offer_retry_fn( + dexie=dexie, + offer_text=offer_text, + drop_only=drop_only, + claim_rewards=claim_rewards, + ) + last_result = dict(result) + if not bool(result.get("success", False)): + return result posted_offer_id = str(result.get("id", "")).strip() visibility_error = verify_dexie_offer_visible_by_id_fn( dexie=dexie, @@ -1904,13 +2043,22 @@ def cloud_wallet_post_offer_phase( expected_requested_asset_id=str(expected_requested_asset_id), expected_requested_symbol=str(expected_requested_symbol), ) - if visibility_error: + if not visibility_error: + return result + last_visibility_error = str(visibility_error) + if not is_transient_dexie_visibility_404_error(last_visibility_error): return { **result, "success": False, - "error": visibility_error, + "error": last_visibility_error, } - return result + if attempt < _DEXIE_VISIBILITY_POST_MAX_ATTEMPTS: + sleep_fn(_DEXIE_VISIBILITY_POST_DELAY_SECONDS) + return { + **last_result, + "success": False, + "error": (last_visibility_error or "dexie_offer_not_visible_after_publish"), + } assert splash is not None return splash.post_offer(offer_text) @@ -2038,19 +2186,32 @@ def build_and_post_offer_cloud_wallet( if bool(bootstrap_result.get("fallback_to_cloud_wallet_offer_split", False)): split_input_coins_fee = 0 - create_phase = cloud_wallet_create_offer_phase_fn( - wallet=wallet, - 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, - offer_fee_mojos=offer_fee_mojos, - split_input_coins_fee=split_input_coins_fee, - expiry_unit=expiry_unit, - expiry_value=expiry_value, - action_side=side, - ) + try: + create_phase = cloud_wallet_create_offer_phase_fn( + wallet=wallet, + 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, + offer_fee_mojos=offer_fee_mojos, + split_input_coins_fee=split_input_coins_fee, + expiry_unit=expiry_unit, + expiry_value=expiry_value, + action_side=side, + ) + except Exception as exc: + post_results.append( + { + "venue": publish_venue, + "result": { + "success": False, + "error": str(exc), + }, + } + ) + publish_failures += 1 + continue signature_request_id = str(create_phase["signature_request_id"]).strip() signature_state = str(create_phase["signature_state"]).strip() wait_events = list(create_phase["wait_events"]) @@ -2130,9 +2291,9 @@ def build_and_post_offer_cloud_wallet( claim_rewards=claim_rewards, market=market, expected_offered_asset_id=( - str(market.quote_asset) + str(resolved_quote_asset_id) if str(create_phase.get("side", "sell")).strip().lower() == "buy" - else str(market.base_asset) + else str(resolved_base_asset_id) ), expected_offered_symbol=( str(getattr(market, "quote_asset", "")) @@ -2140,9 +2301,9 @@ def build_and_post_offer_cloud_wallet( else str(getattr(market, "base_symbol", "")) ), expected_requested_asset_id=( - str(market.base_asset) + str(resolved_base_asset_id) if str(create_phase.get("side", "sell")).strip().lower() == "buy" - else str(market.quote_asset) + else str(resolved_quote_asset_id) ), expected_requested_symbol=( str(getattr(market, "base_symbol", "")) diff --git a/greenfloor/config/models.py b/greenfloor/config/models.py index 214e044..881f10d 100644 --- a/greenfloor/config/models.py +++ b/greenfloor/config/models.py @@ -1,11 +1,13 @@ from __future__ import annotations +import warnings from dataclasses import dataclass, field from typing import Any from greenfloor.logging_setup import normalize_log_level_name _CANONICAL_CAT_UNIT_MOJOS = 1000 +_CANONICAL_XCH_UNIT_MOJOS = 1_000_000_000_000 _XCH_UNIT_SYMBOLS = frozenset({"xch", "txch", "1"}) @@ -111,7 +113,11 @@ def _req(mapping: dict[str, Any], key: str) -> Any: def _validate_strategy_pricing( pricing: dict[str, Any], market_id: str, quote_asset_type: str | None = None ) -> None: - _ = quote_asset_type + quote_type = str(quote_asset_type or "").strip().lower() + for legacy_field in ("reference_source", "reference_pair"): + if pricing.get(legacy_field) is not None: + raise ValueError(f"market {market_id}: {legacy_field} is no longer supported") + spread_raw = pricing.get("strategy_target_spread_bps") if spread_raw is not None: try: @@ -150,32 +156,40 @@ def _validate_strategy_pricing( f"market {market_id}: strategy_min_xch_price_usd must be <= strategy_max_xch_price_usd" ) - expiry_unit_raw = pricing.get("strategy_offer_expiry_unit") - expiry_value_raw = pricing.get("strategy_offer_expiry_value") - expiry_unit = str(expiry_unit_raw).strip().lower() if expiry_unit_raw is not None else None - has_expiry_unit = bool(expiry_unit) - has_expiry_value = expiry_value_raw is not None - if has_expiry_unit != has_expiry_value: + if ( + pricing.get("strategy_offer_expiry_unit") is not None + or pricing.get("strategy_offer_expiry_value") is not None + ): raise ValueError( - f"market {market_id}: strategy_offer_expiry_unit and strategy_offer_expiry_value must be set together" + f"market {market_id}: strategy_offer_expiry_unit/value are no longer supported; use strategy_offer_expiry_minutes" ) - if has_expiry_unit: - if expiry_unit not in {"minutes", "hours"}: - raise ValueError( - f"market {market_id}: strategy_offer_expiry_unit must be one of: minutes, hours" - ) - if expiry_value_raw is None: + + expiry_minutes_raw = pricing.get("strategy_offer_expiry_minutes") + if expiry_minutes_raw is not None: + try: + expiry_minutes = int(expiry_minutes_raw) + except (TypeError, ValueError) as exc: raise ValueError( - f"market {market_id}: strategy_offer_expiry_unit and strategy_offer_expiry_value must be set together" + f"market {market_id}: strategy_offer_expiry_minutes must be an integer" + ) from exc + if expiry_minutes <= 0: + raise ValueError(f"market {market_id}: strategy_offer_expiry_minutes must be positive") + if quote_type == "unstable" and expiry_minutes > 15: + warnings.warn( + f"market {market_id}: unstable strategy_offer_expiry_minutes={expiry_minutes} exceeds 15 minutes", + stacklevel=2, ) + + threshold_raw = pricing.get("cancel_move_threshold_bps") + if threshold_raw is not None: try: - expiry_value = int(expiry_value_raw) + threshold = int(threshold_raw) except (TypeError, ValueError) as exc: raise ValueError( - f"market {market_id}: strategy_offer_expiry_value must be an integer" + f"market {market_id}: cancel_move_threshold_bps must be an integer" ) from exc - if expiry_value <= 0: - raise ValueError(f"market {market_id}: strategy_offer_expiry_value must be positive") + if threshold <= 0: + raise ValueError(f"market {market_id}: cancel_move_threshold_bps must be positive") def _uses_cat_units(asset_id: str) -> bool: @@ -191,6 +205,8 @@ def canonicalize_asset_unit_mojo_multiplier( market_id: str, ) -> int: if raw_value in (None, ""): + if str(asset_id).strip().lower() in _XCH_UNIT_SYMBOLS: + return _CANONICAL_XCH_UNIT_MOJOS return _CANONICAL_CAT_UNIT_MOJOS try: multiplier = int(raw_value) diff --git a/greenfloor/core/strategy.py b/greenfloor/core/strategy.py index 0b11657..1a8cf8d 100644 --- a/greenfloor/core/strategy.py +++ b/greenfloor/core/strategy.py @@ -10,6 +10,7 @@ class MarketState: tens: int hundreds: int xch_price_usd: float | None = None + bucket_counts_by_size: dict[int, int] | None = None @dataclass(frozen=True, slots=True) @@ -21,8 +22,8 @@ class StrategyConfig: target_spread_bps: int | None = None min_xch_price_usd: float | None = None max_xch_price_usd: float | None = None - offer_expiry_unit: str | None = None - offer_expiry_value: int | None = None + offer_expiry_minutes: int | None = None + target_counts_by_size: dict[int, int] | None = None @dataclass(frozen=True, slots=True) @@ -38,10 +39,36 @@ class PlannedAction: side: str = "sell" -_PAIR_EXPIRY_CONFIG: dict[str, tuple[str, int]] = { - "xch": ("minutes", 10), - "usdc": ("minutes", 10), -} +_DEFAULT_OFFER_EXPIRY_MINUTES = 10 + + +def _strategy_target_counts(config: StrategyConfig) -> list[tuple[int, int]]: + if config.target_counts_by_size: + return sorted( + ( + (int(size), int(target)) + for size, target in config.target_counts_by_size.items() + if int(size) > 0 and int(target) >= 0 + ), + key=lambda entry: entry[0], + ) + return [ + (1, int(config.ones_target)), + (10, int(config.tens_target)), + (100, int(config.hundreds_target)), + ] + + +def _state_count_for_size(state: MarketState, size: int) -> int: + if state.bucket_counts_by_size is not None: + return int(state.bucket_counts_by_size.get(size, 0)) + if size == 1: + return int(state.ones) + if size == 10: + return int(state.tens) + if size == 100: + return int(state.hundreds) + return 0 def evaluate_market( @@ -60,23 +87,15 @@ def evaluate_market( return [] if config.max_xch_price_usd is not None and state.xch_price_usd > config.max_xch_price_usd: return [] - expiry_unit, expiry_value = _PAIR_EXPIRY_CONFIG.get(pair, _PAIR_EXPIRY_CONFIG["xch"]) - configured_expiry_unit = str(config.offer_expiry_unit or "").strip().lower() - configured_expiry_value = ( - int(config.offer_expiry_value) if config.offer_expiry_value is not None else None + expiry_minutes = ( + int(config.offer_expiry_minutes) + if config.offer_expiry_minutes is not None and int(config.offer_expiry_minutes) > 0 + else _DEFAULT_OFFER_EXPIRY_MINUTES ) - if configured_expiry_unit in {"minutes", "hours"} and configured_expiry_value is not None: - if configured_expiry_value > 0: - expiry_unit, expiry_value = configured_expiry_unit, configured_expiry_value - - offer_configs = [ - (1, state.ones, config.ones_target), - (10, state.tens, config.tens_target), - (100, state.hundreds, config.hundreds_target), - ] actions: list[PlannedAction] = [] - for size, current, target in offer_configs: + for size, target in _strategy_target_counts(config): + current = _state_count_for_size(state, size) if current < target: actions.append( PlannedAction( @@ -84,8 +103,8 @@ def evaluate_market( repeat=target - current, side="sell", pair=pair, - expiry_unit=expiry_unit, - expiry_value=expiry_value, + expiry_unit="minutes", + expiry_value=expiry_minutes, cancel_after_create=True, reason="below_target", target_spread_bps=config.target_spread_bps, diff --git a/greenfloor/daemon/main.py b/greenfloor/daemon/main.py index 5abe278..6819346 100644 --- a/greenfloor/daemon/main.py +++ b/greenfloor/daemon/main.py @@ -26,11 +26,12 @@ extract_coinset_tx_ids_from_offer_payload, ) from greenfloor.adapters.dexie import DexieAdapter -from greenfloor.adapters.price import PriceAdapter +from greenfloor.adapters.price import PriceAdapter, XchPriceProvider from greenfloor.adapters.splash import SplashAdapter from greenfloor.adapters.wallet import WalletAdapter from greenfloor.cloud_wallet_offer_runtime import ( build_and_post_offer_cloud_wallet, + is_transient_dexie_visibility_404_error, resolve_cloud_wallet_offer_asset_ids, ) from greenfloor.config.io import ( @@ -48,7 +49,7 @@ from greenfloor.core.strategy import MarketState, PlannedAction, StrategyConfig, evaluate_market from greenfloor.daemon.coinset_ws import CoinsetWebsocketClient, capture_coinset_websocket_once from greenfloor.daemon.reservations import AssetReservationCoordinator -from greenfloor.hex_utils import is_hex_id +from greenfloor.hex_utils import default_mojo_multiplier_for_asset, is_hex_id from greenfloor.keys.router import resolve_market_key from greenfloor.logging_setup import ( initialize_service_file_logging, @@ -122,6 +123,10 @@ def _coin_meets_coin_op_min_amount(coin: dict[str, Any], *, canonical_asset_id: return amount >= _coin_op_min_amount_mojos(canonical_asset_id=canonical_asset_id) +def _coin_op_target_amount_allowed(*, amount_mojos: int, canonical_asset_id: str) -> bool: + return int(amount_mojos) >= _coin_op_min_amount_mojos(canonical_asset_id=canonical_asset_id) + + def _coin_matches_direct_spendable_lookup( *, wallet: Any, @@ -238,6 +243,10 @@ def _warn_if_log_level_auto_healed(*, program, program_path: Path) -> None: ) +def _log_daemon_event(*, level: int, payload: dict[str, Any]) -> None: + _daemon_logger.log(level, "daemon_event %s", json.dumps(payload, sort_keys=True)) + + def _consume_reload_marker(state_dir: Path) -> bool: marker = state_dir / "reload_request.json" if not marker.exists(): @@ -291,7 +300,16 @@ def _resolve_db_path(program_home_dir: str, explicit_db_path: str | None) -> Pat return (Path(program_home_dir).expanduser() / "db" / "greenfloor.sqlite").resolve() -def _cancel_move_threshold_bps() -> int: +def _cancel_move_threshold_bps(*, market: Any | None = None) -> int: + pricing = dict(getattr(market, "pricing", {}) or {}) if market is not None else {} + threshold_raw = pricing.get("cancel_move_threshold_bps") + if threshold_raw is not None: + try: + parsed_threshold = int(threshold_raw) + except (TypeError, ValueError): + parsed_threshold = 0 + if parsed_threshold > 0: + return parsed_threshold raw = os.getenv("GREENFLOOR_UNSTABLE_CANCEL_MOVE_BPS", "").strip() if not raw: return _DEFAULT_CANCEL_MOVE_THRESHOLD_BPS @@ -477,6 +495,22 @@ def _market_pricing(market: Any) -> dict[str, Any]: return dict(getattr(market, "pricing", {}) or {}) +def _normalize_target_counts( + raw: dict, + *, + defaults: dict[int, int] | None = None, +) -> dict[int, int]: + """Normalize a {size: target_count} mapping from config or ladder data. + + Drops non-positive sizes, clamps negative targets to zero, and falls back + to *defaults* when the result would otherwise be empty. + """ + out = {int(k): max(0, int(v)) for k, v in raw.items() if int(k) > 0} + if not out and defaults: + return dict(defaults) + return out + + def _strategy_config_from_market(market) -> StrategyConfig: sell_ladder = market.ladders.get("sell", []) targets_by_size = {int(e.size_base_units): int(e.target_count) for e in sell_ladder} @@ -500,17 +534,18 @@ def _to_float(value: Any) -> float | None: return None return parsed + normalized_targets = _normalize_target_counts(targets_by_size, defaults={1: 5, 10: 2, 100: 1}) + return StrategyConfig( pair=_normalize_strategy_pair(market.quote_asset), - ones_target=int(targets_by_size.get(1, 5)), - tens_target=int(targets_by_size.get(10, 2)), - hundreds_target=int(targets_by_size.get(100, 1)), + ones_target=int(normalized_targets.get(1, 0)), + tens_target=int(normalized_targets.get(10, 0)), + hundreds_target=int(normalized_targets.get(100, 0)), target_spread_bps=_to_int(pricing.get("strategy_target_spread_bps")), min_xch_price_usd=_to_float(pricing.get("strategy_min_xch_price_usd")), max_xch_price_usd=_to_float(pricing.get("strategy_max_xch_price_usd")), - offer_expiry_unit=str(pricing.get("strategy_offer_expiry_unit", "")).strip().lower() - or None, - offer_expiry_value=_to_int(pricing.get("strategy_offer_expiry_value")), + offer_expiry_minutes=_to_int(pricing.get("strategy_offer_expiry_minutes")), + target_counts_by_size=normalized_targets, ) @@ -520,22 +555,23 @@ def _strategy_config_for_side(*, market: Any, side: str) -> StrategyConfig: targets_by_size = {int(e.size_base_units): int(e.target_count) for e in side_ladder} pricing = _market_pricing(market) - expiry_value_raw = pricing.get("strategy_offer_expiry_value") - expiry_value: int | None = None - if expiry_value_raw is not None: + expiry_minutes_raw = pricing.get("strategy_offer_expiry_minutes") + expiry_minutes: int | None = None + if expiry_minutes_raw is not None: try: - expiry_value = int(expiry_value_raw) + expiry_minutes = int(expiry_minutes_raw) except (TypeError, ValueError): - expiry_value = None + expiry_minutes = None + + normalized_targets = _normalize_target_counts(targets_by_size) return StrategyConfig( pair=_normalize_strategy_pair(market.quote_asset), - ones_target=int(targets_by_size.get(1, 0)), - tens_target=int(targets_by_size.get(10, 0)), - hundreds_target=int(targets_by_size.get(100, 0)), - offer_expiry_unit=str(pricing.get("strategy_offer_expiry_unit", "")).strip().lower() - or None, - offer_expiry_value=expiry_value, + ones_target=int(normalized_targets.get(1, 0)), + tens_target=int(normalized_targets.get(10, 0)), + hundreds_target=int(normalized_targets.get(100, 0)), + offer_expiry_minutes=expiry_minutes, + target_counts_by_size=normalized_targets, ) @@ -544,14 +580,70 @@ def _strategy_state_from_bucket_counts( *, xch_price_usd: float | None, ) -> MarketState: + normalized_bucket_counts = {int(size): int(count) for size, count in bucket_counts.items()} return MarketState( - ones=int(bucket_counts.get(1, 0)), - tens=int(bucket_counts.get(10, 0)), - hundreds=int(bucket_counts.get(100, 0)), + ones=int(normalized_bucket_counts.get(1, 0)), + tens=int(normalized_bucket_counts.get(10, 0)), + hundreds=int(normalized_bucket_counts.get(100, 0)), xch_price_usd=xch_price_usd, + bucket_counts_by_size=normalized_bucket_counts, ) +def _effective_sell_bucket_counts_for_coin_ops( + *, + sell_ladder: list[Any], + wallet_bucket_counts: dict[int, int], + active_sell_offer_counts_by_size: dict[int, int] | None, + newly_executed_sell_offer_counts_by_size: dict[int, int] | None = None, +) -> dict[int, int]: + effective_counts = dict(wallet_bucket_counts) + active_sell_counts = active_sell_offer_counts_by_size or {} + newly_executed_sell_counts = newly_executed_sell_offer_counts_by_size or {} + for entry in sell_ladder: + size_base_units = int(getattr(entry, "size_base_units", 0)) + if size_base_units <= 0: + continue + target_count = max(0, int(getattr(entry, "target_count", 0))) + newly_executed_sell_count = max(0, int(newly_executed_sell_counts.get(size_base_units, 0))) + wallet_count = max( + 0, + int(wallet_bucket_counts.get(size_base_units, 0)) - newly_executed_sell_count, + ) + active_sell_count = max(0, int(active_sell_counts.get(size_base_units, 0))) + effective_active_sell_count = active_sell_count + newly_executed_sell_count + # Count live sell offers toward the market target, but not toward the + # split buffer. That preserves at most one extra ready coin above the + # active sell ladder coverage. + effective_counts[size_base_units] = wallet_count + min( + effective_active_sell_count, + target_count, + ) + return effective_counts + + +def _executed_sell_offer_counts_by_size(offer_execution: dict[str, Any]) -> dict[int, int]: + counts: dict[int, int] = {} + items = offer_execution.get("items", []) + if not isinstance(items, list): + return counts + for item in items: + if not isinstance(item, dict): + continue + if str(item.get("status", "")).strip().lower() != "executed": + continue + if _normalize_offer_side(item.get("side", "sell")) != "sell": + continue + try: + size = int(item.get("size", 0)) + except (TypeError, ValueError): + continue + if size <= 0: + continue + counts[size] = counts.get(size, 0) + 1 + return counts + + def _evaluate_two_sided_market_actions( *, market: Any, @@ -589,6 +681,16 @@ def _evaluate_two_sided_market_actions( OfferLifecycleState.REFRESH_DUE.value, } _RESEED_MEMPOOL_MAX_AGE_SECONDS = 3 * 60 +_PENDING_VISIBILITY_RECHECK_MAX_AGE_SECONDS = 2 * 60 +_PENDING_VISIBILITY_REASON = "cloud_wallet_post_success_dexie_visibility_pending" + + +@dataclass(frozen=True, slots=True) +class _OfferExecutionMetadata: + size: int + side: str | None + reason: str + created_at: str def _is_recent_mempool_observed_offer_state( @@ -618,6 +720,12 @@ def _is_recent_mempool_observed_offer_state( def _strategy_target_counts_by_size(strategy_config: StrategyConfig) -> dict[int, int]: + if strategy_config.target_counts_by_size: + return { + int(size): int(target) + for size, target in sorted(strategy_config.target_counts_by_size.items()) + if int(size) > 0 and int(target) >= 0 + } return { 1: int(strategy_config.ones_target), 10: int(strategy_config.tens_target), @@ -673,14 +781,15 @@ def _parse_offer_side_metadata(value: Any) -> str | None: def _recent_offer_metadata_by_offer_id( *, store: SqliteStore, market_id: str -) -> dict[str, tuple[int, str | None]]: +) -> dict[str, _OfferExecutionMetadata]: events = store.list_recent_audit_events( event_types=["strategy_offer_execution"], market_id=market_id, limit=1500, ) - metadata_by_offer_id: dict[str, tuple[int, str | None]] = {} + metadata_by_offer_id: dict[str, _OfferExecutionMetadata] = {} for event in events: + created_at = str(event.get("created_at", "")).strip() payload = event.get("payload") if not isinstance(payload, dict): continue @@ -702,12 +811,107 @@ def _recent_offer_metadata_by_offer_id( if size <= 0: continue side = _parse_offer_side_metadata(item.get("side")) + reason = str(item.get("reason", "")).strip() # Events are returned newest-first; keep first (latest) mapping. if offer_id not in metadata_by_offer_id: - metadata_by_offer_id[offer_id] = (size, side) + metadata_by_offer_id[offer_id] = _OfferExecutionMetadata( + size=size, + side=side, + reason=reason, + created_at=created_at, + ) return metadata_by_offer_id +def _parse_event_created_at(value: Any) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + normalized = raw.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed + + +def _expiry_seconds_for_action(action: PlannedAction) -> int | None: + unit = str(action.expiry_unit or "").strip().lower() + try: + value = int(action.expiry_value) + except (TypeError, ValueError): + return None + if value <= 0: + return None + unit_seconds = { + "second": 1, + "seconds": 1, + "minute": 60, + "minutes": 60, + "hour": 60 * 60, + "hours": 60 * 60, + "day": 24 * 60 * 60, + "days": 24 * 60 * 60, + }.get(unit) + if unit_seconds is None: + return None + return value * unit_seconds + + +def _apply_action_cadence_gate( + *, + actions: list[PlannedAction], + target_counts_by_side: dict[str, dict[int, int]], + active_counts_by_side: dict[str, dict[int, int]], + store: SqliteStore, + market_id: str, + clock: datetime, +) -> tuple[list[PlannedAction], list[dict[str, Any]]]: + _ = target_counts_by_side, active_counts_by_side, store, market_id, clock + passthrough_actions = [action for action in actions if int(action.repeat) > 0] + return passthrough_actions, [] + + +def _is_stale_pending_visibility_offer( + *, + offer_id: str, + metadata: _OfferExecutionMetadata, + dexie_size_by_offer_id: dict[str, int] | None, + clock: datetime, + max_age_seconds: int = _PENDING_VISIBILITY_RECHECK_MAX_AGE_SECONDS, +) -> bool: + if metadata.reason != _PENDING_VISIBILITY_REASON: + return False + if dexie_size_by_offer_id is None: + # No Dexie visibility snapshot available this cycle. + return False + if offer_id in dexie_size_by_offer_id: + return False + created_at_raw = str(metadata.created_at).strip() + if not created_at_raw: + return True + normalized = created_at_raw.replace("Z", "+00:00") + try: + created_at = datetime.fromisoformat(normalized) + except ValueError: + return True + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=UTC) + return (clock - created_at).total_seconds() > float(max_age_seconds) + + +def _is_dexie_offer_missing_error(error: Exception) -> bool: + raw = str(error).strip() + if not raw: + return False + normalized = raw.lower() + return is_transient_dexie_visibility_404_error(raw) or ( + "http error 404" in normalized and "not found" in normalized + ) + + def _recent_executed_offer_ids(*, store: SqliteStore, market_id: str) -> set[str]: events = store.list_recent_audit_events( event_types=["strategy_offer_execution"], @@ -777,6 +981,11 @@ def _match_watched_coin_ids(*, observed_coin_ids: list[str]) -> dict[str, list[s return matches +def _watched_coin_ids_for_market(*, market_id: str) -> set[str]: + with _WATCHED_COIN_IDS_LOCK: + return set(_WATCHED_COIN_IDS_BY_MARKET.get(market_id, set())) + + def _update_market_coin_watchlist_from_dexie( *, market, @@ -848,7 +1057,7 @@ def _active_offer_state_summary( market_id: str, clock: datetime, limit: int = 500, -) -> tuple[list[str], dict[str, int], dict[str, tuple[int, str | None]]]: +) -> tuple[list[str], dict[str, int], dict[str, _OfferExecutionMetadata]]: offer_states = store.list_offer_states(market_id=market_id, limit=limit) state_counts: dict[str, int] = {} for item in offer_states: @@ -882,6 +1091,7 @@ def _active_offer_counts_by_size( clock: datetime, limit: int = 500, dexie_size_by_offer_id: dict[str, int] | None = None, + tracked_sizes: set[int] | None = None, ) -> tuple[dict[int, int], dict[str, int], int]: active_offer_ids, state_counts, metadata_by_offer_id = _active_offer_state_summary( store=store, @@ -889,11 +1099,24 @@ def _active_offer_counts_by_size( clock=clock, limit=limit, ) - active_counts_by_size: dict[int, int] = {1: 0, 10: 0, 100: 0} + normalized_sizes = ( + {int(size) for size in tracked_sizes if int(size) > 0} + if tracked_sizes is not None + else {1, 10, 100} + ) + active_counts_by_size: dict[int, int] = {size: 0 for size in sorted(normalized_sizes)} active_unmapped_offer_ids = 0 for offer_id in active_offer_ids: metadata = metadata_by_offer_id.get(offer_id) - size = metadata[0] if metadata is not None else None + if metadata is not None and _is_stale_pending_visibility_offer( + offer_id=offer_id, + metadata=metadata, + dexie_size_by_offer_id=dexie_size_by_offer_id, + clock=clock, + ): + active_unmapped_offer_ids += 1 + continue + size = metadata.size if metadata is not None else None if size is None and dexie_size_by_offer_id: size = dexie_size_by_offer_id.get(offer_id) if size in active_counts_by_size: @@ -910,10 +1133,16 @@ def _active_offer_counts_by_size_and_side( clock: datetime, limit: int = 500, dexie_size_by_offer_id: dict[str, int] | None = None, + tracked_sizes: set[int] | None = None, ) -> tuple[dict[str, dict[int, int]], dict[str, int], int]: + normalized_sizes = ( + {int(size) for size in tracked_sizes if int(size) > 0} + if tracked_sizes is not None + else {1, 10, 100} + ) counts_by_side: dict[str, dict[int, int]] = { - "buy": {1: 0, 10: 0, 100: 0}, - "sell": {1: 0, 10: 0, 100: 0}, + "buy": {size: 0 for size in sorted(normalized_sizes)}, + "sell": {size: 0 for size in sorted(normalized_sizes)}, } active_offer_ids, state_counts, metadata_by_offer_id = _active_offer_state_summary( store=store, @@ -924,8 +1153,16 @@ def _active_offer_counts_by_size_and_side( active_unmapped_offer_ids = 0 for offer_id in active_offer_ids: metadata = metadata_by_offer_id.get(offer_id) - size = metadata[0] if metadata is not None else None - side = metadata[1] if metadata is not None else None + if metadata is not None and _is_stale_pending_visibility_offer( + offer_id=offer_id, + metadata=metadata, + dexie_size_by_offer_id=dexie_size_by_offer_id, + clock=clock, + ): + active_unmapped_offer_ids += 1 + continue + size = metadata.size if metadata is not None else None + side = metadata.side if metadata is not None else None if metadata is None or side is None: # Do not assume buy/sell direction when metadata is unavailable. active_unmapped_offer_ids += 1 @@ -964,6 +1201,7 @@ def _inject_reseed_action_if_no_active_offers( market_id=market.market_id, clock=clock, dexie_size_by_offer_id=dexie_size_by_offer_id, + tracked_sizes=set(target_by_size.keys()), ) missing_by_size = { size: max(0, int(target_by_size.get(size, 0)) - int(active_counts_by_size.get(size, 0))) @@ -984,7 +1222,7 @@ def _inject_reseed_action_if_no_active_offers( return strategy_actions seed_candidates = evaluate_market( - state=MarketState(ones=0, tens=0, hundreds=0, xch_price_usd=xch_price_usd), + state=_strategy_state_from_bucket_counts({}, xch_price_usd=xch_price_usd), config=strategy_config, clock=clock, ) @@ -1032,6 +1270,28 @@ def _inject_reseed_action_if_no_active_offers( candidate_sizes=sorted(one_per_size), ) return strategy_actions + reseed_actions, cadence_limited_sizes = _apply_action_cadence_gate( + actions=reseed_actions, + target_counts_by_side={"buy": {}, "sell": dict(target_by_size)}, + active_counts_by_side={ + "buy": {}, + "sell": {int(size): int(count) for size, count in active_counts_by_size.items()}, + }, + store=store, + market_id=market.market_id, + clock=clock, + ) + if not reseed_actions: + _log_market_decision( + market.market_id, + "reseed_skip", + reason="reseed_cadence_gate_active", + active_counts_by_size=active_counts_by_size, + target_counts_by_size=target_by_size, + missing_by_size=missing_by_size, + cadence_limited_sizes=cadence_limited_sizes, + ) + return strategy_actions _log_market_decision( market.market_id, @@ -1046,6 +1306,7 @@ def _inject_reseed_action_if_no_active_offers( pair=strategy_config.pair, expiry_unit=reseed_actions[0].expiry_unit, expiry_value=int(reseed_actions[0].expiry_value), + cadence_limited_sizes=cadence_limited_sizes, ) return reseed_actions @@ -1095,14 +1356,15 @@ def _build_offer_for_action( "reason": f"offer_builder_failed:{exc}", "offer": None, } + resolved_quote_asset = _resolve_quote_asset_for_offer( + quote_asset=str(market.quote_asset), + network=network, + ) payload = { "market_id": market.market_id, "base_asset": market.base_asset, "base_symbol": market.base_symbol, - "quote_asset": _resolve_quote_asset_for_offer( - quote_asset=str(market.quote_asset), - network=network, - ), + "quote_asset": resolved_quote_asset, "quote_asset_type": market.quote_asset_type, "receive_address": market.receive_address, "size_base_units": int(action.size), @@ -1114,8 +1376,18 @@ def _build_offer_for_action( "expiry_unit": action.expiry_unit, "expiry_value": int(action.expiry_value), "quote_price_quote_per_base": quote_price, - "base_unit_mojo_multiplier": int(pricing.get("base_unit_mojo_multiplier", 1000)), - "quote_unit_mojo_multiplier": int(pricing.get("quote_unit_mojo_multiplier", 1000)), + "base_unit_mojo_multiplier": int( + pricing.get( + "base_unit_mojo_multiplier", + default_mojo_multiplier_for_asset(str(market.base_asset)), + ) + ), + "quote_unit_mojo_multiplier": int( + pricing.get( + "quote_unit_mojo_multiplier", + default_mojo_multiplier_for_asset(str(resolved_quote_asset)), + ) + ), "key_id": market.signer_key_id, "keyring_yaml_path": keyring_yaml_path, "network": network, @@ -1221,10 +1493,11 @@ def _cloud_wallet_spendable_profiles_by_asset( def _base_unit_mojo_multiplier_for_market(*, market: Any) -> int: pricing = getattr(market, "pricing", {}) or {} + default_multiplier = default_mojo_multiplier_for_asset(str(getattr(market, "base_asset", ""))) try: - multiplier = int(pricing.get("base_unit_mojo_multiplier", 1000)) + multiplier = int(pricing.get("base_unit_mojo_multiplier", default_multiplier)) except (TypeError, ValueError): - multiplier = 1000 + multiplier = default_multiplier return max(1, multiplier) @@ -1643,6 +1916,17 @@ def _execute_single_cloud_wallet_action( offer_id=cloud_wallet_offer_id, ) if not visible: + # Transient 404 → Dexie propagation lag; mark as pending so the + # active-count reader keeps the offer in scope until the grace + # period expires (see _is_stale_pending_visibility_offer). + if is_transient_dexie_visibility_404_error(visibility_error or ""): + return { + "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), + "status": "executed", + "reason": _PENDING_VISIBILITY_REASON, + "offer_id": cloud_wallet_offer_id or None, + } return { "size": action.size, "side": _normalize_offer_side(getattr(action, "side", "sell")), @@ -1745,9 +2029,8 @@ def _execute_single_local_action( def _expand_strategy_actions(strategy_actions: list[Any]) -> list[Any]: - ordered_actions = sorted(strategy_actions, key=lambda action: int(action.size), reverse=True) expanded_actions: list[Any] = [] - for action in ordered_actions: + for action in strategy_actions: expanded_actions.extend(action for _ in range(int(action.repeat))) return expanded_actions @@ -1796,7 +2079,6 @@ def _single_input_preferred_skip_reason( def _prepare_parallel_cloud_wallet_submission( *, - program: Any, market: Any, action: Any, cloud_wallet: CloudWalletAdapter, @@ -1804,8 +2086,7 @@ def _prepare_parallel_cloud_wallet_submission( resolved_quote_asset_id: str, resolved_xch_asset_id: str, fee_amount_mojos: int, - reservation_coordinator: AssetReservationCoordinator, -) -> tuple[str | None, dict[str, Any] | None]: +) -> tuple[dict[str, int] | None, dict[str, int] | None, dict[str, Any] | None]: requested_amounts = _reservation_request_for_cloud_offer_with_assets( market=market, action=action, @@ -1815,7 +2096,11 @@ def _prepare_parallel_cloud_wallet_submission( fee_amount_mojos=fee_amount_mojos, ) if not requested_amounts: - return None, _cloud_wallet_skip_item(action=action, reason="reservation_invalid_request") + return ( + None, + None, + _cloud_wallet_skip_item(action=action, reason="reservation_invalid_request"), + ) spendable_profiles = _cloud_wallet_spendable_profiles_by_asset( wallet=cloud_wallet, asset_ids=set(requested_amounts.keys()), @@ -1828,19 +2113,8 @@ def _prepare_parallel_cloud_wallet_submission( spendable_profiles=spendable_profiles, ) if single_input_skip_reason: - return None, _cloud_wallet_skip_item(action=action, reason=single_input_skip_reason) - acquired = reservation_coordinator.try_acquire( - market_id=str(market.market_id), - wallet_id=str(program.cloud_wallet_vault_id).strip(), - requested_amounts=requested_amounts, - available_amounts=available_amounts, - ) - if not acquired.ok or not acquired.reservation_id: - return None, _cloud_wallet_skip_item( - action=action, - reason=str(acquired.error or "reservation_rejected"), - ) - return str(acquired.reservation_id), None + return None, None, _cloud_wallet_skip_item(action=action, reason=single_input_skip_reason) + return requested_amounts, available_amounts, None def _execute_strategy_actions( @@ -1883,37 +2157,97 @@ def _execute_strategy_actions( ) ) fee_amount_mojos = _estimate_cloud_offer_fee_reservation_mojos(program=program) - submissions: list[tuple[int, Any, str]] = [] + # Health-check the coordinator once per batch before dispatching. + # Using an empty request avoids any lease writes while still + # surfacing storage/runtime failures early so we can fail over. + reservation_coordinator.try_acquire( + market_id=str(market.market_id), + wallet_id=str(program.cloud_wallet_vault_id).strip(), + requested_amounts={}, + available_amounts={}, + ) + submissions: list[tuple[int, Any, dict[str, int], dict[str, int]]] = [] for submit_index, action in enumerate(expanded_actions): - reservation_id, skip_item = _prepare_parallel_cloud_wallet_submission( - program=program, - market=market, - action=action, - cloud_wallet=cloud_wallet, - resolved_base_asset_id=resolved_base_asset_id, - resolved_quote_asset_id=resolved_quote_asset_id, - resolved_xch_asset_id=resolved_xch_asset_id, - fee_amount_mojos=fee_amount_mojos, - reservation_coordinator=reservation_coordinator, + requested_amounts, available_amounts, skip_item = ( + _prepare_parallel_cloud_wallet_submission( + market=market, + action=action, + cloud_wallet=cloud_wallet, + resolved_base_asset_id=resolved_base_asset_id, + resolved_quote_asset_id=resolved_quote_asset_id, + resolved_xch_asset_id=resolved_xch_asset_id, + fee_amount_mojos=fee_amount_mojos, + ) ) if skip_item is not None: items.append(skip_item) continue - assert reservation_id is not None - submissions.append((submit_index, action, reservation_id)) + assert requested_amounts is not None + assert available_amounts is not None + submissions.append((submit_index, action, requested_amounts, available_amounts)) if submissions: + coordinator = reservation_coordinator + assert coordinator is not None + wallet_id = str(program.cloud_wallet_vault_id).strip() max_workers = min( len(submissions), max(1, int(getattr(program, "runtime_offer_parallelism_max_workers", 4))), ) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: - future_to_submission: dict[ - concurrent.futures.Future[dict[str, Any]], tuple[int, str] - ] = {} - for submit_index, action, reservation_id in submissions: - future = pool.submit( - _execute_single_cloud_wallet_action, + _log_market_decision( + str(getattr(market, "market_id", "")), + "parallel_offer_dispatch", + planned_count=len(expanded_actions), + queued_count=len(submissions), + workers=max_workers, + ) + + def _run_parallel_submission( + *, + submit_index: int, + action: Any, + requested_amounts: dict[str, int], + available_amounts: dict[str, int], + queued_at_monotonic: float, + ) -> dict[str, Any]: + queue_wait_ms = int((time.monotonic() - queued_at_monotonic) * 1000) + _log_market_decision( + str(getattr(market, "market_id", "")), + "parallel_offer_queue_wait", + submit_index=submit_index, + size=int(getattr(action, "size", 0)), + side=_normalize_offer_side(getattr(action, "side", "sell")), + queue_wait_ms=queue_wait_ms, + ) + acquire_started = time.monotonic() + acquired = coordinator.try_acquire( + market_id=str(market.market_id), + wallet_id=wallet_id, + requested_amounts=requested_amounts, + available_amounts=available_amounts, + ) + acquire_ms = int((time.monotonic() - acquire_started) * 1000) + if not acquired.ok or not acquired.reservation_id: + return { + **_cloud_wallet_skip_item( + action=action, + reason=str(acquired.error or "reservation_rejected"), + ), + "queue_wait_ms": queue_wait_ms, + "reservation_acquire_ms": acquire_ms, + } + reservation_id = str(acquired.reservation_id) + reserved_at = time.monotonic() + _log_market_decision( + str(getattr(market, "market_id", "")), + "parallel_offer_reservation_acquired", + submit_index=submit_index, + reservation_id=reservation_id, + queue_wait_ms=queue_wait_ms, + reservation_acquire_ms=acquire_ms, + ) + try: + item = _execute_single_cloud_wallet_action( program=program, market=market, action=action, @@ -1921,10 +2255,50 @@ def _execute_strategy_actions( runtime_dry_run=runtime_dry_run, dexie=dexie, ) - future_to_submission[future] = (submit_index, reservation_id) + except Exception as exc: + item = { + "size": 0, + "side": "sell", + "status": "skipped", + "reason": f"parallel_offer_worker_error:{exc}", + "offer_id": None, + } + release_status = ( + "released_success" + if str(item.get("status", "")).strip().lower() == "executed" + else "released_failed" + ) + coordinator.release(reservation_id=reservation_id, status=release_status) + reservation_hold_ms = int((time.monotonic() - reserved_at) * 1000) + _log_market_decision( + str(getattr(market, "market_id", "")), + "parallel_offer_reservation_released", + submit_index=submit_index, + reservation_id=reservation_id, + release_status=release_status, + reservation_hold_ms=reservation_hold_ms, + ) + item["reservation_id"] = reservation_id + item["queue_wait_ms"] = queue_wait_ms + item["reservation_acquire_ms"] = acquire_ms + item["reservation_hold_ms"] = reservation_hold_ms + return item + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: + future_to_submission: dict[concurrent.futures.Future[dict[str, Any]], int] = {} + for submit_index, action, requested_amounts, available_amounts in submissions: + future = pool.submit( + _run_parallel_submission, + submit_index=submit_index, + action=action, + requested_amounts=requested_amounts, + available_amounts=available_amounts, + queued_at_monotonic=time.monotonic(), + ) + future_to_submission[future] = submit_index submitted_items: list[tuple[int, dict[str, Any]]] = [] for future in concurrent.futures.as_completed(future_to_submission): - submit_index, reservation_id = future_to_submission[future] + submit_index = future_to_submission[future] try: item = future.result() except Exception as exc: @@ -1935,15 +2309,6 @@ def _execute_strategy_actions( "reason": f"parallel_offer_worker_error:{exc}", "offer_id": None, } - release_status = ( - "released_success" - if str(item.get("status", "")).strip().lower() == "executed" - else "released_failed" - ) - reservation_coordinator.release( - reservation_id=reservation_id, status=release_status - ) - item["reservation_id"] = reservation_id submitted_items.append((submit_index, item)) for _, item in sorted(submitted_items, key=lambda pair: pair[0]): if item.get("status") == "executed": @@ -2024,7 +2389,7 @@ def _execute_cancel_policy_for_market( quote_type = str(market.quote_asset_type).strip().lower() pricing = _market_pricing(market) stable_vs_unstable = bool(pricing.get("cancel_policy_stable_vs_unstable", False)) - threshold_bps = _cancel_move_threshold_bps() + threshold_bps = _cancel_move_threshold_bps(market=market) if quote_type != "unstable": return { "eligible": False, @@ -2154,6 +2519,7 @@ class _MarketCycleResult: def _reconcile_offer_states( *, market: Any, + network: str, dexie: DexieAdapter, store: SqliteStore, now: datetime, @@ -2166,13 +2532,18 @@ def _reconcile_offer_states( beyond-cap individually-fetched offers. """ dexie_fetch_error: str | None = None + dexie_offered_asset = str(market.base_asset).strip() + dexie_requested_asset = _resolve_quote_asset_for_offer( + quote_asset=str(market.quote_asset), + network=network, + ) try: - offers = dexie.get_offers(market.base_asset, market.quote_asset) + offers = dexie.get_offers(dexie_offered_asset, dexie_requested_asset) _log_market_decision( market.market_id, "dexie_offers_fetched", - offered=market.base_asset, - requested=market.quote_asset, + offered=dexie_offered_asset, + requested=dexie_requested_asset, count=len(offers), ) except Exception as exc: # pragma: no cover - network dependent @@ -2213,16 +2584,55 @@ def _reconcile_offer_states( # Refresh all of our watched offers individually. Dexie list snapshots can # lag status transitions; direct offer fetches make lifecycle state updates # deterministic for strategy planning. + missing_watched_offer_ids: set[str] = set() for watched_offer_id in sorted(our_offer_ids): try: single_payload = dexie.get_offer(watched_offer_id, timeout=5) single_offer = single_payload.get("offer") if isinstance(single_payload, dict) else None if isinstance(single_offer, dict): augmented_by_id[watched_offer_id] = single_offer - except Exception: # pragma: no cover - network dependent + except Exception as exc: # pragma: no cover - network dependent + if _is_dexie_offer_missing_error(exc): + transition = apply_offer_signal(OfferLifecycleState.OPEN, OfferSignal.EXPIRED) + missing_watched_offer_ids.add(watched_offer_id) + _log_market_decision( + market.market_id, + "offer_transition", + offer_id=watched_offer_id, + dexie_status=None, + signal_source="dexie_get_offer_404", + old_state=transition.old_state.value, + new_state=transition.new_state.value, + signal=transition.signal.value, + ) + store.upsert_offer_state( + offer_id=watched_offer_id, + market_id=market.market_id, + state=transition.new_state.value, + last_seen_status=None, + ) + store.add_audit_event( + "offer_lifecycle_transition", + { + "offer_id": watched_offer_id, + "market_id": market.market_id, + "old_state": transition.old_state.value, + "new_state": transition.new_state.value, + "signal": transition.signal.value, + "action": transition.action, + "reason": transition.reason, + "dexie_status": None, + "signal_source": "dexie_get_offer_404", + "dexie_error": str(exc), + "coinset_tx_ids": [], + "coinset_confirmed_tx_ids": [], + "coinset_mempool_tx_ids": [], + }, + market_id=market.market_id, + ) continue - for beyond_offer_id in beyond_cap_ids: + for beyond_offer_id in beyond_cap_ids - missing_watched_offer_ids: try: single_payload = dexie.get_offer(beyond_offer_id, timeout=5) single_offer = single_payload.get("offer") if isinstance(single_payload, dict) else None @@ -2325,10 +2735,18 @@ def _evaluate_and_execute_strategy( dexie_size_by_offer_id: dict[str, int], result: _MarketCycleResult, reservation_coordinator: AssetReservationCoordinator | None = None, -) -> None: +) -> tuple[dict[str, dict[int, int]], dict[int, int]]: """Evaluate market strategy, inject reseed if needed, and execute offer actions.""" market_mode = str(getattr(market, "mode", "")).strip().lower() strategy_config = _strategy_config_from_market(market) + tracked_sizes = { + int(entry.size_base_units) + for side_entries in (getattr(market, "ladders", {}) or {}).values() + for entry in side_entries + if int(getattr(entry, "size_base_units", 0)) > 0 + } + if not tracked_sizes: + tracked_sizes = set(_strategy_target_counts_by_size(strategy_config).keys()) if market_mode == "two_sided": offer_counts_by_side, offer_state_counts, active_unmapped_offer_ids = ( _active_offer_counts_by_size_and_side( @@ -2336,12 +2754,21 @@ def _evaluate_and_execute_strategy( market_id=market.market_id, clock=now, dexie_size_by_offer_id=dexie_size_by_offer_id, + tracked_sizes=tracked_sizes, ) ) active_offer_counts_by_size = { - 1: int(offer_counts_by_side["buy"][1]) + int(offer_counts_by_side["sell"][1]), - 10: int(offer_counts_by_side["buy"][10]) + int(offer_counts_by_side["sell"][10]), - 100: int(offer_counts_by_side["buy"][100]) + int(offer_counts_by_side["sell"][100]), + size: int(offer_counts_by_side["buy"].get(size, 0)) + + int(offer_counts_by_side["sell"].get(size, 0)) + for size in sorted(tracked_sizes) + } + target_counts_by_side = { + "buy": _strategy_target_counts_by_size( + _strategy_config_for_side(market=market, side="buy") + ), + "sell": _strategy_target_counts_by_size( + _strategy_config_for_side(market=market, side="sell") + ), } else: active_offer_counts_by_size, offer_state_counts, active_unmapped_offer_ids = ( @@ -2350,12 +2777,17 @@ def _evaluate_and_execute_strategy( market_id=market.market_id, clock=now, dexie_size_by_offer_id=dexie_size_by_offer_id, + tracked_sizes=tracked_sizes, ) ) offer_counts_by_side = { - "buy": {1: 0, 10: 0, 100: 0}, + "buy": {size: 0 for size in sorted(tracked_sizes)}, "sell": dict(active_offer_counts_by_size), } + target_counts_by_side = { + "buy": {}, + "sell": _strategy_target_counts_by_size(strategy_config), + } _log_market_decision( market.market_id, "strategy_state_source", @@ -2380,6 +2812,14 @@ def _evaluate_and_execute_strategy( config=strategy_config, clock=now, ) + strategy_actions, cadence_limited_sizes = _apply_action_cadence_gate( + actions=strategy_actions, + target_counts_by_side=target_counts_by_side, + active_counts_by_side=offer_counts_by_side, + store=store, + market_id=market.market_id, + clock=now, + ) _log_market_decision( market.market_id, "strategy_evaluated", @@ -2388,6 +2828,7 @@ def _evaluate_and_execute_strategy( offer_counts=active_offer_counts_by_size, xch_price_usd=xch_price_usd, action_count=len(strategy_actions), + cadence_limited_sizes=cadence_limited_sizes, ) if market_mode != "two_sided": strategy_actions = _inject_reseed_action_if_no_active_offers( @@ -2461,6 +2902,7 @@ def _evaluate_and_execute_strategy( }, market_id=market.market_id, ) + return offer_counts_by_side, _executed_sell_offer_counts_by_size(offer_execution) def _plan_and_execute_coin_ops( @@ -2470,11 +2912,52 @@ def _plan_and_execute_coin_ops( wallet: WalletAdapter, store: SqliteStore, sell_ladder: list[Any], - bucket_counts: dict[int, int], + wallet_bucket_counts: dict[int, int], + active_sell_offer_counts_by_size: dict[int, int] | None, + newly_executed_sell_offer_counts_by_size: dict[int, int] | None, signer_selection: Any, state_dir: Path, ) -> None: """Plan and execute coin split/combine operations for a market.""" + bucket_counts = _effective_sell_bucket_counts_for_coin_ops( + sell_ladder=sell_ladder, + wallet_bucket_counts=wallet_bucket_counts, + active_sell_offer_counts_by_size=active_sell_offer_counts_by_size, + newly_executed_sell_offer_counts_by_size=newly_executed_sell_offer_counts_by_size, + ) + base_unit_mojo_multiplier = _base_unit_mojo_multiplier_for_market(market=market) + canonical_base_asset_id = str(getattr(market, "base_asset", "")).strip() + invalid_buckets: list[dict[str, int]] = [] + valid_sell_ladder: list[Any] = [] + for entry in sell_ladder: + size_base_units = int(getattr(entry, "size_base_units", 0)) + if size_base_units <= 0: + continue + target_amount_mojos = size_base_units * int(base_unit_mojo_multiplier) + if _coin_op_target_amount_allowed( + amount_mojos=target_amount_mojos, + canonical_asset_id=canonical_base_asset_id, + ): + valid_sell_ladder.append(entry) + continue + invalid_buckets.append( + { + "size_base_units": size_base_units, + "target_amount_mojos": int(target_amount_mojos), + "minimum_allowed_mojos": int( + _coin_op_min_amount_mojos(canonical_asset_id=canonical_base_asset_id) + ), + } + ) + if invalid_buckets: + _log_market_decision( + market.market_id, + "coin_ops_skip_sub_minimum_target_amount", + invalid_bucket_count=len(invalid_buckets), + invalid_buckets=invalid_buckets, + ) + if not valid_sell_ladder: + return buckets = [ BucketSpec( size_base_units=e.size_base_units, @@ -2483,7 +2966,7 @@ def _plan_and_execute_coin_ops( combine_when_excess_factor=e.combine_when_excess_factor, current_count=int(bucket_counts.get(e.size_base_units, 0)), ) - for e in sell_ladder + for e in valid_sell_ladder ] plans = plan_coin_ops( buckets=buckets, @@ -2770,7 +3253,48 @@ def _spendable_asset_scoped_coins(coins: list[dict[str, Any]]) -> list[dict[str, try: if op_type == "split": + if op_count == 1: + # A one-output split only manufactures bookkeeping churn. + # Let the market continue rather than creating a cosmetic + # "split 1 coin into 1 coin" transaction. + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "split_single_coin_noop_skipped", + "operation_id": None, + } + ) + continue amount_per_coin_mojos = size_base_units * base_unit_mojo_multiplier + canonical_asset_id = str(getattr(market, "base_asset", "")).strip() + # Defensive inner check: _plan_and_execute_coin_ops filters the + # ladder before reaching here, but callers that bypass that + # layer (e.g. direct tests or future call sites) also get a + # clean rejection rather than a failed RPC call. + if not _coin_op_target_amount_allowed( + amount_mojos=amount_per_coin_mojos, + canonical_asset_id=canonical_asset_id, + ): + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "split_amount_below_coin_op_minimum", + "operation_id": None, + "data": { + "amount_per_coin_mojos": int(amount_per_coin_mojos), + "minimum_allowed_mojos": int( + _coin_op_min_amount_mojos(canonical_asset_id=canonical_asset_id) + ), + }, + } + ) + continue required_amount = amount_per_coin_mojos * op_count coins = cloud_wallet.list_coins( asset_id=resolved_base_asset_id, include_pending=True @@ -3008,13 +3532,68 @@ def _spendable_asset_scoped_coins(coins: list[dict[str, Any]]) -> list[dict[str, if op_type == "combine": requested_number_of_coins = max(2, op_count) capped_number_of_coins = min(requested_number_of_coins, combine_input_cap) + target_coin_amount_mojos = size_base_units * base_unit_mojo_multiplier + canonical_asset_id = str(getattr(market, "base_asset", "")).strip() + # Defensive inner check — see comment in the split branch above. + if not _coin_op_target_amount_allowed( + amount_mojos=target_coin_amount_mojos, + canonical_asset_id=canonical_asset_id, + ): + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "combine_target_amount_below_coin_op_minimum", + "operation_id": None, + "data": { + "target_coin_amount_mojos": int(target_coin_amount_mojos), + "minimum_allowed_mojos": int( + _coin_op_min_amount_mojos(canonical_asset_id=canonical_asset_id) + ), + }, + } + ) + continue + watched_coin_ids = _watched_coin_ids_for_market( + market_id=str(getattr(market, "market_id", "")).strip() + ) + exact_bucket_coin_ids: list[str] = [] + for coin in _spendable_asset_scoped_coins( + cloud_wallet.list_coins(asset_id=resolved_base_asset_id, include_pending=True) + ): + coin_id = str(coin.get("id", "")).strip() + if not coin_id or coin_id.lower() in watched_coin_ids: + continue + try: + amount_mojos = int(coin.get("amount", 0)) + except (TypeError, ValueError): + continue + if amount_mojos != target_coin_amount_mojos: + continue + exact_bucket_coin_ids.append(coin_id) + combine_input_coin_ids = exact_bucket_coin_ids[:capped_number_of_coins] + if len(combine_input_coin_ids) < 2: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "no_spendable_combine_coin_available", + "operation_id": None, + } + ) + continue result = _combine_coins_with_retry( cloud_wallet=cloud_wallet, combine_kwargs={ - "number_of_coins": capped_number_of_coins, + "number_of_coins": len(combine_input_coin_ids), "fee": int(program.coin_ops_combine_fee_mojos), "asset_id": resolved_base_asset_id, "largest_first": True, + "input_coin_ids": combine_input_coin_ids, }, ) signature_request_id = str(result.get("signature_request_id", "")).strip() @@ -3040,11 +3619,12 @@ def _spendable_asset_scoped_coins(coins: list[dict[str, Any]]) -> list[dict[str, "operation_id": signature_request_id, "data": { "requested_number_of_coins": int(requested_number_of_coins), - "submitted_number_of_coins": int(capped_number_of_coins), + "submitted_number_of_coins": int(len(combine_input_coin_ids)), "input_coin_cap_applied": bool( capped_number_of_coins < requested_number_of_coins ), "input_coin_cap": int(combine_input_cap), + "input_coin_ids": combine_input_coin_ids, }, } ) @@ -3157,12 +3737,13 @@ def _process_single_market( "receive_address": event.receive_address, "reason": event.reason, } - print(json.dumps(payload)) + _log_daemon_event(level=logging.INFO, payload=payload) store.add_audit_event("low_inventory_alert", payload, market_id=market.market_id) send_pushover_alert(program, event) _, dexie_size_by_offer_id, _, offers = _reconcile_offer_states( market=market, + network=program.app_network, dexie=dexie, store=store, now=now, @@ -3304,7 +3885,7 @@ def _process_single_market( }, market_id=market.market_id, ) - _evaluate_and_execute_strategy( + offer_counts_by_side, newly_executed_sell_offer_counts_by_size = _evaluate_and_execute_strategy( market=market, program=program, dexie=dexie, @@ -3322,7 +3903,9 @@ def _process_single_market( wallet=wallet, store=store, sell_ladder=sell_ladder, - bucket_counts=bucket_counts, + wallet_bucket_counts=bucket_counts, + active_sell_offer_counts_by_size=offer_counts_by_side.get("sell", {}), + newly_executed_sell_offer_counts_by_size=newly_executed_sell_offer_counts_by_size, signer_selection=signer_selection, state_dir=state_dir, ) @@ -3410,7 +3993,22 @@ def run_once( dexie = DexieAdapter(program.dexie_api_base) splash = SplashAdapter(program.splash_api_base) wallet = WalletAdapter() - price = PriceAdapter() + cloud_wallet_price_fn = None + if _cloud_wallet_configured(program): + try: + cloud_wallet_price_fn = _new_cloud_wallet_adapter_for_daemon( + program + ).get_chia_usd_quote + except Exception as exc: + store.add_audit_event( + "xch_price_provider_init_error", + {"provider": "cloud_wallet_quote", "error": str(exc)}, + ) + price = XchPriceProvider( + cloud_wallet_price_fn=cloud_wallet_price_fn, + cloud_wallet_ttl_seconds=120, + fallback_price_adapter=PriceAdapter(), + ) previous_xch_price_usd = store.get_latest_xch_price_snapshot() reservation_coordinator: AssetReservationCoordinator | None = None if bool( @@ -3704,7 +4302,7 @@ def _write(store: SqliteStore) -> None: program=current_program, ) if _consume_reload_marker(state_dir): - print(json.dumps({"event": "config_reloaded"})) + _log_daemon_event(level=logging.INFO, payload={"event": "config_reloaded"}) time.sleep(max(1, current_program.runtime_loop_interval_seconds)) current_program = load_program_config(program_path) except KeyboardInterrupt: @@ -3809,7 +4407,18 @@ def _default_testnet_markets_config_path() -> str: state_dir=state_dir, ) except RuntimeError as exc: - print(json.dumps({"event": "daemon_lock_conflict", "error": str(exc)})) + try: + program = load_program_config(Path(args.program_config)) + _initialize_daemon_file_logging( + program.home_dir, log_level=getattr(program, "app_log_level", "INFO") + ) + _warn_if_log_level_auto_healed(program=program, program_path=Path(args.program_config)) + except Exception: + pass + _log_daemon_event( + level=logging.ERROR, + payload={"event": "daemon_lock_conflict", "error": str(exc)}, + ) raise SystemExit(3) from exc raise SystemExit(exit_code) diff --git a/greenfloor/hex_utils.py b/greenfloor/hex_utils.py index ac6d973..e328c95 100644 --- a/greenfloor/hex_utils.py +++ b/greenfloor/hex_utils.py @@ -1,12 +1,17 @@ """Shared hex identifier utilities. Canonical validation and normalization for 64-character hex identifiers -(asset IDs, coin IDs, transaction IDs). +(asset IDs, coin IDs, transaction IDs), plus shared asset-type helpers. """ from __future__ import annotations _HEX_CHARS = frozenset("0123456789abcdef") +# Symbols that identify the native XCH/TXCH coin (asset id "1" is the Chia +# internal representation used in some wallet APIs). +_XCH_ASSET_SYMBOLS = frozenset({"xch", "txch", "1"}) +_CANONICAL_XCH_MOJOS = 1_000_000_000_000 +_CANONICAL_CAT_MOJOS = 1_000 def is_hex_id(value: str) -> bool: @@ -17,6 +22,19 @@ def is_hex_id(value: str) -> bool: return len(normalized) == 64 and all(ch in _HEX_CHARS for ch in normalized) +def canonical_is_xch(asset_id: str) -> bool: + """Return True if *asset_id* refers to the native XCH/TXCH coin.""" + return str(asset_id or "").strip().lower() in _XCH_ASSET_SYMBOLS + + +def default_mojo_multiplier_for_asset(asset_id: str) -> int: + """Return the canonical mojo-per-unit multiplier for *asset_id*. + + XCH/TXCH uses 10^12 mojos per XCH; CATs use 1 000 mojos per CAT unit. + """ + return _CANONICAL_XCH_MOJOS if canonical_is_xch(asset_id) else _CANONICAL_CAT_MOJOS + + def normalize_hex_id(value: object) -> str: """Normalize a hex identifier: strip, lowercase, remove 0x prefix. diff --git a/greenfloor/logging_setup.py b/greenfloor/logging_setup.py index 27f6b4e..93c8455 100644 --- a/greenfloor/logging_setup.py +++ b/greenfloor/logging_setup.py @@ -73,6 +73,8 @@ def apply_level_to_root( # --------------------------------------------------------------------------- _initialized_services: dict[str, ConcurrentRotatingFileHandler] = {} +_active_handler: ConcurrentRotatingFileHandler | None = None +_active_service_name: str | None = None def initialize_service_file_logging( @@ -93,13 +95,19 @@ def initialize_service_file_logging( Returns the handler (or *None* if a handler already exists and *allow_reinit_level* is False). """ + global _active_handler, _active_service_name effective_level = coerce_log_level(log_level) - handler = _initialized_services.get(service_name) + known_handler = _initialized_services.get(service_name) + if known_handler is not None and not allow_reinit_level: + return known_handler + handler = _active_handler if handler is None: handler = create_rotating_file_handler(service_name=service_name, home_dir=home_dir) logging.getLogger().addHandler(handler) - _initialized_services[service_name] = handler - elif not allow_reinit_level: + _active_handler = handler + _active_service_name = service_name + _initialized_services[service_name] = handler + if not allow_reinit_level and known_handler is None and _active_service_name != service_name: return handler apply_level_to_root(effective_level=effective_level, logger=service_logger, handler=handler) return handler diff --git a/scripts/reconcile_byc_wusdc.py b/scripts/reconcile_byc_wusdc.py new file mode 100644 index 0000000..5e5ccce --- /dev/null +++ b/scripts/reconcile_byc_wusdc.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Reconcile BYC/wUSDC holdings against Cloud Wallet offers. + +Usage: + .venv/bin/python scripts/reconcile_byc_wusdc.py + .venv/bin/python scripts/reconcile_byc_wusdc.py --json + +This script is read-only. It fetches: + - wallet asset totals (total/spendable/locked) + - coin-level settled/pending/spendable sums for BYC and wUSDC.b + - paginated creator offers, classified as buy/sell for BYC:wUSDC + +The goal is to make inventory discrepancies easy to spot in one output. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from greenfloor.cli.manager import ( # noqa: E402 + _new_cloud_wallet_adapter, + _require_cloud_wallet_config, + _resolve_cloud_wallet_asset_id, +) +from greenfloor.config.io import load_program_config # noqa: E402 + + +def _graphql_with_retry( + *, wallet: Any, query: str, variables: dict[str, Any], retries: int = 4 +) -> dict[str, Any]: + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + return wallet._graphql(query=query, variables=variables) + except Exception as err: # pragma: no cover - defensive around remote API instability + last_error = err + if attempt == retries: + raise + time.sleep(0.3 * attempt) + if last_error: + raise last_error + return {} + + +def _wallet_asset_rows(*, wallet: Any) -> list[dict[str, Any]]: + query = """ +query walletAssetAmounts($walletId: ID!, $first: Int) { + wallet(id: $walletId) { + assets(first: $first) { + edges { + node { + assetId + totalAmount + spendableAmount + lockedAmount + } + } + } + } +} +""" + payload = _graphql_with_retry( + wallet=wallet, + query=query, + variables={"walletId": wallet.vault_id, "first": 100}, + ) + edges = ((payload.get("wallet") or {}).get("assets") or {}).get("edges") or [] + rows: list[dict[str, Any]] = [] + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(node, dict): + continue + rows.append( + { + "asset_id": str(node.get("assetId", "")).strip(), + "total": int(node.get("totalAmount", 0) or 0), + "spendable": int(node.get("spendableAmount", 0) or 0), + "locked": int(node.get("lockedAmount", 0) or 0), + } + ) + return rows + + +def _coins_summary(*, wallet: Any, asset_id: str) -> dict[str, int]: + coins = wallet.list_coins(asset_id=asset_id, include_pending=True) + settled = 0 + pending = 0 + spendable = 0 + total_items = 0 + for coin in coins: + amount = int(coin.get("amount", 0)) + state = str(coin.get("state", "")).strip().upper() + total_items += amount + if state == "SETTLED": + settled += amount + if state == "PENDING": + pending += amount + if not bool(coin.get("isLocked", False)) and state in {"SETTLED", "CONFIRMED", "UNSPENT"}: + spendable += amount + return { + "coin_count": len(coins), + "items_total": total_items, + "settled": settled, + "pending": pending, + "spendable_estimate": spendable, + } + + +def _fetch_creator_offers(*, wallet: Any) -> list[dict[str, Any]]: + query = """ +query walletOffers($walletId: ID!, $first: Int, $after: String) { + wallet(id: $walletId) { + offers(first: $first, after: $after, isCreator: true) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + state + createdAt + assets(first: 10) { + edges { + node { + amount + type + asset { + id + } + } + } + } + } + } + } + } +} +""" + after: str | None = None + offers: list[dict[str, Any]] = [] + for _ in range(64): + payload = _graphql_with_retry( + wallet=wallet, + query=query, + variables={"walletId": wallet.vault_id, "first": 100, "after": after}, + ) + offers_payload = (payload.get("wallet") or {}).get("offers") or {} + edges = offers_payload.get("edges") or [] + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if isinstance(node, dict): + offers.append(node) + page_info = offers_payload.get("pageInfo") or {} + if not bool(page_info.get("hasNextPage", False)): + break + after_val = page_info.get("endCursor") + if not isinstance(after_val, str) or not after_val: + break + after = after_val + return offers + + +def _classify_offer( + *, + offer: dict[str, Any], + byc_asset_id: str, + quote_asset_id: str, +) -> tuple[str, int, int] | None: + legs = [] + for edge in (offer.get("assets") or {}).get("edges") or []: + node = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(node, dict): + continue + asset = node.get("asset") or {} + if not isinstance(asset, dict): + continue + legs.append( + ( + str(asset.get("id", "")).strip(), + str(node.get("type", "")).strip().upper(), + int(node.get("amount", 0) or 0), + ) + ) + if not legs: + return None + legmap = {(asset_id, leg_type): amount for asset_id, leg_type, amount in legs} + if (byc_asset_id, "OFFERED") in legmap and (quote_asset_id, "REQUESTED") in legmap: + return ("sell", legmap[(byc_asset_id, "OFFERED")], legmap[(quote_asset_id, "REQUESTED")]) + if (quote_asset_id, "OFFERED") in legmap and (byc_asset_id, "REQUESTED") in legmap: + return ("buy", legmap[(byc_asset_id, "REQUESTED")], legmap[(quote_asset_id, "OFFERED")]) + return None + + +def _to_units(value: int) -> str: + return f"{value / 1000:.3f}" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Reconcile BYC/wUSDC balances and offer ledger.") + parser.add_argument( + "--program-config", + default=str(Path("~/.greenfloor/config/program.yaml").expanduser()), + help="Path to program.yaml", + ) + parser.add_argument("--vault-id", default="", help="Optional Cloud Wallet vault override") + parser.add_argument("--byc", default="BYC", help="BYC asset reference (default: BYC)") + parser.add_argument( + "--quote", default="wUSDC.b", help="Quote asset reference (default: wUSDC.b)" + ) + parser.add_argument("--json", action="store_true", help="Output JSON report") + args = parser.parse_args() + + program = load_program_config(Path(args.program_config)) + wallet = _new_cloud_wallet_adapter(program) + if args.vault_id.strip() and args.vault_id.strip() != wallet.vault_id: + cfg = _require_cloud_wallet_config(program) + cfg = cfg.__class__( + base_url=cfg.base_url, + user_key_id=cfg.user_key_id, + private_key_pem_path=cfg.private_key_pem_path, + vault_id=args.vault_id.strip(), + network=cfg.network, + kms_key_id=cfg.kms_key_id, + kms_region=cfg.kms_region, + kms_public_key_hex=cfg.kms_public_key_hex, + ) + from greenfloor.adapters.cloud_wallet import ( + CloudWalletAdapter, # local import to avoid script startup side effects + ) + + wallet = CloudWalletAdapter(cfg) + + byc_asset_id = _resolve_cloud_wallet_asset_id( + wallet=wallet, + canonical_asset_id=args.byc, + symbol_hint=args.byc, + ) + quote_asset_id = _resolve_cloud_wallet_asset_id( + wallet=wallet, + canonical_asset_id=args.quote, + symbol_hint=args.quote, + ) + + asset_rows = _wallet_asset_rows(wallet=wallet) + asset_map = {row["asset_id"]: row for row in asset_rows} + byc_asset = asset_map.get(byc_asset_id, {"total": 0, "spendable": 0, "locked": 0}) + quote_asset = asset_map.get(quote_asset_id, {"total": 0, "spendable": 0, "locked": 0}) + byc_coins = _coins_summary(wallet=wallet, asset_id=byc_asset_id) + quote_coins = _coins_summary(wallet=wallet, asset_id=quote_asset_id) + + offers = _fetch_creator_offers(wallet=wallet) + settled = {"buy_count": 0, "sell_count": 0, "byc_mojos": 0, "quote_mojos": 0} + open_offers = {"buy_count": 0, "sell_count": 0, "byc_mojos": 0, "quote_mojos": 0} + matched_offers = 0 + for offer in offers: + classified = _classify_offer( + offer=offer, + byc_asset_id=byc_asset_id, + quote_asset_id=quote_asset_id, + ) + if classified is None: + continue + matched_offers += 1 + side, byc_mojos, quote_mojos = classified + state = str(offer.get("state", "")).strip().upper() + bucket = settled if state == "SETTLED" else open_offers if state == "OPEN" else None + if bucket is None: + continue + bucket[f"{side}_count"] += 1 + bucket["byc_mojos"] += byc_mojos if side == "buy" else -byc_mojos + bucket["quote_mojos"] += -quote_mojos if side == "buy" else quote_mojos + + ui_total = int(byc_asset["total"]) + int(quote_asset["total"]) + report = { + "vault_id": wallet.vault_id, + "resolved_assets": {"byc": byc_asset_id, "quote": quote_asset_id}, + "wallet_assets": { + "byc": byc_asset, + "quote": quote_asset, + }, + "coin_state": { + "byc": byc_coins, + "quote": quote_coins, + }, + "offers": { + "fetched_creator_offers": len(offers), + "matched_byc_quote_offers": matched_offers, + "settled": settled, + "open": open_offers, + }, + "derived": { + "ui_total_mojos": ui_total, + "ui_total_units": _to_units(ui_total), + "ui_plus_byc_pending_mojos": ui_total + int(byc_coins["pending"]), + "ui_plus_byc_pending_units": _to_units(ui_total + int(byc_coins["pending"])), + "settled_net_quote_mojos": int(settled["quote_mojos"]), + "settled_net_quote_units": _to_units(int(settled["quote_mojos"])), + "settled_net_byc_mojos": int(settled["byc_mojos"]), + "settled_net_byc_units": _to_units(int(settled["byc_mojos"])), + }, + } + + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + print(f"vault_id: {report['vault_id']}") + print(f"assets: BYC={byc_asset_id} QUOTE={quote_asset_id}") + print() + print("wallet assets (mojos / units):") + print( + f" BYC total={byc_asset['total']} ({_to_units(int(byc_asset['total']))})" + f" spendable={byc_asset['spendable']} ({_to_units(int(byc_asset['spendable']))})" + f" locked={byc_asset['locked']} ({_to_units(int(byc_asset['locked']))})" + ) + print( + f" QUOTE total={quote_asset['total']} ({_to_units(int(quote_asset['total']))})" + f" spendable={quote_asset['spendable']} ({_to_units(int(quote_asset['spendable']))})" + f" locked={quote_asset['locked']} ({_to_units(int(quote_asset['locked']))})" + ) + print() + print("coin state sums (from list_coins):") + print( + f" BYC settled={byc_coins['settled']} ({_to_units(byc_coins['settled'])})" + f" pending={byc_coins['pending']} ({_to_units(byc_coins['pending'])})" + f" item_sum={byc_coins['items_total']} ({_to_units(byc_coins['items_total'])})" + ) + print( + f" QUOTE settled={quote_coins['settled']} ({_to_units(quote_coins['settled'])})" + f" pending={quote_coins['pending']} ({_to_units(quote_coins['pending'])})" + f" item_sum={quote_coins['items_total']} ({_to_units(quote_coins['items_total'])})" + ) + print() + print("offer ledger (creator offers, BYC<->QUOTE only):") + print( + f" settled: buy_count={settled['buy_count']} sell_count={settled['sell_count']}" + f" net_byc={settled['byc_mojos']} ({_to_units(settled['byc_mojos'])})" + f" net_quote={settled['quote_mojos']} ({_to_units(settled['quote_mojos'])})" + ) + print( + f" open: buy_count={open_offers['buy_count']} sell_count={open_offers['sell_count']}" + f" net_byc={open_offers['byc_mojos']} ({_to_units(open_offers['byc_mojos'])})" + f" net_quote={open_offers['quote_mojos']} ({_to_units(open_offers['quote_mojos'])})" + ) + print() + print( + f"derived: ui_total={ui_total} ({_to_units(ui_total)})" + f" ui_plus_byc_pending={ui_total + int(byc_coins['pending'])}" + f" ({_to_units(ui_total + int(byc_coins['pending']))})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/trace_locked_quote_coins.py b/scripts/trace_locked_quote_coins.py new file mode 100644 index 0000000..399d3df --- /dev/null +++ b/scripts/trace_locked_quote_coins.py @@ -0,0 +1,657 @@ +#!/usr/bin/env python3 +"""Trace hidden locked quote coins in a Cloud Wallet vault. + +Usage: + .venv/bin/python scripts/trace_locked_quote_coins.py + .venv/bin/python scripts/trace_locked_quote_coins.py --json + .venv/bin/python scripts/trace_locked_quote_coins.py --coin-id + +This script is read-only. It is meant for cases where a wallet asset shows a +non-zero locked balance, but the normal coin list only exposes a smaller +spendable subset. The script: + + - fetches the quote asset totals from `wallet.assets` + - fetches the raw quote coin set with `includeSpent=true` + - isolates current unspent locked coins + - walks each locked coin's local lineage + - fetches nearby creator offers that offer the quote asset + - inspects linked wallet transactions and reservation-split relations + +The goal is to make stale reservation / create-offer locks visible in one run. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from greenfloor.cli.manager import ( # noqa: E402 + _new_cloud_wallet_adapter, + _require_cloud_wallet_config, + _resolve_cloud_wallet_asset_id, +) +from greenfloor.config.io import load_program_config # noqa: E402 + + +def _graphql_with_retry( + *, + wallet: Any, + query: str, + variables: dict[str, Any], + retries: int = 5, +) -> dict[str, Any]: + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + return wallet._graphql(query=query, variables=variables) + except Exception as err: # pragma: no cover - defensive around remote API instability + last_error = err + if attempt == retries: + raise + time.sleep(0.5 * attempt) + if last_error: + raise last_error + return {} + + +def _parse_dt(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _coin_hex(value: Any) -> str: + text = str(value or "").strip() + if text.startswith("CoinRecord_"): + return text.removeprefix("CoinRecord_").lower() + return text.lower() + + +def _wallet_asset_row(*, wallet: Any, asset_id: str) -> dict[str, int] | None: + query = """ +query walletAssetAmounts($walletId: ID!, $first: Int) { + wallet(id: $walletId) { + assets(first: $first) { + edges { + node { + assetId + totalAmount + spendableAmount + lockedAmount + } + } + } + } +} +""" + payload = _graphql_with_retry( + wallet=wallet, + query=query, + variables={"walletId": wallet.vault_id, "first": 100}, + ) + edges = ((payload.get("wallet") or {}).get("assets") or {}).get("edges") or [] + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(node, dict): + continue + if str(node.get("assetId", "")).strip() != asset_id: + continue + return { + "total": int(node.get("totalAmount", 0) or 0), + "spendable": int(node.get("spendableAmount", 0) or 0), + "locked": int(node.get("lockedAmount", 0) or 0), + } + return None + + +def _fetch_quote_coins(*, wallet: Any, asset_id: str) -> list[dict[str, Any]]: + query = """ +query quoteCoins( + $walletId: ID! + $assetId: ID + $includePending: Boolean + $includeSpent: Boolean + $first: Int +) { + coins( + walletId: $walletId + assetId: $assetId + includePending: $includePending + includeSpent: $includeSpent + sortKey: CREATED_AT + first: $first + ) { + edges { + node { + id + name + createdAt + createdBlockHeight + spentBlockHeight + amount + state + isLocked + isLinkedToOpenOffer + puzzleHash + parentCoinName + } + } + } +} +""" + payload = _graphql_with_retry( + wallet=wallet, + query=query, + variables={ + "walletId": wallet.vault_id, + "assetId": asset_id, + "includePending": True, + "includeSpent": True, + "first": 100, + }, + ) + edges = (payload.get("coins") or {}).get("edges") or [] + rows: list[dict[str, Any]] = [] + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(node, dict): + continue + rows.append( + { + "id": str(node.get("id", "")).strip(), + "coin_id": _coin_hex(node.get("id")), + "name": str(node.get("name", "")).strip().lower(), + "created_at": str(node.get("createdAt", "")).strip(), + "created_dt": _parse_dt(node.get("createdAt")), + "created_block_height": node.get("createdBlockHeight"), + "spent_block_height": node.get("spentBlockHeight"), + "amount": int(node.get("amount", 0) or 0), + "state": str(node.get("state", "")).strip().upper(), + "is_locked": bool(node.get("isLocked", False)), + "is_linked_to_open_offer": bool(node.get("isLinkedToOpenOffer", False)), + "puzzle_hash": str(node.get("puzzleHash", "")).strip(), + "parent_coin_id": str(node.get("parentCoinName", "")).strip().lower(), + } + ) + return rows + + +def _current_locked_quote_coins(coins: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows = [ + coin + for coin in coins + if coin["state"] == "SETTLED" + and coin["spent_block_height"] is None + and coin["is_locked"] + and not coin["is_linked_to_open_offer"] + ] + rows.sort( + key=lambda coin: (coin["created_dt"] or datetime.min.replace(tzinfo=UTC), coin["coin_id"]) + ) + return rows + + +def _lineage_for_coin( + *, coin: dict[str, Any], coins_by_id: dict[str, dict[str, Any]] +) -> list[dict[str, Any]]: + lineage: list[dict[str, Any]] = [] + current = coin + seen: set[str] = set() + for _ in range(32): + coin_id = str(current.get("coin_id", "")).strip().lower() + if not coin_id or coin_id in seen: + break + seen.add(coin_id) + lineage.append(current) + parent_id = str(current.get("parent_coin_id", "")).strip().lower() + if not parent_id: + break + parent = coins_by_id.get(parent_id) + if parent is None: + break + current = parent + return lineage + + +def _fetch_creator_quote_offers_basic(*, wallet: Any, quote_asset_id: str) -> list[dict[str, Any]]: + query = """ +query walletOffers($walletId: ID!, $first: Int, $after: String) { + wallet(id: $walletId) { + offers(first: $first, after: $after, isCreator: true) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + offerId + state + settlementType + createdAt + expiresAt + assets(first: 4) { + edges { + node { + amount + type + asset { + id + } + } + } + } + } + } + } + } +} +""" + after: str | None = None + offers: list[dict[str, Any]] = [] + for _ in range(128): + payload = _graphql_with_retry( + wallet=wallet, + query=query, + variables={"walletId": wallet.vault_id, "first": 100, "after": after}, + ) + offers_payload = (payload.get("wallet") or {}).get("offers") or {} + edges = offers_payload.get("edges") or [] + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(node, dict): + continue + legs = [] + for asset_edge in (node.get("assets") or {}).get("edges") or []: + asset_node = asset_edge.get("node") if isinstance(asset_edge, dict) else None + if not isinstance(asset_node, dict): + continue + asset = asset_node.get("asset") or {} + if not isinstance(asset, dict): + continue + legs.append( + { + "asset_id": str(asset.get("id", "")).strip(), + "type": str(asset_node.get("type", "")).strip().upper(), + "amount": int(asset_node.get("amount", 0) or 0), + } + ) + if not any( + leg["asset_id"] == quote_asset_id and leg["type"] == "OFFERED" for leg in legs + ): + continue + offers.append( + { + "wallet_offer_id": str(node.get("id", "")).strip(), + "offer_id": str(node.get("offerId", "")).strip(), + "state": str(node.get("state", "")).strip(), + "settlement_type": node.get("settlementType"), + "created_at": str(node.get("createdAt", "")).strip(), + "created_dt": _parse_dt(node.get("createdAt")), + "expires_at": str(node.get("expiresAt", "")).strip(), + "legs": legs, + } + ) + page_info = offers_payload.get("pageInfo") or {} + if not bool(page_info.get("hasNextPage", False)): + break + after_value = page_info.get("endCursor") + if not isinstance(after_value, str) or not after_value: + break + after = after_value + return offers + + +def _fetch_wallet_offer_detail(*, wallet: Any, wallet_offer_id: str) -> dict[str, Any] | None: + query = """ +query walletOfferDetail($id: ID!) { + walletOffer(id: $id) { + id + offerId + state + settlementType + createdAt + expiresAt + assets(first: 4) { + edges { + node { + amount + type + asset { + id + } + } + } + } + transactions { + id + createdAt + type + fee + state + inputs { + edges { + node { + id + amount + asset { + id + } + } + } + } + outputs { + edges { + node { + id + amount + asset { + id + } + } + } + } + } + } +} +""" + payload = _graphql_with_retry(wallet=wallet, query=query, variables={"id": wallet_offer_id}) + node = payload.get("walletOffer") + if not isinstance(node, dict): + return None + return node if isinstance(node, dict) else None + + +def _transaction_summary(node: dict[str, Any]) -> dict[str, Any]: + def _legs(section: str) -> list[dict[str, Any]]: + edges = (node.get(section) or {}).get("edges") or [] + rows: list[dict[str, Any]] = [] + for edge in edges: + leg = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(leg, dict): + continue + asset = leg.get("asset") or {} + rows.append( + { + "coin_id": _coin_hex(leg.get("id")), + "amount": int(leg.get("amount", 0) or 0), + "asset_id": str(asset.get("id", "")).strip() if isinstance(asset, dict) else "", + } + ) + return rows + + return { + "id": str(node.get("id", "")).strip(), + "created_at": str(node.get("createdAt", "")).strip(), + "amount": int(node.get("amount", 0) or 0), + "type": str(node.get("type", "")).strip(), + "state": str(node.get("state", "")).strip(), + "inputs": _legs("inputs"), + "outputs": _legs("outputs"), + } + + +def _coin_match_summary(*, coin_id: str, tx: dict[str, Any]) -> dict[str, bool]: + inputs = {row["coin_id"] for row in tx.get("inputs", [])} + outputs = {row["coin_id"] for row in tx.get("outputs", [])} + return { + "tx_input_match": coin_id in inputs, + "tx_output_match": coin_id in outputs, + } + + +@dataclass +class CoinTrace: + coin: dict[str, Any] + lineage: list[dict[str, Any]] + nearby_offers: list[dict[str, Any]] + + +def _trace_coin( + *, + wallet: Any, + coin: dict[str, Any], + coins_by_id: dict[str, dict[str, Any]], + creator_quote_offers: list[dict[str, Any]], + window_hours: int, +) -> CoinTrace: + lineage = _lineage_for_coin(coin=coin, coins_by_id=coins_by_id) + coin_created = coin.get("created_dt") + nearby_offers: list[dict[str, Any]] = [] + if coin_created is None: + return CoinTrace(coin=coin, lineage=lineage, nearby_offers=nearby_offers) + + window = timedelta(hours=max(1, int(window_hours))) + candidates = [ + offer + for offer in creator_quote_offers + if offer["created_dt"] is not None and abs(offer["created_dt"] - coin_created) <= window + ] + candidates.sort(key=lambda offer: abs((offer["created_dt"] - coin_created).total_seconds())) + + for offer in candidates[:8]: + detail = _fetch_wallet_offer_detail(wallet=wallet, wallet_offer_id=offer["wallet_offer_id"]) + if not isinstance(detail, dict): + continue + summarized_transactions: list[dict[str, Any]] = [] + for raw_tx in detail.get("transactions") or []: + if not isinstance(raw_tx, dict): + continue + tx_detail = _transaction_summary(raw_tx) + match_summary = _coin_match_summary(coin_id=coin["coin_id"], tx=tx_detail) + summarized_transactions.append({**tx_detail, "match": match_summary}) + nearby_offers.append( + { + "wallet_offer_id": str(detail.get("id", "")).strip(), + "offer_id": str(detail.get("offerId", "")).strip(), + "state": str(detail.get("state", "")).strip(), + "settlement_type": detail.get("settlementType"), + "created_at": str(detail.get("createdAt", "")).strip(), + "expires_at": str(detail.get("expiresAt", "")).strip(), + "transactions": summarized_transactions, + } + ) + return CoinTrace(coin=coin, lineage=lineage, nearby_offers=nearby_offers) + + +def _render_text_report( + *, + vault_id: str, + quote_asset_id: str, + wallet_asset: dict[str, int] | None, + current_locked: list[dict[str, Any]], + traces: list[CoinTrace], +) -> str: + lines: list[str] = [] + lines.append(f"vault_id: {vault_id}") + lines.append(f"quote_asset_id: {quote_asset_id}") + if wallet_asset is not None: + lines.append( + "wallet_asset: " + f"total={wallet_asset['total']} " + f"spendable={wallet_asset['spendable']} " + f"locked={wallet_asset['locked']}" + ) + lines.append(f"current_locked_coin_count: {len(current_locked)}") + lines.append(f"current_locked_total: {sum(int(row['amount']) for row in current_locked)}") + lines.append("") + for trace in traces: + coin = trace.coin + lines.append( + "locked_coin: " + f"{coin['coin_id']} " + f"amount={coin['amount']} " + f"created_at={coin['created_at']} " + f"created_height={coin['created_block_height']}" + ) + lines.append("lineage:") + for row in trace.lineage: + lines.append( + " " + f"{row['coin_id']} " + f"amount={row['amount']} " + f"created_at={row['created_at']} " + f"spent_height={row['spent_block_height']}" + ) + if not trace.nearby_offers: + lines.append("nearby_offers: none") + lines.append("") + continue + lines.append("nearby_offers:") + for offer in trace.nearby_offers: + lines.append( + " " + f"{offer['wallet_offer_id']} " + f"state={offer['state']} " + f"settlement={offer['settlement_type']} " + f"created_at={offer['created_at']}" + ) + if not offer["transactions"]: + lines.append(" transactions: none") + continue + for tx in offer["transactions"]: + match = tx["match"] + lines.append( + " " + f"{tx['id']} " + f"type={tx['type']} " + f"state={tx['state']} " + f"match={json.dumps(match, sort_keys=True)}" + ) + reservation = tx.get("reservation") + if reservation is not None: + lines.append( + " " + f"reservation={reservation['id']} " + f"type={reservation['type']} " + f"state={reservation['state']}" + ) + lines.append("") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Trace hidden locked quote coins in a Cloud Wallet vault." + ) + parser.add_argument( + "--program-config", + default=str(Path("~/.greenfloor/config/program.yaml").expanduser()), + help="Path to program.yaml", + ) + parser.add_argument("--vault-id", default="", help="Optional Cloud Wallet vault override") + parser.add_argument( + "--quote", default="wUSDC.b", help="Quote asset reference (default: wUSDC.b)" + ) + parser.add_argument( + "--coin-id", + action="append", + default=[], + help="Restrict tracing to one or more coin ids (hex or CoinRecord_...)", + ) + parser.add_argument( + "--window-hours", + type=int, + default=24, + help="Creator-offer correlation window around locked coin creation time", + ) + parser.add_argument("--json", action="store_true", help="Output JSON report") + args = parser.parse_args() + + program = load_program_config(Path(args.program_config)) + wallet = _new_cloud_wallet_adapter(program) + if args.vault_id.strip() and args.vault_id.strip() != wallet.vault_id: + cfg = _require_cloud_wallet_config(program) + cfg = cfg.__class__( + base_url=cfg.base_url, + user_key_id=cfg.user_key_id, + private_key_pem_path=cfg.private_key_pem_path, + vault_id=args.vault_id.strip(), + network=cfg.network, + kms_key_id=cfg.kms_key_id, + kms_region=cfg.kms_region, + kms_public_key_hex=cfg.kms_public_key_hex, + ) + from greenfloor.adapters.cloud_wallet import CloudWalletAdapter # noqa: PLC0415 + + wallet = CloudWalletAdapter(cfg) + + quote_asset_id = _resolve_cloud_wallet_asset_id( + wallet=wallet, + canonical_asset_id=args.quote, + symbol_hint=args.quote, + ) + wallet_asset = _wallet_asset_row(wallet=wallet, asset_id=quote_asset_id) + quote_coins = _fetch_quote_coins(wallet=wallet, asset_id=quote_asset_id) + coins_by_id = {row["coin_id"]: row for row in quote_coins if row["coin_id"]} + current_locked = _current_locked_quote_coins(quote_coins) + + requested_coin_ids = {_coin_hex(value) for value in args.coin_id} + if requested_coin_ids: + current_locked = [row for row in current_locked if row["coin_id"] in requested_coin_ids] + + creator_quote_offers = _fetch_creator_quote_offers_basic( + wallet=wallet, quote_asset_id=quote_asset_id + ) + traces = [ + _trace_coin( + wallet=wallet, + coin=coin, + coins_by_id=coins_by_id, + creator_quote_offers=creator_quote_offers, + window_hours=args.window_hours, + ) + for coin in current_locked + ] + + payload = { + "vault_id": wallet.vault_id, + "quote_asset_id": quote_asset_id, + "wallet_asset": wallet_asset, + "current_locked_coins": current_locked, + "traces": [ + { + "coin": trace.coin, + "lineage": trace.lineage, + "nearby_offers": trace.nearby_offers, + } + for trace in traces + ], + } + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + print( + _render_text_report( + vault_id=wallet.vault_id, + quote_asset_id=quote_asset_id, + wallet_asset=wallet_asset, + current_locked=current_locked, + traces=traces, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/logging_helpers.py b/tests/logging_helpers.py index 715662b..0b1668a 100644 --- a/tests/logging_helpers.py +++ b/tests/logging_helpers.py @@ -20,3 +20,5 @@ def reset_concurrent_log_handlers(*, module) -> None: module._daemon_file_log_handler = None # Reset the shared logging_setup registry so handlers are re-created. _logging_setup._initialized_services.clear() + _logging_setup._active_handler = None + _logging_setup._active_service_name = None diff --git a/tests/test_cloud_wallet_adapter.py b/tests/test_cloud_wallet_adapter.py index a9646ee..0323b09 100644 --- a/tests/test_cloud_wallet_adapter.py +++ b/tests/test_cloud_wallet_adapter.py @@ -182,6 +182,31 @@ def _fake_graphql(*, query, variables): assert "asset {" in queries[0] +def test_cloud_wallet_get_chia_usd_quote_reads_numeric_price(monkeypatch, tmp_path: Path) -> None: + adapter = _build_adapter(tmp_path) + monkeypatch.setattr( + adapter, + "_graphql", + lambda *, query, variables: { # noqa: ARG005 + "quote": { + "price": "31.42", + "baseAsset": "chia", + "currency": "usd", + "source": "coingecko.com", + "createdAt": "2026-03-10T12:00:00Z", + } + }, + ) + assert adapter.get_chia_usd_quote() == 31.42 + + +def test_cloud_wallet_get_chia_usd_quote_rejects_missing_quote(monkeypatch, tmp_path: Path) -> None: + adapter = _build_adapter(tmp_path) + monkeypatch.setattr(adapter, "_graphql", lambda *, query, variables: {"quote": None}) # noqa: ARG005 + with pytest.raises(RuntimeError, match="cloud_wallet_missing_quote"): + adapter.get_chia_usd_quote() + + def test_cloud_wallet_graphql_http_error_contains_status_and_snippet( monkeypatch, tmp_path: Path ) -> None: @@ -451,6 +476,25 @@ def _fake_urlopen(req, timeout=0): assert variables["first"] == 25 # type: ignore[index] +def test_cloud_wallet_get_wallet_clamps_first_to_cloud_wallet_max( + monkeypatch, tmp_path: Path +) -> None: + adapter = _build_adapter(tmp_path) + monkeypatch.setattr(adapter, "_build_auth_headers", lambda _body: {}) + captured: dict[str, object] = {} + + def _fake_urlopen(req, timeout=0): + _ = timeout + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeHttpResponse({"data": {"wallet": {"offers": {"edges": []}}}}) + + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + payload = adapter.get_wallet(is_creator=True, states=["OPEN"], first=120) + assert payload == {"offers": []} + variables = captured["body"]["variables"] # type: ignore[index] + assert variables["first"] == 100 # type: ignore[index] + + # --------------------------------------------------------------------------- # split_coins / combine_coins # --------------------------------------------------------------------------- diff --git a/tests/test_cloud_wallet_offer_runtime.py b/tests/test_cloud_wallet_offer_runtime.py index 6743749..9f70d40 100644 --- a/tests/test_cloud_wallet_offer_runtime.py +++ b/tests/test_cloud_wallet_offer_runtime.py @@ -1,11 +1,16 @@ from __future__ import annotations +import datetime as dt from pathlib import Path -from typing import cast +from typing import Any, cast from greenfloor.adapters.cloud_wallet import CloudWalletAdapter from greenfloor.cloud_wallet_offer_runtime import ( build_and_post_offer_cloud_wallet, + cloud_wallet_create_offer_phase, + cloud_wallet_post_offer_phase, + cloud_wallet_wait_offer_artifact_phase, + is_transient_dexie_visibility_404_error, resolve_cloud_wallet_offer_asset_ids, ) @@ -201,3 +206,169 @@ class _Wallet: assert payload["built_offers_preview"] == [ {"offer_prefix": "offer1runtime", "offer_length": str(len("offer1runtime"))} ] + + +def test_cloud_wallet_wait_offer_artifact_phase_prefers_signature_request_lookup() -> None: + calls = {"signature": 0, "generic": 0} + + result = cloud_wallet_wait_offer_artifact_phase( + wallet=cast(CloudWalletAdapter, object()), + known_markers={"id:known"}, + offer_request_started_at=dt.datetime(2026, 1, 1, tzinfo=dt.UTC), + signature_request_id="sr-123", + timeout_seconds=30, + poll_offer_artifact_by_signature_request_fn=lambda **_kwargs: ( + calls.__setitem__("signature", calls["signature"] + 1) or "offer1signature" + ), + poll_offer_artifact_until_available_fn=lambda **_kwargs: ( + calls.__setitem__("generic", calls["generic"] + 1) or "offer1generic" + ), + ) + + assert result == "offer1signature" + assert calls == {"signature": 1, "generic": 0} + + +def test_cloud_wallet_wait_offer_artifact_phase_falls_back_after_signature_timeout() -> None: + calls = {"signature": 0, "generic": 0} + + def _signature_poll(**_kwargs: Any) -> str: + calls["signature"] += 1 + raise RuntimeError("cloud_wallet_offer_artifact_timeout") + + def _generic_poll(**_kwargs: Any) -> str: + calls["generic"] += 1 + return "offer1generic" + + result = cloud_wallet_wait_offer_artifact_phase( + wallet=cast(CloudWalletAdapter, object()), + known_markers={"id:known"}, + offer_request_started_at=dt.datetime(2026, 1, 1, tzinfo=dt.UTC), + signature_request_id="sr-123", + timeout_seconds=30, + poll_offer_artifact_by_signature_request_fn=_signature_poll, + poll_offer_artifact_until_available_fn=_generic_poll, + ) + + assert result == "offer1generic" + assert calls == {"signature": 2, "generic": 1} + + +def test_cloud_wallet_post_offer_phase_fails_after_repeated_dexie_404_visibility() -> None: + class _Dexie: + pass + + post_attempts: list[int] = [] + result = cloud_wallet_post_offer_phase( + publish_venue="dexie", + dexie=cast(Any, _Dexie()), + splash=None, + offer_text="offer1abc", + drop_only=True, + claim_rewards=False, + market=object(), + expected_offered_asset_id="asset_a", + expected_offered_symbol="A", + expected_requested_asset_id="asset_b", + expected_requested_symbol="B", + post_dexie_offer_with_invalid_offer_retry_fn=lambda **_kwargs: ( + post_attempts.append(1) or {"success": True, "id": "offer-123"} + ), + verify_dexie_offer_visible_by_id_fn=lambda **_kwargs: ( + "dexie_get_offer_error:HTTP Error 404: Not Found" + ), + sleep_fn=lambda _seconds: None, + ) + + assert result["success"] is False + assert result["id"] == "offer-123" + assert "dexie_get_offer_error:HTTP Error 404: Not Found" in str(result["error"]) + assert len(post_attempts) == 3 + + +def test_cloud_wallet_post_offer_phase_retries_transient_dexie_404_until_visible() -> None: + class _Dexie: + pass + + verify_calls = {"count": 0} + + def _verify(**_kwargs: Any) -> str | None: + verify_calls["count"] += 1 + if verify_calls["count"] < 3: + return "dexie_get_offer_error:HTTP Error 404: Not Found" + return None + + result = cloud_wallet_post_offer_phase( + publish_venue="dexie", + dexie=cast(Any, _Dexie()), + splash=None, + offer_text="offer1abc", + drop_only=True, + claim_rewards=False, + market=object(), + expected_offered_asset_id="asset_a", + expected_offered_symbol="A", + expected_requested_asset_id="asset_b", + expected_requested_symbol="B", + post_dexie_offer_with_invalid_offer_retry_fn=lambda **_kwargs: { + "success": True, + "id": "offer-123", + }, + verify_dexie_offer_visible_by_id_fn=_verify, + sleep_fn=lambda _seconds: None, + ) + + assert result == {"success": True, "id": "offer-123"} + assert verify_calls["count"] == 3 + + +def test_is_transient_dexie_visibility_404_error_matches_common_404_shapes() -> None: + assert is_transient_dexie_visibility_404_error( + "dexie_get_offer_error:HTTP Error 404: Not Found" + ) + assert is_transient_dexie_visibility_404_error("dexie_http_error:404") + assert not is_transient_dexie_visibility_404_error("dexie_network_error:timed out") + + +def test_cloud_wallet_create_offer_phase_rejects_insufficient_spendable_balance() -> None: + class _Wallet: + vault_id = "wallet-1" + network = "mainnet" + + @staticmethod + def list_coins(*, asset_id=None, include_pending=True): + _ = asset_id, include_pending + return [ + {"id": "coin-a", "amount": 10_000, "state": "SETTLED"}, + {"id": "coin-b", "amount": 10_000, "state": "SETTLED"}, + ] + + @staticmethod + def create_offer(**_kwargs): + raise AssertionError("create_offer must not run when spendable balance is insufficient") + + class _Market: + pricing = { + "base_unit_mojo_multiplier": 1000, + "quote_unit_mojo_multiplier": 1_000_000_000_000, + } + + try: + cloud_wallet_create_offer_phase( + wallet=cast(CloudWalletAdapter, _Wallet()), + market=_Market(), + size_base_units=50, + quote_price=2.94117647, + resolved_base_asset_id="Asset_base", + resolved_quote_asset_id="Asset_quote", + offer_fee_mojos=0, + split_input_coins_fee=0, + expiry_unit="minutes", + expiry_value=10, + action_side="sell", + wallet_get_wallet_offers_fn=lambda *_args, **_kwargs: {"offers": []}, + poll_signature_request_until_not_unsigned_fn=lambda **_kwargs: ("SUBMITTED", []), + ) + raise AssertionError("expected insufficient spendable balance error") + except RuntimeError as exc: + assert "cloud_wallet_offer_insufficient_spendable_balance" in str(exc) diff --git a/tests/test_config_models.py b/tests/test_config_models.py index 24aa85f..e459afe 100644 --- a/tests/test_config_models.py +++ b/tests/test_config_models.py @@ -109,35 +109,64 @@ def test_parse_markets_config_accepts_valid_strategy_controls() -> None: assert out.markets[0].pricing["strategy_target_spread_bps"] == 120 -def test_parse_markets_config_rejects_partial_strategy_expiry_override() -> None: +def test_parse_markets_config_rejects_legacy_strategy_expiry_fields() -> None: row = _base_market_row() - row["pricing"] = {"strategy_offer_expiry_unit": "hours"} + row["pricing"] = {"strategy_offer_expiry_unit": "hours", "strategy_offer_expiry_value": 2} with pytest.raises( ValueError, - match="strategy_offer_expiry_unit and strategy_offer_expiry_value must be set together", + match="strategy_offer_expiry_unit/value are no longer supported", ): parse_markets_config({"markets": [row]}) -def test_parse_markets_config_rejects_invalid_strategy_expiry_unit() -> None: +def test_parse_markets_config_rejects_invalid_strategy_expiry_minutes_type() -> None: row = _base_market_row() row["pricing"] = { - "strategy_offer_expiry_unit": "days", - "strategy_offer_expiry_value": 1, + "strategy_offer_expiry_minutes": "abc", } - with pytest.raises(ValueError, match="strategy_offer_expiry_unit must be one of"): + with pytest.raises(ValueError, match="strategy_offer_expiry_minutes must be an integer"): parse_markets_config({"markets": [row]}) def test_parse_markets_config_accepts_strategy_expiry_override() -> None: row = _base_market_row() + row["quote_asset_type"] = "stable" row["pricing"] = { - "strategy_offer_expiry_unit": "hours", - "strategy_offer_expiry_value": 2, + "strategy_offer_expiry_minutes": 120, } out = parse_markets_config({"markets": [row]}) - assert out.markets[0].pricing["strategy_offer_expiry_unit"] == "hours" - assert out.markets[0].pricing["strategy_offer_expiry_value"] == 2 + assert out.markets[0].pricing["strategy_offer_expiry_minutes"] == 120 + + +def test_parse_markets_config_warns_when_unstable_expiry_above_15_minutes() -> None: + row = _base_market_row() + row["pricing"] = {"strategy_offer_expiry_minutes": 30} + with pytest.warns(UserWarning, match="exceeds 15 minutes"): + parse_markets_config({"markets": [row]}) + + +def test_parse_markets_config_rejects_legacy_reference_fields() -> None: + row = _base_market_row() + row["pricing"] = { + "reference_source": "coingecko", + "reference_pair": "xch_usd", + } + with pytest.raises(ValueError, match="reference_source is no longer supported"): + parse_markets_config({"markets": [row]}) + + +def test_parse_markets_config_rejects_invalid_cancel_move_threshold_bps() -> None: + row = _base_market_row() + row["pricing"] = {"cancel_move_threshold_bps": 0} + with pytest.raises(ValueError, match="cancel_move_threshold_bps must be positive"): + parse_markets_config({"markets": [row]}) + + +def test_parse_markets_config_accepts_cancel_move_threshold_bps() -> None: + row = _base_market_row() + row["pricing"] = {"cancel_move_threshold_bps": 250} + out = parse_markets_config({"markets": [row]}) + assert out.markets[0].pricing["cancel_move_threshold_bps"] == 250 def test_parse_markets_config_stable_quote_validates_present_strategy_fields() -> None: @@ -174,6 +203,14 @@ def test_parse_markets_config_defaults_cat_unit_multipliers_to_1000() -> None: assert out.markets[0].pricing["quote_unit_mojo_multiplier"] == 1000 +def test_parse_markets_config_defaults_xch_quote_multiplier_to_one_trillion() -> None: + row = _base_market_row() + row["quote_asset"] = "xch" + out = parse_markets_config({"markets": [row]}) + assert out.markets[0].pricing["base_unit_mojo_multiplier"] == 1000 + assert out.markets[0].pricing["quote_unit_mojo_multiplier"] == 1_000_000_000_000 + + def test_parse_markets_config_rejects_noncanonical_cat_base_multiplier() -> None: row = _base_market_row() row["base_asset"] = "BYC" diff --git a/tests/test_daemon_cancel_policy.py b/tests/test_daemon_cancel_policy.py index 0a1c4ce..5f9a60a 100644 --- a/tests/test_daemon_cancel_policy.py +++ b/tests/test_daemon_cancel_policy.py @@ -87,6 +87,26 @@ def test_cancel_policy_requires_strong_price_move() -> None: assert out["reason"] == "price_move_below_threshold" +def test_cancel_policy_uses_market_specific_threshold_when_present() -> None: + import greenfloor.daemon.main as daemon_main + + daemon_main._CANCEL_COOLDOWN_UNTIL.clear() + market = _market("unstable", stable_vs_unstable=True) + market.pricing["cancel_move_threshold_bps"] = 100 + out = _execute_cancel_policy_for_market( + market=market, + offers=[{"id": "o1", "status": 0}], + runtime_dry_run=True, + current_xch_price_usd=30.6, + previous_xch_price_usd=30.0, + dexie=cast(Any, _FakeDexie({"success": True})), + store=cast(Any, _FakeStore()), + ) + assert out["eligible"] is True + assert out["triggered"] is True + assert out["threshold_bps"] == 100 + + def test_cancel_policy_dry_run_marks_planned_only() -> None: import greenfloor.daemon.main as daemon_main diff --git a/tests/test_daemon_offer_execution.py b/tests/test_daemon_offer_execution.py index fbf7c78..10649a6 100644 --- a/tests/test_daemon_offer_execution.py +++ b/tests/test_daemon_offer_execution.py @@ -3,6 +3,7 @@ import threading from datetime import UTC, datetime, timedelta from pathlib import Path +from types import SimpleNamespace from typing import Any, cast from greenfloor.config.models import MarketConfig, MarketInventoryConfig @@ -90,6 +91,39 @@ def add_audit_event(self, event_type: str, payload: dict, market_id: str | None ) +class _CloudWalletProgram: + """Minimal program stub for cloud-wallet offer-execution tests (4-field variant).""" + + cloud_wallet_base_url = "https://api.vault.chia.net" + cloud_wallet_user_key_id = "UserAuthKey_abc" + cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" + cloud_wallet_vault_id = "Wallet_abc" + + +class _ParallelCloudWalletProgram(_CloudWalletProgram): + """Cloud-wallet program stub with offer parallelism enabled.""" + + runtime_offer_parallelism_enabled = True + runtime_offer_parallelism_max_workers = 2 + runtime_reservation_ttl_seconds = 300 + coin_ops_minimum_fee_mojos = 0 + coin_ops_split_fee_mojos = 0 + + +class _CoinOpsProgram: + """Minimal program stub for coin-op tests (includes dry-run and fee fields).""" + + runtime_dry_run = False + app_network = "mainnet" + cloud_wallet_base_url = "https://wallet.example" + cloud_wallet_user_key_id = "user-key" + cloud_wallet_private_key_pem_path = "/tmp/key.pem" + cloud_wallet_vault_id = "vault-1" + cloud_wallet_kms_key_id = "kms-key" + coin_ops_split_fee_mojos = 0 + coin_ops_combine_fee_mojos = 0 + + def _market() -> MarketConfig: return MarketConfig( market_id="m1", @@ -142,6 +176,33 @@ def test_execute_strategy_actions_dry_run_plans_without_posting() -> None: assert store.offer_states == [] +def test_expand_strategy_actions_preserves_strategy_order() -> None: + actions = [ + PlannedAction( + size=1, + repeat=2, + pair="xch", + expiry_unit="minutes", + expiry_value=10, + cancel_after_create=True, + reason="below_target", + ), + PlannedAction( + size=10, + repeat=2, + pair="xch", + expiry_unit="minutes", + expiry_value=10, + cancel_after_create=True, + reason="below_target", + ), + ] + + expanded = daemon_main._expand_strategy_actions(actions) + + assert [action.size for action in expanded] == [1, 1, 10, 10] + + def test_execute_strategy_actions_skips_when_builder_skips(monkeypatch) -> None: import greenfloor.daemon.main as daemon_main @@ -475,6 +536,161 @@ def test_inject_reseed_action_when_only_mempool_offer_is_stale() -> None: assert all(action.reason == "offer_size_gap_reseed" for action in actions) +def test_inject_reseed_action_refills_missing_same_size_offers_immediately() -> None: + store = _FakeStore() + now = datetime.now(UTC) + store.offer_states = [ + {"offer_id": "one-1", "market_id": "m1", "state": "open"}, + {"offer_id": "one-2", "market_id": "m1", "state": "open"}, + ] + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "created_at": (now - timedelta(seconds=60)).isoformat(), + "payload": { + "items": [ + {"offer_id": "recent-one", "size": 1, "status": "executed"}, + {"offer_id": "one-1", "size": 1, "status": "executed"}, + {"offer_id": "one-2", "size": 1, "status": "executed"}, + ] + }, + } + ] + market = _market() + strategy_config = _strategy_config_from_market(market) + + actions = _inject_reseed_action_if_no_active_offers( + strategy_actions=[], + strategy_config=strategy_config, + market=market, + store=cast(Any, store), + xch_price_usd=30.0, + clock=now, + ) + + assert [action.size for action in actions] == [1, 10, 100] + assert [action.repeat for action in actions] == [3, 2, 1] + + +def test_inject_reseed_action_is_not_limited_by_old_cadence_window() -> None: + store = _FakeStore() + now = datetime.now(UTC) + store.offer_states = [ + {"offer_id": "one-1", "market_id": "m1", "state": "open"}, + {"offer_id": "one-2", "market_id": "m1", "state": "open"}, + ] + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "created_at": (now - timedelta(minutes=4)).isoformat(), + "payload": { + "items": [ + {"offer_id": "stale-one", "size": 1, "status": "executed"}, + {"offer_id": "one-1", "size": 1, "status": "executed"}, + {"offer_id": "one-2", "size": 1, "status": "executed"}, + ] + }, + } + ] + market = _market() + strategy_config = _strategy_config_from_market(market) + + actions = _inject_reseed_action_if_no_active_offers( + strategy_actions=[], + strategy_config=strategy_config, + market=market, + store=cast(Any, store), + xch_price_usd=30.0, + clock=now, + ) + + assert [action.size for action in actions] == [1, 10, 100] + assert [action.repeat for action in actions] == [3, 2, 1] + + +def test_apply_action_cadence_gate_passes_through_general_strategy_actions() -> None: + store = _FakeStore() + now = datetime.now(UTC) + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "created_at": (now - timedelta(seconds=60)).isoformat(), + "payload": { + "items": [ + {"offer_id": "recent-one", "size": 1, "side": "sell", "status": "executed"} + ] + }, + } + ] + actions = [ + PlannedAction( + size=1, + repeat=2, + pair="xch", + expiry_unit="minutes", + expiry_value=10, + cancel_after_create=True, + reason="below_target", + side="sell", + ) + ] + + gated, blocked = daemon_main._apply_action_cadence_gate( + actions=actions, + target_counts_by_side={"buy": {}, "sell": {1: 5}}, + active_counts_by_side={"buy": {}, "sell": {1: 3}}, + store=cast(Any, store), + market_id="m1", + clock=now, + ) + + assert gated == actions + assert blocked == [] + + +def test_apply_action_cadence_gate_filters_zero_repeat_actions() -> None: + store = _FakeStore() + now = datetime.now(UTC) + actions = [ + PlannedAction( + size=1, + repeat=0, + pair="xch", + expiry_unit="minutes", + expiry_value=10, + cancel_after_create=True, + reason="below_target", + side="sell", + ), + PlannedAction( + size=1, + repeat=1, + pair="xch", + expiry_unit="minutes", + expiry_value=10, + cancel_after_create=True, + reason="below_target", + side="sell", + ), + ] + + gated, blocked = daemon_main._apply_action_cadence_gate( + actions=actions, + target_counts_by_side={"buy": {}, "sell": {1: 5}}, + active_counts_by_side={"buy": {}, "sell": {1: 4}}, + store=cast(Any, store), + market_id="m1", + clock=now, + ) + + assert len(gated) == 1 + assert gated[0].repeat == 1 + assert blocked == [] + + def test_active_offer_counts_by_size_uses_offer_state_and_size_mapping() -> None: store = _FakeStore() now = datetime.now(UTC) @@ -736,6 +952,251 @@ def test_active_offer_counts_by_size_foreign_offer_stays_unmapped() -> None: assert unmapped == 1, "Foreign offer must stay unmapped, not inflate the count" +def test_active_offer_counts_by_size_tracks_non_legacy_size() -> None: + store = _FakeStore() + now = datetime.now(UTC) + store.offer_states = [ + {"offer_id": "ours-50", "market_id": "m1", "state": "open"}, + ] + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "payload": {"items": [{"offer_id": "ours-50", "size": 50, "status": "executed"}]}, + } + ] + counts, _, unmapped = _active_offer_counts_by_size( + store=cast(Any, store), + market_id="m1", + clock=now, + tracked_sizes={1, 10, 50}, + ) + assert counts == {1: 0, 10: 0, 50: 1} + assert unmapped == 0 + + +def test_active_offer_counts_excludes_stale_pending_visibility_offer() -> None: + store = _FakeStore() + now = datetime.now(UTC) + stale_created_at = (now - timedelta(minutes=5)).isoformat() + store.offer_states = [ + {"offer_id": "pending-50", "market_id": "m1", "state": "open"}, + ] + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "created_at": stale_created_at, + "payload": { + "items": [ + { + "offer_id": "pending-50", + "size": 50, + "status": "executed", + "reason": "cloud_wallet_post_success_dexie_visibility_pending", + } + ] + }, + } + ] + counts, _, unmapped = _active_offer_counts_by_size( + store=cast(Any, store), + market_id="m1", + clock=now, + dexie_size_by_offer_id={}, + tracked_sizes={50}, + ) + assert counts == {50: 0} + assert unmapped == 1 + + +def test_active_offer_counts_keeps_pending_visibility_offer_when_seen_on_dexie() -> None: + store = _FakeStore() + now = datetime.now(UTC) + stale_created_at = (now - timedelta(minutes=5)).isoformat() + store.offer_states = [ + {"offer_id": "pending-50", "market_id": "m1", "state": "open"}, + ] + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "created_at": stale_created_at, + "payload": { + "items": [ + { + "offer_id": "pending-50", + "size": 50, + "status": "executed", + "reason": "cloud_wallet_post_success_dexie_visibility_pending", + } + ] + }, + } + ] + counts, _, unmapped = _active_offer_counts_by_size( + store=cast(Any, store), + market_id="m1", + clock=now, + dexie_size_by_offer_id={"pending-50": 50}, + tracked_sizes={50}, + ) + assert counts == {50: 1} + assert unmapped == 0 + + +def test_active_offer_counts_keeps_pending_when_no_dexie_snapshot() -> None: + """When dexie_size_by_offer_id is None (no Dexie snapshot this cycle), + _is_stale_pending_visibility_offer returns False unconditionally, so the + offer is not evicted regardless of age. + """ + store = _FakeStore() + now = datetime.now(UTC) + very_old = (now - timedelta(hours=1)).isoformat() + store.offer_states = [ + {"offer_id": "pending-old", "market_id": "m1", "state": "open"}, + ] + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "created_at": very_old, + "payload": { + "items": [ + { + "offer_id": "pending-old", + "size": 50, + "status": "executed", + "reason": "cloud_wallet_post_success_dexie_visibility_pending", + } + ] + }, + } + ] + counts, _, unmapped = _active_offer_counts_by_size( + store=cast(Any, store), + market_id="m1", + clock=now, + dexie_size_by_offer_id=None, + tracked_sizes={50}, + ) + assert counts == {50: 1} + assert unmapped == 0 + + +def test_reconcile_offer_states_expires_watched_offer_on_direct_dexie_404(tmp_path: Path) -> None: + db_path = tmp_path / "state.sqlite" + store = SqliteStore(db_path) + market = _market() + now = datetime.now(UTC) + try: + store.upsert_offer_state( + offer_id="offer-50", + market_id=market.market_id, + state="open", + last_seen_status=0, + ) + store.add_audit_event( + "strategy_offer_execution", + { + "market_id": market.market_id, + "planned_count": 1, + "executed_count": 1, + "items": [ + { + "offer_id": "offer-50", + "size": 50, + "side": "sell", + "status": "executed", + "reason": "dexie_post_success", + } + ], + }, + market_id=market.market_id, + ) + + class _FakeDexie: + def get_offers(self, offered: str, requested: str) -> list[dict[str, Any]]: + _ = offered, requested + return [] + + def get_offer(self, offer_id: str, *, timeout: int = 20) -> dict[str, Any]: + _ = offer_id, timeout + raise RuntimeError("HTTP Error 404: Not Found") + + result = daemon_main._MarketCycleResult() + daemon_main._reconcile_offer_states( + market=market, + network="mainnet", + dexie=cast(Any, _FakeDexie()), + store=store, + now=now, + result=result, + ) + + rows = { + r["offer_id"]: r for r in store.list_offer_states(market_id=market.market_id, limit=20) + } + transitions = store.list_recent_audit_events( + event_types=["offer_lifecycle_transition"], + market_id=market.market_id, + limit=20, + ) + finally: + store.close() + + assert rows["offer-50"]["state"] == "expired" + assert rows["offer-50"]["last_seen_status"] is None + assert transitions[0]["payload"]["offer_id"] == "offer-50" + assert transitions[0]["payload"]["signal_source"] == "dexie_get_offer_404" + assert transitions[0]["payload"]["dexie_error"] == "HTTP Error 404: Not Found" + + +def test_reconcile_offer_states_resolves_quote_asset_before_dexie_fetch( + monkeypatch, tmp_path: Path +) -> None: + store = daemon_main.SqliteStore(tmp_path / "state.db") + market = _market() + market.quote_asset = "wUSDC.b" + cats = tmp_path / "cats.yaml" + cats.write_text( + "\n".join( + [ + "cats:", + " - base_symbol: wUSDC.b", + " asset_id: fa4a180ac326e67ea289b869e3448256f6af05721f7cf934cb9901baa6b7a99d", + ] + ), + encoding="utf-8", + ) + monkeypatch.setattr(daemon_main, "_default_cats_config_path", lambda: cats) + captured: dict[str, str] = {} + + class _FakeDexie: + def get_offers(self, offered: str, requested: str) -> list[dict[str, Any]]: + captured["offered"] = offered + captured["requested"] = requested + return [] + + try: + result = daemon_main._MarketCycleResult() + daemon_main._reconcile_offer_states( + market=market, + network="mainnet", + dexie=cast(Any, _FakeDexie()), + store=store, + now=datetime.now(UTC), + result=result, + ) + finally: + store.close() + + assert captured == { + "offered": "asset", + "requested": "fa4a180ac326e67ea289b869e3448256f6af05721f7cf934cb9901baa6b7a99d", + } + + def test_match_watched_coin_ids_returns_empty_without_overlap() -> None: _set_watched_coin_ids_for_market(market_id="m-empty", coin_ids={"c" * 64}) assert _match_watched_coin_ids(observed_coin_ids=["d" * 64]) == {} @@ -770,11 +1231,7 @@ def test_execute_strategy_actions_uses_cloud_wallet_path_when_configured(monkeyp lambda **_kwargs: {"success": True, "offer_id": "offer-fallback-1"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" + _Program = _CloudWalletProgram dexie = _FakeDexie(post_result={"success": True, "id": "offer-1"}) dexie.visible_offer_ids = {"offer-fallback-1"} @@ -814,13 +1271,13 @@ def test_execute_strategy_actions_cloud_wallet_requires_dexie_visibility(monkeyp lambda **_kwargs: {"success": True, "offer_id": "offer-fallback-missing"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" + _Program = _CloudWalletProgram + + class _DexieNon404: + def get_offer(self, offer_id: str) -> dict[str, Any]: + _ = offer_id + raise RuntimeError("dexie_http_error:500") - dexie = _FakeDexie(post_result={"success": True, "id": "offer-1"}) store = _FakeStore() actions = [ PlannedAction( @@ -839,7 +1296,7 @@ class _Program: strategy_actions=actions, runtime_dry_run=False, xch_price_usd=30.0, - dexie=cast(Any, dexie), + dexie=cast(Any, _DexieNon404()), store=cast(Any, store), publish_venue="dexie", program=_Program(), @@ -850,7 +1307,59 @@ class _Program: assert "cloud_wallet_post_not_visible_on_dexie" in result["items"][0]["reason"] -def test_execute_strategy_actions_posts_larger_sizes_first(monkeypatch) -> None: +def test_execute_strategy_actions_cloud_wallet_accepts_transient_dexie_http_404( + monkeypatch, +) -> None: + """A transient 404 from Dexie is treated as pending-visibility, not a hard failure. + + The offer is counted as executed with _PENDING_VISIBILITY_REASON so the + active-offer reader keeps it in scope until the grace period expires. + """ + daemon_main._POST_COOLDOWN_UNTIL.clear() + monkeypatch.setattr( + daemon_main, + "_cloud_wallet_offer_post_fallback", + lambda **_kwargs: {"success": True, "offer_id": "offer-fallback-pending"}, + ) + + _Program = _CloudWalletProgram + + class _Dexie404: + def get_offer(self, offer_id: str) -> dict[str, Any]: + _ = offer_id + raise RuntimeError("HTTP Error 404: Not Found") + + store = _FakeStore() + actions = [ + PlannedAction( + size=50, + repeat=1, + pair="usdc", + expiry_unit="hours", + expiry_value=8, + cancel_after_create=True, + reason="offer_size_gap_reseed", + ) + ] + + result = _execute_strategy_actions( + market=_market(), + strategy_actions=actions, + runtime_dry_run=False, + xch_price_usd=30.0, + dexie=cast(Any, _Dexie404()), + store=cast(Any, store), + publish_venue="dexie", + program=_Program(), + ) + + assert result["executed_count"] == 1 + assert result["items"][0]["status"] == "executed" + assert result["items"][0]["reason"] == daemon_main._PENDING_VISIBILITY_REASON + assert result["items"][0]["offer_id"] == "offer-fallback-pending" + + +def test_execute_strategy_actions_preserves_planned_size_order(monkeypatch) -> None: daemon_main._POST_COOLDOWN_UNTIL.clear() seen_sizes: list[int] = [] @@ -861,11 +1370,7 @@ def _fake_cloud_wallet_post(**kwargs: Any) -> dict[str, Any]: monkeypatch.setattr(daemon_main, "_cloud_wallet_offer_post_fallback", _fake_cloud_wallet_post) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" + _Program = _CloudWalletProgram dexie = _FakeDexie(post_result={"success": True, "id": "offer-1"}) dexie.visible_offer_ids = {"offer-100", "offer-10", "offer-1"} @@ -912,7 +1417,7 @@ class _Program: ) assert result["executed_count"] == 3 - assert seen_sizes == [100, 10, 1] + assert seen_sizes == [1, 10, 100] def test_execute_strategy_actions_cloud_wallet_failure_skips_without_builder(monkeypatch) -> None: @@ -930,11 +1435,7 @@ def _unexpected_builder(**_kwargs): lambda **_kwargs: {"success": False, "error": "vault_signing_unavailable"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" + _Program = _CloudWalletProgram dexie = _FakeDexie(post_result={"success": True, "id": "offer-1"}) store = _FakeStore() @@ -967,9 +1468,7 @@ class _Program: assert calls["builder"] == 0 -def test_execute_strategy_actions_parallel_cloud_wallet_reservation_contention( - monkeypatch, tmp_path -) -> None: +def test_execute_strategy_actions_parallel_cloud_wallet_reservation_contention(monkeypatch) -> None: daemon_main._POST_COOLDOWN_UNTIL.clear() class _FakeCloudWallet: @@ -1004,19 +1503,31 @@ def list_coins(self, *, include_pending: bool = True): lambda **_kwargs: {"success": True, "offer_id": "offer-parallel"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" - runtime_offer_parallelism_enabled = True - runtime_offer_parallelism_max_workers = 2 - runtime_reservation_ttl_seconds = 300 - coin_ops_minimum_fee_mojos = 0 - coin_ops_split_fee_mojos = 0 + _Program = _ParallelCloudWalletProgram - db_path = tmp_path / "reservations.sqlite" - coordinator = AssetReservationCoordinator(db_path=db_path, lease_seconds=300) + class _DeterministicContentionCoordinator: + def __init__(self) -> None: + self.non_empty_acquire_calls = 0 + self.released: list[tuple[str, str]] = [] + + def try_acquire(self, **kwargs): + requested = dict(kwargs.get("requested_amounts", {}) or {}) + if not requested: + # Daemon health-check path. + return SimpleNamespace(ok=True, reservation_id="res-health", error=None) + self.non_empty_acquire_calls += 1 + if self.non_empty_acquire_calls == 1: + return SimpleNamespace(ok=True, reservation_id="res-1", error=None) + return SimpleNamespace( + ok=False, + reservation_id=None, + error="reservation_insufficient_asset", + ) + + def release(self, *, reservation_id: str, status: str) -> None: + self.released.append((str(reservation_id), str(status))) + + coordinator = _DeterministicContentionCoordinator() dexie = _FakeDexie(post_result={"success": True, "id": "offer-parallel"}) dexie.visible_offer_ids = {"offer-parallel"} store = _FakeStore() @@ -1040,18 +1551,12 @@ class _Program: store=cast(Any, store), publish_venue="dexie", program=_Program(), - reservation_coordinator=coordinator, + reservation_coordinator=cast(Any, coordinator), ) assert result["planned_count"] == 2 assert result["executed_count"] == 1 assert any("reservation_insufficient_asset" in str(item["reason"]) for item in result["items"]) - sqlite_store = SqliteStore(db_path) - try: - rows = sqlite_store.list_offer_reservation_leases() - assert len(rows) == 1 - assert rows[0]["status"] == "released_success" - finally: - sqlite_store.close() + assert coordinator.released == [("res-1", "released_success")] def test_execute_strategy_actions_parallel_releases_reservation_on_failure( @@ -1091,16 +1596,7 @@ def list_coins(self, *, include_pending: bool = True): lambda **_kwargs: {"success": False, "error": "vault_unavailable"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" - runtime_offer_parallelism_enabled = True - runtime_offer_parallelism_max_workers = 2 - runtime_reservation_ttl_seconds = 300 - coin_ops_minimum_fee_mojos = 0 - coin_ops_split_fee_mojos = 0 + _Program = _ParallelCloudWalletProgram db_path = tmp_path / "reservations.sqlite" coordinator = AssetReservationCoordinator(db_path=db_path, lease_seconds=300) @@ -1197,16 +1693,8 @@ def list_coins(self, *, include_pending: bool = True): lambda **_kwargs: {"success": True, "offer_id": "offer-parallel"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" - runtime_offer_parallelism_enabled = True - runtime_offer_parallelism_max_workers = 2 - runtime_reservation_ttl_seconds = 300 + class _Program(_ParallelCloudWalletProgram): coin_ops_minimum_fee_mojos = 10 - coin_ops_split_fee_mojos = 0 db_path = tmp_path / "reservations.sqlite" coordinator = AssetReservationCoordinator(db_path=db_path, lease_seconds=300) @@ -1271,16 +1759,7 @@ def try_acquire(self, **_kwargs): lambda **_kwargs: {"success": True, "offer_id": "offer-fallback"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" - runtime_offer_parallelism_enabled = True - runtime_offer_parallelism_max_workers = 2 - runtime_reservation_ttl_seconds = 300 - coin_ops_minimum_fee_mojos = 0 - coin_ops_split_fee_mojos = 0 + _Program = _ParallelCloudWalletProgram dexie = _FakeDexie(post_result={"success": True, "id": "offer-fallback"}) dexie.visible_offer_ids = {"offer-fallback"} @@ -1340,16 +1819,7 @@ def list_coins(self, *, include_pending: bool = True): lambda **_kwargs: {"success": True, "offer_id": "offer-resolved-asset"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" - runtime_offer_parallelism_enabled = True - runtime_offer_parallelism_max_workers = 2 - runtime_reservation_ttl_seconds = 300 - coin_ops_minimum_fee_mojos = 0 - coin_ops_split_fee_mojos = 0 + _Program = _ParallelCloudWalletProgram market = _market() market.base_asset = "asset-local-only" @@ -1425,16 +1895,7 @@ def list_coins( lambda **_kwargs: {"success": True, "offer_id": "offer-scoped"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" - runtime_offer_parallelism_enabled = True - runtime_offer_parallelism_max_workers = 2 - runtime_reservation_ttl_seconds = 300 - coin_ops_minimum_fee_mojos = 0 - coin_ops_split_fee_mojos = 0 + _Program = _ParallelCloudWalletProgram market = _market() market.base_asset = "asset-local-only" @@ -1518,16 +1979,7 @@ def list_coins( lambda **_kwargs: {"success": True, "offer_id": "offer-should-not-post"}, ) - class _Program: - cloud_wallet_base_url = "https://api.vault.chia.net" - cloud_wallet_user_key_id = "UserAuthKey_abc" - cloud_wallet_private_key_pem_path = "~/.greenfloor/keys/cloud-wallet-user-auth-key.pem" - cloud_wallet_vault_id = "Wallet_abc" - runtime_offer_parallelism_enabled = True - runtime_offer_parallelism_max_workers = 2 - runtime_reservation_ttl_seconds = 300 - coin_ops_minimum_fee_mojos = 0 - coin_ops_split_fee_mojos = 0 + _Program = _ParallelCloudWalletProgram market = _market() market.base_asset = "asset-local-only" @@ -1699,16 +2151,8 @@ def _attempt(coordinator: AssetReservationCoordinator) -> None: def test_execute_coin_ops_cloud_wallet_kms_only_requires_kms() -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" + class _Program(_CoinOpsProgram): cloud_wallet_kms_key_id = "" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 class _Signer: key_id = "key-main-2" @@ -1730,16 +2174,7 @@ class _Signer: def test_execute_coin_ops_cloud_wallet_kms_only_split_submits(monkeypatch) -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -1800,16 +2235,7 @@ def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): def test_execute_coin_ops_cloud_wallet_kms_only_split_retries_on_not_spendable( monkeypatch, ) -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -1876,16 +2302,7 @@ def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): def test_execute_coin_ops_cloud_wallet_kms_only_split_ignores_asset_mismatch( monkeypatch, ) -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -1931,16 +2348,7 @@ def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): def test_execute_coin_ops_cloud_wallet_kms_only_split_revalidates_coin_identity( monkeypatch, ) -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -2020,16 +2428,7 @@ def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): def test_execute_coin_ops_cloud_wallet_kms_only_split_requires_sufficient_amount( monkeypatch, ) -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -2075,16 +2474,7 @@ def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): def test_execute_coin_ops_cloud_wallet_kms_only_split_combines_when_aggregate_sufficient( monkeypatch, ) -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -2166,16 +2556,7 @@ def test_execute_coin_ops_cloud_wallet_kms_only_split_combine_cap_submits_progre ) -> None: monkeypatch.setenv("GREENFLOOR_COIN_OPS_COMBINE_INPUT_COIN_CAP", "5") - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -2258,16 +2639,7 @@ def combine_coins( def test_execute_coin_ops_cloud_wallet_kms_only_split_ignores_sub_cat_dust_on_scoped_reads( monkeypatch, ) -> None: - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -2283,8 +2655,8 @@ def list_coins(self, *, asset_id: str | None = None, include_pending: bool = Tru for idx in range(89) ] return [ - {"id": "big-a", "amount": 1100, "state": "SETTLED", "asset": None}, - {"id": "big-b", "amount": 1100, "state": "SETTLED", "asset": None}, + {"id": "big-a", "amount": 2500, "state": "SETTLED", "asset": None}, + {"id": "big-b", "amount": 2500, "state": "SETTLED", "asset": None}, {"id": "stray-310", "amount": 310, "state": "SETTLED", "asset": None}, *dust_rows, ] @@ -2328,7 +2700,7 @@ def combine_coins( market = _market() market.base_asset = "BYC" - market.pricing = {"fixed_quote_per_base": 1.0, "base_unit_mojo_multiplier": 30} + market.pricing = {"fixed_quote_per_base": 1.0, "base_unit_mojo_multiplier": 100} result = _execute_coin_ops_cloud_wallet_kms_only( market=market, program=_Program(), @@ -2341,7 +2713,7 @@ def combine_coins( assert len(fake.combine_calls) == 1 assert fake.combine_calls[0]["number_of_coins"] == 2 assert fake.combine_calls[0]["input_coin_ids"] == ["big-a", "big-b"] - assert fake.combine_calls[0]["target_amount"] == 1200 + assert fake.combine_calls[0]["target_amount"] == 4000 assert result["executed_count"] == 1 assert ( result["items"][0]["reason"] @@ -2349,22 +2721,108 @@ def combine_coins( ) +def test_execute_coin_ops_cloud_wallet_kms_only_split_rejects_sub_minimum_cat_outputs( + monkeypatch, +) -> None: + _Program = _CoinOpsProgram + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + { + "id": "coin-big", + "amount": 100_000, + "state": "SETTLED", + "asset": {"id": "Asset_byc"}, + }, + ] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + raise AssertionError("split_coins should not be called for sub-minimum CAT outputs") + + monkeypatch.setattr( + daemon_main, + "_new_cloud_wallet_adapter_for_daemon", + lambda _program: _FakeCloudWallet(), + ) + monkeypatch.setattr( + daemon_main, + "_resolve_cloud_wallet_offer_asset_ids_for_reservation", + lambda **_kwargs: ("Asset_byc", "Asset_usdc", "Asset_xch"), + ) + + from greenfloor.core.coin_ops import CoinOpPlan + + market = _market() + market.base_asset = "BYC" + market.pricing = {"fixed_quote_per_base": 1.0, "base_unit_mojo_multiplier": 1} + result = _execute_coin_ops_cloud_wallet_kms_only( + market=market, + program=_Program(), + plans=[CoinOpPlan(op_type="split", size_base_units=1, op_count=4, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert result["executed_count"] == 0 + assert result["items"][0]["status"] == "skipped" + assert result["items"][0]["reason"] == "split_amount_below_coin_op_minimum" + assert result["items"][0]["data"]["amount_per_coin_mojos"] == 1 + assert result["items"][0]["data"]["minimum_allowed_mojos"] == 1000 + + +def test_execute_coin_ops_cloud_wallet_kms_only_skips_single_output_split( + monkeypatch, +) -> None: + _Program = _CoinOpsProgram + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + raise AssertionError("single-output split should be skipped") + + fake = _FakeCloudWallet() + monkeypatch.setattr( + daemon_main, + "_new_cloud_wallet_adapter_for_daemon", + lambda _program: fake, + ) + monkeypatch.setattr( + daemon_main, + "_resolve_cloud_wallet_offer_asset_ids_for_reservation", + lambda **_kwargs: ("Asset_byc", "Asset_usdc", "Asset_xch"), + ) + + from greenfloor.core.coin_ops import CoinOpPlan + + result = _execute_coin_ops_cloud_wallet_kms_only( + market=_market(), + program=_Program(), + plans=[CoinOpPlan(op_type="split", size_base_units=10, op_count=1, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert result["executed_count"] == 0 + assert result["items"][0]["status"] == "skipped" + assert result["items"][0]["reason"] == "split_single_coin_noop_skipped" + + def test_execute_coin_ops_cloud_wallet_kms_only_combine_retries_on_429( monkeypatch, ) -> None: monkeypatch.setenv("GREENFLOOR_COIN_OPS_COMBINE_MAX_ATTEMPTS", "3") monkeypatch.setenv("GREENFLOOR_COIN_OPS_COMBINE_BACKOFF_MS", "0") - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -2373,6 +2831,13 @@ class _FakeCloudWallet: def __init__(self) -> None: self.combine_calls = 0 + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + {"id": "c1", "amount": 10_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "c2", "amount": 10_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + ] + def combine_coins(self, **_kwargs): self.combine_calls += 1 if self.combine_calls < 3: @@ -2413,16 +2878,7 @@ def test_execute_coin_ops_cloud_wallet_kms_only_combine_applies_input_coin_cap( ) -> None: monkeypatch.setenv("GREENFLOOR_COIN_OPS_COMBINE_INPUT_COIN_CAP", "7") - class _Program: - runtime_dry_run = False - app_network = "mainnet" - cloud_wallet_base_url = "https://wallet.example" - cloud_wallet_user_key_id = "user-key" - cloud_wallet_private_key_pem_path = "/tmp/key.pem" - cloud_wallet_vault_id = "vault-1" - cloud_wallet_kms_key_id = "kms-key" - coin_ops_split_fee_mojos = 0 - coin_ops_combine_fee_mojos = 0 + _Program = _CoinOpsProgram class _Signer: key_id = "key-main-2" @@ -2430,9 +2886,23 @@ class _Signer: class _FakeCloudWallet: def __init__(self) -> None: self.last_number_of_coins: int | None = None + self.last_input_coin_ids: list[str] | None = None + + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + { + "id": f"coin-{idx}", + "amount": 10_000, + "state": "SETTLED", + "asset": {"id": "Asset_byc"}, + } + for idx in range(12) + ] def combine_coins(self, **kwargs): self.last_number_of_coins = int(kwargs.get("number_of_coins", 0)) + self.last_input_coin_ids = list(kwargs.get("input_coin_ids", [])) return {"signature_request_id": "sig-combine-cap", "status": "SUBMITTED"} fake = _FakeCloudWallet() @@ -2459,8 +2929,69 @@ def combine_coins(self, **kwargs): ) assert fake.last_number_of_coins == 7 + assert fake.last_input_coin_ids == [f"coin-{idx}" for idx in range(7)] assert result["executed_count"] == 1 assert result["items"][0]["status"] == "executed" assert result["items"][0]["data"]["requested_number_of_coins"] == 100 assert result["items"][0]["data"]["submitted_number_of_coins"] == 7 assert result["items"][0]["data"]["input_coin_cap_applied"] is True + + +def test_execute_coin_ops_cloud_wallet_kms_only_combine_excludes_watched_coin_ids( + monkeypatch, +) -> None: + _Program = _CoinOpsProgram + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def __init__(self) -> None: + self.last_input_coin_ids: list[str] | None = None + + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + { + "id": "watched", + "amount": 1_000, + "state": "SETTLED", + "asset": {"id": "Asset_byc"}, + }, + {"id": "free-1", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "free-2", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + ] + + def combine_coins(self, **kwargs): + self.last_input_coin_ids = list(kwargs.get("input_coin_ids", [])) + return {"signature_request_id": "sig-combine-safe", "status": "SUBMITTED"} + + fake = _FakeCloudWallet() + monkeypatch.setattr( + daemon_main, + "_new_cloud_wallet_adapter_for_daemon", + lambda _program: fake, + ) + monkeypatch.setattr( + daemon_main, + "_resolve_cloud_wallet_offer_asset_ids_for_reservation", + lambda **_kwargs: ("Asset_byc", "Asset_usdc", "Asset_xch"), + ) + + market = _market() + daemon_main._set_watched_coin_ids_for_market(market_id=market.market_id, coin_ids={"watched"}) + + from greenfloor.core.coin_ops import CoinOpPlan + + result = _execute_coin_ops_cloud_wallet_kms_only( + market=market, + program=_Program(), + plans=[CoinOpPlan(op_type="combine", size_base_units=1, op_count=2, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert fake.last_input_coin_ids == ["free-1", "free-2"] + assert result["executed_count"] == 1 + daemon_main._set_watched_coin_ids_for_market(market_id=market.market_id, coin_ids=set()) diff --git a/tests/test_daemon_strategy_integration.py b/tests/test_daemon_strategy_integration.py index 00e4f84..7b4ab69 100644 --- a/tests/test_daemon_strategy_integration.py +++ b/tests/test_daemon_strategy_integration.py @@ -4,7 +4,9 @@ from greenfloor.config.models import MarketConfig, MarketInventoryConfig, MarketLadderEntry from greenfloor.daemon.main import ( + _effective_sell_bucket_counts_for_coin_ops, _evaluate_two_sided_market_actions, + _executed_sell_offer_counts_by_size, _normalize_strategy_pair, _strategy_config_from_market, _strategy_state_from_bucket_counts, @@ -60,6 +62,35 @@ def test_strategy_config_from_market_uses_sell_ladder_targets() -> None: assert cfg.ones_target == 7 assert cfg.tens_target == 3 assert cfg.hundreds_target == 2 + assert cfg.target_counts_by_size == {1: 7, 10: 3, 100: 2} + + +def test_strategy_config_from_market_tracks_non_legacy_ladder_sizes() -> None: + market = _market_with_quote("xch") + market.ladders = { + "sell": [ + MarketLadderEntry( + size_base_units=1, + target_count=5, + split_buffer_count=1, + combine_when_excess_factor=2.0, + ), + MarketLadderEntry( + size_base_units=10, + target_count=2, + split_buffer_count=1, + combine_when_excess_factor=2.0, + ), + MarketLadderEntry( + size_base_units=50, + target_count=1, + split_buffer_count=0, + combine_when_excess_factor=2.0, + ), + ] + } + cfg = _strategy_config_from_market(market) + assert cfg.target_counts_by_size == {1: 5, 10: 2, 50: 1} def test_strategy_config_from_market_reads_configurable_price_bands_and_spread() -> None: @@ -86,6 +117,62 @@ def test_strategy_state_from_bucket_counts_includes_xch_price() -> None: assert state.xch_price_usd == 32.5 +def _sell_ladder(*entries: tuple[int, int]) -> list[MarketLadderEntry]: + """Build a sell ladder from (size_base_units, target_count) pairs.""" + return [ + MarketLadderEntry( + size_base_units=size, + target_count=target, + split_buffer_count=1, + combine_when_excess_factor=2.0, + ) + for size, target in entries + ] + + +def test_effective_sell_bucket_counts_for_coin_ops_counts_live_sells_toward_target_only() -> None: + effective = _effective_sell_bucket_counts_for_coin_ops( + sell_ladder=_sell_ladder((10, 3)), + wallet_bucket_counts={10: 0}, + active_sell_offer_counts_by_size={10: 3}, + ) + assert effective[10] == 3 + + +def test_effective_sell_bucket_counts_for_coin_ops_caps_live_sell_credit_at_target() -> None: + effective = _effective_sell_bucket_counts_for_coin_ops( + sell_ladder=_sell_ladder((10, 3)), + wallet_bucket_counts={10: 0}, + active_sell_offer_counts_by_size={10: 4}, + ) + assert effective[10] == 3 + + +def test_effective_sell_bucket_counts_for_coin_ops_accounts_for_new_sell_posts_in_cycle() -> None: + effective = _effective_sell_bucket_counts_for_coin_ops( + sell_ladder=_sell_ladder((10, 2)), + wallet_bucket_counts={10: 2}, + active_sell_offer_counts_by_size={10: 0}, + newly_executed_sell_offer_counts_by_size={10: 2}, + ) + assert effective[10] == 2 + + +def test_executed_sell_offer_counts_by_size_counts_only_executed_sell_items() -> None: + counts = _executed_sell_offer_counts_by_size( + { + "items": [ + {"status": "executed", "side": "sell", "size": 10}, + {"status": "executed", "side": "sell", "size": 10}, + {"status": "executed", "side": "buy", "size": 10}, + {"status": "skipped", "side": "sell", "size": 10}, + {"status": "executed", "side": "sell", "size": 1}, + ] + } + ) + assert counts == {10: 2, 1: 1} + + def test_evaluate_two_sided_market_actions_uses_side_targets_from_ladders() -> None: market = _market_with_quote("wUSDC.b") market.mode = "two_sided" diff --git a/tests/test_daemon_websocket_runtime.py b/tests/test_daemon_websocket_runtime.py index 88a97ae..5fa5026 100644 --- a/tests/test_daemon_websocket_runtime.py +++ b/tests/test_daemon_websocket_runtime.py @@ -717,6 +717,11 @@ def test_daemon_instance_lock_rejects_second_holder(tmp_path: Path) -> None: def test_main_once_exits_with_lock_conflict(monkeypatch, tmp_path: Path, capsys) -> None: import greenfloor.daemon.main as daemon_mod + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + program = tmp_path / "program.yaml" + _write_program(program, home) + reset_concurrent_log_handlers(module=daemon_mod) state_dir = tmp_path / "state" with daemon_mod._acquire_daemon_instance_lock(state_dir=state_dir, mode="loop"): monkeypatch.setattr( @@ -724,6 +729,8 @@ def test_main_once_exits_with_lock_conflict(monkeypatch, tmp_path: Path, capsys) [ "greenfloord", "--once", + "--program-config", + str(program), "--state-dir", str(state_dir), ], @@ -731,5 +738,7 @@ def test_main_once_exits_with_lock_conflict(monkeypatch, tmp_path: Path, capsys) with pytest.raises(SystemExit) as exc: daemon_mod.main() assert exc.value.code == 3 - out = capsys.readouterr().out - assert "daemon_lock_conflict" in out + captured = capsys.readouterr() + assert captured.out == "" + log_text = (home / "logs" / "debug.log").read_text(encoding="utf-8") + assert "daemon_lock_conflict" in log_text diff --git a/tests/test_dexie_adapter.py b/tests/test_dexie_adapter.py index d7d50a2..302fcbd 100644 --- a/tests/test_dexie_adapter.py +++ b/tests/test_dexie_adapter.py @@ -136,3 +136,32 @@ def test_dexie_get_offer_requires_non_empty_offer_id() -> None: adapter = DexieAdapter("https://api.dexie.space") with pytest.raises(ValueError, match="offer_id is required"): adapter.get_offer(" ") + + +def test_dexie_lookup_uses_ttl_cache_for_tokens(monkeypatch) -> None: + adapter = DexieAdapter("https://api.dexie.space", cache_ttl_seconds=900) + calls = {"count": 0} + + def _fake_urlopen(url, timeout=0): + _ = timeout + calls["count"] += 1 + if str(url).endswith("/v1/swap/tokens"): + return _FakeHttpResponse( + { + "tokens": [ + { + "id": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "code": "BYC", + "name": "Biochar", + } + ] + } + ) + raise AssertionError(f"unexpected url: {url}") + + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + first = adapter.lookup_token_by_symbol("BYC") + second = adapter.lookup_token_by_symbol("BYC") + assert first is not None + assert second is not None + assert calls["count"] == 1 diff --git a/tests/test_logging_setup.py b/tests/test_logging_setup.py index 3e01495..bbb9fe6 100644 --- a/tests/test_logging_setup.py +++ b/tests/test_logging_setup.py @@ -7,8 +7,10 @@ cast_log_level, coerce_log_level, create_rotating_file_handler, + initialize_service_file_logging, normalize_log_level_name, ) +from tests.logging_helpers import reset_concurrent_log_handlers def test_normalize_log_level_name_valid_levels() -> None: @@ -47,3 +49,34 @@ def test_create_rotating_file_handler_creates_log_dir(tmp_path) -> None: log_dir = tmp_path / "logs" assert log_dir.exists() handler.close() + + +def test_initialize_service_file_logging_reuses_single_process_handler(tmp_path) -> None: + import greenfloor.logging_setup as logging_setup_mod + + reset_concurrent_log_handlers(module=logging_setup_mod) + root_logger = logging.getLogger() + logger_a = logging.getLogger("greenfloor.manager") + logger_b = logging.getLogger("greenfloor.daemon") + try: + handler_a = initialize_service_file_logging( + service_name="manager", + home_dir=str(tmp_path), + log_level="INFO", + service_logger=logger_a, + ) + handler_b = initialize_service_file_logging( + service_name="daemon", + home_dir=str(tmp_path), + log_level="INFO", + service_logger=logger_b, + ) + rotating_handlers = [ + handler + for handler in root_logger.handlers + if handler.__class__.__name__.endswith("RotatingFileHandler") + ] + assert handler_a is handler_b + assert len(rotating_handlers) == 1 + finally: + reset_concurrent_log_handlers(module=logging_setup_mod) diff --git a/tests/test_manager_post_offer.py b/tests/test_manager_post_offer.py index 7a38378..a6cc715 100644 --- a/tests/test_manager_post_offer.py +++ b/tests/test_manager_post_offer.py @@ -121,12 +121,21 @@ def _fake_lookup_by_cat(*, canonical_cat_id_hex: str, network: str): monkeypatch.setattr( "greenfloor.cli.manager._dexie_lookup_token_for_cat_id", _fake_lookup_by_cat ) + monkeypatch.setattr( + "greenfloor.cloud_wallet_offer_runtime._dexie_lookup_token_for_cat_id", _fake_lookup_by_cat + ) monkeypatch.setattr( "greenfloor.cli.manager._dexie_lookup_token_for_symbol", lambda *, asset_ref, network: ( {"id": quote_cat, "code": "wUSDC.b"} if asset_ref == "wUSDC.b" else None ), ) + monkeypatch.setattr( + "greenfloor.cloud_wallet_offer_runtime._dexie_lookup_token_for_symbol", + lambda *, asset_ref, network: ( + {"id": quote_cat, "code": "wUSDC.b"} if asset_ref == "wUSDC.b" else None + ), + ) base_asset, quote_asset = manager_mod._resolve_cloud_wallet_offer_asset_ids( wallet=cast(CloudWalletAdapter, _FakeWallet()), @@ -765,8 +774,7 @@ def test_build_and_post_offer_uses_market_configured_expiry_override( _write_markets(markets) raw = yaml.safe_load(markets.read_text(encoding="utf-8")) pricing = dict(raw["markets"][0].get("pricing") or {}) - pricing["strategy_offer_expiry_unit"] = "hours" - pricing["strategy_offer_expiry_value"] = 8 + pricing["strategy_offer_expiry_minutes"] = 12 raw["markets"][0]["pricing"] = pricing markets.write_text(yaml.safe_dump(raw, sort_keys=False), encoding="utf-8") @@ -805,8 +813,8 @@ def _fake_build(payload: dict) -> str: dry_run=False, ) assert code == 0 - assert captured_payload["expiry_unit"] == "hours" - assert captured_payload["expiry_value"] == 8 + assert captured_payload["expiry_unit"] == "minutes" + assert captured_payload["expiry_value"] == 12 payload = json.loads(capsys.readouterr().out.strip()) assert payload["publish_failures"] == 0 assert payload["results"][0]["result"]["id"] == "offer-expiry-1" @@ -1602,15 +1610,34 @@ class _ConditionWithExpiry: def parse_assert_before_seconds_relative(): return object() - class _CoinSpendWithExpiry: + class _OutputValue: @staticmethod - def conditions(): + def to_list(): return [_ConditionWithExpiry()] + class _Output: + value = _OutputValue() + + class _Program: + @staticmethod + def run(_solution, _max_cost: int, _mempool_mode: bool): + return _Output() + + class _Clvm: + @staticmethod + def deserialize(_blob: bytes): + return _Program() + + class _CoinSpendWithExpiry: + puzzle_reveal = b"puzzle" + solution = b"solution" + class _SpendBundleWithExpiry: coin_spends = [_CoinSpendWithExpiry()] class _Sdk: + Clvm = _Clvm + @staticmethod def validate_offer(offer: str) -> None: assert offer == "offer1ok" @@ -1636,15 +1663,34 @@ class _ConditionWithExpiry: def parse_assert_before_height_absolute(): return object() - class _CoinSpendWithExpiry: + class _OutputValue: @staticmethod - def conditions(): + def to_list(): return [_ConditionWithExpiry()] + class _Output: + value = _OutputValue() + + class _Program: + @staticmethod + def run(_solution, _max_cost: int, _mempool_mode: bool): + return _Output() + + class _Clvm: + @staticmethod + def deserialize(_blob: bytes): + return _Program() + + class _CoinSpendWithExpiry: + puzzle_reveal = b"puzzle" + solution = b"solution" + class _SpendBundleWithExpiry: coin_spends = [_CoinSpendWithExpiry()] class _Sdk: + Clvm = _Clvm + @staticmethod def verify_offer(offer: str) -> bool: return offer == "offer1ok" @@ -1706,6 +1752,114 @@ def decode_offer(_offer: str): assert _verify_offer_text_for_dexie("offer1noexpiry") == "wallet_sdk_offer_missing_expiration" +def test_verify_offer_text_for_dexie_extracts_expiry_from_coin_spend_program( + monkeypatch, +) -> None: + def _import_module(name: str): + if name == "greenfloor_native": + raise ImportError("disable native path for this test") + return __import__(name) + + monkeypatch.setattr("greenfloor.cli.manager.importlib.import_module", _import_module) + + class _ConditionWithExpiry: + @staticmethod + def parse_assert_before_seconds_absolute(): + return object() + + class _OutputValue: + @staticmethod + def to_list(): + return [_ConditionWithExpiry()] + + class _Output: + value = _OutputValue() + + class _Program: + @staticmethod + def run(_solution, _max_cost: int, _mempool_mode: bool): + return _Output() + + class _Clvm: + @staticmethod + def deserialize(_blob: bytes): + return _Program() + + class _CoinSpend: + puzzle_reveal = b"puzzle" + solution = b"solution" + + class _SpendBundle: + coin_spends = [_CoinSpend()] + + class _Sdk: + Clvm = _Clvm + + @staticmethod + def validate_offer(_offer: str) -> None: + return None + + @staticmethod + def decode_offer(_offer: str): + return _SpendBundle() + + monkeypatch.setitem(sys.modules, "chia_wallet_sdk", _Sdk) + assert _verify_offer_text_for_dexie("offer1ok") is None + + +def test_verify_offer_text_for_dexie_rejects_duplicate_spent_coin_ids( + monkeypatch, +) -> None: + def _import_module(name: str): + if name == "greenfloor_native": + raise ImportError("disable native path for this test") + return __import__(name) + + monkeypatch.setattr("greenfloor.cli.manager.importlib.import_module", _import_module) + + class _ConditionWithExpiry: + @staticmethod + def parse_assert_before_height_absolute(): + return object() + + class _Coin: + def __init__(self, coin_id: str): + self._coin_id = coin_id + + def coin_id(self): + return self._coin_id + + class _CoinSpend: + def __init__(self, coin_id: str): + self.coin = _Coin(coin_id) + + @staticmethod + def conditions(): + return [_ConditionWithExpiry()] + + class _SpendBundleWithDuplicates: + coin_spends = [_CoinSpend("aa" * 32), _CoinSpend("aa" * 32)] + + class _Sdk: + @staticmethod + def validate_offer(_offer: str) -> None: + return None + + @staticmethod + def decode_offer(_offer: str): + return _SpendBundleWithDuplicates() + + @staticmethod + def to_hex(value): + return str(value) + + monkeypatch.setitem(sys.modules, "chia_wallet_sdk", _Sdk) + assert ( + _verify_offer_text_for_dexie("offer1duplicate") + == "wallet_sdk_offer_duplicate_spent_coin_ids" + ) + + def test_verify_offer_text_for_dexie_uses_greenfloor_native_before_sdk(monkeypatch) -> None: calls = {} @@ -1738,6 +1892,54 @@ def validate_offer(_offer: str) -> None: ) +def test_verify_offer_text_for_dexie_checks_duplicate_spends_after_native_validation( + monkeypatch, +) -> None: + class _Native: + @staticmethod + def validate_offer(_offer: str) -> None: + return None + + class _ConditionWithExpiry: + @staticmethod + def parse_assert_before_height_absolute(): + return object() + + class _Coin: + def __init__(self, coin_id: str): + self._coin_id = coin_id + + def coin_id(self): + return self._coin_id + + class _CoinSpend: + def __init__(self, coin_id: str): + self.coin = _Coin(coin_id) + + @staticmethod + def conditions(): + return [_ConditionWithExpiry()] + + class _SpendBundleWithDuplicates: + coin_spends = [_CoinSpend("bb" * 32), _CoinSpend("bb" * 32)] + + class _Sdk: + @staticmethod + def decode_offer(_offer: str): + return _SpendBundleWithDuplicates() + + @staticmethod + def to_hex(value): + return str(value) + + monkeypatch.setitem(sys.modules, "greenfloor_native", _Native) + monkeypatch.setitem(sys.modules, "chia_wallet_sdk", _Sdk) + assert ( + _verify_offer_text_for_dexie("offer1native-dupe") + == "wallet_sdk_offer_duplicate_spent_coin_ids" + ) + + def test_coins_list_returns_minimal_fields(monkeypatch, tmp_path: Path, capsys) -> None: program = tmp_path / "program.yaml" _write_program_with_cloud_wallet(program) @@ -3142,6 +3344,60 @@ def list_coins(*, include_pending=True, asset_id=None): assert payload["unknown_coin_ids"] == ["missing-coin-name"] +def test_coin_combine_rejects_mixed_asset_coin_ids_before_api_call( + monkeypatch, tmp_path: Path, capsys +) -> None: + program = tmp_path / "program.yaml" + markets = tmp_path / "markets.yaml" + _write_program_with_cloud_wallet(program) + _write_markets(markets) + + class _FakeWallet: + vault_id = "wallet-1" + + def __init__(self, _config): + pass + + @staticmethod + def list_coins(*, include_pending=True, asset_id=None): + _ = include_pending, asset_id + return [ + {"id": "Coin_xch", "name": "coin-xch", "asset": {"id": "xch"}}, + {"id": "Coin_cat", "name": "coin-cat", "asset": {"id": "Asset_cat"}}, + ] + + @staticmethod + def combine_coins(*, number_of_coins, fee, largest_first, asset_id, input_coin_ids=None): + _ = number_of_coins, fee, largest_first, asset_id, input_coin_ids + raise AssertionError("combine_coins should not be called for mixed assets") + + monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) + monkeypatch.setattr( + "greenfloor.cli.manager._resolve_taker_or_coin_operation_fee", + lambda *, network, minimum_fee_mojos=0: (0, "config_minimum_fee_fallback"), + ) + code = _coin_combine( + program_path=program, + markets_path=markets, + network="mainnet", + market_id="m1", + pair=None, + number_of_coins=2, + asset_id="xch", + coin_ids=["coin-xch", "coin-cat"], + no_wait=True, + ) + assert code == 2 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["success"] is False + assert payload["error"] == "coin_id_asset_mismatch" + assert payload["resolved_asset_id"] == "xch" + assert payload["mismatched_coin_ids"] == ["Coin_cat"] + assert payload["mismatched_coin_assets"] == [ + {"coin_id": "Coin_cat", "coin_asset_id": "asset_cat"} + ] + + def test_coin_split_uses_market_ladder_target_when_size_is_provided( monkeypatch, tmp_path: Path, capsys ) -> None: @@ -3397,6 +3653,99 @@ def combine_coins(*, number_of_coins, fee, largest_first, asset_id, input_coin_i assert payload["resolved_asset_id"] == "Asset_split_base" +def test_coin_combine_auto_selection_directly_filters_cross_asset_scoped_rows( + monkeypatch, tmp_path: Path, capsys +) -> None: + program = tmp_path / "program.yaml" + markets = tmp_path / "markets.yaml" + _write_program_with_cloud_wallet(program) + _write_markets(markets) + calls = {} + + class _FakeWallet: + vault_id = "wallet-1" + + def __init__(self, _config): + pass + + @staticmethod + def list_coins(*, include_pending=True, asset_id=None): + _ = include_pending + if asset_id == "Asset_split_base": + return [ + {"id": "Coin_good_1", "name": "good-1", "amount": 1000, "state": "SETTLED"}, + {"id": "Coin_bad", "name": "bad", "amount": 1000, "state": "SETTLED"}, + {"id": "Coin_good_2", "name": "good-2", "amount": 1000, "state": "SETTLED"}, + ] + return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] + + @staticmethod + def get_coin_record(*, coin_id): + mapping = { + "Coin_good_1": { + "id": "Coin_good_1", + "amount": 1000, + "state": "SETTLED", + "isLocked": False, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_split_base"}, + }, + "Coin_good_2": { + "id": "Coin_good_2", + "amount": 1000, + "state": "SETTLED", + "isLocked": False, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_split_base"}, + }, + "Coin_bad": { + "id": "Coin_bad", + "amount": 1000, + "state": "SETTLED", + "isLocked": False, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_huun64oh7dbt9f1f9ie8khuw"}, + }, + } + return mapping[coin_id] + + @staticmethod + def combine_coins(*, number_of_coins, fee, largest_first, asset_id, input_coin_ids=None): + calls["combine"] = (number_of_coins, fee, largest_first, asset_id, input_coin_ids) + return {"signature_request_id": "sr-combine", "status": "UNSIGNED"} + + monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) + monkeypatch.setattr( + "greenfloor.cli.manager._resolve_cloud_wallet_asset_id", + lambda *, wallet, canonical_asset_id, symbol_hint=None: "Asset_split_base", + ) + monkeypatch.setattr( + "greenfloor.cli.manager._resolve_taker_or_coin_operation_fee", + lambda *, network, minimum_fee_mojos=0: (77, "coinset_conservative"), + ) + code = _coin_combine( + program_path=program, + markets_path=markets, + network="mainnet", + market_id="m1", + pair=None, + number_of_coins=2, + asset_id="a1", + coin_ids=[], + no_wait=True, + ) + assert code == 0 + assert calls["combine"] == ( + 2, + 77, + True, + "Asset_split_base", + ["Coin_good_1", "Coin_good_2"], + ) + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["coin_selection_mode"] == "adapter_auto_select" + + def test_coin_split_until_ready_ignores_unknown_states_and_string_asset( monkeypatch, tmp_path: Path, capsys ) -> None: @@ -4557,8 +4906,7 @@ def test_build_and_post_offer_cloud_wallet_uses_market_configured_expiry_overrid prog, mkt = _load_program_and_market(program_path, markets_path) prog.home_dir = str(tmp_path) pricing = dict(mkt.pricing or {}) - pricing["strategy_offer_expiry_unit"] = "hours" - pricing["strategy_offer_expiry_value"] = 8 + pricing["strategy_offer_expiry_minutes"] = 12 mkt.pricing = pricing captured_expires: dict[str, str] = {} @@ -4634,8 +4982,8 @@ def get_offer(offer_id: str) -> dict[str, object]: expires_at = dt.datetime.fromisoformat(captured_expires["iso"]) now = dt.datetime.now(dt.UTC) delta_seconds = (expires_at - now).total_seconds() - assert delta_seconds > 7 * 3600 - assert delta_seconds < 9 * 3600 + assert delta_seconds > 10 * 60 + assert delta_seconds < 14 * 60 payload = json.loads(capsys.readouterr().out.strip()) assert payload["publish_failures"] == 0 @@ -4783,7 +5131,7 @@ def post_offer(_offer: str, *, drop_only: bool, claim_rewards: bool | None): @staticmethod def get_offer(_offer_id: str) -> dict[str, object]: - raise RuntimeError("dexie_http_error:404") + raise RuntimeError("dexie_http_error:500") monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) monkeypatch.setattr( @@ -6182,3 +6530,44 @@ class _Dexie: ) assert result["success"] is False assert "dexie_offer_not_visible_after_publish" in str(result["error"]) + + +def test_cloud_wallet_post_offer_phase_fails_after_repeated_transient_dexie_404( + monkeypatch, +) -> None: + class _Dexie: + pass + + dexie = _Dexie() + post_calls = {"count": 0} + monkeypatch.setattr( + manager_mod, + "_post_dexie_offer_with_invalid_offer_retry", + lambda **_kwargs: ( + post_calls.__setitem__("count", post_calls["count"] + 1) + or {"success": True, "id": "offer-1"} + ), + ) + monkeypatch.setattr( + manager_mod, + "_verify_dexie_offer_visible_by_id", + lambda **_kwargs: "dexie_get_offer_error:HTTP Error 404: Not Found", + ) + market = type("Market", (), {"base_asset": "asset"})() + result = manager_mod._cloud_wallet_post_offer_phase( + publish_venue="dexie", + dexie=cast(Any, dexie), + splash=None, + offer_text="offer1abc", + drop_only=False, + claim_rewards=False, + market=market, + expected_offered_asset_id="asset", + expected_offered_symbol="asset", + expected_requested_asset_id="xch", + expected_requested_symbol="xch", + sleep_fn=lambda _seconds: None, + ) + assert result["success"] is False + assert "404" in str(result["error"]) + assert post_calls["count"] == 3 diff --git a/tests/test_price_adapter.py b/tests/test_price_adapter.py index 547ee03..9cab828 100644 --- a/tests/test_price_adapter.py +++ b/tests/test_price_adapter.py @@ -4,7 +4,7 @@ import pytest -from greenfloor.adapters.price import PriceAdapter +from greenfloor.adapters.price import PriceAdapter, XchPriceProvider class _FakeResponse: @@ -111,3 +111,82 @@ def test_get_xch_price_raises_when_no_cache_and_fetch_fails() -> None: with pytest.raises(RuntimeError, match="offline"): asyncio.run(adapter.get_xch_price()) + + +def test_xch_price_provider_prefers_cloud_wallet_and_uses_ttl_cache() -> None: + now = {"value": 1_000.0} + calls = {"count": 0} + fallback_calls = {"count": 0} + + def _cloud_wallet_quote() -> float: + calls["count"] += 1 + return 42.0 + + class _NeverCalledFallback(PriceAdapter): + async def get_xch_price(self) -> float: + fallback_calls["count"] += 1 + raise AssertionError("fallback must not be called when cloud wallet is healthy") + + provider = XchPriceProvider( + cloud_wallet_price_fn=_cloud_wallet_quote, + cloud_wallet_ttl_seconds=120, + fallback_price_adapter=_NeverCalledFallback(), + now_fn=lambda: now["value"], + ) + + first = asyncio.run(provider.get_xch_price()) + second = asyncio.run(provider.get_xch_price()) + + assert first == 42.0 + assert second == 42.0 + assert calls["count"] == 1 + assert fallback_calls["count"] == 0 + + +def test_xch_price_provider_falls_back_to_coincodex_when_cloud_wallet_fails() -> None: + fallback_counter = {"count": 0} + provider = XchPriceProvider( + cloud_wallet_price_fn=lambda: (_ for _ in ()).throw(RuntimeError("cw down")), + fallback_price_adapter=PriceAdapter( + ttl_seconds=60, + now_fn=lambda: 1_000.0, + session_factory=lambda: _FakeSession([{"last_price_usd": "33.50"}], fallback_counter), + ), + now_fn=lambda: 1_000.0, + ) + + value = asyncio.run(provider.get_xch_price()) + assert value == 33.5 + assert fallback_counter["count"] == 1 + + +def test_xch_price_provider_returns_last_good_price_when_all_sources_fail() -> None: + """After a successful fetch, both sources failing returns the stale value.""" + now = {"value": 1_000.0} + cw_healthy = {"ok": True} + + def _cloud_wallet_quote() -> float: + if not cw_healthy["ok"]: + raise RuntimeError("cw offline") + return 44.0 + + provider = XchPriceProvider( + cloud_wallet_price_fn=_cloud_wallet_quote, + cloud_wallet_ttl_seconds=60, + fallback_price_adapter=PriceAdapter( + ttl_seconds=60, + now_fn=lambda: now["value"], + session_factory=lambda: _FakeSession([RuntimeError("coincodex down")], {"count": 0}), + ), + now_fn=lambda: now["value"], + ) + + good = asyncio.run(provider.get_xch_price()) + assert good == 44.0 + + # Expire the CW TTL so _get_cloud_wallet_price re-fetches, then break it. + now["value"] = 1_062.0 + cw_healthy["ok"] = False + + stale = asyncio.run(provider.get_xch_price()) + assert stale == 44.0 diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 27d5b24..186b5ac 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -164,11 +164,29 @@ def test_evaluate_market_uses_configured_expiry_override() -> None: state=MarketState(ones=4, tens=2, hundreds=1, xch_price_usd=30.0), config=StrategyConfig( pair="xch", - offer_expiry_unit="hours", - offer_expiry_value=2, + offer_expiry_minutes=120, ), clock=_clock(), ) assert len(actions) == 1 - assert actions[0].expiry_unit == "hours" - assert actions[0].expiry_value == 2 + assert actions[0].expiry_unit == "minutes" + assert actions[0].expiry_value == 120 + + +def test_evaluate_market_respects_dynamic_target_sizes() -> None: + actions = evaluate_market( + state=MarketState( + ones=5, + tens=2, + hundreds=0, + xch_price_usd=30.0, + bucket_counts_by_size={1: 5, 10: 2, 50: 0}, + ), + config=StrategyConfig( + pair="xch", + target_counts_by_size={1: 5, 10: 2, 50: 1}, + ), + clock=_clock(), + ) + assert [action.size for action in actions] == [50] + assert [action.repeat for action in actions] == [1]