diff --git a/AGENTS.md b/AGENTS.md index b3a2443..0173431 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ Severity tags: - `[MUST]` Fix the primary path; do not add fallback execution paths to hide correctness gaps. - `[SHOULD]` Temporary sdk symbol-rename shims are allowed only during explicit migrations and must be removed once the pinned baseline stabilizes. - `[MUST]` Network symbol discipline: mainnet uses `xch`, testnet11 uses `txch` in examples, defaults, runbooks, workflows, and operator commands. +- `[MUST]` CAT denomination discipline: 1000 mojos of a CAT is exactly 1 unit of that CAT in examples, operator output, runbooks, tests, and code comments. +- `[SHOULD]` When debugging, prefer the existing log pipeline: set the host log level to `DEBUG` in `program.yaml` and use the service logs instead of adding ad hoc debug code or one-off debug files. - `[SHOULD]` Offer cancellation is exceptional (stable-vs-unstable only, and only on strong unstable-side moves). - `[MUST]` All posted offers must include expiry; stable-vs-unstable pairs should use shorter expiries. diff --git a/config/markets.yaml b/config/markets.yaml index 75fac13..63aaf3d 100644 --- a/config/markets.yaml +++ b/config/markets.yaml @@ -343,7 +343,7 @@ markets: combine_when_excess_factor: 2.0 - id: byc_two_sided_wusdbc - enabled: false + enabled: true mode: two_sided base_asset: "BYC" base_symbol: "BYC" @@ -352,9 +352,7 @@ markets: signer_key_id: "key-main-2" receive_address: "xch1u3tytpv45sj0h4lpwmtkyzh2ggvw4x7jccyxzu995p2aj40wzcxqvymyn3" pricing: - buy_min_quote_per_base: 0.99 - sell_max_quote_per_base: 1.01 - slippage_bps: 50 + fixed_quote_per_base: 0.999 inventory: low_watermark_base_units: 200 low_inventory_alert_threshold_base_units: 200 @@ -364,11 +362,11 @@ markets: ladders: buy: - size_base_units: 10 - target_count: 8 + target_count: 1 split_buffer_count: 1 combine_when_excess_factor: 2.0 sell: - size_base_units: 10 - target_count: 8 + target_count: 3 split_buffer_count: 1 combine_when_excess_factor: 2.0 diff --git a/docs/ent-wallet-upstream-byc-coin-query-issue.md b/docs/ent-wallet-upstream-byc-coin-query-issue.md new file mode 100644 index 0000000..cb465e5 --- /dev/null +++ b/docs/ent-wallet-upstream-byc-coin-query-issue.md @@ -0,0 +1,192 @@ +# Draft Upstream Issue: BYC asset-scoped coin query returns stray row and mismatched totals + +## Proposed title + +`coins(assetId=...)` can include a stray coin with incoherent asset lineage; per-row asset resolution and wallet asset totals also diverge + +## Summary + +While debugging a BYC vault on `John-Deere`, `greenfloor-manager coins-list --asset BYC` reported: + +- live coin-row sum: `50200` mojos (`50.200 BYC`) +- wallet asset total: `50300` mojos (`50.300 BYC`) +- expected real vault value: `50000` mojos (`50.000 BYC`) + +The `+200` mojo overcount in the live row set localizes to a single suspicious current coin: + +- coin: `4344df4191e68429233d787130b7eff6e2655673840edfa6feecfdcfc920933d` +- amount: `310` +- puzzle hash: `7ff9f7e13048e191717a34ff04c31b951254aced5cd93e1caac1e8849f700144` + +Its ancestry is not a coherent BYC-only conservation chain. The lineage also flips from BYC to XCH resolution several generations up, which strongly suggests an indexing / asset-association problem rather than real extra BYC in the vault. + +Separately, `walletAsset.totalAmount` is `100` mojos higher than the summed live coin rows, which appears to be an independent `balanceRecords` drift. + +## Why this matters + +`greenfloor` queries `coins(assetId=...)` to list vault inventory for a single CAT. If the backend can return a stray row with inconsistent asset ancestry, inventory accounting is wrong even before any client-side display logic runs. + +## Reproduction context + +- Host: `John-Deere` +- Asset: `BYC` +- Expected vault inventory: `50000` mojos (`50.000 BYC`) +- Observed live branch under puzzle hash `7ff9...0144`: `20200` mojos + +That suspicious branch breaks down as: + +- `19480` mojo pending leaf: `1a1d1e8e9ea204e7f5c94a8f9665a934955bf5c4ff13bbbae77c2e69b022b539` +- `41` separate leaves of `10` mojos each +- `310` mojo settled leaf: `4344df4191e68429233d787130b7eff6e2655673840edfa6feecfdcfc920933d` + +So the bad excess is concentrated in the `310`-mojo row. + +## Suspicious lineage + +Tracing parent links for the stray `310` coin yields this leading chain: + +1. `4344df4191e68429233d787130b7eff6e2655673840edfa6feecfdcfc920933d` -> `310` +2. `6e15e693ac02c62c066da59e413a0b1070be41f616441c54bc9823b1892fc123` -> `270` +3. `d49e2f717da21e1ebed1612dfc542a7c26bcbc01e7338d6b4cacfd6189e4cb61` -> `140` +4. `9a3a5667c112b8af09d53386c7ec5b7a8519959cf25114bfdb44ed2123a8eb8a` -> `100` +5. `0fbeb6dda5ad099d149c9563917e2ed14d13ebda21ee68974539a1bc0051682f` -> `0` +6. `62fe778efe3b88b99ce4fa37772ab1732eba14fba402921190afee439e99a9e0` -> `900` +7. `fafe0e302e92bbd624974d4dd77059152f249d13537f305c45f57e6796548a15` -> `1000` + +That is not a sane conservation pattern for a single-asset leaf chain. + +Even more suspiciously: + +- the first few nodes in this chain resolve as BYC, +- by the `62fe...` ancestor, the per-row resolver is already returning XCH (`Asset_huun64oh7dbt9f1f9ie8khuw`), +- so the current BYC-scoped result set appears to contain a row whose ancestry crosses into XCH-resolved history. + +## Expected + +- `coins(walletId: ..., assetId: BYC, ...)` should return only current BYC coin rows that are coherently part of the BYC asset lineage. +- Summing returned rows should match the actual current BYC inventory for the vault. +- If a per-row asset cannot be resolved, the API should not silently relabel it as XCH. +- `walletAsset.totalAmount` should match the same current inventory snapshot used by the coin query, or clearly document snapshot lag / sync semantics. + +## Actual + +- `coins(assetId=BYC)` returned rows summing to `50200`, not the expected `50000`. +- The excess localizes to a single `310`-mojo row with incoherent ancestry. +- Per-row asset resolution can flip to XCH inside the suspicious chain. +- `walletAsset.totalAmount` reports `50300`, which is `100` mojos above the live coin-row sum. + +## Relevant code paths + +### Coin filtering uses `outerPuzzleId` + +`coins(assetId=...)` scopes rows using `puzzleHashes.outerPuzzleId`: + +- `../ent-wallet/apps/api/src/dataSources/coinRecords.ts` +- `getCoinRecords(...)` +- current filter: + +```ts +if (asset?.identifier) { + whereConditions.push( + eq(puzzleHashes.outerPuzzleId, Buffer.from(asset.identifier, "hex")), + ); +} +``` + +### Per-row asset resolution falls back to XCH + +`getByCoinName()` returns base currency when no asset is found: + +- `../ent-wallet/apps/api/src/dataSources/assets.ts` + +```ts +if (!asset) { + const xch = await findByIdentifier(ctx, ctx.network.genesisChallenge); + return xch; +} +``` + +That fallback hides asset-association failures and can make mixed-asset output look superficially valid. + +### First-party client does not ask for `node.asset` + +The Cloud Wallet UI coin list query omits per-row `asset` entirely: + +- `../ent-wallet/apps/app/src/components/Wallet/WalletCoins.graphql` + +This suggests the intended stable contract is the scoped query itself, not `node.asset` on each row. + +### Aggregate totals appear to come from a different snapshot path + +The extra `+100` mojo in `walletAsset.totalAmount` looks like a separate `balanceRecords` / wallet sync drift rather than the same bug as the stray `310` row. + +## Suggested fix surface + +### Primary bug + +Investigate why `coins(assetId=...)` can include the stray `310`-mojo row at all. + +Things to inspect: + +- whether `puzzleHashes.outerPuzzleId` can point to stale or ambiguous asset mappings for recycled / transformed coin histories, +- whether the join path can associate a current row with an ancestor-side asset classification that no longer reflects the current coin, +- whether puzzle-hash lineage transitions involving CAT/XCH wrappers can leak non-CAT rows into CAT-scoped results. + +### Secondary bug + +Stop silently defaulting unresolved per-row assets to XCH in `getByCoinName()`. + +Better options: + +- return `null` / unresolved, +- raise an explicit error for debugging, +- or expose an `unknown` asset state that does not masquerade as chain base currency. + +### Separate follow-up + +Investigate why `walletAsset.totalAmount` is `100` mojos above the live row sum for the same BYC vault snapshot. + +Likely area: + +- wallet sync / `balanceRecords` update timing or stale-state accumulation. + +## Notes for `greenfloor` + +As a client-side mitigation, `greenfloor` should avoid requesting per-row `asset` when the query is already scoped by `assetId`, matching the first-party Cloud Wallet UI pattern. That does not fix the upstream stray-row bug, but it avoids surfacing misleading XCH fallback metadata. + +## Additional live evidence (2026-03-05) + +After re-enabling the BYC market on `John-Deere`, the same scoped query leak showed up in a more operationally dangerous form: + +- `coins(assetId=BYC)` returned three `10000`-mojo rows plus one `19480`-mojo pending row as if they were BYC candidates, +- the daemon selected one of those `10000`-mojo rows for a BYC split prerequisite, +- Cloud Wallet rejected the split with `Some selected coins are not spendable`. + +Direct `node(id: ...)` lookups for those rows showed they were not BYC at all: + +- `CoinRecord_d6dc31acf63aa4022fe0dafedde6032c8be089eedae4fe1a2a682ef47932f921` -> `asset.id = Asset_huun64oh7dbt9f1f9ie8khuw` (`CRYPTOCURRENCY` / XCH) +- `CoinRecord_0d26203557f42398c223d3f7e9fbfe22a38aaa19bb23477b66ad361f14ff64de` -> same XCH asset +- `CoinRecord_d899186795556f1698b573a354a72165c5419636ced8fa18d113af1972b8868b` -> same XCH asset +- `CoinRecord_1a1d1e8e9ea204e7f5c94a8f9665a934955bf5c4ff13bbbae77c2e69b022b539` -> same XCH asset, `PENDING` + +Notably: + +- the leaked `10000`-mojo rows looked perfectly spendable from the scoped query surface (`SETTLED`, not linked to open offers, one even reported `isLocked=false`), +- at least one of those rows did **not** appear in an unscoped `coins(walletId=...)` read at all, +- so the scoped query is not merely mislabeling a real BYC coin; it appears to be returning rows that should not be in the BYC result set. + +This moves the bug from "inventory accounting drift" into "coin-op candidate corruption": a client that trusts the scoped BYC query can attempt invalid CAT splits/combinations against XCH rows. + +## Temporary client mitigation now deployed + +`greenfloor` now carries a temporary fail-closed mitigation for coin ops: + +- keep the existing scoped query path for inventory discovery, +- but before using a scoped row as a CAT split/combine candidate, re-fetch that exact `CoinRecord` by id, +- require the direct lookup to confirm: + - matching asset id, + - spendable state, + - `isLocked = false`, + - `isLinkedToOpenOffer = false`. + +This mitigation is intentionally temporary and should be removed once the upstream `coins(assetId=...)` query stops leaking cross-asset rows. diff --git a/docs/progress.md b/docs/progress.md index 6efc3ff..b884d00 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,5 +1,133 @@ # Progress Log +## 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: + - the failing split candidate `CoinRecord_d6dc31acf63aa4022fe0dafedde6032c8be089eedae4fe1a2a682ef47932f921` came from `coins(assetId=BYC)` but direct `node(id)` lookup resolved it as `Asset_huun64oh7dbt9f1f9ie8khuw` (`CRYPTOCURRENCY` / XCH), + - two other `10000`-mojo rows returned by the same BYC-scoped query also resolved to XCH, + - the `19480`-mojo pending branch under puzzle hash `7ff9f7e13048e191717a34ff04c31b951254aced5cd93e1caac1e8849f700144` also resolved to XCH. +- Implemented a temporary upstream-defense in `greenfloor/daemon/main.py`: + - added `_coin_matches_direct_spendable_lookup(...)`, + - coin-op selection now re-fetches each candidate `CoinRecord` by id and requires: + - matching asset id, + - spendable state, + - `isLocked = false`, + - `isLinkedToOpenOffer = false`, + - added explicit code comment that this is temporary until Cloud Wallet fixes the scoped-query leak. +- Added `CloudWalletAdapter.get_coin_record(...)` in `greenfloor/adapters/cloud_wallet.py` for direct coin validation during daemon coin-op selection. +- Added deterministic regression coverage in `tests/test_daemon_offer_execution.py`: + - direct-lookup filtering for `_cloud_wallet_spendable_base_unit_coin_amounts(...)`, + - split candidate revalidation that skips wrong-asset and locked rows before submission. +- Validation completed locally: + - targeted daemon suite passed: `5 passed`. +- John-Deere rollout + verification: + - synced updated `greenfloor/adapters/cloud_wallet.py` and `greenfloor/daemon/main.py`, + - restarted daemon against existing `~/.greenfloor/config/*.yaml`, + - direct live helper probe on host showed: + - `19480` pending row -> rejected, + - locked `10000` row -> rejected, + - leaked XCH `d6dc...` row -> rejected, + - leaked XCH `0d26...` row -> rejected, + - only the known stray `310`-mojo row remained lookup-admissible, but it stays below CAT min-amount filtering. +- Post-deploy BYC cycle result on John-Deere: + - daemon still posted/maintained the expected 4 correctly priced BYC offers on Dexie, + - `inventory_scan_wallet` for BYC moved to `coin_count=0 bucket_counts={10: 0}`, + - previous split failure `cloud_wallet_graphql_error:Some selected coins are not spendable:selected_coin_id=CoinRecord_d6dc...` disappeared, + - replacement fail-closed outcome is now `reason=no_spendable_split_coin_available`. +- Documentation updated: + - extended `docs/ent-wallet-upstream-byc-coin-query-issue.md` with the new live evidence that BYC-scoped rows can directly resolve to XCH on `node(id)` lookup, + - recorded the temporary client-side mitigation as an operational stopgap pending upstream fix. + +## 2026-03-05 (Cloud Wallet combine 429 hardening + John-Deere smoke) + +- Added daemon-side Cloud Wallet combine retry/backoff in `greenfloor/daemon/main.py` for `429` responses: + - new env knobs: `GREENFLOOR_COIN_OPS_COMBINE_MAX_ATTEMPTS` (default `3`) and `GREENFLOOR_COIN_OPS_COMBINE_BACKOFF_MS` (default `1000`), + - retries use exponential delay (`base * 2^(attempt-1)`). +- Added bounded combine fan-out guard in daemon coin-ops: + - `GREENFLOOR_COIN_OPS_COMBINE_INPUT_COIN_CAP` default changed to `5` (minimum `2`), + - normal combine submissions now cap `number_of_coins` to this limit. +- Improved split-prereq combine behavior under cap: + - when full target requires more inputs than cap, daemon now submits a capped progress combine (instead of skipping), + - execution payload now records cap metadata and emits `next_step_note` indicating next cycle is likely a 2-coin combine. +- Added daemon log note for capped progress combines: + - `coin_ops_combine_cap_progress` with market id, required amount, selected total, before/after counts, and cap value. +- Added/updated deterministic tests in `tests/test_daemon_offer_execution.py`: + - retry on `429`, + - normal combine cap application, + - split-prereq capped progress combine path. +- Validation completed: + - local targeted daemon suite: `4 passed`, + - John-Deere targeted daemon suite: `4 passed`. +- John-Deere runtime operations: + - restarted daemon and monitored for 12 minutes, + - observed executed combine event with cap metadata (`op_count=5`, `input_coin_cap=5`, `input_coin_cap_applied=true`, progress `next_step_note` present). +- Temporary BYC pause on John-Deere: + - set `~/.greenfloor/config/markets.yaml` market `byc_two_sided_wusdbc` to `enabled: false`, + - confirmed daemon remained running and other markets continued `coin_ops_plan` activity while BYC events stopped advancing. +- Documented upstream Cloud Wallet / `ent-wallet` investigation draft in `docs/ent-wallet-upstream-byc-coin-query-issue.md`: + - localized the `+0.200 BYC` live row overcount to stray coin `4344df4191e68429233d787130b7eff6e2655673840edfa6feecfdcfc920933d` (`310` mojos), + - captured the incoherent ancestry (`310 -> 270 -> 140 -> 100 -> 0 -> 900 -> 1000 -> ...`) and mixed BYC/XCH asset resolution evidence, + - separated that bug from the independent `walletAsset.totalAmount` `+100` mojo drift. + +## 2026-03-04 (Coin-split fee re-enabled + John-Deere branch rollout) + +- Re-enabled default coin-split fee behavior in `greenfloor/cli/manager.py`: + - removed temporary CAT split zero-fee override in `_effective_coin_split_fee_for_asset(...)`, + - `coin-split` now uses normal advised/config fallback fee resolution again. +- Updated deterministic manager tests in `tests/test_manager_post_offer.py`: + - CAT split fee assertions now expect standard advised fee flow (`coinset_conservative`) rather than forced-zero policy. +- Validation completed before push: + - `pytest -q tests/test_manager_post_offer.py` passed (`130 passed`), + - `pre-commit run --all-files` passed (ruff, format, prettier, yamllint, pyright, pytest). +- Pushed update branch commit: + - branch: `feat/byc-two-sided-market-simplification`, + - commit: `d84328a` (`fix: re-enable default coin-split fee resolution`), + - PR updated: #54. +- John-Deere operational prep + verification: + - using `~/.greenfloor/config/program.yaml` as-is, confirmed `coin_ops.minimum_fee_mojos = 10_000_000`, + - executed fee-less direct cloud-wallet split of one spendable 1 XCH coin into: + - `40` coins of `10_000_000_000` mojos (`minimum_fee_mojos * 1000`), + - `1` remainder coin of `600_000_000_000` mojos, + - verified resulting state as spendable inventory in the active vault. +- John-Deere daemon branch cutover: + - direct GitHub fetch was unavailable on host (`Permission denied (publickey)`), so branch sync used a transferred git bundle, + - checked out `feat/byc-two-sided-market-simplification` at `d84328a` in `/home/hoffmang/greenfloor`, + - restarted daemon with existing runtime args against `~/.greenfloor/config/*.yaml` and verified running process: + - PID `285247`, + - cwd `/home/hoffmang/greenfloor`, + - command `.venv/bin/python -m greenfloor.daemon.main --program-config /home/hoffmang/.greenfloor/config/program.yaml --markets-config /home/hoffmang/.greenfloor/config/markets.yaml --state-dir /home/hoffmang/.greenfloor/state`. +- Follow-up PR-review hardening and rollout: + - pushed `845b38c` (`fix: fail-closed offer side metadata and persist manager side`) on `feat/byc-two-sided-market-simplification`, + - update includes fail-closed side metadata parsing in daemon offer accounting and explicit `side` persistence in manager `strategy_offer_execution` audit items, + - added/updated deterministic coverage in `tests/test_daemon_offer_execution.py` and `tests/test_manager_post_offer.py`, + - validation at commit time: `pre-commit run --all-files` passed (includes pytest hook), and focused suite passed (`164 passed`). +- John-Deere updated to newest branch commit: + - synced host repo to `845b38c` via transferred git bundle and `git merge --ff-only`, + - restarted daemon against existing `~/.greenfloor/config/*.yaml` runtime args and verified active process, + - verified host repo head at runtime: `845b38c`. + +## 2026-03-03 (BYC<>wUSDC.b two-sided activation + config simplification hooks) + +- Implemented explicit side-aware offer execution flow for two-sided markets: + - `PlannedAction` now carries `side` (`buy`/`sell`) with sell as the default for existing paths, + - daemon strategy evaluation now supports `mode: two_sided` by reading both `ladders.buy` and `ladders.sell` as config-driven targets, + - execution/audit payloads now include action `side` so active-offer accounting can track buy vs sell counts. +- Extended cloud-wallet offer posting to be side-aware: + - buy offers now invert offered/requested legs (`offered=quote`, `requested=base`), + - sell offers keep the existing behavior (`offered=base`, `requested=quote`). +- Updated bootstrap denomination planning for two-sided behavior: + - sell-side bootstrap remains base-asset denominated, + - buy-side bootstrap is quote-asset-only and derives per-offer quote denomination from `size_base_units * fixed_quote_per_base`. +- Kept CAT split-fee policy unchanged: + - offer creation continues to enforce `split_input_coins_fee=0`, + - CAT coin-split paths still use temporary zero-fee policy. +- Simplified BYC market config in `config/markets.yaml`: + - enabled `byc_two_sided_wusdbc`, + - replaced unused two-sided min/max knobs with `fixed_quote_per_base: 0.999`, + - set target mix to 1 buy + 3 sell at size 10 with small buffers. +- Added explicit extension-hook intent (without implementing new model logic): + - side-aware price/inventory planning boundaries are now clearer for future sophisticated pricing and inventory models. + ## 2026-03-03 (Temporary CAT split zero-fee policy + John-Deere rollout) - Implemented temporary CAT coin-split fee policy in `greenfloor/cli/manager.py`: diff --git a/docs/runbook.md b/docs/runbook.md index 13068c5..8bf1c80 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -38,7 +38,9 @@ Optional developer bootstrap for testnet markets: - `cloud_wallet.vault_id`: open the target vault and copy the URL segment in `.../wallet//...`; use the `Wallet_...` value, not `vaultLauncherId`. - Review vault coin inventory before shaping or posting: - `greenfloor-manager coins-list` + - `greenfloor-manager coin-status` - Optional asset scope: `greenfloor-manager coins-list --asset ` + - Optional asset scope: `greenfloor-manager coin-status --asset ` - Shape denominations for the selected market context: - Split: `greenfloor-manager coin-split --pair TDBX:txch --coin-id --amount-per-coin 1000 --number-of-coins 10` - Combine: `greenfloor-manager coin-combine --pair TDBX:txch --input-coin-count 10 --asset-id xch` diff --git a/greenfloor/adapters/cloud_wallet.py b/greenfloor/adapters/cloud_wallet.py index 1b7ed9d..dad8c10 100644 --- a/greenfloor/adapters/cloud_wallet.py +++ b/greenfloor/adapters/cloud_wallet.py @@ -4,6 +4,7 @@ import json import logging import random +import re import string import time import urllib.error @@ -72,8 +73,20 @@ def list_coins( asset_id: str | None = None, include_pending: bool = True, ) -> list[dict[str, Any]]: - query = """ -query listCoins($walletId: ID!, $includePending: Boolean, $after: String, $assetId: ID) { + # Upstream Cloud Wallet can mis-resolve `node.asset` on asset-scoped coin + # queries, including falling back to XCH for rows that were already + # selected by the requested CAT scope. Match the first-party UI here: + # when `asset_id` is provided, trust the query scope and omit row asset + # metadata instead of importing misleading fallback values. + asset_fields = "" + if not asset_id: + asset_fields = """ + asset { + id + type + }""" + query = f""" +query listCoins($walletId: ID!, $includePending: Boolean, $after: String, $assetId: ID) {{ coins( walletId: $walletId assetId: $assetId @@ -83,28 +96,25 @@ def list_coins( sortKey: AMOUNT first: 100 after: $after - ) { - pageInfo { + ) {{ + pageInfo {{ hasNextPage endCursor - } - edges { + }} + edges {{ cursor - node { + node {{ id name amount state + isLocked puzzleHash - parentCoinName - asset { - id - type - } - } - } - } -} + parentCoinName{asset_fields} + }} + }} + }} +}} """ after: str | None = None coins: list[dict[str, Any]] = [] @@ -322,6 +332,81 @@ def get_signature_request(self, *, signature_request_id: str) -> dict[str, Any]: return {"id": signature_request_id, "status": "UNKNOWN"} return signature_request + def get_coin_record(self, *, coin_id: str) -> dict[str, Any]: + clean_coin_id = str(coin_id).strip() + if not clean_coin_id: + raise ValueError("coin_id is required") + query = """ +query getCoinRecord($id: ID!) { + node(id: $id) { + __typename + ... on CoinRecord { + id + name + amount + state + isLocked + isLinkedToOpenOffer + puzzleHash + parentCoinName + createdBlockHeight + spentBlockHeight + asset { + id + type + } + } + } +} +""" + payload = self._graphql(query=query, variables={"id": clean_coin_id}) + coin_record = payload.get("node") + if not isinstance(coin_record, dict): + return {"id": clean_coin_id, "state": "UNKNOWN"} + return coin_record + + def get_signature_request_offer(self, *, signature_request_id: str) -> dict[str, Any]: + query = """ +query getSignatureRequestOffer($id: ID!) { + signatureRequest(id: $id) { + id + status + transaction { + offer { + id + offerId + bech32 + state + createdAt + } + } + } +} +""" + payload = self._graphql(query=query, variables={"id": signature_request_id}) + signature_request = payload.get("signatureRequest") or {} + if not isinstance(signature_request, dict): + return {"id": signature_request_id, "status": "UNKNOWN"} + transaction = signature_request.get("transaction") or {} + offer = transaction.get("offer") if isinstance(transaction, dict) else None + if not isinstance(offer, dict): + return { + "id": str(signature_request.get("id", signature_request_id)).strip(), + "status": str(signature_request.get("status", "UNKNOWN")).strip(), + "offer_id": "", + "bech32": "", + "state": "", + "created_at": "", + } + return { + "id": str(signature_request.get("id", signature_request_id)).strip(), + "status": str(signature_request.get("status", "UNKNOWN")).strip(), + "offer_id": str(offer.get("id", "")).strip() or str(offer.get("offerId", "")).strip(), + "bech32": str(offer.get("bech32", "")).strip(), + "state": str(offer.get("state", "")).strip(), + "created_at": str(offer.get("createdAt", "")).strip(), + } + def get_wallet( self, *, @@ -535,6 +620,35 @@ def _auto_sign_if_kms(self, result: dict[str, Any]) -> dict[str, Any]: result["status"] = sr.get("status", result.get("status")) return result + @staticmethod + def _parse_retry_after_seconds(value: str) -> int | None: + text = str(value or "").strip() + if not text: + return None + if text.isdigit(): + seconds = int(text) + return seconds if seconds > 0 else None + match = re.search(r"try again in\s+(\d+)\s+seconds?", text, flags=re.IGNORECASE) + if match is None: + return None + seconds = int(match.group(1)) + return seconds if seconds > 0 else None + + @staticmethod + def _is_rate_limit_error_message(message: str) -> bool: + normalized = str(message or "").strip().lower() + return "rate limit" in normalized or "too many requests" in normalized + + @staticmethod + def _backoff_seconds_for_attempt( + *, attempt_index: int, retry_after_seconds: int | None + ) -> float: + # attempt_index is zero-based. + exponential = min(16.0, float(2**attempt_index)) + if retry_after_seconds is None: + return exponential + return float(max(exponential, int(retry_after_seconds))) + def _graphql(self, *, query: str, variables: dict[str, Any]) -> dict[str, Any]: body = json.dumps({"query": query, "variables": variables}, separators=(",", ":")) headers = self._build_auth_headers(body) @@ -549,30 +663,71 @@ def _graphql(self, *, query: str, variables: dict[str, Any]) -> dict[str, Any]: **headers, }, ) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - payload = json.loads(resp.read().decode("utf-8")) - except urllib.error.HTTPError as exc: - raw = exc.read().decode("utf-8", errors="replace").strip() - snippet = raw[:200] if raw else "" - message = f"cloud_wallet_http_error:{exc.code}" - if snippet: - message = f"{message}:{snippet}" - raise RuntimeError(message) from exc - except urllib.error.URLError as exc: - raise RuntimeError(f"cloud_wallet_network_error:{exc.reason}") from exc - if not isinstance(payload, dict): - raise RuntimeError("cloud_wallet_invalid_response") - errors = payload.get("errors") - if isinstance(errors, list) and errors: - first = errors[0] - if isinstance(first, dict): - raise RuntimeError(f"cloud_wallet_graphql_error:{first.get('message', 'unknown')}") - raise RuntimeError(f"cloud_wallet_graphql_error:{first}") - data = payload.get("data") - if not isinstance(data, dict): - raise RuntimeError("cloud_wallet_missing_data") - return data + max_attempts = 5 + for attempt in range(max_attempts): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace").strip() + snippet = raw[:200] if raw else "" + retry_after_header = exc.headers.get("Retry-After") if exc.headers else None + retry_after_seconds = self._parse_retry_after_seconds(str(retry_after_header or "")) + if retry_after_seconds is None: + retry_after_seconds = self._parse_retry_after_seconds(raw) + if int(exc.code) == 429 and attempt < (max_attempts - 1): + sleep_seconds = self._backoff_seconds_for_attempt( + attempt_index=attempt, + retry_after_seconds=retry_after_seconds, + ) + logger.warning( + "cloud_wallet_rate_limited http_status=429 attempt=%s/%s sleep_seconds=%.1f retry_after_seconds=%s", + attempt + 1, + max_attempts, + sleep_seconds, + retry_after_seconds, + ) + time.sleep(sleep_seconds) + continue + message = f"cloud_wallet_http_error:{exc.code}" + if snippet: + message = f"{message}:{snippet}" + raise RuntimeError(message) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"cloud_wallet_network_error:{exc.reason}") from exc + if not isinstance(payload, dict): + raise RuntimeError("cloud_wallet_invalid_response") + errors = payload.get("errors") + if isinstance(errors, list) and errors: + first = errors[0] + if isinstance(first, dict): + error_message = str(first.get("message", "unknown")) + else: + error_message = str(first) + retry_after_seconds = self._parse_retry_after_seconds(error_message) + if self._is_rate_limit_error_message(error_message) and attempt < ( + max_attempts - 1 + ): + sleep_seconds = self._backoff_seconds_for_attempt( + attempt_index=attempt, + retry_after_seconds=retry_after_seconds, + ) + logger.warning( + "cloud_wallet_rate_limited graphql_error attempt=%s/%s sleep_seconds=%.1f retry_after_seconds=%s message=%s", + attempt + 1, + max_attempts, + sleep_seconds, + retry_after_seconds, + error_message, + ) + time.sleep(sleep_seconds) + continue + raise RuntimeError(f"cloud_wallet_graphql_error:{error_message}") + data = payload.get("data") + if not isinstance(data, dict): + raise RuntimeError("cloud_wallet_missing_data") + return data + raise RuntimeError("cloud_wallet_rate_limit_retry_exhausted") def _build_auth_headers(self, raw_body: str) -> dict[str, str]: nonce = self._random_nonce(10) diff --git a/greenfloor/cli/manager.py b/greenfloor/cli/manager.py index 54e9fe0..cb27f66 100644 --- a/greenfloor/cli/manager.py +++ b/greenfloor/cli/manager.py @@ -24,6 +24,69 @@ from greenfloor.adapters.dexie import DexieAdapter from greenfloor.adapters.splash import SplashAdapter from greenfloor.cli.offer_builder_sdk import build_offer_text +from greenfloor.cloud_wallet_offer_runtime import ( + _require_cloud_wallet_config as _shared_require_cloud_wallet_config, +) +from greenfloor.cloud_wallet_offer_runtime import ( + build_and_post_offer_cloud_wallet as _shared_build_and_post_offer_cloud_wallet, +) +from greenfloor.cloud_wallet_offer_runtime import ( + call_with_moderate_retry as _shared_call_with_moderate_retry, +) +from greenfloor.cloud_wallet_offer_runtime import ( + cloud_wallet_create_offer_phase as _shared_cloud_wallet_create_offer_phase, +) +from greenfloor.cloud_wallet_offer_runtime import ( + cloud_wallet_post_offer_phase as _shared_cloud_wallet_post_offer_phase, +) +from greenfloor.cloud_wallet_offer_runtime import ( + cloud_wallet_wait_offer_artifact_phase as _shared_cloud_wallet_wait_offer_artifact_phase, +) +from greenfloor.cloud_wallet_offer_runtime import ( + dexie_offer_view_url as _shared_dexie_offer_view_url, +) +from greenfloor.cloud_wallet_offer_runtime import ( + ensure_offer_bootstrap_denominations as _shared_ensure_offer_bootstrap_denominations, +) +from greenfloor.cloud_wallet_offer_runtime import ( + log_signed_offer_artifact as _shared_log_signed_offer_artifact, +) +from greenfloor.cloud_wallet_offer_runtime import ( + normalize_offer_side as _shared_normalize_offer_side, +) +from greenfloor.cloud_wallet_offer_runtime import ( + poll_offer_artifact_by_signature_request as _shared_poll_offer_artifact_by_signature_request, +) +from greenfloor.cloud_wallet_offer_runtime import ( + poll_offer_artifact_until_available as _shared_poll_offer_artifact_until_available, +) +from greenfloor.cloud_wallet_offer_runtime import ( + poll_signature_request_until_not_unsigned as _shared_poll_signature_request_until_not_unsigned, +) +from greenfloor.cloud_wallet_offer_runtime import ( + post_dexie_offer_with_invalid_offer_retry as _shared_post_dexie_offer_with_invalid_offer_retry, +) +from greenfloor.cloud_wallet_offer_runtime import ( + recent_market_resolved_asset_id_hints as _shared_recent_market_resolved_asset_id_hints, +) +from greenfloor.cloud_wallet_offer_runtime import ( + resolve_cloud_wallet_offer_asset_ids as _shared_resolve_cloud_wallet_offer_asset_ids, +) +from greenfloor.cloud_wallet_offer_runtime import ( + resolve_maker_offer_fee as _shared_resolve_maker_offer_fee, +) +from greenfloor.cloud_wallet_offer_runtime import ( + resolve_offer_expiry_for_market as _shared_resolve_offer_expiry_for_market, +) +from greenfloor.cloud_wallet_offer_runtime import ( + verify_dexie_offer_visible_by_id as _shared_verify_dexie_offer_visible_by_id, +) +from greenfloor.cloud_wallet_offer_runtime import ( + verify_offer_text_for_dexie as _shared_verify_offer_text_for_dexie, +) +from greenfloor.cloud_wallet_offer_runtime import ( + wallet_get_wallet_offers as _shared_wallet_get_wallet_offers, +) from greenfloor.config.io import ( default_cats_config_path as _default_cats_config_path_shared, ) @@ -81,44 +144,6 @@ def _warn_if_log_level_auto_healed(*, program, program_path: Path) -> None: ) -def _condition_has_offer_expiration(condition: object) -> bool: - parse_names = ( - "parse_assert_before_seconds_relative", - "parse_assert_before_seconds_absolute", - "parse_assert_before_height_relative", - "parse_assert_before_height_absolute", - ) - for parse_name in parse_names: - parse_fn = getattr(condition, parse_name, None) - if not callable(parse_fn): - continue - try: - if parse_fn() is not None: - return True - except Exception: - continue - return False - - -def _offer_has_expiration_condition(sdk: object, offer_text: str) -> bool: - decode_offer = getattr(sdk, "decode_offer", None) - if not callable(decode_offer): - return False - 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: - if _condition_has_offer_expiration(condition): - return True - return False - - def _extract_coin_id_hints_from_offer_text(offer_text: str) -> list[str]: try: sdk = importlib.import_module("chia_wallet_sdk") @@ -155,59 +180,8 @@ def _extract_coin_id_hints_from_offer_text(offer_text: str) -> list[str]: return list(dict.fromkeys(hints)) -def _log_signed_offer_artifact( - *, - offer_text: str, - ticker: str, - amount: int, - trading_pair: str, - expiry: str, -) -> None: - coin_id_hints = _extract_coin_id_hints_from_offer_text(offer_text) - coin_id = coin_id_hints[0] if coin_id_hints else "" - _manager_logger.info("signed_offer_file:%s", offer_text) - _manager_logger.info( - "signed_offer_metadata:ticker=%s coinid=%s amount=%s trading_pair=%s expiry=%s", - ticker, - coin_id, - amount, - trading_pair, - expiry, - ) - - -def _verify_offer_text_for_dexie(offer_text: str) -> str | None: - try: - native = importlib.import_module("greenfloor_native") - except Exception: - native = None - else: - try: - native.validate_offer(offer_text) - return None - 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: - 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" - - if not _offer_has_expiration_condition(sdk, offer_text): - return "wallet_sdk_offer_missing_expiration" - except Exception as exc: - return f"wallet_sdk_offer_validate_failed:{exc}" - return None +_log_signed_offer_artifact = _shared_log_signed_offer_artifact +_verify_offer_text_for_dexie = _shared_verify_offer_text_for_dexie def _default_program_config_path() -> str: @@ -259,25 +233,7 @@ def __init__( super().__init__(f"{failure_kind}:{detail}") -def _require_cloud_wallet_config(program) -> CloudWalletConfig: - if not program.cloud_wallet_base_url: - raise ValueError("cloud_wallet.base_url is required") - if not program.cloud_wallet_user_key_id: - raise ValueError("cloud_wallet.user_key_id is required") - if not program.cloud_wallet_private_key_pem_path: - raise ValueError("cloud_wallet.private_key_pem_path is required") - if not program.cloud_wallet_vault_id: - raise ValueError("cloud_wallet.vault_id is required") - return CloudWalletConfig( - base_url=program.cloud_wallet_base_url, - user_key_id=program.cloud_wallet_user_key_id, - private_key_pem_path=program.cloud_wallet_private_key_pem_path, - vault_id=program.cloud_wallet_vault_id, - network=program.app_network, - kms_key_id=program.cloud_wallet_kms_key_id or None, - kms_region=program.cloud_wallet_kms_region or None, - kms_public_key_hex=program.cloud_wallet_kms_public_key_hex or None, - ) +_require_cloud_wallet_config = _shared_require_cloud_wallet_config def _new_cloud_wallet_adapter(program) -> CloudWalletAdapter: @@ -1001,67 +957,8 @@ def _resolve_cloud_wallet_asset_id( raise RuntimeError(f"cloud_wallet_asset_resolution_failed:unmatched_wallet_cat_asset_for:{raw}") -def _resolve_cloud_wallet_offer_asset_ids( - *, - wallet: CloudWalletAdapter, - base_asset_id: str, - quote_asset_id: str, - base_symbol_hint: str | None = None, - quote_symbol_hint: str | None = None, - base_global_id_hint: str | None = None, - quote_global_id_hint: str | None = None, -) -> tuple[str, str]: - resolved_base = _resolve_cloud_wallet_asset_id( - wallet=wallet, - canonical_asset_id=base_asset_id, - symbol_hint=(base_symbol_hint or "").strip() or str(base_asset_id).strip(), - global_id_hint=(base_global_id_hint or "").strip() or None, - ) - resolved_quote = _resolve_cloud_wallet_asset_id( - wallet=wallet, - canonical_asset_id=quote_asset_id, - symbol_hint=(quote_symbol_hint or "").strip() or str(quote_asset_id).strip(), - global_id_hint=(quote_global_id_hint or "").strip() or None, - ) - 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_cloud_global_id(base_asset_id) - and not _canonical_is_cloud_global_id(quote_asset_id) - ): - raise RuntimeError( - "cloud_wallet_asset_resolution_failed:resolved_assets_collide_for_non_xch_pair" - ) - return resolved_base, resolved_quote - - -def _recent_market_resolved_asset_id_hints( - *, - program_home_dir: str, - market_id: str, -) -> tuple[str | None, str | None]: - db_path = (Path(program_home_dir).expanduser() / "db" / "greenfloor.sqlite").resolve() - if not db_path.exists(): - return None, None - store = SqliteStore(db_path) - try: - events = store.list_recent_audit_events( - event_types=["strategy_offer_execution"], - market_id=market_id, - limit=200, - ) - finally: - store.close() - for event in events: - payload = event.get("payload") - if not isinstance(payload, dict): - continue - base_hint = str(payload.get("resolved_base_asset_id", "")).strip() - quote_hint = str(payload.get("resolved_quote_asset_id", "")).strip() - if base_hint.startswith("Asset_") and quote_hint.startswith("Asset_"): - return base_hint, quote_hint - return None, None +_resolve_cloud_wallet_offer_asset_ids = _shared_resolve_cloud_wallet_offer_asset_ids +_recent_market_resolved_asset_id_hints = _shared_recent_market_resolved_asset_id_hints def _parse_iso8601(value: str) -> dt.datetime | None: @@ -1078,71 +975,7 @@ def _parse_iso8601(value: str) -> dt.datetime | None: return parsed.astimezone(dt.UTC) -def _offer_markers(offers: list[dict]) -> set[str]: - markers: set[str] = set() - for offer in offers: - offer_id = str(offer.get("offerId", "")).strip() - if offer_id: - markers.add(f"id:{offer_id}") - bech32 = str(offer.get("bech32", "")).strip() - if bech32: - markers.add(f"bech32:{bech32}") - return markers - - -def _pick_new_offer_artifact( - *, - offers: list[dict], - known_markers: set[str], - min_created_at: dt.datetime | None = None, - require_open_state: bool = False, -) -> str: - candidates: list[tuple[dt.datetime, dt.datetime, str]] = [] - for offer in offers: - state = str(offer.get("state", "")).strip().upper() - if require_open_state and state != "OPEN": - continue - bech32 = str(offer.get("bech32", "")).strip() - if not bech32.startswith("offer1"): - continue - offer_id = str(offer.get("offerId", "")).strip() - markers = {f"bech32:{bech32}"} - if offer_id: - markers.add(f"id:{offer_id}") - if markers.issubset(known_markers): - continue - created_at = _parse_iso8601(str(offer.get("createdAt", "")).strip()) - if min_created_at is not None: - if created_at is None: - continue - if created_at < min_created_at: - continue - expires_at = _parse_iso8601(str(offer.get("expiresAt", "")).strip()) - candidates.append( - ( - created_at or dt.datetime.min.replace(tzinfo=dt.UTC), - expires_at or dt.datetime.min.replace(tzinfo=dt.UTC), - bech32, - ) - ) - if not candidates: - return "" - # Prefer the newest artifact by creation time to avoid reposting stale open offers. - candidates.sort(key=lambda row: (row[0], row[1]), reverse=True) - return candidates[0][2] - - -def _wallet_get_wallet_offers( - wallet: CloudWalletAdapter, - *, - is_creator: bool, - states: list[str], -) -> dict[str, Any]: - try: - return wallet.get_wallet(is_creator=is_creator, states=states, first=100) - except TypeError: - # Backward compatibility for deterministic test doubles that still expose get_wallet() with no args. - return wallet.get_wallet() +_wallet_get_wallet_offers = _shared_wallet_get_wallet_offers def _dexie_offer_status(payload: dict[str, Any]) -> int | None: @@ -1167,28 +1000,14 @@ def _call_with_moderate_retry( events: list[dict[str, str]] | None = None, max_attempts: int = 4, ): - attempt = 0 - sleep_seconds = 0.5 - while True: - try: - return call() - except Exception as exc: - attempt += 1 - if attempt >= max_attempts: - raise RuntimeError(f"{action}_retry_exhausted:{exc}") from exc - if events is not None: - events.append( - { - "event": "poll_retry", - "action": action, - "attempt": str(attempt), - "elapsed_seconds": str(elapsed_seconds), - "wait_reason": "transient_poll_failure", - "error": str(exc), - } - ) - time.sleep(sleep_seconds) - sleep_seconds = min(8.0, sleep_seconds * 2.0) + return _shared_call_with_moderate_retry( + action=action, + call=call, + elapsed_seconds=elapsed_seconds, + events=events, + max_attempts=max_attempts, + sleep_fn=time.sleep, + ) def _post_dexie_offer_with_invalid_offer_retry( @@ -1198,26 +1017,13 @@ def _post_dexie_offer_with_invalid_offer_retry( drop_only: bool, claim_rewards: bool, ) -> dict[str, Any]: - attempt = 0 - sleep_seconds = _DEXIE_INVALID_OFFER_RETRY_INITIAL_DELAY_SECONDS - while True: - result = dexie.post_offer( - offer_text, - drop_only=drop_only, - claim_rewards=claim_rewards, - ) - error = str(result.get("error", "")).strip() - should_retry = ( - bool(error) - and "dexie_http_error:400" in error - and "Invalid Offer" in error - and attempt < (_DEXIE_INVALID_OFFER_RETRY_MAX_ATTEMPTS - 1) - ) - if not should_retry: - return result - attempt += 1 - time.sleep(sleep_seconds) - sleep_seconds = min(8.0, sleep_seconds * 2.0) + return _shared_post_dexie_offer_with_invalid_offer_retry( + dexie=dexie, + offer_text=offer_text, + drop_only=drop_only, + claim_rewards=claim_rewards, + sleep_fn=time.sleep, + ) def _verify_dexie_offer_visible_by_id( @@ -1226,58 +1032,22 @@ def _verify_dexie_offer_visible_by_id( offer_id: str, max_attempts: int = 4, delay_seconds: float = 1.5, - expected_base_asset_id: str | None = None, - expected_base_amount: float | None = None, + expected_offered_asset_id: str | None = None, + expected_offered_symbol: str | None = None, + expected_requested_asset_id: str | None = None, + expected_requested_symbol: str | None = None, ) -> str | None: - clean_offer_id = str(offer_id).strip() - if not clean_offer_id: - return "dexie_offer_missing_id_after_publish" - attempts = max(1, int(max_attempts)) - last_error = "dexie_offer_not_visible_after_publish" - for attempt in range(1, attempts + 1): - try: - payload = dexie.get_offer(clean_offer_id) - except Exception as exc: - last_error = f"dexie_get_offer_error:{exc}" - if attempt < attempts: - time.sleep(delay_seconds) - continue - offer_payload = payload.get("offer") if isinstance(payload, dict) else None - visible_id = ( - str(offer_payload.get("id", "")).strip() if isinstance(offer_payload, dict) else "" - ) - if visible_id == clean_offer_id: - if ( - expected_base_asset_id - and expected_base_amount is not None - and isinstance(offer_payload, dict) - ): - offered = offer_payload.get("offered") - if isinstance(offered, list): - observed_amount: float | None = None - expected_asset = str(expected_base_asset_id).strip().lower() - for row in offered: - if not isinstance(row, dict): - continue - asset_id = str(row.get("id", "")).strip().lower() - if asset_id != expected_asset: - continue - try: - observed_amount = float(row.get("amount") or 0) - except (TypeError, ValueError): - observed_amount = None - break - if observed_amount is not None: - if abs(observed_amount - float(expected_base_amount)) > 1e-9: - return ( - "dexie_offer_base_amount_mismatch:" - f"expected={expected_base_amount}:observed={observed_amount}" - ) - return None - last_error = "dexie_offer_visibility_payload_mismatch" - if attempt < attempts: - time.sleep(delay_seconds) - return last_error + return _shared_verify_dexie_offer_visible_by_id( + dexie=dexie, + offer_id=offer_id, + max_attempts=max_attempts, + delay_seconds=delay_seconds, + expected_offered_asset_id=expected_offered_asset_id, + expected_offered_symbol=expected_offered_symbol, + expected_requested_asset_id=expected_requested_asset_id, + expected_requested_symbol=expected_requested_symbol, + sleep_fn=time.sleep, + ) def _coinset_coin_url(*, coin_name: str, network: str = "mainnet") -> str: @@ -1404,34 +1174,42 @@ def _poll_offer_artifact_until_available( timeout_seconds: int, min_created_at: dt.datetime | None = None, require_open_state: bool = False, + states: tuple[str, ...] | None = ("OPEN", "PENDING"), + prefer_newest: bool = True, ) -> str: - start = time.monotonic() - sleep_seconds = 2.0 - while True: - elapsed = int(time.monotonic() - start) - wallet_payload = _call_with_moderate_retry( - action="wallet_get_wallet", - call=lambda: _wallet_get_wallet_offers( - wallet, - is_creator=True, - states=["OPEN", "PENDING"], - ), - elapsed_seconds=elapsed, - ) - offers = wallet_payload.get("offers", []) - if isinstance(offers, list): - offer_text = _pick_new_offer_artifact( - offers=offers, - known_markers=known_markers, - min_created_at=min_created_at, - require_open_state=require_open_state, - ) - if offer_text: - return offer_text - if elapsed >= timeout_seconds: - raise RuntimeError("cloud_wallet_offer_artifact_timeout") - time.sleep(sleep_seconds) - sleep_seconds = min(20.0, sleep_seconds * 1.5) + return _shared_poll_offer_artifact_until_available( + wallet=wallet, + known_markers=known_markers, + timeout_seconds=timeout_seconds, + min_created_at=min_created_at, + require_open_state=require_open_state, + states=states, + prefer_newest=prefer_newest, + wallet_get_wallet_offers_fn=_wallet_get_wallet_offers, + retry_fn=_call_with_moderate_retry, + sleep_fn=time.sleep, + monotonic_fn=time.monotonic, + ) + + +def _poll_offer_artifact_by_signature_request( + *, + wallet: CloudWalletAdapter, + signature_request_id: str, + known_markers: set[str], + timeout_seconds: int, + min_created_at: dt.datetime | None = None, +) -> str: + return _shared_poll_offer_artifact_by_signature_request( + wallet=wallet, + signature_request_id=signature_request_id, + known_markers=known_markers, + timeout_seconds=timeout_seconds, + min_created_at=min_created_at, + retry_fn=_call_with_moderate_retry, + sleep_fn=time.sleep, + monotonic_fn=time.monotonic, + ) def _coinset_base_url(*, network: str) -> str: @@ -1601,8 +1379,7 @@ def _resolve_taker_or_coin_operation_fee( ) -def _resolve_maker_offer_fee(*, network: str) -> tuple[int, str]: - return _resolve_operation_fee(role="maker_create_offer", network=network) +_resolve_maker_offer_fee = _shared_resolve_maker_offer_fee def _poll_signature_request_until_not_unsigned( @@ -1612,62 +1389,15 @@ def _poll_signature_request_until_not_unsigned( timeout_seconds: int, warning_interval_seconds: int, ) -> tuple[str, list[dict[str, str]]]: - events: list[dict[str, str]] = [] - start = time.monotonic() - next_warning = warning_interval_seconds - warning_count = 0 - next_heartbeat = 5 - sleep_seconds = 2.0 - while True: - elapsed = int(time.monotonic() - start) - status_payload = _call_with_moderate_retry( - action="wallet_get_signature_request", - call=lambda: wallet.get_signature_request(signature_request_id=signature_request_id), - elapsed_seconds=elapsed, - events=events, - ) - status = str(status_payload.get("status", "")).strip().upper() - if status and status != "UNSIGNED": - # Keep terminal output readable when heartbeat dots were emitted. - if next_heartbeat > 5: - print("", file=sys.stderr, flush=True) - print( - f"signature submitted: {signature_request_id} status={status}", - file=sys.stderr, - flush=True, - ) - return status, events - - if elapsed >= next_heartbeat: - print(".", end="", file=sys.stderr, flush=True) - next_heartbeat += 5 - if elapsed >= timeout_seconds: - raise RuntimeError("signature_request_timeout_waiting_for_signature") - if elapsed >= next_warning: - warning_count += 1 - events.append( - { - "event": "signature_wait_warning", - "elapsed_seconds": str(elapsed), - "signing_state_age_seconds": str(elapsed), - "message": "still_waiting_on_user_signature", - "wait_reason": "waiting_on_user_signature", - "warning_count": str(warning_count), - } - ) - if warning_count >= 2: - events.append( - { - "event": "signature_wait_escalation", - "elapsed_seconds": str(elapsed), - "message": "extended_user_signature_delay", - "wait_reason": "waiting_on_user_signature", - "warning_count": str(warning_count), - } - ) - next_warning += warning_interval_seconds - time.sleep(sleep_seconds) - sleep_seconds = min(20.0, sleep_seconds * 1.5) + return _shared_poll_signature_request_until_not_unsigned( + wallet=wallet, + signature_request_id=signature_request_id, + timeout_seconds=timeout_seconds, + warning_interval_seconds=warning_interval_seconds, + retry_fn=_call_with_moderate_retry, + sleep_fn=time.sleep, + monotonic_fn=time.monotonic, + ) def _wait_for_mempool_then_confirmation( @@ -1788,6 +1518,8 @@ def _wait_for_mempool_then_confirmation( def _is_spendable_coin(coin: dict) -> bool: + if bool(coin.get("isLocked", False)): + return False coin_state = str(coin.get("state", "")).strip().upper() if not coin_state: return False @@ -1804,6 +1536,58 @@ def _is_spendable_coin(coin: dict) -> bool: return coin_state in {"CONFIRMED", "UNSPENT", "SPENDABLE", "AVAILABLE", "SETTLED"} +def _wallet_asset_amounts_for_scope( + *, + wallet: CloudWalletAdapter, + asset_id: str, +) -> tuple[int | None, int | None, int | None]: + """Return (total, spendable, locked) amounts for a resolved wallet asset id.""" + if not hasattr(wallet, "_graphql"): + return None, None, None + query = """ +query walletAssetAmounts($walletId: ID!, $first: Int) { + wallet(id: $walletId) { + assets(first: $first) { + edges { + node { + assetId + totalAmount + spendableAmount + lockedAmount + } + } + } + } +} +""" + try: + payload = wallet._graphql( + query=query, + variables={"walletId": wallet.vault_id, "first": 100}, + ) + except Exception: + return None, None, None + wallet_payload = payload.get("wallet") or {} + assets_payload = wallet_payload.get("assets") or {} + edges = assets_payload.get("edges") or [] + target = asset_id.strip() + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(node, dict): + continue + node_asset_id = str(node.get("assetId", "")).strip() + if node_asset_id != target: + continue + try: + total_amount = int(node.get("totalAmount", 0)) + spendable_amount = int(node.get("spendableAmount", 0)) + locked_amount = int(node.get("lockedAmount", 0)) + except (TypeError, ValueError): + return None, None, None + return total_amount, spendable_amount, locked_amount + return None, None, None + + def _coin_asset_id(coin: dict) -> str: asset_raw = coin.get("asset") if isinstance(asset_raw, dict): @@ -1813,6 +1597,22 @@ def _coin_asset_id(coin: dict) -> str: return "xch" +def _coin_op_min_amount_mojos(*, canonical_asset_id: str) -> int: + # Temporary workaround for the upstream Cloud Wallet / ent-wallet asset-scope + # bug documented in docs/ent-wallet-upstream-byc-coin-query-issue.md. + # Ignore sub-1-CAT dust during local split/combine candidate selection so + # tiny stray rows do not get pulled into operational coin management. + if _canonical_is_xch(canonical_asset_id): + return 0 + return 1000 + + +def _coin_meets_coin_op_min_amount(coin: dict, *, canonical_asset_id: str) -> bool: + return int(coin.get("amount", 0)) >= _coin_op_min_amount_mojos( + canonical_asset_id=canonical_asset_id + ) + + def _evaluate_denomination_readiness( *, wallet: CloudWalletAdapter, @@ -1964,7 +1764,7 @@ def _resolve_coin_op_fee( return None -_TEMP_ZERO_FEE_FOR_CAT_SPLITS = True +_normalize_offer_side = _shared_normalize_offer_side def _effective_coin_split_fee_for_asset( @@ -1974,15 +1774,8 @@ def _effective_coin_split_fee_for_asset( fee_mojos: int, fee_source: str, ) -> tuple[int, str]: - """Return coin-split fee policy for the target asset. - - Temporary policy: CAT splits are forced to zero fee until backend support for - the default split-fee path is fixed. Keep this as a single switch point so - reverting to the default fee process is one-line. - """ + """Return coin-split fee policy for the target asset.""" _ = resolved_asset_id - if _TEMP_ZERO_FEE_FOR_CAT_SPLITS and not _canonical_is_xch(canonical_asset_id): - return 0, "temporary_cat_split_zero_fee" return int(fee_mojos), str(fee_source) @@ -2384,19 +2177,7 @@ def _resolve_dexie_base_url(network: str, explicit_base_url: str | None) -> str: raise ValueError(f"unsupported network for dexie posting: {network}") -def _dexie_offer_view_url(*, dexie_base_url: str, offer_id: str) -> str: - clean_offer_id = str(offer_id).strip() - if not clean_offer_id: - return "" - parsed = urllib.parse.urlparse(str(dexie_base_url).strip()) - host = parsed.netloc.strip().lower() - if not host: - return "" - if host.startswith("api-testnet."): - host = host[len("api-") :] - elif host.startswith("api."): - host = host[len("api.") :] - return f"https://{host}/offers/{urllib.parse.quote(clean_offer_id)}" +_dexie_offer_view_url = _shared_dexie_offer_view_url def _resolve_splash_base_url(explicit_base_url: str | None) -> str: @@ -2454,18 +2235,7 @@ def _resolve_market_denomination_entry(market, *, size_base_units: int): ) -def _resolve_offer_expiry_for_market(market) -> 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 +_resolve_offer_expiry_for_market = _shared_resolve_offer_expiry_for_market def _use_local_offer_build_path_for_size(*, size_base_units: int) -> bool: @@ -2572,165 +2342,28 @@ def _ensure_offer_bootstrap_denominations( market: Any, wallet: CloudWalletAdapter, resolved_base_asset_id: str, + resolved_quote_asset_id: str, key_id: str, keyring_yaml_path: str, + quote_price: float, + action_side: str = "sell", ) -> dict[str, Any]: - ladders = getattr(market, "ladders", {}) or {} - sell_ladder = list(ladders.get("sell", []) or []) if isinstance(ladders, dict) else [] - if not sell_ladder: - return {"status": "skipped", "reason": "missing_sell_ladder"} - if not hasattr(wallet, "list_coins"): - return { - "status": "skipped", - "reason": "wallet_list_coins_unavailable_for_bootstrap", - "fallback_to_cloud_wallet_offer_split": True, - } - - asset_scoped_coins = wallet.list_coins(asset_id=resolved_base_asset_id, include_pending=True) - spendable_asset_coins = [coin for coin in asset_scoped_coins if _is_spendable_coin(coin)] - bootstrap_plan = plan_bootstrap_mixed_outputs( - sell_ladder=sell_ladder, - spendable_coins=spendable_asset_coins, - ) - if bootstrap_plan is None: - return {"status": "skipped", "reason": "already_ready"} - if not keyring_yaml_path: - return { - "status": "skipped", - "reason": "missing_keyring_yaml_path_for_bootstrap", - "fallback_to_cloud_wallet_offer_split": True, - } - if not Path(keyring_yaml_path).expanduser().exists(): - return { - "status": "skipped", - "reason": "keyring_yaml_path_not_found_for_bootstrap", - "fallback_to_cloud_wallet_offer_split": True, - } - - fee_mojos, fee_source, fee_lookup_error = _resolve_bootstrap_split_fee( - network=str(program.app_network), - minimum_fee_mojos=int(program.coin_ops_minimum_fee_mojos), - output_count=len(bootstrap_plan.output_amounts_base_units), - ) - existing_coin_ids = { - str(c.get("id", "")).strip() for c in asset_scoped_coins if str(c.get("id", "")).strip() - } - signing_payload = { - "key_id": key_id, - "network": str(program.app_network), - "receive_address": str(market.receive_address), - "keyring_yaml_path": keyring_yaml_path, - "asset_id": str(market.base_asset), - "selected_coin_ids": [bootstrap_plan.source_coin_id], - "output_amounts_base_units": list(bootstrap_plan.output_amounts_base_units), - "fee_mojos": int(fee_mojos), - "cloud_wallet_base_url": str(program.cloud_wallet_base_url or "").strip(), - "cloud_wallet_user_key_id": str(program.cloud_wallet_user_key_id or "").strip(), - "cloud_wallet_private_key_pem_path": str( - program.cloud_wallet_private_key_pem_path or "" - ).strip(), - "cloud_wallet_vault_id": str(program.cloud_wallet_vault_id or "").strip(), - "cloud_wallet_kms_key_id": str(program.cloud_wallet_kms_key_id or "").strip(), - "cloud_wallet_kms_region": str(program.cloud_wallet_kms_region or "").strip(), - "cloud_wallet_kms_public_key_hex": str( - program.cloud_wallet_kms_public_key_hex or "" - ).strip(), - } - bootstrap_result = sign_and_broadcast_mixed_split(signing_payload) - if str(bootstrap_result.get("status", "")).strip().lower() != "executed": - bootstrap_reason = str(bootstrap_result.get("reason", "bootstrap_signing_failed")) - operator_guidance = None - if "insufficient_xch_fee_balance_for_mixed_split" in bootstrap_reason: - operator_guidance = ( - "insufficient spendable xch balance for bootstrap fee; add or free xch " - "coins in the signing wallet, or reduce coin_ops.minimum_fee_mojos only " - "if zero-fee fallback is acceptable for your environment" - ) - elif "mixed_split_vault_with_fee_not_supported" in bootstrap_reason: - operator_guidance = ( - "local vault mixed-split with explicit fee is not supported; manager will " - "fall back to cloud-wallet offer-time split for this attempt" - ) - return { - "status": "failed", - "reason": bootstrap_reason, - "operator_guidance": operator_guidance, - "fallback_to_cloud_wallet_offer_split": True, - "fee_mojos": int(fee_mojos), - "fee_source": fee_source, - "fee_lookup_error": fee_lookup_error, - "plan": { - "source_coin_id": bootstrap_plan.source_coin_id, - "output_count": len(bootstrap_plan.output_amounts_base_units), - "total_output_amount": bootstrap_plan.total_output_amount, - }, - } - - wait_events: list[dict[str, str]] = [] - wait_error: str | None = None - try: - wait_events = _wait_for_mempool_then_confirmation( - wallet=wallet, - network=str(program.app_network), - initial_coin_ids=existing_coin_ids, - asset_id=resolved_base_asset_id, - mempool_warning_seconds=5 * 60, - confirmation_warning_seconds=15 * 60, - timeout_seconds=20 * 60, - ) - except Exception as exc: - wait_error = str(exc) - return { - "status": "failed", - "reason": "bootstrap_wait_failed", - "wait_error": wait_error, - "fallback_to_cloud_wallet_offer_split": True, - "fee_mojos": int(fee_mojos), - "fee_source": fee_source, - "fee_lookup_error": fee_lookup_error, - "plan": { - "source_coin_id": bootstrap_plan.source_coin_id, - "source_amount": bootstrap_plan.source_amount, - "output_count": len(bootstrap_plan.output_amounts_base_units), - "total_output_amount": bootstrap_plan.total_output_amount, - "change_amount": bootstrap_plan.change_amount, - }, - "operation_id": str(bootstrap_result.get("operation_id", "")).strip(), - "wait_events": wait_events, - } - refreshed_asset_coins = wallet.list_coins(asset_id=resolved_base_asset_id, include_pending=True) - refreshed_spendable = [coin for coin in refreshed_asset_coins if _is_spendable_coin(coin)] - remaining_plan = plan_bootstrap_mixed_outputs( - sell_ladder=sell_ladder, - spendable_coins=refreshed_spendable, + return _shared_ensure_offer_bootstrap_denominations( + program=program, + market=market, + wallet=wallet, + resolved_base_asset_id=resolved_base_asset_id, + resolved_quote_asset_id=resolved_quote_asset_id, + key_id=key_id, + keyring_yaml_path=keyring_yaml_path, + quote_price=quote_price, + action_side=action_side, + plan_bootstrap_mixed_outputs_fn=plan_bootstrap_mixed_outputs, + resolve_bootstrap_split_fee_fn=_resolve_bootstrap_split_fee, + sign_and_broadcast_mixed_split_fn=sign_and_broadcast_mixed_split, + wait_for_mempool_then_confirmation_fn=_wait_for_mempool_then_confirmation, + is_spendable_coin_fn=_is_spendable_coin, ) - return { - "status": "executed", - "reason": "bootstrap_submitted", - "ready": remaining_plan is None, - "fee_mojos": int(fee_mojos), - "fee_source": fee_source, - "fee_lookup_error": fee_lookup_error, - "wait_error": wait_error, - "plan": { - "source_coin_id": bootstrap_plan.source_coin_id, - "source_amount": bootstrap_plan.source_amount, - "output_count": len(bootstrap_plan.output_amounts_base_units), - "total_output_amount": bootstrap_plan.total_output_amount, - "change_amount": bootstrap_plan.change_amount, - "deficits": [ - { - "size_base_units": d.size_base_units, - "required_count": d.required_count, - "current_count": d.current_count, - "deficit_count": d.deficit_count, - } - for d in bootstrap_plan.deficits - ], - }, - "operation_id": str(bootstrap_result.get("operation_id", "")).strip(), - "wait_events": wait_events, - } def _cloud_wallet_create_offer_phase( @@ -2745,59 +2378,23 @@ def _cloud_wallet_create_offer_phase( split_input_coins_fee: int, expiry_unit: str, expiry_value: int, + action_side: str = "sell", ) -> dict[str, Any]: - prior_wallet_payload = _wallet_get_wallet_offers( - wallet, - is_creator=True, - states=["OPEN", "PENDING"], - ) - prior_offers = prior_wallet_payload.get("offers", []) - 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)) - ) - request_amount = int( - round( - float(size_base_units) - * float(quote_price) - * int((market.pricing or {}).get("quote_unit_mojo_multiplier", 1000)) - ) - ) - if request_amount <= 0: - raise ValueError("request_amount must be positive") - offered = [{"assetId": resolved_base_asset_id, "amount": offer_amount}] - requested = [{"assetId": resolved_quote_asset_id, "amount": request_amount}] - expires_at_delta = {expiry_unit: int(expiry_value)} - expires_at = (dt.datetime.now(dt.UTC) + dt.timedelta(**expires_at_delta)).isoformat() - create_result = wallet.create_offer( - offered=offered, - requested=requested, - fee=offer_fee_mojos, - expires_at_iso=expires_at, - split_input_coins=True, + return _shared_cloud_wallet_create_offer_phase( + 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=action_side, + wallet_get_wallet_offers_fn=_wallet_get_wallet_offers, + poll_signature_request_until_not_unsigned_fn=_poll_signature_request_until_not_unsigned, ) - signature_request_id = str(create_result.get("signature_request_id", "")).strip() - wait_events: list[dict[str, str]] = [] - signature_state = str(create_result.get("status", "UNKNOWN")).strip() - if signature_request_id: - signature_state, signature_wait_events = _poll_signature_request_until_not_unsigned( - wallet=wallet, - signature_request_id=signature_request_id, - timeout_seconds=15 * 60, - warning_interval_seconds=10 * 60, - ) - wait_events.extend(signature_wait_events) - return { - "known_offer_markers": known_offer_markers, - "offer_request_started_at": offer_request_started_at, - "signature_request_id": signature_request_id, - "signature_state": signature_state, - "wait_events": wait_events, - "expires_at": expires_at, - "offer_amount": offer_amount, - } def _cloud_wallet_wait_offer_artifact_phase( @@ -2805,13 +2402,17 @@ def _cloud_wallet_wait_offer_artifact_phase( wallet: CloudWalletAdapter, known_markers: set[str], offer_request_started_at: dt.datetime, + signature_request_id: str = "", + timeout_seconds: int = 15 * 60, ) -> str: - return _poll_offer_artifact_until_available( + return _shared_cloud_wallet_wait_offer_artifact_phase( wallet=wallet, known_markers=known_markers, - timeout_seconds=15 * 60, - min_created_at=offer_request_started_at, - require_open_state=True, + offer_request_started_at=offer_request_started_at, + signature_request_id=signature_request_id, + timeout_seconds=timeout_seconds, + poll_offer_artifact_until_available_fn=_poll_offer_artifact_until_available, + poll_offer_artifact_by_signature_request_fn=_poll_offer_artifact_by_signature_request, ) @@ -2824,33 +2425,26 @@ def _cloud_wallet_post_offer_phase( drop_only: bool, claim_rewards: bool, market, - size_base_units: int, + expected_offered_asset_id: str, + expected_offered_symbol: str, + expected_requested_asset_id: str, + expected_requested_symbol: str, ) -> dict[str, Any]: - if publish_venue == "dexie": - assert dexie is not None - result = _post_dexie_offer_with_invalid_offer_retry( - dexie=dexie, - offer_text=offer_text, - drop_only=drop_only, - claim_rewards=claim_rewards, - ) - if bool(result.get("success", False)): - posted_offer_id = str(result.get("id", "")).strip() - visibility_error = _verify_dexie_offer_visible_by_id( - dexie=dexie, - offer_id=posted_offer_id, - expected_base_asset_id=str(market.base_asset), - expected_base_amount=float(size_base_units), - ) - if visibility_error: - return { - **result, - "success": False, - "error": visibility_error, - } - return result - assert splash is not None - return splash.post_offer(offer_text) + return _shared_cloud_wallet_post_offer_phase( + publish_venue=publish_venue, + dexie=dexie, + splash=splash, + offer_text=offer_text, + drop_only=drop_only, + claim_rewards=claim_rewards, + market=market, + expected_offered_asset_id=expected_offered_asset_id, + expected_offered_symbol=expected_offered_symbol, + expected_requested_asset_id=expected_requested_asset_id, + 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, + ) def _build_and_post_offer_cloud_wallet( @@ -2868,227 +2462,41 @@ def _build_and_post_offer_cloud_wallet( claim_rewards: bool, quote_price: float, dry_run: bool, + action_side: str = "sell", + offer_artifact_timeout_seconds: int = 15 * 60, ) -> tuple[int, dict[str, Any]]: - _initialize_manager_file_logging( - program.home_dir, log_level=getattr(program, "app_log_level", "INFO") - ) - wallet = _new_cloud_wallet_adapter(program) - cfg_base_global = str(getattr(market, "cloud_wallet_base_global_id", "")).strip() - cfg_quote_global = str(getattr(market, "cloud_wallet_quote_global_id", "")).strip() - db_base_hint, db_quote_hint = _recent_market_resolved_asset_id_hints( - program_home_dir=str(program.home_dir), - market_id=str(market.market_id), - ) - base_global_hint = cfg_base_global or db_base_hint - quote_global_hint = cfg_quote_global or db_quote_hint - resolved_base_asset_id, resolved_quote_asset_id = _resolve_cloud_wallet_offer_asset_ids( - wallet=wallet, - base_asset_id=str(market.base_asset), - quote_asset_id=str(market.quote_asset), - base_symbol_hint=str(getattr(market, "base_symbol", "") or ""), - quote_symbol_hint=str(getattr(market, "quote_asset", "") or ""), - base_global_id_hint=base_global_hint, - quote_global_id_hint=quote_global_hint, + return _shared_build_and_post_offer_cloud_wallet( + program=program, + market=market, + key_id=key_id, + keyring_yaml_path=keyring_yaml_path, + size_base_units=size_base_units, + repeat=repeat, + publish_venue=publish_venue, + dexie_base_url=dexie_base_url, + splash_base_url=splash_base_url, + drop_only=drop_only, + claim_rewards=claim_rewards, + quote_price=quote_price, + dry_run=dry_run, + action_side=action_side, + offer_artifact_timeout_seconds=offer_artifact_timeout_seconds, + wallet_factory=_new_cloud_wallet_adapter, + dexie_adapter_cls=DexieAdapter, + splash_adapter_cls=SplashAdapter, + initialize_manager_file_logging_fn=_initialize_manager_file_logging, + recent_market_resolved_asset_id_hints_fn=_recent_market_resolved_asset_id_hints, + resolve_cloud_wallet_offer_asset_ids_fn=_resolve_cloud_wallet_offer_asset_ids, + resolve_maker_offer_fee_fn=_resolve_maker_offer_fee, + resolve_offer_expiry_for_market_fn=_resolve_offer_expiry_for_market, + ensure_offer_bootstrap_denominations_fn=_ensure_offer_bootstrap_denominations, + cloud_wallet_create_offer_phase_fn=_cloud_wallet_create_offer_phase, + cloud_wallet_wait_offer_artifact_phase_fn=_cloud_wallet_wait_offer_artifact_phase, + log_signed_offer_artifact_fn=_log_signed_offer_artifact, + verify_offer_text_for_dexie_fn=_verify_offer_text_for_dexie, + cloud_wallet_post_offer_phase_fn=_cloud_wallet_post_offer_phase, + dexie_offer_view_url_fn=_dexie_offer_view_url, ) - db_path = (Path(program.home_dir).expanduser() / "db" / "greenfloor.sqlite").resolve() - store = SqliteStore(db_path) - post_results: list[dict] = [] - built_offers_preview: list[dict[str, str]] = [] - bootstrap_actions: list[dict[str, Any]] = [] - publish_failures = 0 - offer_fee_mojos, offer_fee_source = _resolve_maker_offer_fee(network=program.app_network) - expiry_unit, expiry_value = _resolve_offer_expiry_for_market(market) - dexie = DexieAdapter(dexie_base_url) if (not dry_run and publish_venue == "dexie") else None - splash = SplashAdapter(splash_base_url) if (not dry_run and publish_venue == "splash") else None - - for _ in range(repeat): - split_input_coins_fee = 0 - if dry_run: - bootstrap_actions.append({"status": "skipped", "reason": "dry_run"}) - else: - bootstrap_result = _ensure_offer_bootstrap_denominations( - program=program, - market=market, - wallet=wallet, - resolved_base_asset_id=resolved_base_asset_id, - key_id=key_id, - keyring_yaml_path=keyring_yaml_path, - ) - bootstrap_actions.append(bootstrap_result) - # Offer creation must remain zero-fee. Any split/combine fee belongs - # to explicit coin operations, not the offer itself. - if bool(bootstrap_result.get("fallback_to_cloud_wallet_offer_split", False)): - split_input_coins_fee = 0 - - create_phase = _cloud_wallet_create_offer_phase( - 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, - ) - 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"]) - expires_at = str(create_phase["expires_at"]) - offer_text = "" - try: - offer_text = _cloud_wallet_wait_offer_artifact_phase( - wallet=wallet, - known_markers=set(create_phase["known_offer_markers"]), - offer_request_started_at=create_phase["offer_request_started_at"], - ) - except RuntimeError as exc: - post_results.append( - { - "venue": publish_venue, - "result": { - "success": False, - "error": str(exc), - "signature_request_id": signature_request_id, - "signature_state": signature_state, - "wait_events": wait_events, - }, - } - ) - publish_failures += 1 - continue - if not offer_text: - publish_failures += 1 - post_results.append( - { - "venue": publish_venue, - "result": { - "success": False, - "error": "cloud_wallet_offer_artifact_unavailable", - "signature_request_id": signature_request_id, - "signature_state": signature_state, - "wait_events": wait_events, - }, - } - ) - continue - - _log_signed_offer_artifact( - offer_text=offer_text, - ticker=str(market.base_symbol), - amount=int(size_base_units), - trading_pair=f"{market.base_symbol}:{market.quote_asset}", - expiry=str(expires_at), - ) - - verify_error = _verify_offer_text_for_dexie(offer_text) - if verify_error: - publish_failures += 1 - post_results.append( - { - "venue": publish_venue, - "result": {"success": False, "error": verify_error}, - } - ) - continue - - if dry_run: - built_offers_preview.append( - { - "offer_prefix": offer_text[:24], - "offer_length": str(len(offer_text)), - } - ) - continue - - result = _cloud_wallet_post_offer_phase( - publish_venue=publish_venue, - dexie=dexie, - splash=splash, - offer_text=offer_text, - drop_only=drop_only, - claim_rewards=claim_rewards, - market=market, - size_base_units=size_base_units, - ) - if result.get("success") is False: - publish_failures += 1 - offer_id = str(result.get("id", "")).strip() - result_payload = { - **result, - "signature_request_id": signature_request_id, - "signature_state": signature_state, - "wait_events": wait_events, - } - if publish_venue == "dexie" and offer_id: - result_payload["offer_view_url"] = _dexie_offer_view_url( - dexie_base_url=dexie_base_url, - offer_id=offer_id, - ) - if offer_id and bool(result.get("success", False)): - store.upsert_offer_state( - offer_id=offer_id, - market_id=str(market.market_id), - state=OfferLifecycleState.OPEN.value, - last_seen_status=None, - ) - store.add_audit_event( - "strategy_offer_execution", - { - "market_id": str(market.market_id), - "planned_count": 1, - "executed_count": 1, - "items": [ - { - "size": int(size_base_units), - "status": "executed", - "reason": f"{publish_venue}_post_success", - "offer_id": offer_id, - "attempts": 1, - } - ], - "venue": publish_venue, - "signature_request_id": signature_request_id, - "signature_state": signature_state, - "resolved_base_asset_id": resolved_base_asset_id, - "resolved_quote_asset_id": resolved_quote_asset_id, - }, - market_id=str(market.market_id), - ) - post_results.append( - { - "venue": publish_venue, - "result": result_payload, - } - ) - - payload: dict[str, Any] = { - "market_id": market.market_id, - "pair": f"{market.base_asset}:{market.quote_asset}", - "resolved_base_asset_id": resolved_base_asset_id, - "resolved_quote_asset_id": resolved_quote_asset_id, - "network": program.app_network, - "size_base_units": size_base_units, - "repeat": repeat, - "publish_venue": publish_venue, - "dexie_base_url": dexie_base_url, - "splash_base_url": splash_base_url if publish_venue == "splash" else None, - "drop_only": drop_only, - "claim_rewards": claim_rewards, - "dry_run": bool(dry_run), - "publish_attempts": len(post_results), - "publish_failures": publish_failures, - "built_offers_preview": built_offers_preview, - "bootstrap_actions": bootstrap_actions, - "results": post_results, - "offer_fee_mojos": offer_fee_mojos, - "offer_fee_source": offer_fee_source, - } - print(_format_json_output(payload)) - store.close() - exit_code = 0 if publish_failures == 0 else 2 - return exit_code, payload def _build_and_post_offer( @@ -3416,15 +2824,26 @@ def _coins_list( symbol_hint=effective_asset, ) coins = wallet.list_coins(asset_id=resolved_asset_filter, include_pending=True) + filtered_asset_id = str(resolved_asset_filter or "").strip().lower() + scoped_asset_id = str(resolved_asset_filter).strip() if filtered_asset_id else None items = [] for coin in coins: coin_state = str(coin.get("state", "")).strip().upper() pending = coin_state in {"PENDING", "MEMPOOL"} spendable = _is_spendable_coin(coin) asset_raw = coin.get("asset") - asset_id = "xch" + # Asset-scoped queries now intentionally omit `coin.asset` because the + # upstream resolver can report a bogus fallback asset. Preserve missing + # row metadata as `None`; only concrete conflicting ids should trigger + # the mixed-asset warning path below. + reported_asset_id: str | None = None if isinstance(asset_raw, dict): - asset_id = str(asset_raw.get("id", "xch")).strip() + raw_reported_asset_id = str(asset_raw.get("id", "")).strip() + reported_asset_id = raw_reported_asset_id or None + # When Cloud Wallet coin listing is asset-scoped, trust the query scope for + # membership and normalize output asset id to that scope. Some backends + # may return mixed asset metadata in scoped responses. + output_asset_id = scoped_asset_id if filtered_asset_id else (reported_asset_id or "xch") items.append( { "coin_id": str(coin.get("name", coin.get("id", ""))).strip(), @@ -3432,22 +2851,118 @@ def _coins_list( "state": coin_state or "UNKNOWN", "pending": pending, "spendable": spendable, - "asset": asset_id, + "asset": output_asset_id, + "reported_asset": reported_asset_id, + "scoped_asset": scoped_asset_id, } ) + scoped_total_amount: int | None = None + scoped_spendable_amount: int | None = None + scoped_locked_amount: int | None = None + if filtered_asset_id: + ( + scoped_total_amount, + scoped_spendable_amount, + scoped_locked_amount, + ) = _wallet_asset_amounts_for_scope( + wallet=wallet, + asset_id=str(resolved_asset_filter).strip(), + ) + warnings: list[dict[str, Any]] = [] + items_amount_sum = sum(int(item.get("amount", 0)) for item in items) + raw_scoped_total_amount = scoped_total_amount + asset_totals_withheld_reason: str | None = None + if filtered_asset_id: + # Ignore missing row-level asset metadata here; the scoped query may omit + # it on purpose as a workaround for the upstream fallback-to-XCH bug. + distinct_reported_asset_ids = sorted( + { + reported_asset_id.strip() + for item in items + for reported_asset_id in [item.get("reported_asset")] + if isinstance(reported_asset_id, str) and reported_asset_id.strip() + } + ) + unexpected_reported_asset_ids = sorted( + { + reported_asset_id + for reported_asset_id in distinct_reported_asset_ids + if reported_asset_id.lower() != filtered_asset_id + } + ) + if unexpected_reported_asset_ids: + warning_payload = { + "code": "mixed_reported_asset_ids_detected", + "message": "asset-scoped coin query returned mixed reported asset ids; scoped asset totals withheld", + "resolved_asset_id": scoped_asset_id, + "reported_asset_ids": distinct_reported_asset_ids, + "unexpected_reported_asset_ids": unexpected_reported_asset_ids, + } + warnings.append(warning_payload) + _manager_logger.warning( + "coins_list_mixed_asset_metadata vault_id=%s resolved_asset_id=%s reported_asset_ids=%s", + wallet.vault_id, + scoped_asset_id, + ",".join(distinct_reported_asset_ids), + ) + asset_totals_withheld_reason = "mixed_reported_asset_ids_detected" + scoped_total_amount = None + scoped_spendable_amount = None + scoped_locked_amount = None + if raw_scoped_total_amount is not None and items_amount_sum != int(raw_scoped_total_amount): + warning_payload = { + "code": "item_amount_sum_mismatch", + "message": "sum(items.amount) does not match wallet asset total amount", + "resolved_asset_id": scoped_asset_id, + "items_amount_sum": items_amount_sum, + "wallet_asset_total_amount": int(raw_scoped_total_amount), + "difference_amount": items_amount_sum - int(raw_scoped_total_amount), + } + warnings.append(warning_payload) + _manager_logger.warning( + "coins_list_amount_mismatch vault_id=%s resolved_asset_id=%s items_amount_sum=%s wallet_asset_total_amount=%s difference_amount=%s", + wallet.vault_id, + scoped_asset_id, + items_amount_sum, + int(raw_scoped_total_amount), + items_amount_sum - int(raw_scoped_total_amount), + ) print( _format_json_output( { "vault_id": wallet.vault_id, "network": wallet.network, + "resolved_asset_id": scoped_asset_id, "count": len(items), + "item_amount_sum": items_amount_sum, "items": items, + "asset_total_amount": scoped_total_amount, + "asset_spendable_amount": scoped_spendable_amount, + "asset_locked_amount": scoped_locked_amount, + "asset_totals_withheld_reason": asset_totals_withheld_reason, + "warnings": warnings, } ) ) return 0 +def _coin_status( + *, + program_path: Path, + asset: str | None, + vault_id: str | None, + cat_id: str | None = None, +) -> int: + """Show per-coin state/spendability for an optional asset scope.""" + return _coins_list( + program_path=program_path, + asset=asset, + vault_id=vault_id, + cat_id=cat_id, + ) + + @dataclass(slots=True) class _CoinOpSetup: program: Any @@ -3590,6 +3105,7 @@ def _coin_split( split_gate: dict[str, int | bool | str] | None = None stop_reason = "single_pass" unresolved_coin_ids: list[str] = [] + min_coin_amount_mojos = _coin_op_min_amount_mojos(canonical_asset_id=str(market.base_asset)) for iteration in range(1, max_iterations + 1): wallet_coins = wallet.list_coins(include_pending=True) @@ -3601,7 +3117,9 @@ def _coin_split( spendable_asset_coin_ids = { str(c.get("id", "")).strip() for c in asset_scoped_coins - if _is_spendable_coin(c) and str(c.get("id", "")).strip() + if _is_spendable_coin(c) + and _coin_meets_coin_op_min_amount(c, canonical_asset_id=str(market.base_asset)) + and str(c.get("id", "")).strip() } if denomination_target is not None: split_gate = _evaluate_coin_split_gate( @@ -3630,7 +3148,12 @@ def _coin_split( if unresolved_coin_ids: break else: - spendable_asset_coins = [c for c in asset_scoped_coins if _is_spendable_coin(c)] + spendable_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=str(market.base_asset)) + ] if not spendable_asset_coins: print( _format_json_output( @@ -3641,10 +3164,12 @@ def _coin_split( "error": "no_spendable_split_coin_available", "asset_id": str(market.base_asset), "resolved_asset_id": resolved_split_asset_id, + "temporary_min_coin_amount_mojos": int(min_coin_amount_mojos), "operator_guidance": ( "no spendable coins are currently available for this asset; " "wait for pending/signature requests to settle or free locked offers, " - "then retry coin-split" + "then retry coin-split. Temporary workaround: CAT split selection " + "ignores coins smaller than 1 CAT unit (1000 mojos)." ), } ) @@ -3831,6 +3356,8 @@ def _coin_combine( final_readiness: dict[str, int | bool | str] | None = None stop_reason = "single_pass" unresolved_coin_ids: list[str] = [] + combine_canonical_asset_id = requested_asset_id or str(market.base_asset) + min_coin_amount_mojos = _coin_op_min_amount_mojos(canonical_asset_id=combine_canonical_asset_id) for iteration in range(1, max_iterations + 1): wallet_coins = wallet.list_coins(include_pending=True) @@ -3846,6 +3373,42 @@ def _coin_combine( raise ValueError( "when --coin-id is provided, --input-coin-count must match the number of --coin-id values" ) + elif min_coin_amount_mojos > 0: + asset_scoped_coins = wallet.list_coins(asset_id=resolved_asset_id, include_pending=True) + 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 str(c.get("id", "")).strip() + ] + if len(eligible_asset_coins) < number_of_coins: + print( + _format_json_output( + { + **_coin_op_base_payload(market, selected_venue, wallet), + "waited": False, + "success": False, + "error": "insufficient_combine_coins_after_temp_cat_floor", + "asset_id": combine_canonical_asset_id, + "resolved_asset_id": resolved_asset_id, + "required_coin_count": int(number_of_coins), + "eligible_coin_count": len(eligible_asset_coins), + "temporary_min_coin_amount_mojos": int(min_coin_amount_mojos), + "operator_guidance": ( + "not enough spendable coins remain after ignoring CAT coins " + "smaller than 1 CAT unit (1000 mojos). Wait for larger coins, " + "re-split inventory, or pass explicit --coin-id values if you " + "intend to override the temporary workaround." + ), + } + ) + ) + return 2 + eligible_asset_coins.sort(key=lambda coin: int(coin.get("amount", 0)), reverse=True) + resolved_input_coin_ids = [ + str(coin.get("id", "")).strip() for coin in eligible_asset_coins[:number_of_coins] + ] combine_result = wallet.combine_coins( number_of_coins=number_of_coins, @@ -4744,6 +4307,11 @@ def main() -> None: p_coins_list.add_argument("--vault-id", default="") p_coins_list.add_argument("--cat-id", default="", help="hex CAT asset_id to filter by") + p_coin_status = sub.add_parser("coin-status") + p_coin_status.add_argument("--asset", default="") + p_coin_status.add_argument("--vault-id", default="") + p_coin_status.add_argument("--cat-id", default="", help="hex CAT asset_id to filter by") + p_coin_split = sub.add_parser("coin-split") split_market_group = p_coin_split.add_mutually_exclusive_group(required=True) split_market_group.add_argument("--market-id", default="") @@ -4916,6 +4484,13 @@ def main() -> None: vault_id=args.vault_id or None, cat_id=args.cat_id or None, ) + elif args.command == "coin-status": + code = _coin_status( + program_path=Path(args.program_config), + asset=args.asset or None, + vault_id=args.vault_id or None, + cat_id=args.cat_id or None, + ) elif args.command == "coin-split": code = _coin_split( program_path=Path(args.program_config), diff --git a/greenfloor/cloud_wallet_offer_runtime.py b/greenfloor/cloud_wallet_offer_runtime.py new file mode 100644 index 0000000..4552213 --- /dev/null +++ b/greenfloor/cloud_wallet_offer_runtime.py @@ -0,0 +1,2224 @@ +from __future__ import annotations + +import collections.abc +import datetime as dt +import importlib +import json +import logging +import os +import re +import sys +import time +import urllib.parse +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from greenfloor.adapters.cloud_wallet import CloudWalletAdapter, CloudWalletConfig +from greenfloor.adapters.coinset import CoinsetAdapter +from greenfloor.adapters.dexie import DexieAdapter +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.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 +_runtime_logger = logging.getLogger("greenfloor.manager") +_JSON_OUTPUT_COMPACT = False + + +def _format_json_output(payload: object) -> str: + if _JSON_OUTPUT_COMPACT: + return json.dumps(payload, separators=(",", ":")) + return json.dumps(payload, indent=2) + + +def _require_cloud_wallet_config(program: Any) -> CloudWalletConfig: + if not program.cloud_wallet_base_url: + raise ValueError("cloud_wallet.base_url is required") + if not program.cloud_wallet_user_key_id: + raise ValueError("cloud_wallet.user_key_id is required") + if not program.cloud_wallet_private_key_pem_path: + raise ValueError("cloud_wallet.private_key_pem_path is required") + if not program.cloud_wallet_vault_id: + raise ValueError("cloud_wallet.vault_id is required") + return CloudWalletConfig( + base_url=program.cloud_wallet_base_url, + user_key_id=program.cloud_wallet_user_key_id, + private_key_pem_path=program.cloud_wallet_private_key_pem_path, + vault_id=program.cloud_wallet_vault_id, + network=program.app_network, + kms_key_id=program.cloud_wallet_kms_key_id or None, + kms_region=program.cloud_wallet_kms_region or None, + kms_public_key_hex=program.cloud_wallet_kms_public_key_hex or None, + ) + + +def new_cloud_wallet_adapter(program: Any) -> CloudWalletAdapter: + return CloudWalletAdapter(_require_cloud_wallet_config(program)) + + +def initialize_manager_file_logging(home_dir: str, *, log_level: str | None) -> None: + initialize_service_file_logging( + service_name=_MANAGER_SERVICE_NAME, + home_dir=home_dir, + log_level=log_level, + service_logger=_runtime_logger, + ) + + +def _condition_has_offer_expiration(condition: object) -> bool: + parse_names = ( + "parse_assert_before_seconds_relative", + "parse_assert_before_seconds_absolute", + "parse_assert_before_height_relative", + "parse_assert_before_height_absolute", + ) + for parse_name in parse_names: + parse_fn = getattr(condition, parse_name, None) + if not callable(parse_fn): + continue + try: + if parse_fn() is not None: + return True + except Exception: + continue + return False + + +def _offer_has_expiration_condition(sdk: object, offer_text: str) -> bool: + decode_offer = getattr(sdk, "decode_offer", None) + if not callable(decode_offer): + return False + 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: + if _condition_has_offer_expiration(condition): + return True + return False + + +def _extract_coin_id_hints_from_offer_text(offer_text: str) -> list[str]: + try: + sdk = importlib.import_module("chia_wallet_sdk") + except Exception: + return [] + decode_offer = getattr(sdk, "decode_offer", None) + if not callable(decode_offer): + return [] + try: + spend_bundle = decode_offer(offer_text) + except Exception: + return [] + coin_spends = getattr(spend_bundle, "coin_spends", None) or [] + hints: list[str] = [] + 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_obj = coin_id_fn() + to_hex = getattr(sdk, "to_hex", None) + if not callable(to_hex): + continue + coin_id_hex = str(to_hex(coin_id_obj)).strip().lower() + except Exception: + continue + normalized = normalize_hex_id(coin_id_hex) + if normalized: + hints.append(normalized) + return list(dict.fromkeys(hints)) + + +def log_signed_offer_artifact( + *, + offer_text: str, + ticker: str, + amount: int, + trading_pair: str, + expiry: str, +) -> None: + coin_id_hints = _extract_coin_id_hints_from_offer_text(offer_text) + coin_id = coin_id_hints[0] if coin_id_hints else "" + _runtime_logger.info("signed_offer_file:%s", offer_text) + _runtime_logger.info( + "signed_offer_metadata:ticker=%s coinid=%s amount=%s trading_pair=%s expiry=%s", + ticker, + coin_id, + amount, + trading_pair, + expiry, + ) + + +def verify_offer_text_for_dexie(offer_text: str) -> str | None: + try: + native = importlib.import_module("greenfloor_native") + except Exception: + native = None + else: + try: + native.validate_offer(offer_text) + return None + 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: + 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" + if not _offer_has_expiration_condition(sdk, offer_text): + return "wallet_sdk_offer_missing_expiration" + except Exception as exc: + return f"wallet_sdk_offer_validate_failed:{exc}" + return None + + +class _CoinsetFeeLookupPreflightError(RuntimeError): + def __init__( + self, + *, + failure_kind: str, + detail: str, + diagnostics: dict[str, str], + ) -> None: + self.failure_kind = failure_kind + self.detail = detail + self.diagnostics = diagnostics + 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_") + + +def _is_hex_asset_id(value: str) -> bool: + return is_hex_id(value) + + +def _normalize_label(value: str) -> str: + return "".join(ch for ch in value.strip().lower() if ch.isalnum()) + + +def _label_tokens(value: str) -> list[str]: + tokens: list[str] = [] + current: list[str] = [] + for ch in value.strip().lower(): + if ch.isalnum(): + current.append(ch) + else: + if current: + tokens.append("".join(current)) + current = [] + if current: + tokens.append("".join(current)) + return tokens + + +def _labels_match(left: str, right: str) -> bool: + a = _normalize_label(left) + b = _normalize_label(right) + if not a or not b: + return False + if a == b: + return True + if len(a) >= 5 and a in b: + return True + if len(b) >= 5 and b in a: + return True + left_tokens = {token for token in _label_tokens(left) if len(token) >= 3} + right_tokens = {token for token in _label_tokens(right) if len(token) >= 3} + return bool(left_tokens and right_tokens and len(left_tokens & right_tokens) >= 2) + + +def _wallet_label_matches_asset_ref( + *, + cat_assets: list[dict[str, str]], + label: str, +) -> list[str]: + target = label.strip() + if not target: + return [] + matches: list[str] = [] + for cat in cat_assets: + asset_id = cat.get("asset_id", "").strip() + if not asset_id: + continue + display_name = cat.get("display_name", "").strip() + symbol = cat.get("symbol", "").strip() + if _labels_match(display_name, target) or _labels_match(symbol, target): + matches.append(asset_id) + return sorted(set(matches)) + + +def _resolve_dexie_base_url(network: str, explicit_base_url: str | None = None) -> str: + if explicit_base_url and explicit_base_url.strip(): + return explicit_base_url.strip().rstrip("/") + network_l = network.strip().lower() + if network_l in {"mainnet", ""}: + return "https://api.dexie.space" + if is_testnet(network_l): + return "https://api-testnet.dexie.space" + raise ValueError(f"unsupported network for dexie posting: {network}") + + +def _dexie_lookup_token_for_cat_id(*, canonical_cat_id_hex: str, network: str) -> dict | None: + adapter = DexieAdapter(_resolve_dexie_base_url(network, None)) + return adapter.lookup_token_by_cat_id(canonical_cat_id_hex) + + +def _dexie_lookup_token_for_symbol(*, asset_ref: str, network: str) -> dict | None: + adapter = DexieAdapter(_resolve_dexie_base_url(network, None)) + return adapter.lookup_token_by_symbol(asset_ref, label_matcher=_labels_match) + + +def _normalize_hex_asset_id(asset_id: str) -> str: + result = normalize_hex_id(asset_id) + if result: + return result + normalized = str(asset_id).strip().lower() + if normalized.startswith("0x"): + normalized = normalized[2:] + return normalized + + +def _local_catalog_label_hints_for_asset_id(*, canonical_asset_id: str) -> list[str]: + canonical = canonical_asset_id.strip().lower() + if not canonical: + return [] + repo_root = Path(__file__).resolve().parents[1] + cats_path = repo_root / "config" / "cats.yaml" + markets_path = repo_root / "config" / "markets.yaml" + try: + cats_payload = load_yaml(cats_path) if cats_path.exists() else {} + markets_payload = load_yaml(markets_path) if markets_path.exists() else {} + except Exception: + return [] + hints: list[str] = [] + cats_rows = cats_payload.get("cats") if isinstance(cats_payload, dict) else None + if isinstance(cats_rows, list): + for row in cats_rows: + if not isinstance(row, dict): + continue + row_asset_id = str(row.get("asset_id", "")).strip().lower() + if row_asset_id != canonical: + continue + for key in ("base_symbol", "name"): + value = str(row.get(key, "")).strip() + if value: + hints.append(value) + aliases = row.get("aliases") + if isinstance(aliases, list): + for alias in aliases: + value = str(alias).strip() + if value: + hints.append(value) + assets_rows = markets_payload.get("assets") if isinstance(markets_payload, dict) else None + if isinstance(assets_rows, list): + for row in assets_rows: + if not isinstance(row, dict): + continue + row_asset_id = str(row.get("asset_id", "")).strip().lower() + if row_asset_id != canonical: + continue + for key in ("base_symbol", "name"): + value = str(row.get(key, "")).strip() + if value: + hints.append(value) + markets_rows = markets_payload.get("markets") if isinstance(markets_payload, dict) else None + if isinstance(markets_rows, list): + for row in markets_rows: + if not isinstance(row, dict): + continue + base_asset = str(row.get("base_asset", "")).strip().lower() + if base_asset != canonical: + continue + base_symbol = str(row.get("base_symbol", "")).strip() + if base_symbol: + hints.append(base_symbol) + return sorted(set(hints)) + + +def _resolve_asset_by_identifier(wallet: CloudWalletAdapter, hex_identifier: str) -> str | None: + query = """ +query resolveAssetByIdentifier($identifier: String) { + asset(identifier: $identifier) { + id + type + } +} +""" + try: + payload = wallet._graphql(query=query, variables={"identifier": hex_identifier}) + except Exception: + return None + asset = payload.get("asset") + if not isinstance(asset, dict): + return None + global_id = str(asset.get("id", "")).strip() + asset_type = str(asset.get("type", "")).strip().upper() + if global_id.startswith("Asset_") and asset_type in {"CAT2", "CAT"}: + return global_id + return None + + +def resolve_cloud_wallet_asset_id( + *, + wallet: CloudWalletAdapter, + canonical_asset_id: str, + symbol_hint: str | None = None, + global_id_hint: str | None = None, + allow_dexie_lookup: bool = True, +) -> str: + raw = canonical_asset_id.strip() + if not raw: + raise ValueError("asset_id must be non-empty") + if _canonical_is_cloud_global_id(raw): + return raw + if not hasattr(wallet, "_graphql"): + return raw + query = """ +query resolveWalletAssets($walletId: ID!) { + wallet(id: $walletId) { + assets { + edges { + node { + assetId + type + displayName + symbol + } + } + } + } +} +""" + payload = wallet._graphql(query=query, variables={"walletId": wallet.vault_id}) + wallet_payload = payload.get("wallet") or {} + assets_payload = wallet_payload.get("assets") or {} + edges = assets_payload.get("edges") or [] + crypto_asset_ids: list[str] = [] + cat_assets: list[dict[str, str]] = [] + for edge in edges: + node = edge.get("node") if isinstance(edge, dict) else None + if not isinstance(node, dict): + continue + asset_global_id = str(node.get("assetId", "")).strip() + asset_type = str(node.get("type", "")).strip().upper() + display_name = str(node.get("displayName", "")).strip() + symbol = str(node.get("symbol", "")).strip() + if not asset_global_id.startswith("Asset_"): + continue + if asset_type == "CRYPTOCURRENCY": + crypto_asset_ids.append(asset_global_id) + elif asset_type in {"CAT2", "CAT"}: + cat_assets.append( + { + "asset_id": asset_global_id, + "display_name": display_name, + "symbol": symbol, + } + ) + if _canonical_is_xch(raw): + hinted = str(global_id_hint or "").strip() + if hinted and hinted in set(crypto_asset_ids): + return hinted + if len(crypto_asset_ids) == 1: + return crypto_asset_ids[0] + if len(crypto_asset_ids) == 0: + raise RuntimeError("cloud_wallet_asset_resolution_failed:no_crypto_asset_found_for_xch") + raise RuntimeError("cloud_wallet_asset_resolution_failed:ambiguous_crypto_asset_for_xch") + if not cat_assets: + raise RuntimeError( + f"cloud_wallet_asset_resolution_failed:no_wallet_cat_asset_candidates_for:{raw}" + ) + hinted = str(global_id_hint or "").strip() + if hinted: + cat_asset_ids = {str(row.get("asset_id", "")).strip() for row in cat_assets} + if hinted in cat_asset_ids: + return hinted + canonical_hex = raw.lower() + if _is_hex_asset_id(canonical_hex): + identifier_match = _resolve_asset_by_identifier(wallet, canonical_hex) + if identifier_match is not None: + return identifier_match + preferred_labels: list[str] = [] + if symbol_hint: + preferred_labels.append(symbol_hint) + if not _is_hex_asset_id(canonical_hex): + direct_matches = _wallet_label_matches_asset_ref(cat_assets=cat_assets, label=raw) + if len(direct_matches) == 1: + return direct_matches[0] + if len(direct_matches) > 1: + raise RuntimeError( + f"cloud_wallet_asset_resolution_failed:ambiguous_wallet_cat_asset_for:{raw}" + ) + if not allow_dexie_lookup: + raise RuntimeError( + f"cloud_wallet_asset_resolution_failed:unsupported_canonical_asset_id:{raw}" + ) + token_row = _dexie_lookup_token_for_symbol(asset_ref=raw, network=wallet.network) + if token_row is None: + raise RuntimeError( + f"cloud_wallet_asset_resolution_failed:unsupported_canonical_asset_id:{raw}" + ) + token_id = str(token_row.get("id", "")).strip().lower() + if not _is_hex_asset_id(token_id): + raise RuntimeError( + f"cloud_wallet_asset_resolution_failed:dexie_symbol_unresolved_to_cat_id:{raw}" + ) + canonical_hex = token_id + preferred_labels.extend( + _local_catalog_label_hints_for_asset_id(canonical_asset_id=canonical_hex) + ) + dexie_token = ( + _dexie_lookup_token_for_cat_id( + canonical_cat_id_hex=canonical_hex, + network=wallet.network, + ) + if allow_dexie_lookup + else None + ) + if dexie_token is not None: + preferred_labels.extend( + [ + str(dexie_token.get("code", "")).strip(), + str(dexie_token.get("name", "")).strip(), + str(dexie_token.get("base_code", "")).strip(), + str(dexie_token.get("base_name", "")).strip(), + str(dexie_token.get("target_code", "")).strip(), + str(dexie_token.get("target_name", "")).strip(), + ] + ) + preferred_labels = [label for label in preferred_labels if label] + matched_assets: list[str] = [] + for label in preferred_labels: + matched_assets.extend(_wallet_label_matches_asset_ref(cat_assets=cat_assets, label=label)) + unique_matches = sorted(set(matched_assets)) + if len(unique_matches) == 1: + return unique_matches[0] + if len(unique_matches) > 1: + raise RuntimeError( + f"cloud_wallet_asset_resolution_failed:ambiguous_wallet_cat_asset_for:{raw}" + ) + if dexie_token is None and allow_dexie_lookup: + raise RuntimeError( + f"cloud_wallet_asset_resolution_failed:dexie_cat_metadata_not_found_for:{raw}" + ) + if len(cat_assets) == 1: + return cat_assets[0]["asset_id"] + raise RuntimeError(f"cloud_wallet_asset_resolution_failed:unmatched_wallet_cat_asset_for:{raw}") + + +def resolve_cloud_wallet_offer_asset_ids( + *, + wallet: CloudWalletAdapter, + base_asset_id: str, + quote_asset_id: str, + base_symbol_hint: str | None = None, + quote_symbol_hint: str | None = None, + base_global_id_hint: str | None = None, + quote_global_id_hint: str | None = None, +) -> tuple[str, str]: + resolved_base = resolve_cloud_wallet_asset_id( + wallet=wallet, + canonical_asset_id=base_asset_id, + symbol_hint=(base_symbol_hint or "").strip() or str(base_asset_id).strip(), + global_id_hint=(base_global_id_hint or "").strip() or None, + ) + resolved_quote = resolve_cloud_wallet_asset_id( + wallet=wallet, + canonical_asset_id=quote_asset_id, + symbol_hint=(quote_symbol_hint or "").strip() or str(quote_asset_id).strip(), + global_id_hint=(quote_global_id_hint or "").strip() or None, + ) + 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_cloud_global_id(base_asset_id) + and not _canonical_is_cloud_global_id(quote_asset_id) + ): + raise RuntimeError( + "cloud_wallet_asset_resolution_failed:resolved_assets_collide_for_non_xch_pair" + ) + return resolved_base, resolved_quote + + +def recent_market_resolved_asset_id_hints( + *, + program_home_dir: str, + market_id: str, +) -> tuple[str | None, str | None]: + db_path = (Path(program_home_dir).expanduser() / "db" / "greenfloor.sqlite").resolve() + if not db_path.exists(): + return None, None + store = SqliteStore(db_path) + try: + events = store.list_recent_audit_events( + event_types=["strategy_offer_execution"], + market_id=market_id, + limit=200, + ) + finally: + store.close() + for event in events: + payload = event.get("payload") + if not isinstance(payload, dict): + continue + base_hint = str(payload.get("resolved_base_asset_id", "")).strip() + quote_hint = str(payload.get("resolved_quote_asset_id", "")).strip() + if base_hint.startswith("Asset_") and quote_hint.startswith("Asset_"): + return base_hint, quote_hint + return None, None + + +def parse_iso8601(value: str) -> dt.datetime | None: + raw = value.strip() + if not raw: + return None + normalized = raw.replace("Z", "+00:00") + try: + parsed = dt.datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=dt.UTC) + return parsed.astimezone(dt.UTC) + + +def offer_markers(offers: list[dict]) -> set[str]: + markers: set[str] = set() + for offer in offers: + offer_id = str(offer.get("offerId", "")).strip() + if offer_id: + markers.add(f"id:{offer_id}") + bech32 = str(offer.get("bech32", "")).strip() + if bech32: + markers.add(f"bech32:{bech32}") + return markers + + +def pick_new_offer_artifact( + *, + offers: list[dict], + known_markers: set[str], + min_created_at: dt.datetime | None = None, + require_open_state: bool = False, + prefer_newest: bool = True, +) -> str: + candidates: list[tuple[dt.datetime, dt.datetime, str]] = [] + allowed_candidate_states = {"OPEN", "PENDING"} + for offer in offers: + state = str(offer.get("state", "")).strip().upper() + if state not in allowed_candidate_states: + continue + if require_open_state and state != "OPEN": + continue + bech32 = str(offer.get("bech32", "")).strip() + if not bech32.startswith("offer1"): + continue + offer_id = str(offer.get("offerId", "")).strip() + markers = {f"bech32:{bech32}"} + if offer_id: + markers.add(f"id:{offer_id}") + if markers.issubset(known_markers): + continue + created_at = parse_iso8601(str(offer.get("createdAt", "")).strip()) + if min_created_at is not None: + if created_at is None or created_at < min_created_at: + continue + expires_at = parse_iso8601(str(offer.get("expiresAt", "")).strip()) + candidates.append( + ( + created_at or dt.datetime.min.replace(tzinfo=dt.UTC), + expires_at or dt.datetime.min.replace(tzinfo=dt.UTC), + bech32, + ) + ) + if not candidates: + return "" + candidates.sort(key=lambda row: (row[0], row[1]), reverse=bool(prefer_newest)) + return candidates[0][2] + + +def wallet_get_wallet_offers( + wallet: CloudWalletAdapter, + *, + is_creator: bool | None, + states: list[str] | None, +) -> dict[str, Any]: + return wallet.get_wallet(is_creator=is_creator, states=states, first=100) + + +def _safe_int(value: object) -> int | None: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +def _cloud_wallet_rate_limit_retry_seconds(error_text: str) -> float | None: + match = re.search(r"try again in (\d+) seconds", error_text, flags=re.IGNORECASE) + if not match: + return None + try: + return float(int(match.group(1))) + except (TypeError, ValueError): + return None + + +def call_with_moderate_retry( + *, + action: str, + call: collections.abc.Callable[[], Any], + elapsed_seconds: int = 0, + events: list[dict[str, str]] | None = None, + max_attempts: int = 4, + sleep_fn: collections.abc.Callable[[float], None] | None = None, +): + if sleep_fn is None: + sleep_fn = time.sleep + attempt = 0 + sleep_seconds = 0.5 + while True: + try: + return call() + except Exception as exc: + attempt += 1 + error_text = str(exc) + rate_limit_wait = _cloud_wallet_rate_limit_retry_seconds(error_text) + if rate_limit_wait is not None: + sleep_seconds = max(sleep_seconds, min(30.0, rate_limit_wait + 0.25)) + if attempt >= max_attempts: + raise RuntimeError(f"{action}_retry_exhausted:{exc}") from exc + if events is not None: + events.append( + { + "event": "poll_retry", + "action": action, + "attempt": str(attempt), + "elapsed_seconds": str(elapsed_seconds), + "wait_reason": "transient_poll_failure", + "error": error_text, + } + ) + sleep_fn(sleep_seconds) + sleep_seconds = min(8.0, sleep_seconds * 2.0) + + +def post_dexie_offer_with_invalid_offer_retry( + *, + dexie: DexieAdapter, + offer_text: str, + drop_only: bool, + claim_rewards: bool, + sleep_fn: collections.abc.Callable[[float], None] | None = None, +) -> dict[str, Any]: + if sleep_fn is None: + sleep_fn = time.sleep + attempt = 0 + sleep_seconds = _DEXIE_INVALID_OFFER_RETRY_INITIAL_DELAY_SECONDS + while True: + result = dexie.post_offer( + offer_text, + drop_only=drop_only, + claim_rewards=claim_rewards, + ) + error = str(result.get("error", "")).strip() + should_retry = ( + bool(error) + and "dexie_http_error:400" in error + and "Invalid Offer" in error + and attempt < (_DEXIE_INVALID_OFFER_RETRY_MAX_ATTEMPTS - 1) + ) + if not should_retry: + return result + attempt += 1 + sleep_fn(sleep_seconds) + sleep_seconds = min(8.0, sleep_seconds * 2.0) + + +def verify_dexie_offer_visible_by_id( + *, + dexie: DexieAdapter, + offer_id: str, + max_attempts: int = 4, + delay_seconds: float = 1.5, + expected_offered_asset_id: str | None = None, + expected_offered_symbol: str | None = None, + expected_requested_asset_id: str | None = None, + expected_requested_symbol: str | None = None, + sleep_fn: collections.abc.Callable[[float], None] | None = None, +) -> str | None: + if sleep_fn is None: + sleep_fn = time.sleep + clean_offer_id = str(offer_id).strip() + if not clean_offer_id: + return "dexie_offer_missing_id_after_publish" + attempts = max(1, int(max_attempts)) + last_error = "dexie_offer_not_visible_after_publish" + for attempt in range(1, attempts + 1): + try: + payload = dexie.get_offer(clean_offer_id) + except Exception as exc: + last_error = f"dexie_get_offer_error:{exc}" + if attempt < attempts: + sleep_fn(delay_seconds) + continue + offer_payload = payload.get("offer") if isinstance(payload, dict) else None + visible_id = ( + str(offer_payload.get("id", "")).strip() if isinstance(offer_payload, dict) else "" + ) + if visible_id == clean_offer_id: + if isinstance(offer_payload, dict): + offered = offer_payload.get("offered") + requested = offer_payload.get("requested") + if expected_offered_asset_id and isinstance(offered, list): + expected_asset = str(expected_offered_asset_id).strip().lower() + expected_symbol = str(expected_offered_symbol or "").strip().lower() + found = False + for row in offered: + if not isinstance(row, dict): + continue + asset_id = str(row.get("id", "")).strip().lower() + code = str(row.get("code", "")).strip().lower() + name = str(row.get("name", "")).strip().lower() + if asset_id == expected_asset or ( + expected_symbol and (code == expected_symbol or name == expected_symbol) + ): + found = True + break + if not found: + return ( + "dexie_offer_offered_asset_missing:" + f"expected_asset={expected_offered_asset_id}:" + f"expected_symbol={expected_offered_symbol}" + ) + if expected_requested_asset_id and isinstance(requested, list): + expected_asset = str(expected_requested_asset_id).strip().lower() + expected_symbol = str(expected_requested_symbol or "").strip().lower() + found = False + for row in requested: + if not isinstance(row, dict): + continue + asset_id = str(row.get("id", "")).strip().lower() + code = str(row.get("code", "")).strip().lower() + name = str(row.get("name", "")).strip().lower() + if asset_id == expected_asset or ( + expected_symbol and (code == expected_symbol or name == expected_symbol) + ): + found = True + break + if not found: + return ( + "dexie_offer_requested_asset_missing:" + f"expected_asset={expected_requested_asset_id}:" + f"expected_symbol={expected_requested_symbol}" + ) + return None + last_error = "dexie_offer_visibility_payload_mismatch" + if attempt < attempts: + sleep_fn(delay_seconds) + return last_error + + +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()}" + + +def _coinset_reconcile_coin_state(*, network: str, coin_name: str) -> dict[str, str]: + adapter = CoinsetAdapter(None, network=network) + try: + record = call_with_moderate_retry( + action="coinset_get_coin_record_by_name", + call=lambda: adapter.get_coin_record_by_name(coin_name_hex=coin_name), + ) + except Exception as exc: + return {"reconcile": "error", "error": str(exc)} + if not isinstance(record, dict): + return {"reconcile": "not_found"} + confirmed_height = _safe_int(record.get("confirmed_block_index")) + spent_height = _safe_int(record.get("spent_block_index")) + return { + "reconcile": "ok", + "confirmed_block_index": str(confirmed_height if confirmed_height is not None else -1), + "spent_block_index": str(spent_height if spent_height is not None else -1), + "coinbase": str(bool(record.get("coinbase", False))).lower(), + } + + +def _coinset_peak_height(*, network: str) -> int | None: + adapter = CoinsetAdapter(None, network=network) + state = call_with_moderate_retry( + action="coinset_get_blockchain_state", + call=adapter.get_blockchain_state, + ) + if not isinstance(state, dict): + return None + candidates = [state.get("peak_height"), state.get("peakHeight")] + peak = state.get("peak") + if isinstance(peak, dict): + candidates.extend([peak.get("height"), peak.get("peak_height")]) + for candidate in candidates: + parsed = _safe_int(candidate) + if parsed is not None and parsed >= 0: + return parsed + return None + + +def _watch_reorg_risk_with_coinset( + *, + network: str, + confirmed_block_index: int, + additional_blocks: int, + warning_interval_seconds: int, + timeout_seconds: int = 60 * 60, +) -> list[dict[str, str]]: + events: list[dict[str, str]] = [] + target_height = int(confirmed_block_index) + int(additional_blocks) + events.append( + { + "event": "reorg_watch_started", + "confirmed_block_index": str(confirmed_block_index), + "target_height": str(target_height), + } + ) + start = time.monotonic() + next_warning = warning_interval_seconds + sleep_seconds = 8.0 + while True: + elapsed = int(time.monotonic() - start) + peak_height = _coinset_peak_height(network=network) + if peak_height is None: + events.append( + { + "event": "reorg_watch_skipped", + "reason": "coinset_peak_height_unavailable", + "elapsed_seconds": str(elapsed), + } + ) + return events + remaining = target_height - peak_height + if remaining <= 0: + events.append( + { + "event": "reorg_watch_complete", + "peak_height": str(peak_height), + "target_height": str(target_height), + "elapsed_seconds": str(elapsed), + } + ) + return events + if elapsed >= timeout_seconds: + events.append( + { + "event": "reorg_watch_timeout", + "peak_height": str(peak_height), + "target_height": str(target_height), + "remaining_blocks": str(remaining), + "elapsed_seconds": str(elapsed), + } + ) + return events + if elapsed >= next_warning: + events.append( + { + "event": "reorg_watch_warning", + "peak_height": str(peak_height), + "target_height": str(target_height), + "remaining_blocks": str(remaining), + "elapsed_seconds": str(elapsed), + } + ) + next_warning += warning_interval_seconds + time.sleep(sleep_seconds) + sleep_seconds = min(20.0, sleep_seconds * 1.5) + + +def poll_offer_artifact_until_available( + *, + wallet: CloudWalletAdapter, + known_markers: set[str], + timeout_seconds: int, + min_created_at: dt.datetime | None = None, + require_open_state: bool = False, + states: tuple[str, ...] | None = ("OPEN", "PENDING"), + prefer_newest: bool = True, + wallet_get_wallet_offers_fn: collections.abc.Callable[..., dict[str, Any]] | None = None, + retry_fn: collections.abc.Callable[..., Any] | None = None, + sleep_fn: collections.abc.Callable[[float], None] | None = None, + monotonic_fn: collections.abc.Callable[[], float] | None = None, +) -> str: + if wallet_get_wallet_offers_fn is None: + wallet_get_wallet_offers_fn = wallet_get_wallet_offers + if retry_fn is None: + retry_fn = call_with_moderate_retry + if sleep_fn is None: + sleep_fn = time.sleep + if monotonic_fn is None: + monotonic_fn = time.monotonic + start = monotonic_fn() + sleep_seconds = 2.0 + while True: + elapsed = int(monotonic_fn() - start) + wallet_payload = retry_fn( + action="wallet_get_wallet", + call=lambda: wallet_get_wallet_offers_fn( + wallet, + is_creator=True, + states=list(states) if states is not None else None, + ), + elapsed_seconds=elapsed, + ) + offers = wallet_payload.get("offers", []) + if isinstance(offers, list): + offer_text = pick_new_offer_artifact( + offers=offers, + known_markers=known_markers, + min_created_at=min_created_at, + require_open_state=require_open_state, + prefer_newest=prefer_newest, + ) + if offer_text: + return offer_text + if elapsed >= timeout_seconds: + raise RuntimeError("cloud_wallet_offer_artifact_timeout") + sleep_fn(sleep_seconds) + sleep_seconds = min(20.0, sleep_seconds * 1.5) + + +def poll_offer_artifact_by_signature_request( + *, + wallet: CloudWalletAdapter, + signature_request_id: str, + known_markers: set[str], + timeout_seconds: int, + min_created_at: dt.datetime | None = None, + retry_fn: collections.abc.Callable[..., Any] | None = None, + sleep_fn: collections.abc.Callable[[float], None] | None = None, + monotonic_fn: collections.abc.Callable[[], float] | None = None, +) -> str: + if retry_fn is None: + retry_fn = call_with_moderate_retry + if sleep_fn is None: + sleep_fn = time.sleep + if monotonic_fn is None: + monotonic_fn = time.monotonic + start = monotonic_fn() + sleep_seconds = 2.0 + while True: + elapsed = int(monotonic_fn() - start) + payload = retry_fn( + action="wallet_get_signature_request_offer", + call=lambda: wallet.get_signature_request_offer( + signature_request_id=signature_request_id + ), + elapsed_seconds=elapsed, + ) + bech32 = str(payload.get("bech32", "")).strip() + offer_id = str(payload.get("offer_id", "")).strip() + offer_state = str(payload.get("state", "")).strip().upper() + created_at = parse_iso8601(str(payload.get("created_at", "")).strip()) + markers = {f"bech32:{bech32}"} if bech32 else set() + if offer_id: + markers.add(f"id:{offer_id}") + markers_already_known = bool(markers) and markers.issubset(known_markers) + created_at_gte_min = ( + bool(created_at and min_created_at and created_at >= min_created_at) + if min_created_at is not None + else True + ) + if ( + bech32.startswith("offer1") + and offer_state in {"OPEN", "PENDING", "SETTLED"} + and not markers_already_known + and created_at_gte_min + ): + return bech32 + if elapsed >= timeout_seconds: + raise RuntimeError("cloud_wallet_offer_artifact_timeout") + sleep_fn(sleep_seconds) + sleep_seconds = min(20.0, sleep_seconds * 1.5) + + +def _coinset_base_url(*, network: str) -> str: + base = os.getenv("GREENFLOOR_COINSET_BASE_URL", "").strip() + if not base: + return "" + if is_testnet(network): + allow_mainnet = os.getenv("GREENFLOOR_ALLOW_MAINNET_COINSET_FOR_TESTNET11", "").strip() + if ( + "coinset.org" in base + and "testnet11.api.coinset.org" not in base + and allow_mainnet != "1" + ): + raise RuntimeError("coinset_base_url_mainnet_not_allowed_for_testnet11") + return base + + +def _coinset_adapter(*, network: str) -> CoinsetAdapter: + base_url = _coinset_base_url(network=network) + require_testnet11 = is_testnet(network) + try: + return CoinsetAdapter( + base_url or None, network=network, require_testnet11=require_testnet11 + ) + except TypeError as exc: + if "require_testnet11" not in str(exc): + raise + return CoinsetAdapter(base_url or None, network=network) + + +def _coinset_fee_lookup_preflight( + *, + network: str, + fee_cost: int = 1_000_000, + spend_count: int | None = None, +) -> dict[str, str]: + try: + coinset = _coinset_adapter(network=network) + except Exception as exc: + raise _CoinsetFeeLookupPreflightError( + failure_kind="endpoint_validation_failed", + detail=str(exc), + diagnostics={ + "coinset_network": network.strip().lower(), + "coinset_base_url": os.getenv("GREENFLOOR_COINSET_BASE_URL", "").strip(), + }, + ) from exc + diagnostics = { + "coinset_network": str(getattr(coinset, "network", network.strip().lower())), + "coinset_base_url": str( + getattr(coinset, "base_url", os.getenv("GREENFLOOR_COINSET_BASE_URL", "").strip()) + ), + } + try: + try: + payload = coinset.get_fee_estimate( + target_times=[300, 600, 1200], + cost=max(1, int(fee_cost)), + spend_count=spend_count, + ) + except TypeError as exc: + if "unexpected keyword argument" not in str(exc): + raise + payload = coinset.get_fee_estimate(target_times=[300, 600, 1200]) + except Exception as exc: + raise _CoinsetFeeLookupPreflightError( + failure_kind="endpoint_validation_failed", + detail=str(exc), + diagnostics=diagnostics, + ) from exc + if not bool(payload.get("success", False)): + detail = str( + payload.get("error") + or payload.get("message") + or payload.get("reason") + or "coinset_fee_estimate_unsuccessful" + ) + raise _CoinsetFeeLookupPreflightError( + failure_kind="temporary_fee_advice_unavailable", + detail=detail, + diagnostics=diagnostics, + ) + try: + recommended = coinset.get_conservative_fee_estimate( + cost=max(1, int(fee_cost)), + spend_count=spend_count, + ) + except TypeError as exc: + if "unexpected keyword argument" not in str(exc): + raise + recommended = coinset.get_conservative_fee_estimate() + if recommended is None: + raise _CoinsetFeeLookupPreflightError( + failure_kind="temporary_fee_advice_unavailable", + detail="coinset_conservative_fee_unavailable", + diagnostics=diagnostics, + ) + diagnostics["recommended_fee_mojos"] = str(int(recommended)) + return diagnostics + + +def _resolve_operation_fee( + *, + role: str, + network: str, + minimum_fee_mojos: int = 0, + fee_cost: int = 1_000_000, + spend_count: int | None = None, +) -> tuple[int, str]: + if role == "maker_create_offer": + return 0, "maker_default_zero" + if role != "taker_or_coin_operation": + raise ValueError(f"unsupported fee role: {role}") + minimum_fee = int(minimum_fee_mojos) + max_attempts = int(os.getenv("GREENFLOOR_COINSET_FEE_MAX_ATTEMPTS", "4")) + coinset = _coinset_adapter(network=network) + for attempt in range(max_attempts): + advised = None + try: + try: + advised = coinset.get_conservative_fee_estimate( + cost=max(1, int(fee_cost)), + spend_count=spend_count, + ) + except TypeError as exc: + if "unexpected keyword argument" not in str(exc): + raise + advised = coinset.get_conservative_fee_estimate() + except Exception: + advised = None + if advised is not None: + advised_fee = int(advised) + if advised_fee < minimum_fee: + return minimum_fee, "coinset_conservative_minimum_floor" + return advised_fee, "coinset_conservative" + if attempt < max_attempts - 1: + time.sleep(min(8.0, 0.5 * (2**attempt))) + return minimum_fee, "config_minimum_fee_fallback" + + +def _resolve_taker_or_coin_operation_fee( + *, + network: str, + minimum_fee_mojos: int = 0, + fee_cost: int = 1_000_000, + spend_count: int | None = None, +) -> tuple[int, str]: + _coinset_fee_lookup_preflight( + network=network, + fee_cost=fee_cost, + spend_count=spend_count, + ) + return _resolve_operation_fee( + role="taker_or_coin_operation", + network=network, + minimum_fee_mojos=minimum_fee_mojos, + fee_cost=fee_cost, + spend_count=spend_count, + ) + + +def resolve_maker_offer_fee(*, network: str) -> tuple[int, str]: + return _resolve_operation_fee(role="maker_create_offer", network=network) + + +def poll_signature_request_until_not_unsigned( + *, + wallet: CloudWalletAdapter, + signature_request_id: str, + timeout_seconds: int, + warning_interval_seconds: int, + retry_fn: collections.abc.Callable[..., Any] | None = None, + sleep_fn: collections.abc.Callable[[float], None] | None = None, + monotonic_fn: collections.abc.Callable[[], float] | None = None, +) -> tuple[str, list[dict[str, str]]]: + if retry_fn is None: + retry_fn = call_with_moderate_retry + if sleep_fn is None: + sleep_fn = time.sleep + if monotonic_fn is None: + monotonic_fn = time.monotonic + events: list[dict[str, str]] = [] + start = monotonic_fn() + next_warning = warning_interval_seconds + warning_count = 0 + next_heartbeat = 5 + sleep_seconds = 2.0 + while True: + elapsed = int(monotonic_fn() - start) + status_payload = retry_fn( + action="wallet_get_signature_request", + call=lambda: wallet.get_signature_request(signature_request_id=signature_request_id), + elapsed_seconds=elapsed, + events=events, + ) + status = str(status_payload.get("status", "")).strip().upper() + if status and status != "UNSIGNED": + if next_heartbeat > 5: + print("", file=sys.stderr, flush=True) + print( + f"signature submitted: {signature_request_id} status={status}", + file=sys.stderr, + flush=True, + ) + return status, events + if elapsed >= next_heartbeat: + print(".", end="", file=sys.stderr, flush=True) + next_heartbeat += 5 + if elapsed >= timeout_seconds: + raise RuntimeError("signature_request_timeout_waiting_for_signature") + if elapsed >= next_warning: + warning_count += 1 + events.append( + { + "event": "signature_wait_warning", + "elapsed_seconds": str(elapsed), + "signing_state_age_seconds": str(elapsed), + "message": "still_waiting_on_user_signature", + "wait_reason": "waiting_on_user_signature", + "warning_count": str(warning_count), + } + ) + if warning_count >= 2: + events.append( + { + "event": "signature_wait_escalation", + "elapsed_seconds": str(elapsed), + "message": "extended_user_signature_delay", + "wait_reason": "waiting_on_user_signature", + "warning_count": str(warning_count), + } + ) + next_warning += warning_interval_seconds + sleep_fn(sleep_seconds) + sleep_seconds = min(20.0, sleep_seconds * 1.5) + + +def _coin_asset_id(coin: dict) -> str: + asset_raw = coin.get("asset") + if isinstance(asset_raw, dict): + return str(asset_raw.get("id", "xch")).strip() or "xch" + if isinstance(asset_raw, str): + return asset_raw.strip() or "xch" + return "xch" + + +def wait_for_mempool_then_confirmation( + *, + wallet: CloudWalletAdapter, + network: str, + initial_coin_ids: set[str], + asset_id: str | None = None, + mempool_warning_seconds: int, + confirmation_warning_seconds: int, + timeout_seconds: int | None = None, +) -> list[dict[str, str]]: + events: list[dict[str, str]] = [] + start = time.monotonic() + seen_pending = False + next_heartbeat = 5 + sleep_seconds = 2.0 + next_mempool_warning = mempool_warning_seconds + next_confirmation_warning = confirmation_warning_seconds + target_asset = ( + asset_id.strip().lower() if isinstance(asset_id, str) and asset_id.strip() else None + ) + while True: + elapsed = int(time.monotonic() - start) + coins = call_with_moderate_retry( + action="wallet_list_coins", + call=lambda: wallet.list_coins(include_pending=True), + elapsed_seconds=elapsed, + events=events, + ) + pending = [ + c + for c in coins + if target_asset is None or _coin_asset_id(c).lower() == target_asset + if str(c.get("id", "")).strip() not in initial_coin_ids + if str(c.get("state", "")).strip().upper() in {"PENDING", "MEMPOOL"} + ] + confirmed = [ + c + for c in coins + if target_asset is None or _coin_asset_id(c).lower() == target_asset + if str(c.get("id", "")).strip() not in initial_coin_ids + if str(c.get("state", "")).strip().upper() not in {"PENDING", "MEMPOOL"} + ] + if pending and not seen_pending: + seen_pending = True + sample = str(pending[0].get("name", pending[0].get("id", ""))).strip() + sample_id = str(pending[0].get("id", "")).strip() + coinset_url = _coinset_coin_url(coin_name=sample, network=network) + reconcile = _coinset_reconcile_coin_state(network=network, coin_name=sample) + events.append( + { + "event": "in_mempool", + "coin_id": sample_id, + "coin_name": sample, + "coinset_url": coinset_url, + "elapsed_seconds": str(elapsed), + "wait_reason": "waiting_for_mempool_admission", + **reconcile, + } + ) + if next_heartbeat > 5: + print("", file=sys.stderr, flush=True) + print(f"in mempool: {coinset_url}", file=sys.stderr, flush=True) + if confirmed: + sample_confirmed = str(confirmed[0].get("name", confirmed[0].get("id", ""))).strip() + confirmation_reconcile = _coinset_reconcile_coin_state( + network=network, coin_name=sample_confirmed + ) + confirmed_height = _safe_int(confirmation_reconcile.get("confirmed_block_index")) + events.append( + { + "event": "confirmed", + "coin_name": sample_confirmed, + "coinset_url": _coinset_coin_url(coin_name=sample_confirmed, network=network), + "elapsed_seconds": str(elapsed), + "wait_reason": "waiting_for_confirmation", + **confirmation_reconcile, + } + ) + if confirmed_height is not None and confirmed_height >= 0: + events.extend( + _watch_reorg_risk_with_coinset( + network=network, + confirmed_block_index=confirmed_height, + additional_blocks=6, + warning_interval_seconds=15 * 60, + ) + ) + if next_heartbeat > 5: + print("", file=sys.stderr, flush=True) + return events + if elapsed >= next_heartbeat: + print(".", end="", file=sys.stderr, flush=True) + next_heartbeat += 5 + if timeout_seconds is not None and timeout_seconds > 0 and elapsed >= timeout_seconds: + raise RuntimeError("confirmation_wait_timeout") + if not seen_pending and elapsed >= next_mempool_warning: + events.append({"event": "mempool_wait_warning", "elapsed_seconds": str(elapsed)}) + next_mempool_warning += mempool_warning_seconds + if seen_pending and elapsed >= next_confirmation_warning: + events.append({"event": "confirmation_wait_warning", "elapsed_seconds": str(elapsed)}) + next_confirmation_warning += confirmation_warning_seconds + time.sleep(sleep_seconds) + sleep_seconds = min(20.0, sleep_seconds * 1.5) + + +def _is_spendable_coin(coin: dict) -> bool: + if bool(coin.get("isLocked", False)): + return False + coin_state = str(coin.get("state", "")).strip().upper() + if not coin_state: + return False + if coin_state in { + "PENDING", + "MEMPOOL", + "SPENT", + "SPENDING", + "LOCKED", + "RESERVED", + "UNCONFIRMED", + }: + return False + return coin_state in {"CONFIRMED", "UNSPENT", "SPENDABLE", "AVAILABLE", "SETTLED"} + + +def normalize_offer_side(value: str | None) -> str: + side = str(value or "").strip().lower() + return "buy" if side == "buy" else "sell" + + +def dexie_offer_view_url(*, dexie_base_url: str, offer_id: str) -> str: + clean_offer_id = str(offer_id).strip() + if not clean_offer_id: + return "" + parsed = urllib.parse.urlparse(str(dexie_base_url).strip()) + host = parsed.netloc.strip().lower() + if not host: + return "" + if host.startswith("api-testnet."): + host = host[len("api-") :] + elif host.startswith("api."): + host = host[len("api.") :] + return f"https://{host}/offers/{urllib.parse.quote(clean_offer_id)}" + + +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 + + +def _bootstrap_fee_cost_for_output_count(output_count: int) -> int: + count = max(1, int(output_count)) + return 1_000_000 + max(0, count - 1) * 250_000 + + +def _resolve_bootstrap_split_fee( + *, + network: str, + minimum_fee_mojos: int, + output_count: int, +) -> tuple[int, str, str | None]: + fee_cost = _bootstrap_fee_cost_for_output_count(output_count) + spend_count = max(1, int(output_count)) + try: + fee_mojos, fee_source = _resolve_taker_or_coin_operation_fee( + network=network, + minimum_fee_mojos=minimum_fee_mojos, + fee_cost=fee_cost, + spend_count=spend_count, + ) + return int(fee_mojos), fee_source, None + except Exception as exc: + fallback_fee = max(0, int(minimum_fee_mojos)) + return fallback_fee, "config_minimum_fee_fallback", str(exc) + + +@dataclass(frozen=True, slots=True) +class _BootstrapLadderEntry: + size_base_units: int + target_count: int + split_buffer_count: int + + +def ensure_offer_bootstrap_denominations( + *, + program: Any, + market: Any, + wallet: CloudWalletAdapter, + resolved_base_asset_id: str, + resolved_quote_asset_id: str, + key_id: str, + keyring_yaml_path: str, + quote_price: float, + action_side: str = "sell", + plan_bootstrap_mixed_outputs_fn: collections.abc.Callable[..., Any] | None = None, + resolve_bootstrap_split_fee_fn: collections.abc.Callable[..., tuple[int, str, str | None]] + | None = None, + sign_and_broadcast_mixed_split_fn: collections.abc.Callable[[dict[str, Any]], dict[str, Any]] + | None = None, + wait_for_mempool_then_confirmation_fn: collections.abc.Callable[..., list[dict[str, str]]] + | None = None, + is_spendable_coin_fn: collections.abc.Callable[[dict], bool] | None = None, +) -> dict[str, Any]: + if plan_bootstrap_mixed_outputs_fn is None: + plan_bootstrap_mixed_outputs_fn = plan_bootstrap_mixed_outputs + if resolve_bootstrap_split_fee_fn is None: + resolve_bootstrap_split_fee_fn = _resolve_bootstrap_split_fee + if sign_and_broadcast_mixed_split_fn is None: + sign_and_broadcast_mixed_split_fn = sign_and_broadcast_mixed_split + if wait_for_mempool_then_confirmation_fn is None: + wait_for_mempool_then_confirmation_fn = wait_for_mempool_then_confirmation + if is_spendable_coin_fn is None: + is_spendable_coin_fn = _is_spendable_coin + side = normalize_offer_side(action_side) + ladders = getattr(market, "ladders", {}) or {} + side_ladder = list(ladders.get(side, []) or []) if isinstance(ladders, dict) else [] + 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)) + if side == "buy": + ladder_for_split = [] + for entry in side_ladder: + quote_amount = int( + round(float(entry.size_base_units) * float(quote_price) * quote_unit_multiplier) + ) + if quote_amount <= 0: + continue + ladder_for_split.append( + _BootstrapLadderEntry( + size_base_units=quote_amount, + target_count=int(entry.target_count), + split_buffer_count=int(entry.split_buffer_count), + ) + ) + split_asset_id = str(resolved_quote_asset_id).strip() + else: + ladder_for_split = side_ladder + split_asset_id = str(resolved_base_asset_id).strip() + if not split_asset_id: + return {"status": "skipped", "reason": f"missing_{side}_asset_for_bootstrap"} + if not hasattr(wallet, "list_coins"): + return { + "status": "skipped", + "reason": "wallet_list_coins_unavailable_for_bootstrap", + "fallback_to_cloud_wallet_offer_split": True, + } + asset_scoped_coins = wallet.list_coins(asset_id=split_asset_id, include_pending=True) + spendable_asset_coins = [coin for coin in asset_scoped_coins if is_spendable_coin_fn(coin)] + bootstrap_plan = plan_bootstrap_mixed_outputs_fn( + sell_ladder=ladder_for_split, + spendable_coins=spendable_asset_coins, + ) + if bootstrap_plan is None: + return {"status": "skipped", "reason": "already_ready"} + if not keyring_yaml_path: + return { + "status": "skipped", + "reason": "missing_keyring_yaml_path_for_bootstrap", + "fallback_to_cloud_wallet_offer_split": True, + } + if not Path(keyring_yaml_path).expanduser().exists(): + return { + "status": "skipped", + "reason": "keyring_yaml_path_not_found_for_bootstrap", + "fallback_to_cloud_wallet_offer_split": True, + } + fee_mojos, fee_source, fee_lookup_error = resolve_bootstrap_split_fee_fn( + network=str(program.app_network), + minimum_fee_mojos=int(program.coin_ops_minimum_fee_mojos), + output_count=len(bootstrap_plan.output_amounts_base_units), + ) + existing_coin_ids = { + str(c.get("id", "")).strip() for c in asset_scoped_coins if str(c.get("id", "")).strip() + } + signing_payload = { + "key_id": key_id, + "network": str(program.app_network), + "receive_address": str(market.receive_address), + "keyring_yaml_path": keyring_yaml_path, + "asset_id": str(market.quote_asset if side == "buy" else market.base_asset), + "selected_coin_ids": [bootstrap_plan.source_coin_id], + "output_amounts_base_units": list(bootstrap_plan.output_amounts_base_units), + "fee_mojos": int(fee_mojos), + "cloud_wallet_base_url": str(program.cloud_wallet_base_url or "").strip(), + "cloud_wallet_user_key_id": str(program.cloud_wallet_user_key_id or "").strip(), + "cloud_wallet_private_key_pem_path": str( + program.cloud_wallet_private_key_pem_path or "" + ).strip(), + "cloud_wallet_vault_id": str(program.cloud_wallet_vault_id or "").strip(), + "cloud_wallet_kms_key_id": str(program.cloud_wallet_kms_key_id or "").strip(), + "cloud_wallet_kms_region": str(program.cloud_wallet_kms_region or "").strip(), + "cloud_wallet_kms_public_key_hex": str( + program.cloud_wallet_kms_public_key_hex or "" + ).strip(), + } + bootstrap_result = sign_and_broadcast_mixed_split_fn(signing_payload) + if str(bootstrap_result.get("status", "")).strip().lower() != "executed": + bootstrap_reason = str(bootstrap_result.get("reason", "bootstrap_signing_failed")) + operator_guidance = None + if "insufficient_xch_fee_balance_for_mixed_split" in bootstrap_reason: + operator_guidance = ( + "insufficient spendable xch balance for bootstrap fee; add or free xch " + "coins in the signing wallet, or reduce coin_ops.minimum_fee_mojos only " + "if zero-fee fallback is acceptable for your environment" + ) + elif "mixed_split_vault_with_fee_not_supported" in bootstrap_reason: + operator_guidance = ( + "local vault mixed-split with explicit fee is not supported; manager will " + "fall back to cloud-wallet offer-time split for this attempt" + ) + return { + "status": "failed", + "reason": bootstrap_reason, + "operator_guidance": operator_guidance, + "fallback_to_cloud_wallet_offer_split": True, + "fee_mojos": int(fee_mojos), + "fee_source": fee_source, + "fee_lookup_error": fee_lookup_error, + "plan": { + "source_coin_id": bootstrap_plan.source_coin_id, + "output_count": len(bootstrap_plan.output_amounts_base_units), + "total_output_amount": bootstrap_plan.total_output_amount, + }, + } + wait_events: list[dict[str, str]] = [] + wait_error: str | None = None + try: + wait_events = wait_for_mempool_then_confirmation_fn( + wallet=wallet, + network=str(program.app_network), + initial_coin_ids=existing_coin_ids, + asset_id=split_asset_id, + mempool_warning_seconds=5 * 60, + confirmation_warning_seconds=15 * 60, + timeout_seconds=20 * 60, + ) + except Exception as exc: + wait_error = str(exc) + return { + "status": "failed", + "reason": "bootstrap_wait_failed", + "wait_error": wait_error, + "fallback_to_cloud_wallet_offer_split": True, + "fee_mojos": int(fee_mojos), + "fee_source": fee_source, + "fee_lookup_error": fee_lookup_error, + "plan": { + "source_coin_id": bootstrap_plan.source_coin_id, + "source_amount": bootstrap_plan.source_amount, + "output_count": len(bootstrap_plan.output_amounts_base_units), + "total_output_amount": bootstrap_plan.total_output_amount, + "change_amount": bootstrap_plan.change_amount, + }, + "operation_id": str(bootstrap_result.get("operation_id", "")).strip(), + "wait_events": wait_events, + } + refreshed_asset_coins = wallet.list_coins(asset_id=split_asset_id, include_pending=True) + refreshed_spendable = [coin for coin in refreshed_asset_coins if is_spendable_coin_fn(coin)] + remaining_plan = plan_bootstrap_mixed_outputs_fn( + sell_ladder=ladder_for_split, + spendable_coins=refreshed_spendable, + ) + return { + "status": "executed", + "reason": "bootstrap_submitted", + "ready": remaining_plan is None, + "fee_mojos": int(fee_mojos), + "fee_source": fee_source, + "fee_lookup_error": fee_lookup_error, + "wait_error": wait_error, + "plan": { + "source_coin_id": bootstrap_plan.source_coin_id, + "source_amount": bootstrap_plan.source_amount, + "output_count": len(bootstrap_plan.output_amounts_base_units), + "total_output_amount": bootstrap_plan.total_output_amount, + "change_amount": bootstrap_plan.change_amount, + "deficits": [ + { + "size_base_units": d.size_base_units, + "required_count": d.required_count, + "current_count": d.current_count, + "deficit_count": d.deficit_count, + } + for d in bootstrap_plan.deficits + ], + }, + "operation_id": str(bootstrap_result.get("operation_id", "")).strip(), + "wait_events": wait_events, + } + + +def cloud_wallet_create_offer_phase( + *, + wallet: CloudWalletAdapter, + market: Any, + size_base_units: int, + quote_price: float, + resolved_base_asset_id: str, + resolved_quote_asset_id: str, + offer_fee_mojos: int, + split_input_coins_fee: int, + expiry_unit: str, + expiry_value: int, + action_side: str = "sell", + wallet_get_wallet_offers_fn: collections.abc.Callable[..., dict[str, Any]] | None = None, + poll_signature_request_until_not_unsigned_fn: collections.abc.Callable[..., Any] | None = None, +) -> dict[str, Any]: + if wallet_get_wallet_offers_fn is None: + wallet_get_wallet_offers_fn = wallet_get_wallet_offers + if poll_signature_request_until_not_unsigned_fn is None: + poll_signature_request_until_not_unsigned_fn = poll_signature_request_until_not_unsigned + side = normalize_offer_side(action_side) + prior_wallet_payload = wallet_get_wallet_offers_fn( + wallet, + is_creator=True, + states=["OPEN", "PENDING"], + ) + prior_offers = prior_wallet_payload.get("offers", []) + 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)) + ) + request_amount = int( + round( + float(size_base_units) + * float(quote_price) + * int((market.pricing or {}).get("quote_unit_mojo_multiplier", 1000)) + ) + ) + if request_amount <= 0: + raise ValueError("request_amount must be positive") + if side == "buy": + offered = [{"assetId": resolved_quote_asset_id, "amount": request_amount}] + requested = [{"assetId": resolved_base_asset_id, "amount": offer_amount}] + else: + offered = [{"assetId": resolved_base_asset_id, "amount": offer_amount}] + requested = [{"assetId": resolved_quote_asset_id, "amount": request_amount}] + expires_at = ( + dt.datetime.now(dt.UTC) + dt.timedelta(**{expiry_unit: int(expiry_value)}) + ).isoformat() + create_result = wallet.create_offer( + offered=offered, + requested=requested, + fee=offer_fee_mojos, + expires_at_iso=expires_at, + split_input_coins=True, + split_input_coins_fee=split_input_coins_fee, + ) + signature_request_id = str(create_result.get("signature_request_id", "")).strip() + wait_events: list[dict[str, str]] = [] + signature_state = str(create_result.get("status", "UNKNOWN")).strip() + if signature_request_id: + signature_state, signature_wait_events = poll_signature_request_until_not_unsigned_fn( + wallet=wallet, + signature_request_id=signature_request_id, + timeout_seconds=15 * 60, + warning_interval_seconds=10 * 60, + ) + wait_events.extend(signature_wait_events) + return { + "known_offer_markers": known_offer_markers, + "offer_request_started_at": offer_request_started_at, + "signature_request_id": signature_request_id, + "signature_state": signature_state, + "wait_events": wait_events, + "expires_at": expires_at, + "offer_amount": offer_amount, + "request_amount": request_amount, + "side": side, + } + + +def cloud_wallet_wait_offer_artifact_phase( + *, + wallet: CloudWalletAdapter, + known_markers: set[str], + offer_request_started_at: dt.datetime, + signature_request_id: str = "", + timeout_seconds: int = 15 * 60, + poll_offer_artifact_until_available_fn: collections.abc.Callable[..., str] | None = None, + poll_offer_artifact_by_signature_request_fn: collections.abc.Callable[..., str] | None = None, +) -> str: + if poll_offer_artifact_until_available_fn is None: + poll_offer_artifact_until_available_fn = poll_offer_artifact_until_available + 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 + extended_timeout = max(45, strict_timeout * 3) + 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=int(extended_timeout), + min_created_at=offer_request_started_at, + ) + except RuntimeError: + pass + try: + return poll_offer_artifact_until_available_fn( + wallet=wallet, + known_markers=known_markers, + timeout_seconds=int(extended_timeout), + min_created_at=offer_request_started_at, + require_open_state=False, + states=("OPEN", "PENDING"), + prefer_newest=True, + ) + except RuntimeError as retry_exc: + if str(retry_exc) != "cloud_wallet_offer_artifact_timeout": + raise + return poll_offer_artifact_until_available_fn( + wallet=wallet, + known_markers=known_markers, + timeout_seconds=15, + min_created_at=offer_request_started_at, + require_open_state=False, + states=None, + prefer_newest=False, + ) + + +def cloud_wallet_post_offer_phase( + *, + publish_venue: str, + dexie: DexieAdapter | None, + splash: SplashAdapter | None, + offer_text: str, + drop_only: bool, + claim_rewards: bool, + market: Any, + expected_offered_asset_id: str, + expected_offered_symbol: str, + expected_requested_asset_id: str, + expected_requested_symbol: str, + 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, +) -> 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 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)): + posted_offer_id = str(result.get("id", "")).strip() + visibility_error = verify_dexie_offer_visible_by_id_fn( + dexie=dexie, + offer_id=posted_offer_id, + expected_offered_asset_id=str(expected_offered_asset_id), + expected_offered_symbol=str(expected_offered_symbol), + expected_requested_asset_id=str(expected_requested_asset_id), + expected_requested_symbol=str(expected_requested_symbol), + ) + if visibility_error: + return { + **result, + "success": False, + "error": visibility_error, + } + return result + assert splash is not None + return splash.post_offer(offer_text) + + +def build_and_post_offer_cloud_wallet( + *, + program: Any, + market: Any, + key_id: str = "", + keyring_yaml_path: str = "", + size_base_units: int, + repeat: int, + publish_venue: str, + dexie_base_url: str, + splash_base_url: str, + drop_only: bool, + claim_rewards: bool, + quote_price: float, + dry_run: bool, + action_side: str = "sell", + offer_artifact_timeout_seconds: int = 15 * 60, + wallet_factory: collections.abc.Callable[[Any], CloudWalletAdapter] | None = None, + dexie_adapter_cls: type[DexieAdapter] = DexieAdapter, + splash_adapter_cls: type[SplashAdapter] = SplashAdapter, + initialize_manager_file_logging_fn: collections.abc.Callable[..., None] | None = None, + recent_market_resolved_asset_id_hints_fn: collections.abc.Callable[ + ..., tuple[str | None, str | None] + ] + | None = None, + resolve_cloud_wallet_offer_asset_ids_fn: collections.abc.Callable[..., tuple[str, str]] + | None = None, + resolve_maker_offer_fee_fn: collections.abc.Callable[..., tuple[int, str]] | None = None, + resolve_offer_expiry_for_market_fn: collections.abc.Callable[..., tuple[str, int]] + | None = None, + ensure_offer_bootstrap_denominations_fn: collections.abc.Callable[..., dict[str, Any]] + | None = None, + cloud_wallet_create_offer_phase_fn: collections.abc.Callable[..., dict[str, Any]] | None = None, + cloud_wallet_wait_offer_artifact_phase_fn: collections.abc.Callable[..., str] | None = None, + log_signed_offer_artifact_fn: collections.abc.Callable[..., None] | None = None, + verify_offer_text_for_dexie_fn: collections.abc.Callable[[str], str | None] | None = None, + cloud_wallet_post_offer_phase_fn: collections.abc.Callable[..., dict[str, Any]] | None = None, + dexie_offer_view_url_fn: collections.abc.Callable[..., str] | None = None, +) -> tuple[int, dict[str, Any]]: + if wallet_factory is None: + wallet_factory = new_cloud_wallet_adapter + if initialize_manager_file_logging_fn is None: + initialize_manager_file_logging_fn = initialize_manager_file_logging + if recent_market_resolved_asset_id_hints_fn is None: + recent_market_resolved_asset_id_hints_fn = recent_market_resolved_asset_id_hints + if resolve_cloud_wallet_offer_asset_ids_fn is None: + resolve_cloud_wallet_offer_asset_ids_fn = resolve_cloud_wallet_offer_asset_ids + if resolve_maker_offer_fee_fn is None: + resolve_maker_offer_fee_fn = resolve_maker_offer_fee + if resolve_offer_expiry_for_market_fn is None: + resolve_offer_expiry_for_market_fn = resolve_offer_expiry_for_market + if ensure_offer_bootstrap_denominations_fn is None: + ensure_offer_bootstrap_denominations_fn = ensure_offer_bootstrap_denominations + if cloud_wallet_create_offer_phase_fn is None: + cloud_wallet_create_offer_phase_fn = cloud_wallet_create_offer_phase + if cloud_wallet_wait_offer_artifact_phase_fn is None: + cloud_wallet_wait_offer_artifact_phase_fn = cloud_wallet_wait_offer_artifact_phase + if log_signed_offer_artifact_fn is None: + log_signed_offer_artifact_fn = log_signed_offer_artifact + if verify_offer_text_for_dexie_fn is None: + verify_offer_text_for_dexie_fn = verify_offer_text_for_dexie + if cloud_wallet_post_offer_phase_fn is None: + cloud_wallet_post_offer_phase_fn = cloud_wallet_post_offer_phase + if dexie_offer_view_url_fn is None: + dexie_offer_view_url_fn = dexie_offer_view_url + + side = normalize_offer_side(action_side) + initialize_manager_file_logging_fn( + program.home_dir, log_level=getattr(program, "app_log_level", "INFO") + ) + wallet = wallet_factory(program) + cfg_base_global = str(getattr(market, "cloud_wallet_base_global_id", "")).strip() + cfg_quote_global = str(getattr(market, "cloud_wallet_quote_global_id", "")).strip() + db_base_hint, db_quote_hint = recent_market_resolved_asset_id_hints_fn( + program_home_dir=str(program.home_dir), + market_id=str(market.market_id), + ) + base_global_hint = cfg_base_global or db_base_hint + quote_global_hint = cfg_quote_global or db_quote_hint + resolved_base_asset_id, resolved_quote_asset_id = resolve_cloud_wallet_offer_asset_ids_fn( + wallet=wallet, + base_asset_id=str(market.base_asset), + quote_asset_id=str(market.quote_asset), + base_symbol_hint=str(getattr(market, "base_symbol", "") or ""), + quote_symbol_hint=str(getattr(market, "quote_asset", "") or ""), + base_global_id_hint=base_global_hint, + quote_global_id_hint=quote_global_hint, + ) + db_path = (Path(program.home_dir).expanduser() / "db" / "greenfloor.sqlite").resolve() + store = SqliteStore(db_path) + post_results: list[dict] = [] + built_offers_preview: list[dict[str, str]] = [] + bootstrap_actions: list[dict[str, Any]] = [] + publish_failures = 0 + offer_fee_mojos, offer_fee_source = resolve_maker_offer_fee_fn(network=program.app_network) + expiry_unit, expiry_value = resolve_offer_expiry_for_market_fn(market) + dexie = ( + dexie_adapter_cls(dexie_base_url) if (not dry_run and publish_venue == "dexie") else None + ) + splash = ( + splash_adapter_cls(splash_base_url) if (not dry_run and publish_venue == "splash") else None + ) + + for _ in range(repeat): + split_input_coins_fee = 0 + if dry_run: + bootstrap_actions.append({"status": "skipped", "reason": "dry_run"}) + else: + bootstrap_result = ensure_offer_bootstrap_denominations_fn( + program=program, + market=market, + wallet=wallet, + resolved_base_asset_id=resolved_base_asset_id, + resolved_quote_asset_id=resolved_quote_asset_id, + key_id=key_id, + keyring_yaml_path=keyring_yaml_path, + quote_price=float(quote_price), + action_side=side, + ) + bootstrap_actions.append(bootstrap_result) + 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, + ) + 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"]) + expires_at = str(create_phase["expires_at"]) + offer_text = "" + try: + offer_text = cloud_wallet_wait_offer_artifact_phase_fn( + wallet=wallet, + known_markers=set(create_phase["known_offer_markers"]), + offer_request_started_at=create_phase["offer_request_started_at"], + signature_request_id=signature_request_id, + timeout_seconds=int(offer_artifact_timeout_seconds), + ) + except RuntimeError as exc: + post_results.append( + { + "venue": publish_venue, + "result": { + "success": False, + "error": str(exc), + "signature_request_id": signature_request_id, + "signature_state": signature_state, + "wait_events": wait_events, + }, + } + ) + publish_failures += 1 + continue + if not offer_text: + publish_failures += 1 + post_results.append( + { + "venue": publish_venue, + "result": { + "success": False, + "error": "cloud_wallet_offer_artifact_unavailable", + "signature_request_id": signature_request_id, + "signature_state": signature_state, + "wait_events": wait_events, + }, + } + ) + continue + + log_signed_offer_artifact_fn( + offer_text=offer_text, + ticker=str(market.base_symbol), + amount=int(size_base_units), + trading_pair=f"{market.base_symbol}:{market.quote_asset}", + expiry=str(expires_at), + ) + verify_error = verify_offer_text_for_dexie_fn(offer_text) + if verify_error: + publish_failures += 1 + post_results.append( + { + "venue": publish_venue, + "result": {"success": False, "error": verify_error}, + } + ) + continue + if dry_run: + built_offers_preview.append( + { + "offer_prefix": offer_text[:24], + "offer_length": str(len(offer_text)), + } + ) + continue + + result = cloud_wallet_post_offer_phase_fn( + publish_venue=publish_venue, + dexie=dexie, + splash=splash, + offer_text=offer_text, + drop_only=drop_only, + claim_rewards=claim_rewards, + market=market, + expected_offered_asset_id=( + str(market.quote_asset) + if str(create_phase.get("side", "sell")).strip().lower() == "buy" + else str(market.base_asset) + ), + expected_offered_symbol=( + str(getattr(market, "quote_asset", "")) + if str(create_phase.get("side", "sell")).strip().lower() == "buy" + else str(getattr(market, "base_symbol", "")) + ), + expected_requested_asset_id=( + str(market.base_asset) + if str(create_phase.get("side", "sell")).strip().lower() == "buy" + else str(market.quote_asset) + ), + expected_requested_symbol=( + str(getattr(market, "base_symbol", "")) + if str(create_phase.get("side", "sell")).strip().lower() == "buy" + else str(getattr(market, "quote_asset", "")) + ), + ) + if result.get("success") is False: + publish_failures += 1 + offer_id = str(result.get("id", "")).strip() + result_payload = { + **result, + "signature_request_id": signature_request_id, + "signature_state": signature_state, + "wait_events": wait_events, + } + if publish_venue == "dexie" and offer_id: + result_payload["offer_view_url"] = dexie_offer_view_url_fn( + dexie_base_url=dexie_base_url, + offer_id=offer_id, + ) + if offer_id and bool(result.get("success", False)): + store.upsert_offer_state( + offer_id=offer_id, + market_id=str(market.market_id), + state=OfferLifecycleState.OPEN.value, + last_seen_status=None, + ) + store.add_audit_event( + "strategy_offer_execution", + { + "market_id": str(market.market_id), + "planned_count": 1, + "executed_count": 1, + "items": [ + { + "size": int(size_base_units), + "side": side, + "status": "executed", + "reason": f"{publish_venue}_post_success", + "offer_id": offer_id, + "attempts": 1, + } + ], + "venue": publish_venue, + "signature_request_id": signature_request_id, + "signature_state": signature_state, + "resolved_base_asset_id": resolved_base_asset_id, + "resolved_quote_asset_id": resolved_quote_asset_id, + }, + market_id=str(market.market_id), + ) + post_results.append({"venue": publish_venue, "result": result_payload}) + + payload: dict[str, Any] = { + "market_id": market.market_id, + "pair": f"{market.base_asset}:{market.quote_asset}", + "resolved_base_asset_id": resolved_base_asset_id, + "resolved_quote_asset_id": resolved_quote_asset_id, + "network": program.app_network, + "size_base_units": size_base_units, + "repeat": repeat, + "publish_venue": publish_venue, + "dexie_base_url": dexie_base_url, + "splash_base_url": splash_base_url if publish_venue == "splash" else None, + "drop_only": drop_only, + "claim_rewards": claim_rewards, + "dry_run": bool(dry_run), + "publish_attempts": len(post_results), + "publish_failures": publish_failures, + "built_offers_preview": built_offers_preview, + "bootstrap_actions": bootstrap_actions, + "results": post_results, + "offer_fee_mojos": offer_fee_mojos, + "offer_fee_source": offer_fee_source, + } + print(_format_json_output(payload)) + store.close() + return (0 if publish_failures == 0 else 2), payload diff --git a/greenfloor/config/models.py b/greenfloor/config/models.py index a0b89f2..214e044 100644 --- a/greenfloor/config/models.py +++ b/greenfloor/config/models.py @@ -5,6 +5,9 @@ from greenfloor.logging_setup import normalize_log_level_name +_CANONICAL_CAT_UNIT_MOJOS = 1000 +_XCH_UNIT_SYMBOLS = frozenset({"xch", "txch", "1"}) + @dataclass(frozen=True, slots=True) class SignerKeyConfig: @@ -105,7 +108,10 @@ def _req(mapping: dict[str, Any], key: str) -> Any: return mapping[key] -def _validate_strategy_pricing(pricing: dict[str, Any], market_id: str) -> None: +def _validate_strategy_pricing( + pricing: dict[str, Any], market_id: str, quote_asset_type: str | None = None +) -> None: + _ = quote_asset_type spread_raw = pricing.get("strategy_target_spread_bps") if spread_raw is not None: try: @@ -172,6 +178,31 @@ def _validate_strategy_pricing(pricing: dict[str, Any], market_id: str) -> None: raise ValueError(f"market {market_id}: strategy_offer_expiry_value must be positive") +def _uses_cat_units(asset_id: str) -> bool: + normalized = str(asset_id).strip().lower() + return bool(normalized) and normalized not in _XCH_UNIT_SYMBOLS + + +def canonicalize_asset_unit_mojo_multiplier( + *, + asset_id: str, + raw_value: Any, + field_name: str, + market_id: str, +) -> int: + if raw_value in (None, ""): + return _CANONICAL_CAT_UNIT_MOJOS + try: + multiplier = int(raw_value) + except (TypeError, ValueError) as exc: + raise ValueError(f"market {market_id}: {field_name} must be an integer") from exc + if multiplier <= 0: + raise ValueError(f"market {market_id}: {field_name} must be positive") + if _uses_cat_units(asset_id) and multiplier != _CANONICAL_CAT_UNIT_MOJOS: + raise ValueError(f"market {market_id}: {field_name} must be 1000 for CAT assets") + return multiplier + + def parse_program_config(raw: dict[str, Any]) -> ProgramConfig: app = _req(raw, "app") runtime = _req(raw, "runtime") @@ -340,7 +371,23 @@ def parse_markets_config(raw: dict[str, Any]) -> MarketsConfig: ladders[str(side)] = side_entries market_id = str(_req(row, "id")) pricing = dict(row.get("pricing", {})) - _validate_strategy_pricing(pricing, market_id) + pricing["base_unit_mojo_multiplier"] = canonicalize_asset_unit_mojo_multiplier( + asset_id=str(_req(row, "base_asset")), + raw_value=pricing.get("base_unit_mojo_multiplier"), + field_name="base_unit_mojo_multiplier", + market_id=market_id, + ) + pricing["quote_unit_mojo_multiplier"] = canonicalize_asset_unit_mojo_multiplier( + asset_id=str(_req(row, "quote_asset")), + raw_value=pricing.get("quote_unit_mojo_multiplier"), + field_name="quote_unit_mojo_multiplier", + market_id=market_id, + ) + _validate_strategy_pricing( + pricing, + market_id, + quote_asset_type=str(row.get("quote_asset_type", "")).strip().lower(), + ) markets.append( MarketConfig( market_id=market_id, diff --git a/greenfloor/core/strategy.py b/greenfloor/core/strategy.py index 37370a8..0b11657 100644 --- a/greenfloor/core/strategy.py +++ b/greenfloor/core/strategy.py @@ -35,6 +35,7 @@ class PlannedAction: cancel_after_create: bool reason: str target_spread_bps: int | None = None + side: str = "sell" _PAIR_EXPIRY_CONFIG: dict[str, tuple[str, int]] = { @@ -81,6 +82,7 @@ def evaluate_market( PlannedAction( size=size, repeat=target - current, + side="sell", pair=pair, expiry_unit=expiry_unit, expiry_value=expiry_value, diff --git a/greenfloor/daemon/main.py b/greenfloor/daemon/main.py index 172458e..5abe278 100644 --- a/greenfloor/daemon/main.py +++ b/greenfloor/daemon/main.py @@ -29,6 +29,10 @@ from greenfloor.adapters.price import PriceAdapter 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, + resolve_cloud_wallet_offer_asset_ids, +) from greenfloor.config.io import ( default_cats_config_path, default_state_dir_path, @@ -36,7 +40,7 @@ load_program_config, resolve_quote_asset_for_offer, ) -from greenfloor.core.coin_ops import BucketSpec, plan_coin_ops +from greenfloor.core.coin_ops import BucketSpec, CoinOpPlan, plan_coin_ops from greenfloor.core.fee_budget import partition_plans_by_budget, projected_coin_ops_fee_mojos from greenfloor.core.inventory import compute_bucket_counts_from_coins from greenfloor.core.notifications import AlertState, evaluate_low_inventory_alert, utcnow @@ -88,6 +92,80 @@ class DaemonRunState: "AVAILABLE", "SETTLED", } + + +def _cloud_wallet_coin_matches_asset_scope(*, coin: dict[str, Any], scoped_asset_id: str) -> bool: + target_asset = str(scoped_asset_id).strip().lower() + if not target_asset: + return False + asset_payload = coin.get("asset") + if isinstance(asset_payload, dict): + coin_asset_id = str(asset_payload.get("id", "")).strip().lower() + if coin_asset_id: + return coin_asset_id == target_asset + # Asset-scoped Cloud Wallet coin queries can omit per-row asset metadata. + # When that happens, trust the requested scope rather than discarding rows. + return True + + +def _coin_op_min_amount_mojos(*, canonical_asset_id: str) -> int: + if str(canonical_asset_id).strip().lower() == "xch": + return 0 + return 1000 + + +def _coin_meets_coin_op_min_amount(coin: dict[str, Any], *, canonical_asset_id: str) -> bool: + try: + amount = int(coin.get("amount", 0)) + except (TypeError, ValueError): + return False + return amount >= _coin_op_min_amount_mojos(canonical_asset_id=canonical_asset_id) + + +def _coin_matches_direct_spendable_lookup( + *, + wallet: Any, + coin: dict[str, Any], + 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]) + # Temporary upstream defense: asset-scoped Cloud Wallet coin queries can + # leak cross-asset rows into CAT inventories. Re-check the exact coin + # record before coin-op selection until upstream fixes the scoped query. + try: + coin_record = get_coin_record(coin_id=coin_id) + except Exception: + result = False + else: + if not isinstance(coin_record, dict): + result = False + else: + state = str(coin_record.get("state", "")).strip().upper() + asset_payload = coin_record.get("asset") + asset_id = ( + str(asset_payload.get("id", "")).strip().lower() + if isinstance(asset_payload, dict) + else "" + ) + result = bool( + state in _CLOUD_WALLET_SPENDABLE_STATES + and not bool(coin_record.get("isLocked")) + and not bool(coin_record.get("isLinkedToOpenOffer")) + and asset_id == str(scoped_asset_id).strip().lower() + ) + if cache is not None: + cache[coin_id] = result + return result + + _DAEMON_INSTANCE_LOCK_FILENAME = "daemon.lock" @@ -257,6 +335,43 @@ def _cancel_retry_config() -> tuple[int, int, int]: return attempts, backoff_ms, cooldown_seconds +def _combine_retry_config() -> tuple[int, int]: + attempts = _env_int("GREENFLOOR_COIN_OPS_COMBINE_MAX_ATTEMPTS", 3, minimum=1) + backoff_ms = _env_int("GREENFLOOR_COIN_OPS_COMBINE_BACKOFF_MS", 1000, minimum=0) + return attempts, backoff_ms + + +def _combine_input_coin_cap() -> int: + # Keep CAT parent lookup fan-out bounded when Cloud Wallet resolves many input coins. + return _env_int("GREENFLOOR_COIN_OPS_COMBINE_INPUT_COIN_CAP", 5, minimum=2) + + +def _is_cloud_wallet_rate_limited_error(exc: Exception) -> bool: + text = str(exc).strip().lower() + return "status not ok: 429" in text or " 429" in text or text.endswith(":429") + + +def _combine_coins_with_retry( + *, + cloud_wallet: CloudWalletAdapter, + combine_kwargs: dict[str, Any], +) -> dict[str, Any]: + attempts_max, backoff_ms = _combine_retry_config() + last_exc: Exception | None = None + for attempt in range(1, attempts_max + 1): + try: + return cloud_wallet.combine_coins(**combine_kwargs) + except Exception as exc: + last_exc = exc + if attempt >= attempts_max or not _is_cloud_wallet_rate_limited_error(exc): + raise + if backoff_ms > 0: + time.sleep((backoff_ms * (2 ** (attempt - 1))) / 1000.0) + if last_exc is not None: + raise last_exc + raise RuntimeError("combine_coins_failed_without_exception") + + def _cooldown_remaining_ms(cooldowns: dict[str, float], key: str) -> int: with _COOLDOWN_LOCK: deadline = float(cooldowns.get(key, 0.0)) @@ -399,6 +514,31 @@ def _to_float(value: Any) -> float | None: ) +def _strategy_config_for_side(*, market: Any, side: str) -> StrategyConfig: + ladders = getattr(market, "ladders", {}) or {} + side_ladder = list(ladders.get(side, []) or []) if isinstance(ladders, dict) else [] + 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: + try: + expiry_value = int(expiry_value_raw) + except (TypeError, ValueError): + expiry_value = None + + 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, + ) + + def _strategy_state_from_bucket_counts( bucket_counts: dict[int, int], *, @@ -412,6 +552,38 @@ def _strategy_state_from_bucket_counts( ) +def _evaluate_two_sided_market_actions( + *, + market: Any, + counts_by_side: dict[str, dict[int, int]], + xch_price_usd: float | None, + now: datetime, +) -> list[PlannedAction]: + actions: list[PlannedAction] = [] + for side in ("buy", "sell"): + side_config = _strategy_config_for_side(market=market, side=side) + side_state = _strategy_state_from_bucket_counts( + counts_by_side.get(side, {}), + xch_price_usd=xch_price_usd, + ) + side_actions = evaluate_market(state=side_state, config=side_config, clock=now) + actions.extend( + PlannedAction( + size=int(action.size), + repeat=int(action.repeat), + pair=action.pair, + expiry_unit=action.expiry_unit, + expiry_value=int(action.expiry_value), + cancel_after_create=action.cancel_after_create, + reason=action.reason, + target_spread_bps=action.target_spread_bps, + side=side, + ) + for action in side_actions + ) + return actions + + _ACTIVE_OFFER_STATES_FOR_RESEED = { OfferLifecycleState.OPEN.value, OfferLifecycleState.REFRESH_DUE.value, @@ -487,6 +659,55 @@ def _recent_offer_sizes_by_offer_id(*, store: SqliteStore, market_id: str) -> di return size_by_offer_id +def _normalize_offer_side(value: Any) -> str: + side = str(value or "").strip().lower() + return "buy" if side == "buy" else "sell" + + +def _parse_offer_side_metadata(value: Any) -> str | None: + side = str(value or "").strip().lower() + if side in {"buy", "sell"}: + return side + return None + + +def _recent_offer_metadata_by_offer_id( + *, store: SqliteStore, market_id: str +) -> dict[str, tuple[int, str | None]]: + 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]] = {} + for event in events: + payload = event.get("payload") + if not isinstance(payload, dict): + continue + items = payload.get("items") + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + if str(item.get("status", "")).strip().lower() != "executed": + continue + offer_id = str(item.get("offer_id", "")).strip() + if not offer_id: + continue + try: + size = int(item.get("size") or 0) + except (TypeError, ValueError): + continue + if size <= 0: + continue + side = _parse_offer_side_metadata(item.get("side")) + # 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) + return metadata_by_offer_id + + def _recent_executed_offer_ids(*, store: SqliteStore, market_id: str) -> set[str]: events = store.list_recent_audit_events( event_types=["strategy_offer_execution"], @@ -621,14 +842,13 @@ def _build_dexie_size_by_offer_id( return result -def _active_offer_counts_by_size( +def _active_offer_state_summary( *, store: SqliteStore, market_id: str, clock: datetime, limit: int = 500, - dexie_size_by_offer_id: dict[str, int] | None = None, -) -> tuple[dict[int, int], dict[str, int], int]: +) -> tuple[list[str], dict[str, int], dict[str, tuple[int, str | None]]]: offer_states = store.list_offer_states(market_id=market_id, limit=limit) state_counts: dict[str, int] = {} for item in offer_states: @@ -648,11 +868,32 @@ def _active_offer_counts_by_size( active_offer_id = str(item.get("offer_id", "")).strip() if active_offer_id: active_offer_ids.append(active_offer_id) - size_by_offer_id = _recent_offer_sizes_by_offer_id(store=store, market_id=market_id) + return ( + active_offer_ids, + state_counts, + _recent_offer_metadata_by_offer_id(store=store, market_id=market_id), + ) + + +def _active_offer_counts_by_size( + *, + store: SqliteStore, + market_id: str, + clock: datetime, + limit: int = 500, + dexie_size_by_offer_id: dict[str, 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, + market_id=market_id, + clock=clock, + limit=limit, + ) active_counts_by_size: dict[int, int] = {1: 0, 10: 0, 100: 0} active_unmapped_offer_ids = 0 for offer_id in active_offer_ids: - size = size_by_offer_id.get(offer_id) + metadata = metadata_by_offer_id.get(offer_id) + size = metadata[0] 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: @@ -662,6 +903,43 @@ def _active_offer_counts_by_size( return active_counts_by_size, state_counts, active_unmapped_offer_ids +def _active_offer_counts_by_size_and_side( + *, + store: SqliteStore, + market_id: str, + clock: datetime, + limit: int = 500, + dexie_size_by_offer_id: dict[str, int] | None = None, +) -> tuple[dict[str, dict[int, int]], dict[str, int], int]: + counts_by_side: dict[str, dict[int, int]] = { + "buy": {1: 0, 10: 0, 100: 0}, + "sell": {1: 0, 10: 0, 100: 0}, + } + active_offer_ids, state_counts, metadata_by_offer_id = _active_offer_state_summary( + store=store, + market_id=market_id, + clock=clock, + limit=limit, + ) + 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 None or side is None: + # Do not assume buy/sell direction when metadata is unavailable. + active_unmapped_offer_ids += 1 + continue + if size is None and dexie_size_by_offer_id: + size = dexie_size_by_offer_id.get(offer_id) + normalized_side = _normalize_offer_side(side) + if size in counts_by_side[normalized_side]: + counts_by_side[normalized_side][size] = int(counts_by_side[normalized_side][size]) + 1 + else: + active_unmapped_offer_ids += 1 + return counts_by_side, state_counts, active_unmapped_offer_ids + + def _inject_reseed_action_if_no_active_offers( *, strategy_actions: list[PlannedAction], @@ -801,6 +1079,13 @@ def _build_offer_for_action( ) -> dict[str, Any]: from greenfloor.cli.offer_builder_sdk import build_offer_text + side = _normalize_offer_side(getattr(action, "side", "sell")) + if side == "buy": + return { + "status": "skipped", + "reason": "offer_builder_failed:buy_side_requires_cloud_wallet_path", + "offer": None, + } pricing = _market_pricing(market) try: quote_price = _resolve_quote_price_quote_per_base(market) @@ -823,6 +1108,7 @@ def _build_offer_for_action( "size_base_units": int(action.size), "pair": action.pair, "reason": action.reason, + "side": side, "xch_price_usd": xch_price_usd, "target_spread_bps": action.target_spread_bps, "expiry_unit": action.expiry_unit, @@ -873,10 +1159,21 @@ def _cloud_wallet_spendable_amounts_by_asset( wallet: CloudWalletAdapter, asset_ids: set[str], ) -> dict[str, int]: + profiles = _cloud_wallet_spendable_profiles_by_asset(wallet=wallet, asset_ids=asset_ids) + return {asset_id: int(profile.get("total", 0)) for asset_id, profile in profiles.items()} + + +def _cloud_wallet_spendable_profiles_by_asset( + *, + wallet: CloudWalletAdapter, + asset_ids: set[str], +) -> dict[str, dict[str, int]]: requested_asset_ids = {str(asset_id).strip() for asset_id in asset_ids if str(asset_id).strip()} - totals: dict[str, int] = {asset_id: 0 for asset_id in requested_asset_ids} + profiles: dict[str, dict[str, int]] = { + asset_id: {"total": 0, "max_single": 0, "coin_count": 0} for asset_id in requested_asset_ids + } if not requested_asset_ids: - return totals + return profiles # Query each requested asset directly. Some wallet backends can return # incomplete/unhelpful results for broad unfiltered inventory reads, while @@ -903,12 +1200,10 @@ def _cloud_wallet_spendable_amounts_by_asset( state = str(coin.get("state", "")).strip().upper() if state not in _CLOUD_WALLET_SPENDABLE_STATES: continue - asset_payload = coin.get("asset") - if isinstance(asset_payload, dict): - coin_asset_id = str(asset_payload.get("id", "")).strip() - else: - coin_asset_id = "" - if coin_asset_id.lower() != requested_asset_id_lower: + if not _cloud_wallet_coin_matches_asset_scope( + coin=coin, + scoped_asset_id=requested_asset_id_lower, + ): continue try: amount = int(coin.get("amount", 0)) @@ -916,8 +1211,147 @@ def _cloud_wallet_spendable_amounts_by_asset( amount = 0 if amount <= 0: continue - totals[requested_asset_id] += amount - return totals + profile = profiles[requested_asset_id] + profile["total"] += amount + profile["coin_count"] += 1 + if amount > int(profile.get("max_single", 0)): + profile["max_single"] = amount + return profiles + + +def _base_unit_mojo_multiplier_for_market(*, market: Any) -> int: + pricing = getattr(market, "pricing", {}) or {} + try: + multiplier = int(pricing.get("base_unit_mojo_multiplier", 1000)) + except (TypeError, ValueError): + multiplier = 1000 + return max(1, multiplier) + + +def _cloud_wallet_spendable_base_unit_coin_amounts( + *, + wallet: CloudWalletAdapter, + resolved_asset_id: str, + base_unit_mojo_multiplier: int, + canonical_asset_id: str, +) -> list[int]: + target_asset = str(resolved_asset_id).strip().lower() + if not target_asset: + return [] + multiplier = max(1, int(base_unit_mojo_multiplier)) + try: + coins = wallet.list_coins(asset_id=resolved_asset_id, include_pending=True) + except Exception: + return [] + amounts_base_units: list[int] = [] + direct_lookup_cache: dict[str, bool] = {} + for coin in coins: + if not isinstance(coin, dict): + continue + state = str(coin.get("state", "")).strip().upper() + if state not in _CLOUD_WALLET_SPENDABLE_STATES: + continue + if not _cloud_wallet_coin_matches_asset_scope(coin=coin, scoped_asset_id=target_asset): + continue + if not _coin_meets_coin_op_min_amount(coin, canonical_asset_id=canonical_asset_id): + continue + if not _coin_matches_direct_spendable_lookup( + wallet=wallet, + coin=coin, + scoped_asset_id=target_asset, + cache=direct_lookup_cache, + ): + continue + try: + amount_mojos = int(coin.get("amount", 0)) + except (TypeError, ValueError): + amount_mojos = 0 + if amount_mojos <= 0: + continue + amount_base_units = amount_mojos // multiplier + if amount_base_units > 0: + amounts_base_units.append(amount_base_units) + return amounts_base_units + + +def _select_spendable_coins_for_target_amount( + *, + coins: list[dict[str, Any]], + target_amount: int, +) -> tuple[list[str], int, bool]: + """Pick spendable input coins to reach target; prefer exact sum first. + + Returns (coin_ids, selected_total, exact_match). + """ + required = int(target_amount) + if required <= 0: + return [], 0, False + entries: list[tuple[str, int]] = [] + for coin in coins: + if not isinstance(coin, dict): + continue + coin_id = str(coin.get("id", "")).strip() + if not coin_id: + continue + try: + amount = int(coin.get("amount", 0)) + except (TypeError, ValueError): + amount = 0 + if amount <= 0: + continue + entries.append((coin_id, amount)) + if not entries: + return [], 0, False + + max_amount = max(amount for _, amount in entries) + cap = required + max_amount + # Guard memory on unusually large amount domains. + if cap > 500_000: + ordered = sorted(entries, key=lambda row: row[1], reverse=True) + picked_ids: list[str] = [] + running = 0 + for coin_id, amount in ordered: + picked_ids.append(coin_id) + running += amount + if running >= required: + return picked_ids, running, running == required + return [], 0, False + + best: dict[int, list[int]] = {0: []} + for idx, (_coin_id, amount) in enumerate(entries): + snapshot = list(best.items()) + for prev_sum, subset in snapshot: + next_sum = int(prev_sum) + int(amount) + if next_sum > cap: + continue + candidate = subset + [idx] + existing = best.get(next_sum) + if existing is None or len(candidate) < len(existing): + best[next_sum] = candidate + + exact_subset = best.get(required) + if exact_subset is not None and len(exact_subset) > 0: + ids = [entries[i][0] for i in exact_subset] + total = sum(entries[i][1] for i in exact_subset) + return ids, total, True + + overs = [s for s in best.keys() if s > required] + if not overs: + return [], 0, False + best_over = min( + overs, + key=lambda s: ( + int(s) - required, + len(best.get(s, [])), + int(s), + ), + ) + subset = best.get(best_over, []) + if not subset: + return [], 0, False + ids = [entries[i][0] for i in subset] + total = sum(entries[i][1] for i in subset) + return ids, total, False def _reservation_request_for_cloud_offer(*, market: Any, action: Any) -> dict[str, int]: @@ -927,6 +1361,10 @@ def _reservation_request_for_cloud_offer(*, market: Any, action: Any) -> dict[st resolved_base_asset_id=str( getattr(market, "cloud_wallet_base_global_id", "") or getattr(market, "base_asset", "") ).strip(), + resolved_quote_asset_id=str( + getattr(market, "cloud_wallet_quote_global_id", "") + or getattr(market, "quote_asset", "") + ).strip(), fee_asset_id="xch", fee_amount_mojos=0, ) @@ -937,18 +1375,31 @@ def _reservation_request_for_cloud_offer_with_assets( market: Any, action: Any, resolved_base_asset_id: str, + resolved_quote_asset_id: str, fee_asset_id: str, fee_amount_mojos: int, ) -> dict[str, int]: pricing = market.pricing or {} base_multiplier = int(pricing.get("base_unit_mojo_multiplier", 1000)) + quote_multiplier = int(pricing.get("quote_unit_mojo_multiplier", 1000)) base_asset_id = str(resolved_base_asset_id or "").strip() - if not base_asset_id: + quote_asset_id = str(resolved_quote_asset_id or "").strip() + if not base_asset_id or not quote_asset_id: return {} - offer_amount = int(action.size) * base_multiplier + side = _normalize_offer_side(getattr(action, "side", "sell")) + base_amount = int(action.size) * base_multiplier + quote_amount = int( + round( + float(action.size) + * float(_resolve_quote_price_quote_per_base(market)) + * float(quote_multiplier) + ) + ) + offer_asset_id = quote_asset_id if side == "buy" else base_asset_id + offer_amount = quote_amount if side == "buy" else base_amount if offer_amount <= 0: return {} - request: dict[str, int] = {base_asset_id: offer_amount} + request: dict[str, int] = {offer_asset_id: offer_amount} fee_asset = str(fee_asset_id or "").strip() if fee_asset and int(fee_amount_mojos) > 0: request[fee_asset] = int(request.get(fee_asset, 0)) + int(fee_amount_mojos) @@ -967,14 +1418,12 @@ def _resolve_cloud_wallet_offer_asset_ids_for_reservation( program: Any, market: Any, wallet: CloudWalletAdapter, -) -> tuple[str, str]: - from greenfloor.cli.manager import _resolve_cloud_wallet_offer_asset_ids - +) -> tuple[str, str, str]: quote_asset = _resolve_quote_asset_for_offer( quote_asset=str(getattr(market, "quote_asset", "")), network=str(getattr(program, "app_network", "mainnet")), ) - resolved_base_asset_id, _ = _resolve_cloud_wallet_offer_asset_ids( + resolved_base_asset_id, resolved_quote_asset_id = resolve_cloud_wallet_offer_asset_ids( wallet=wallet, base_asset_id=str(getattr(market, "base_asset", "")).strip(), quote_asset_id=str(quote_asset).strip(), @@ -983,7 +1432,7 @@ def _resolve_cloud_wallet_offer_asset_ids_for_reservation( base_global_id_hint=str(getattr(market, "cloud_wallet_base_global_id", "") or ""), quote_global_id_hint=str(getattr(market, "cloud_wallet_quote_global_id", "") or ""), ) - resolved_xch_asset_id, _ = _resolve_cloud_wallet_offer_asset_ids( + resolved_xch_asset_id, _ = resolve_cloud_wallet_offer_asset_ids( wallet=wallet, base_asset_id="xch", quote_asset_id=str(quote_asset).strip(), @@ -992,7 +1441,7 @@ def _resolve_cloud_wallet_offer_asset_ids_for_reservation( base_global_id_hint="", quote_global_id_hint=str(getattr(market, "cloud_wallet_quote_global_id", "") or ""), ) - return resolved_base_asset_id, resolved_xch_asset_id + return resolved_base_asset_id, resolved_quote_asset_id, resolved_xch_asset_id def _cloud_wallet_offer_post_fallback( @@ -1002,14 +1451,16 @@ def _cloud_wallet_offer_post_fallback( size_base_units: int, publish_venue: str, runtime_dry_run: bool, + side: str = "sell", build_and_post_fn: Callable[..., tuple[int, dict[str, Any]]] | None = None, ) -> dict[str, Any]: if build_and_post_fn is None: - from greenfloor.cli.manager import _build_and_post_offer_cloud_wallet - - build_and_post_fn = _build_and_post_offer_cloud_wallet + build_and_post_fn = build_and_post_offer_cloud_wallet quote_price = _resolve_quote_price_quote_per_base(market) + artifact_timeout_seconds = int( + getattr(program, "runtime_cloud_wallet_offer_artifact_timeout_seconds", 30) + ) exit_code, payload = build_and_post_fn( program=program, market=market, @@ -1022,6 +1473,8 @@ def _cloud_wallet_offer_post_fallback( claim_rewards=False, quote_price=quote_price, dry_run=runtime_dry_run, + action_side=side, + offer_artifact_timeout_seconds=artifact_timeout_seconds, ) if exit_code != 0: results = payload.get("results", []) @@ -1180,6 +1633,7 @@ def _execute_single_cloud_wallet_action( size_base_units=int(action.size), publish_venue=publish_venue, runtime_dry_run=runtime_dry_run, + side=_normalize_offer_side(getattr(action, "side", "sell")), ) if bool(cloud_wallet_post.get("success", False)): cloud_wallet_offer_id = str(cloud_wallet_post.get("offer_id", "")).strip() @@ -1191,18 +1645,21 @@ def _execute_single_cloud_wallet_action( if not visible: return { "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), "status": "skipped", "reason": (f"cloud_wallet_post_not_visible_on_dexie:{visibility_error}"), "offer_id": cloud_wallet_offer_id or None, } return { "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), "status": "executed", "reason": "cloud_wallet_post_success", "offer_id": cloud_wallet_offer_id or None, } return { "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), "status": "skipped", "reason": ( f"cloud_wallet_post_failed:{str(cloud_wallet_post.get('error', 'unknown')).strip()}" @@ -1235,6 +1692,7 @@ def _execute_single_local_action( built_reason = str(built.get("reason", "offer_builder_skipped")) return { "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), "status": "skipped", "reason": built_reason, "offer_id": None, @@ -1245,6 +1703,7 @@ def _execute_single_local_action( if remaining_ms > 0: return { "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), "status": "skipped", "reason": f"post_cooldown_active:{remaining_ms}ms", "offer_id": None, @@ -1268,6 +1727,7 @@ def _execute_single_local_action( ) return { "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), "status": "executed", "reason": f"{publish_venue}_post_success", "offer_id": offer_id, @@ -1276,6 +1736,7 @@ def _execute_single_local_action( _set_cooldown(_POST_COOLDOWN_UNTIL, cooldown_key, cooldown_seconds) return { "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), "status": "skipped", "reason": f"{publish_venue}_post_retry_exhausted:{post_error}", "offer_id": offer_id or None, @@ -1283,6 +1744,105 @@ 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: + expanded_actions.extend(action for _ in range(int(action.repeat))) + return expanded_actions + + +def _cloud_wallet_skip_item(*, action: Any, reason: str) -> dict[str, Any]: + return { + "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), + "status": "skipped", + "reason": reason, + "offer_id": None, + } + + +def _single_input_preferred_skip_reason( + *, + requested_amounts: dict[str, int], + spendable_profiles: dict[str, dict[str, int]], +) -> str | None: + # Prefer single-input offers on our side: if aggregate balance is + # sufficient but no single spendable coin can satisfy the offered + # amount, defer posting and let coin-ops combine first. + primary_request_candidates = [ + (asset_id, int(amount)) + for asset_id, amount in requested_amounts.items() + if str(asset_id).strip() and int(amount) > 0 + ] + if not primary_request_candidates: + return None + primary_asset_id, primary_needed = max( + primary_request_candidates, key=lambda pair: int(pair[1]) + ) + primary_profile = spendable_profiles.get(str(primary_asset_id), {}) + primary_total = int(primary_profile.get("total", 0)) + primary_max = int(primary_profile.get("max_single", 0)) + if primary_total >= primary_needed and primary_max < primary_needed: + return ( + "single_input_preferred_requires_combine" + f":asset_id={primary_asset_id}" + f":needed={primary_needed}" + f":max_single={primary_max}" + f":available={primary_total}" + ) + return None + + +def _prepare_parallel_cloud_wallet_submission( + *, + program: Any, + market: Any, + action: Any, + cloud_wallet: CloudWalletAdapter, + resolved_base_asset_id: str, + resolved_quote_asset_id: str, + resolved_xch_asset_id: str, + fee_amount_mojos: int, + reservation_coordinator: AssetReservationCoordinator, +) -> tuple[str | None, dict[str, Any] | None]: + requested_amounts = _reservation_request_for_cloud_offer_with_assets( + market=market, + action=action, + resolved_base_asset_id=resolved_base_asset_id, + resolved_quote_asset_id=resolved_quote_asset_id, + fee_asset_id=resolved_xch_asset_id, + fee_amount_mojos=fee_amount_mojos, + ) + if not requested_amounts: + return 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()), + ) + available_amounts = { + asset_id: int(profile.get("total", 0)) for asset_id, profile in spendable_profiles.items() + } + single_input_skip_reason = _single_input_preferred_skip_reason( + requested_amounts=requested_amounts, + 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 + + def _execute_strategy_actions( *, market, @@ -1302,10 +1862,7 @@ def _execute_strategy_actions( executed_count = 0 signer_key = (signer_key_registry or {}).get(market.signer_key_id) keyring_yaml_path = str(getattr(signer_key, "keyring_yaml_path", "") or "") - # Prioritize larger ladder sizes first to reduce input-coin contention in - # cloud-wallet sequential posting (e.g. keep 100-size offers from being - # displaced by a burst of 1-size posts in the same cycle). - ordered_actions = sorted(strategy_actions, key=lambda action: int(action.size), reverse=True) + expanded_actions = _expand_strategy_actions(strategy_actions) can_parallelize_cloud_offers = ( program is not None and _cloud_wallet_configured(program) @@ -1318,7 +1875,7 @@ def _execute_strategy_actions( assert program is not None assert reservation_coordinator is not None cloud_wallet = _new_cloud_wallet_adapter_for_daemon(program) - resolved_base_asset_id, resolved_xch_asset_id = ( + resolved_base_asset_id, resolved_quote_asset_id, resolved_xch_asset_id = ( _resolve_cloud_wallet_offer_asset_ids_for_reservation( program=program, market=market, @@ -1327,48 +1884,23 @@ def _execute_strategy_actions( ) fee_amount_mojos = _estimate_cloud_offer_fee_reservation_mojos(program=program) submissions: list[tuple[int, Any, str]] = [] - next_index = 0 - for action in ordered_actions: - for _ in range(int(action.repeat)): - requested_amounts = _reservation_request_for_cloud_offer_with_assets( - market=market, - action=action, - resolved_base_asset_id=resolved_base_asset_id, - fee_asset_id=resolved_xch_asset_id, - fee_amount_mojos=fee_amount_mojos, - ) - if not requested_amounts: - items.append( - { - "size": action.size, - "status": "skipped", - "reason": "reservation_invalid_request", - "offer_id": None, - } - ) - continue - available_amounts = _cloud_wallet_spendable_amounts_by_asset( - wallet=cloud_wallet, - asset_ids=set(requested_amounts.keys()), - ) - 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: - items.append( - { - "size": action.size, - "status": "skipped", - "reason": str(acquired.error or "reservation_rejected"), - "offer_id": None, - } - ) - continue - submissions.append((next_index, action, acquired.reservation_id)) - next_index += 1 + 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, + ) + if skip_item is not None: + items.append(skip_item) + continue + assert reservation_id is not None + submissions.append((submit_index, action, reservation_id)) if submissions: max_workers = min( @@ -1398,6 +1930,7 @@ def _execute_strategy_actions( except Exception as exc: item = { "size": 0, + "side": "sell", "status": "skipped", "reason": f"parallel_offer_worker_error:{exc}", "offer_id": None, @@ -1417,7 +1950,7 @@ def _execute_strategy_actions( executed_count += 1 items.append(item) return { - "planned_count": sum(int(a.repeat) for a in strategy_actions), + "planned_count": len(expanded_actions), "executed_count": executed_count, "items": items, } @@ -1433,44 +1966,44 @@ def _execute_strategy_actions( ) can_parallelize_cloud_offers = False if not can_parallelize_cloud_offers: - for action in ordered_actions: - for _ in range(int(action.repeat)): - if runtime_dry_run: - items.append( - { - "size": action.size, - "status": "planned", - "reason": "dry_run", - "offer_id": None, - } - ) - continue - if program is not None and _cloud_wallet_configured(program): - item = _execute_single_cloud_wallet_action( - program=program, - market=market, - action=action, - publish_venue=publish_venue, - runtime_dry_run=runtime_dry_run, - dexie=dexie, - ) - else: - item = _execute_single_local_action( - market=market, - action=action, - xch_price_usd=xch_price_usd, - app_network=app_network, - keyring_yaml_path=keyring_yaml_path, - dexie=dexie, - splash=splash, - publish_venue=publish_venue, - store=store, - ) - if item.get("status") == "executed": - executed_count += 1 - items.append(item) + for action in expanded_actions: + if runtime_dry_run: + items.append( + { + "size": action.size, + "side": _normalize_offer_side(getattr(action, "side", "sell")), + "status": "planned", + "reason": "dry_run", + "offer_id": None, + } + ) + continue + if program is not None and _cloud_wallet_configured(program): + item = _execute_single_cloud_wallet_action( + program=program, + market=market, + action=action, + publish_venue=publish_venue, + runtime_dry_run=runtime_dry_run, + dexie=dexie, + ) + else: + item = _execute_single_local_action( + market=market, + action=action, + xch_price_usd=xch_price_usd, + app_network=app_network, + keyring_yaml_path=keyring_yaml_path, + dexie=dexie, + splash=splash, + publish_venue=publish_venue, + store=store, + ) + if item.get("status") == "executed": + executed_count += 1 + items.append(item) return { - "planned_count": sum(int(a.repeat) for a in strategy_actions), + "planned_count": len(expanded_actions), "executed_count": executed_count, "items": items, } @@ -1668,14 +2201,36 @@ def _reconcile_offer_states( dexie_offer_ids_in_list = {str(o.get("id", "")).strip() for o in offers if o.get("id")} beyond_cap_ids = our_offer_ids - dexie_offer_ids_in_list augmented_offers = list(offers) + augmented_by_id: dict[str, dict[str, Any]] = {} + for offer in augmented_offers: + if not isinstance(offer, dict): + continue + offer_id = str(offer.get("id", "")).strip() + if not offer_id: + continue + augmented_by_id[offer_id] = offer + + # 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. + 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 + continue + for beyond_offer_id in beyond_cap_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 if isinstance(single_offer, dict): - augmented_offers.append(single_offer) + augmented_by_id[beyond_offer_id] = single_offer except Exception: # pragma: no cover - network dependent pass + augmented_offers = list(augmented_by_id.values()) dexie_size_by_offer_id: dict[str, int] = _build_dexie_size_by_offer_id( augmented_offers, str(market.base_asset) ) @@ -1772,47 +2327,78 @@ def _evaluate_and_execute_strategy( reservation_coordinator: AssetReservationCoordinator | None = None, ) -> None: """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) - active_offer_counts_by_size, offer_state_counts, active_unmapped_offer_ids = ( - _active_offer_counts_by_size( - store=store, - market_id=market.market_id, - clock=now, - dexie_size_by_offer_id=dexie_size_by_offer_id, + if market_mode == "two_sided": + offer_counts_by_side, offer_state_counts, active_unmapped_offer_ids = ( + _active_offer_counts_by_size_and_side( + store=store, + market_id=market.market_id, + clock=now, + dexie_size_by_offer_id=dexie_size_by_offer_id, + ) ) - ) + 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]), + } + else: + active_offer_counts_by_size, offer_state_counts, active_unmapped_offer_ids = ( + _active_offer_counts_by_size( + store=store, + market_id=market.market_id, + clock=now, + dexie_size_by_offer_id=dexie_size_by_offer_id, + ) + ) + offer_counts_by_side = { + "buy": {1: 0, 10: 0, 100: 0}, + "sell": dict(active_offer_counts_by_size), + } _log_market_decision( market.market_id, "strategy_state_source", source="dexie_offer_coverage", active_offer_counts_by_size=active_offer_counts_by_size, + active_offer_counts_by_side=offer_counts_by_side, state_counts=offer_state_counts, active_unmapped_offer_ids=active_unmapped_offer_ids, ) - strategy_actions = evaluate_market( - state=_strategy_state_from_bucket_counts( - active_offer_counts_by_size, xch_price_usd=xch_price_usd - ), - config=strategy_config, - clock=now, - ) + if market_mode == "two_sided": + strategy_actions = _evaluate_two_sided_market_actions( + market=market, + counts_by_side=offer_counts_by_side, + xch_price_usd=xch_price_usd, + now=now, + ) + else: + strategy_actions = evaluate_market( + state=_strategy_state_from_bucket_counts( + active_offer_counts_by_size, xch_price_usd=xch_price_usd + ), + config=strategy_config, + clock=now, + ) _log_market_decision( market.market_id, "strategy_evaluated", pair=strategy_config.pair, + mode=market_mode or "sell_only", offer_counts=active_offer_counts_by_size, xch_price_usd=xch_price_usd, action_count=len(strategy_actions), ) - strategy_actions = _inject_reseed_action_if_no_active_offers( - strategy_actions=strategy_actions, - strategy_config=strategy_config, - market=market, - store=store, - xch_price_usd=xch_price_usd, - clock=now, - dexie_size_by_offer_id=dexie_size_by_offer_id, - ) + if market_mode != "two_sided": + strategy_actions = _inject_reseed_action_if_no_active_offers( + strategy_actions=strategy_actions, + strategy_config=strategy_config, + market=market, + store=store, + xch_price_usd=xch_price_usd, + clock=now, + dexie_size_by_offer_id=dexie_size_by_offer_id, + ) _log_market_decision( market.market_id, "strategy_after_reseed", @@ -1836,6 +2422,7 @@ def _evaluate_and_execute_strategy( "cancel_after_create": action.cancel_after_create, "reason": action.reason, "target_spread_bps": action.target_spread_bps, + "side": _normalize_offer_side(getattr(action, "side", "sell")), } for action in strategy_actions ], @@ -1929,16 +2516,13 @@ def _plan_and_execute_coin_ops( max_daily_fee_budget_mojos=program.coin_ops_max_daily_fee_budget_mojos, ) if executable_plans: - execution = wallet.execute_coin_ops( + execution = _execute_coin_ops_cloud_wallet_kms_only( + market=market, + program=program, plans=executable_plans, - dry_run=program.runtime_dry_run, - key_id=signer_selection.key_id, - network=program.app_network, - market_id=market.market_id, - asset_id=market.base_asset, - receive_address=market.receive_address, - onboarding_selection_path=state_dir / "key_onboarding.json", - signer_fingerprint=signer_selection.fingerprint, + wallet=wallet, + signer_selection=signer_selection, + state_dir=state_dir, ) _log_market_decision( market.market_id, @@ -2065,6 +2649,444 @@ def _plan_and_execute_coin_ops( _log_market_decision(market.market_id, "coin_ops_no_plans") +def _execute_coin_ops_cloud_wallet_kms_only( + *, + market: Any, + program: Any, + plans: list[CoinOpPlan], + wallet: WalletAdapter, + signer_selection: Any, + state_dir: Path, +) -> dict[str, Any]: + _ = wallet, state_dir + if not _cloud_wallet_configured(program): + return { + "dry_run": bool(program.runtime_dry_run), + "planned_count": len(plans), + "executed_count": 0, + "status": "skipped", + "signer_selection": None, + "items": [ + { + "op_type": plan.op_type, + "size_base_units": plan.size_base_units, + "op_count": plan.op_count, + "status": "skipped", + "reason": "cloud_wallet_required_for_coin_ops", + "operation_id": None, + } + for plan in plans + ], + } + + if not str(getattr(program, "cloud_wallet_kms_key_id", "")).strip(): + return { + "dry_run": bool(program.runtime_dry_run), + "planned_count": len(plans), + "executed_count": 0, + "status": "skipped", + "signer_selection": None, + "items": [ + { + "op_type": plan.op_type, + "size_base_units": plan.size_base_units, + "op_count": plan.op_count, + "status": "skipped", + "reason": "cloud_wallet_kms_required_for_coin_ops", + "operation_id": None, + } + for plan in plans + ], + } + + cloud_wallet = _new_cloud_wallet_adapter_for_daemon(program) + resolved_base_asset_id, _, _ = _resolve_cloud_wallet_offer_asset_ids_for_reservation( + program=program, + market=market, + wallet=cloud_wallet, + ) + + base_unit_mojo_multiplier = _base_unit_mojo_multiplier_for_market(market=market) + items: list[dict[str, Any]] = [] + executed_count = 0 + combine_input_cap = _combine_input_coin_cap() + direct_coin_lookup_cache: dict[str, bool] = {} + + def _spendable_asset_scoped_coins(coins: list[dict[str, Any]]) -> list[dict[str, Any]]: + scoped: list[dict[str, Any]] = [] + target_asset = str(resolved_base_asset_id).strip().lower() + canonical_asset_id = str(getattr(market, "base_asset", "")).strip() + for coin in coins: + if not isinstance(coin, dict): + continue + coin_id = str(coin.get("id", "")).strip() + if not coin_id: + continue + state = str(coin.get("state", "")).strip().upper() + if state not in _CLOUD_WALLET_SPENDABLE_STATES: + continue + if not _cloud_wallet_coin_matches_asset_scope(coin=coin, scoped_asset_id=target_asset): + continue + if not _coin_meets_coin_op_min_amount(coin, canonical_asset_id=canonical_asset_id): + continue + if not _coin_matches_direct_spendable_lookup( + wallet=cloud_wallet, + coin=coin, + scoped_asset_id=target_asset, + cache=direct_coin_lookup_cache, + ): + continue + scoped.append(coin) + return scoped + + for plan in plans: + op_type = str(plan.op_type) + op_count = int(plan.op_count) + size_base_units = int(plan.size_base_units) + if op_count <= 0 or size_base_units <= 0: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "invalid_plan", + "operation_id": None, + } + ) + continue + if bool(program.runtime_dry_run): + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "planned", + "reason": "dry_run:cloud_wallet_kms", + "operation_id": None, + } + ) + continue + + try: + if op_type == "split": + amount_per_coin_mojos = size_base_units * base_unit_mojo_multiplier + required_amount = amount_per_coin_mojos * op_count + coins = cloud_wallet.list_coins( + asset_id=resolved_base_asset_id, include_pending=True + ) + spendable = _spendable_asset_scoped_coins(coins) + if not spendable: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "no_spendable_split_coin_available", + "operation_id": None, + } + ) + continue + attempted_coin_ids: set[str] = set() + split_submitted = False + for attempt_index in range(2): + # Re-read before each attempt to avoid selecting stale now-locked coins. + fresh = cloud_wallet.list_coins( + asset_id=resolved_base_asset_id, include_pending=True + ) + candidate_spendable = [ + coin + for coin in _spendable_asset_scoped_coins(fresh) + if str(coin.get("id", "")).strip() not in attempted_coin_ids + ] + fresh_spendable = [ + coin + for coin in candidate_spendable + if int(coin.get("amount", 0)) >= required_amount + ] + if not fresh_spendable: + aggregate_amount = sum( + int(coin.get("amount", 0)) for coin in candidate_spendable + ) + if attempt_index == 0 and aggregate_amount >= required_amount: + combine_coin_ids, combine_total, exact_match = ( + _select_spendable_coins_for_target_amount( + coins=candidate_spendable, + target_amount=required_amount, + ) + ) + if len(combine_coin_ids) >= 2: + amount_by_coin_id = { + str(coin.get("id", "")).strip(): int(coin.get("amount", 0)) + for coin in candidate_spendable + } + combine_input_coin_ids = list(combine_coin_ids[:combine_input_cap]) + combine_cap_applied = len(combine_input_coin_ids) < len( + combine_coin_ids + ) + combine_selected_total = sum( + amount_by_coin_id.get(coin_id, 0) + for coin_id in combine_input_coin_ids + ) + combine_exact_match = combine_selected_total == required_amount + combine_target_amount = ( + required_amount + if combine_selected_total >= required_amount + else combine_selected_total + ) + if combine_cap_applied and combine_selected_total < required_amount: + _daemon_logger.info( + "coin_ops_combine_cap_progress " + "market_id=%s required_amount=%s selected_total=%s " + "selected_before_cap=%s selected_after_cap=%s input_coin_cap=%s " + "note=%s", + str(getattr(market, "market_id", "")).strip() or "unknown", + int(required_amount), + int(combine_selected_total), + int(len(combine_coin_ids)), + int(len(combine_input_coin_ids)), + int(combine_input_cap), + "submitted capped progress combine; next cycle likely needs only 2-coin combine", + ) + try: + combine_result = _combine_coins_with_retry( + cloud_wallet=cloud_wallet, + combine_kwargs={ + "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, + "target_amount": combine_target_amount, + }, + ) + except Exception as exc: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": ( + f"cloud_wallet_coin_op_error:{exc}" + ":combine_for_split_prereq" + ), + "operation_id": None, + } + ) + split_submitted = True + break + combine_sig_id = str( + combine_result.get("signature_request_id", "") + ).strip() + if not combine_sig_id: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "combine_missing_signature_request_id_for_split_prereq", + "operation_id": None, + } + ) + split_submitted = True + break + items.append( + { + "op_type": "combine", + "size_base_units": size_base_units, + "op_count": len(combine_input_coin_ids), + "status": "executed", + "reason": ( + "cloud_wallet_kms_combine_submitted_for_split_prereq_exact" + if combine_exact_match + else "cloud_wallet_kms_combine_submitted_for_split_prereq_with_change" + ), + "operation_id": combine_sig_id, + "data": { + "target_amount": required_amount, + "selected_total": int(combine_selected_total), + "exact_match": bool(combine_exact_match), + "input_coin_cap_applied": bool(combine_cap_applied), + "input_coin_cap": int(combine_input_cap), + "selected_coin_count_before_cap": len(combine_coin_ids), + "selected_coin_count_after_cap": len( + combine_input_coin_ids + ), + "next_step_note": ( + "submitted capped progress combine; next cycle likely needs " + "only 2-coin combine" + if combine_cap_applied + and combine_selected_total < required_amount + else "" + ), + }, + } + ) + executed_count += 1 + split_submitted = True + break + break + selected_coin = max(fresh_spendable, key=lambda c: int(c.get("amount", 0))) + selected_coin_id = str(selected_coin.get("id", "")).strip() + if not selected_coin_id: + break + attempted_coin_ids.add(selected_coin_id) + try: + result = cloud_wallet.split_coins( + coin_ids=[selected_coin_id], + amount_per_coin=amount_per_coin_mojos, + number_of_coins=op_count, + fee=int(program.coin_ops_split_fee_mojos), + ) + except Exception as exc: + error_text = str(exc) + if ( + "Some selected coins are not spendable" in error_text + and attempt_index == 0 + ): + continue + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": ( + f"cloud_wallet_coin_op_error:{exc}" + f":selected_coin_id={selected_coin_id}" + ), + "operation_id": None, + } + ) + split_submitted = True + break + + signature_request_id = str(result.get("signature_request_id", "")).strip() + if not signature_request_id: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "split_missing_signature_request_id", + "operation_id": None, + } + ) + split_submitted = True + break + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "executed", + "reason": "cloud_wallet_kms_split_submitted", + "operation_id": signature_request_id, + } + ) + executed_count += 1 + split_submitted = True + break + + if not split_submitted: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "no_spendable_split_coin_meets_required_amount", + "operation_id": None, + } + ) + continue + + if op_type == "combine": + requested_number_of_coins = max(2, op_count) + capped_number_of_coins = min(requested_number_of_coins, combine_input_cap) + result = _combine_coins_with_retry( + cloud_wallet=cloud_wallet, + combine_kwargs={ + "number_of_coins": capped_number_of_coins, + "fee": int(program.coin_ops_combine_fee_mojos), + "asset_id": resolved_base_asset_id, + "largest_first": True, + }, + ) + signature_request_id = str(result.get("signature_request_id", "")).strip() + if not signature_request_id: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "combine_missing_signature_request_id", + "operation_id": None, + } + ) + continue + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "executed", + "reason": "cloud_wallet_kms_combine_submitted", + "operation_id": signature_request_id, + "data": { + "requested_number_of_coins": int(requested_number_of_coins), + "submitted_number_of_coins": int(capped_number_of_coins), + "input_coin_cap_applied": bool( + capped_number_of_coins < requested_number_of_coins + ), + "input_coin_cap": int(combine_input_cap), + }, + } + ) + executed_count += 1 + continue + + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": "invalid_plan", + "operation_id": None, + } + ) + except Exception as exc: + items.append( + { + "op_type": op_type, + "size_base_units": size_base_units, + "op_count": op_count, + "status": "skipped", + "reason": f"cloud_wallet_coin_op_error:{exc}", + "operation_id": None, + } + ) + + return { + "dry_run": bool(program.runtime_dry_run), + "planned_count": len(plans), + "executed_count": executed_count, + "status": "cloud_wallet_kms", + "signer_selection": { + "selected_source": "signer_registry", + "key_id": str(getattr(signer_selection, "key_id", "")).strip(), + "network": str(getattr(program, "app_network", "")).strip(), + }, + "items": items, + } + + def _process_single_market( *, market: Any, @@ -2188,51 +3210,100 @@ def _process_single_market( sell_ladder = market.ladders.get("sell", []) ladder_sizes = [e.size_base_units for e in sell_ladder] - wallet_coins = wallet.list_asset_coins_base_units( - asset_id=market.base_asset, - key_id=market.signer_key_id, - receive_address=market.receive_address, - network=program.app_network, - ) - if wallet_coins: - bucket_counts = compute_bucket_counts_from_coins( - coin_amounts_base_units=wallet_coins, - ladder_sizes=ladder_sizes, - ) - _log_market_decision( - market.market_id, - "inventory_scan_wallet", - coin_count=len(wallet_coins), - bucket_counts=bucket_counts, - ) - store.add_audit_event( - "inventory_bucket_scan", - { - "market_id": market.market_id, - "source": "wallet_adapter", - "bucket_counts": bucket_counts, - "coin_count": len(wallet_coins), - }, - market_id=market.market_id, - ) - else: - bucket_counts = dict(market.inventory.bucket_counts) - _log_market_decision( - market.market_id, - "inventory_scan_config_fallback", + bucket_counts: dict[int, int] | None = None + wallet_coins: list[int] = [] + + if _cloud_wallet_configured(program): + try: + cloud_wallet = _new_cloud_wallet_adapter_for_daemon(program) + resolved_base_asset_id, _, _ = _resolve_cloud_wallet_offer_asset_ids_for_reservation( + program=program, + market=market, + wallet=cloud_wallet, + ) + wallet_coins = _cloud_wallet_spendable_base_unit_coin_amounts( + wallet=cloud_wallet, + resolved_asset_id=resolved_base_asset_id, + base_unit_mojo_multiplier=_base_unit_mojo_multiplier_for_market(market=market), + canonical_asset_id=str(market.base_asset), + ) + bucket_counts = compute_bucket_counts_from_coins( + coin_amounts_base_units=wallet_coins, + ladder_sizes=ladder_sizes, + ) + _log_market_decision( + market.market_id, + "inventory_scan_wallet", + source="cloud_wallet", + resolved_asset_id=resolved_base_asset_id, + coin_count=len(wallet_coins), + bucket_counts=bucket_counts, + ) + store.add_audit_event( + "inventory_bucket_scan", + { + "market_id": market.market_id, + "source": "cloud_wallet", + "resolved_asset_id": resolved_base_asset_id, + "bucket_counts": bucket_counts, + "coin_count": len(wallet_coins), + }, + market_id=market.market_id, + ) + except Exception as exc: + _daemon_logger.warning( + "cloud_wallet_inventory_scan_failed market_id=%s error=%s", + market.market_id, + exc, + ) + + if bucket_counts is None: + wallet_coins = wallet.list_asset_coins_base_units( asset_id=market.base_asset, - bucket_counts=bucket_counts, - ) - store.add_audit_event( - "inventory_bucket_scan", - { - "market_id": market.market_id, - "source": "config_seed_or_no_asset_scan", - "asset_id": market.base_asset, - "bucket_counts": bucket_counts, - }, - market_id=market.market_id, + key_id=market.signer_key_id, + receive_address=market.receive_address, + network=program.app_network, ) + if wallet_coins: + bucket_counts = compute_bucket_counts_from_coins( + coin_amounts_base_units=wallet_coins, + ladder_sizes=ladder_sizes, + ) + _log_market_decision( + market.market_id, + "inventory_scan_wallet", + source="wallet_adapter", + coin_count=len(wallet_coins), + bucket_counts=bucket_counts, + ) + store.add_audit_event( + "inventory_bucket_scan", + { + "market_id": market.market_id, + "source": "wallet_adapter", + "bucket_counts": bucket_counts, + "coin_count": len(wallet_coins), + }, + market_id=market.market_id, + ) + else: + bucket_counts = dict(market.inventory.bucket_counts) + _log_market_decision( + market.market_id, + "inventory_scan_config_fallback", + asset_id=market.base_asset, + bucket_counts=bucket_counts, + ) + store.add_audit_event( + "inventory_bucket_scan", + { + "market_id": market.market_id, + "source": "config_seed_or_no_asset_scan", + "asset_id": market.base_asset, + "bucket_counts": bucket_counts, + }, + market_id=market.market_id, + ) _evaluate_and_execute_strategy( market=market, program=program, diff --git a/tests/test_cloud_wallet_adapter.py b/tests/test_cloud_wallet_adapter.py index f77a9b0..a9646ee 100644 --- a/tests/test_cloud_wallet_adapter.py +++ b/tests/test_cloud_wallet_adapter.py @@ -138,6 +138,50 @@ def _fake_urlopen(_req, timeout=0): assert calls["n"] == 1 +def test_cloud_wallet_list_coins_omits_row_asset_for_asset_scoped_queries( + monkeypatch, tmp_path: Path +) -> None: + adapter = _build_adapter(tmp_path) + queries: list[str] = [] + + def _fake_graphql(*, query, variables): + queries.append(query) + assert variables["assetId"] == "Asset_byc" + return { + "coins": { + "pageInfo": {"hasNextPage": False, "endCursor": ""}, + "edges": [{"node": {"id": "Coin_1", "name": "11", "amount": 10}}], + } + } + + monkeypatch.setattr(adapter, "_graphql", _fake_graphql) + coins = adapter.list_coins(asset_id="Asset_byc") + assert len(coins) == 1 + assert "asset {" not in queries[0] + + +def test_cloud_wallet_list_coins_keeps_row_asset_for_unscoped_queries( + monkeypatch, tmp_path: Path +) -> None: + adapter = _build_adapter(tmp_path) + queries: list[str] = [] + + def _fake_graphql(*, query, variables): + queries.append(query) + assert variables["assetId"] is None + return { + "coins": { + "pageInfo": {"hasNextPage": False, "endCursor": ""}, + "edges": [{"node": {"id": "Coin_1", "name": "11", "amount": 10}}], + } + } + + monkeypatch.setattr(adapter, "_graphql", _fake_graphql) + coins = adapter.list_coins() + assert len(coins) == 1 + assert "asset {" in queries[0] + + def test_cloud_wallet_graphql_http_error_contains_status_and_snippet( monkeypatch, tmp_path: Path ) -> None: @@ -198,6 +242,70 @@ def _fake_urlopen(_req, timeout=0): adapter._graphql(query="query test {}", variables={}) +def test_cloud_wallet_graphql_retries_http_429_with_backoff(monkeypatch, tmp_path: Path) -> None: + adapter = _build_adapter(tmp_path) + monkeypatch.setattr(adapter, "_build_auth_headers", lambda _body: {}) + calls = {"n": 0} + sleeps: list[float] = [] + + def _fake_sleep(seconds: float) -> None: + sleeps.append(float(seconds)) + + def _fake_urlopen(req, timeout=0): + _ = timeout + calls["n"] += 1 + if calls["n"] == 1: + headers = Message() + headers["Retry-After"] = "3" + raise urllib.error.HTTPError( + req.full_url, + 429, + "too many requests", + headers, + io.BytesIO(b'{"error":"rate limited"}'), + ) + if calls["n"] == 2: + raise urllib.error.HTTPError( + req.full_url, + 429, + "too many requests", + Message(), + io.BytesIO(b'{"error":"rate limited"}'), + ) + return _FakeHttpResponse({"data": {"ok": True}}) + + monkeypatch.setattr("time.sleep", _fake_sleep) + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + payload = adapter._graphql(query="query test {}", variables={}) + assert payload == {"ok": True} + assert calls["n"] == 3 + assert sleeps == [3.0, 2.0] + + +def test_cloud_wallet_graphql_retries_rate_limit_error_payload(monkeypatch, tmp_path: Path) -> None: + adapter = _build_adapter(tmp_path) + monkeypatch.setattr(adapter, "_build_auth_headers", lambda _body: {}) + sleeps: list[float] = [] + responses = [ + {"errors": [{"message": "Rate limit exceeded, please try again in 2 seconds"}]}, + {"errors": [{"message": "Rate limit exceeded, please try again in 2 seconds"}]}, + {"data": {"ok": True}}, + ] + + def _fake_sleep(seconds: float) -> None: + sleeps.append(float(seconds)) + + def _fake_urlopen(_req, timeout=0): + _ = timeout + return _FakeHttpResponse(responses.pop(0)) + + monkeypatch.setattr("time.sleep", _fake_sleep) + monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) + payload = adapter._graphql(query="query test {}", variables={}) + assert payload == {"ok": True} + assert sleeps == [2.0, 2.0] + + def test_cloud_wallet_get_signature_request_handles_non_dict(monkeypatch, tmp_path: Path) -> None: adapter = _build_adapter(tmp_path) monkeypatch.setattr(adapter, "_build_auth_headers", lambda _body: {}) diff --git a/tests/test_cloud_wallet_offer_runtime.py b/tests/test_cloud_wallet_offer_runtime.py new file mode 100644 index 0000000..6743749 --- /dev/null +++ b/tests/test_cloud_wallet_offer_runtime.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from pathlib import Path +from typing import cast + +from greenfloor.adapters.cloud_wallet import CloudWalletAdapter +from greenfloor.cloud_wallet_offer_runtime import ( + build_and_post_offer_cloud_wallet, + resolve_cloud_wallet_offer_asset_ids, +) + + +def test_resolve_cloud_wallet_offer_asset_ids_maps_distinct_cat_assets(monkeypatch) -> None: + base_cat = "4a168910b533e6bb9ddf82a776f8d6248308abd3d56b6f4423a3e1de88f466e7" + quote_cat = "fa4a180ac326e67ea289b869e3448256f6af05721f7cf934cb9901baa6b7a99d" + + class _FakeWallet: + vault_id = "wallet-1" + network = "mainnet" + + @staticmethod + def _graphql(*, query: str, variables: dict): + _ = query, variables + return { + "wallet": { + "assets": { + "edges": [ + { + "node": { + "assetId": "Asset_carbon", + "type": "CAT2", + "displayName": "ECO.181.2022", + "symbol": "", + } + }, + { + "node": { + "assetId": "Asset_wusdc", + "type": "CAT2", + "displayName": "Base Warped USDC", + "symbol": "", + } + }, + ] + } + } + } + + def _fake_lookup_by_cat(*, canonical_cat_id_hex: str, network: str): + _ = network + if canonical_cat_id_hex == base_cat: + return {"ticker_id": f"{base_cat}_xch", "base_code": "ECO.181.2022"} + if canonical_cat_id_hex == quote_cat: + return {"id": quote_cat, "code": "wUSDC.b", "name": "Base warp.green USDC"} + return None + + monkeypatch.setattr( + "greenfloor.cloud_wallet_offer_runtime._dexie_lookup_token_for_cat_id", + _fake_lookup_by_cat, + ) + 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 = resolve_cloud_wallet_offer_asset_ids( + wallet=cast(CloudWalletAdapter, _FakeWallet()), + base_asset_id=base_cat, + quote_asset_id="wUSDC.b", + base_symbol_hint="ECO.181.2022", + quote_symbol_hint="wUSDC.b", + ) + assert base_asset == "Asset_carbon" + assert quote_asset == "Asset_wusdc" + assert base_asset != quote_asset + + +def test_resolve_cloud_wallet_offer_asset_ids_uses_global_hints_without_label_match( + monkeypatch, +) -> None: + base_cat = "4a168910b533e6bb9ddf82a776f8d6248308abd3d56b6f4423a3e1de88f466e7" + quote_cat = "fa4a180ac326e67ea289b869e3448256f6af05721f7cf934cb9901baa6b7a99d" + + class _FakeWallet: + vault_id = "wallet-1" + network = "mainnet" + + @staticmethod + def _graphql(*, query: str, variables: dict): + _ = query, variables + return { + "wallet": { + "assets": { + "edges": [ + { + "node": { + "assetId": "Asset_carbon", + "type": "CAT2", + "displayName": "Legacy Carbon Label", + "symbol": "", + } + }, + { + "node": { + "assetId": "Asset_wusdc", + "type": "CAT2", + "displayName": "USD Coin", + "symbol": "", + } + }, + ] + } + } + } + + monkeypatch.setattr( + "greenfloor.cloud_wallet_offer_runtime._dexie_lookup_token_for_cat_id", + lambda **_: None, + ) + monkeypatch.setattr( + "greenfloor.cloud_wallet_offer_runtime._local_catalog_label_hints_for_asset_id", + lambda **_: [], + ) + + resolved_base, resolved_quote = resolve_cloud_wallet_offer_asset_ids( + wallet=cast(CloudWalletAdapter, _FakeWallet()), + base_asset_id=base_cat, + quote_asset_id=quote_cat, + base_symbol_hint="ECO.181.2022", + quote_symbol_hint="wUSDC.b", + base_global_id_hint="Asset_carbon", + quote_global_id_hint="Asset_wusdc", + ) + assert resolved_base == "Asset_carbon" + assert resolved_quote == "Asset_wusdc" + + +def test_build_and_post_offer_cloud_wallet_runs_without_manager_import(tmp_path: Path) -> None: + class _Program: + home_dir = str(tmp_path) + app_network = "mainnet" + app_log_level = "INFO" + + class _Market: + market_id = "m1" + base_asset = "base-asset" + quote_asset = "quote-asset" + base_symbol = "BASE" + + class _Wallet: + vault_id = "wallet-1" + network = "mainnet" + + exit_code, payload = build_and_post_offer_cloud_wallet( + program=_Program(), + market=_Market(), + size_base_units=5, + repeat=1, + publish_venue="dexie", + dexie_base_url="https://api.dexie.space", + splash_base_url="http://localhost:4000", + drop_only=True, + claim_rewards=False, + quote_price=1.5, + dry_run=True, + wallet_factory=lambda _program: cast(CloudWalletAdapter, _Wallet()), + initialize_manager_file_logging_fn=lambda *args, **kwargs: None, + recent_market_resolved_asset_id_hints_fn=lambda **kwargs: (None, None), + resolve_cloud_wallet_offer_asset_ids_fn=lambda **kwargs: ("Asset_base", "Asset_quote"), + resolve_maker_offer_fee_fn=lambda **kwargs: (0, "test"), + resolve_offer_expiry_for_market_fn=lambda _market: ("minutes", 30), + ensure_offer_bootstrap_denominations_fn=lambda **kwargs: { + "status": "skipped", + "reason": "dry_run", + }, + cloud_wallet_create_offer_phase_fn=lambda **kwargs: { + "known_offer_markers": set(), + "offer_request_started_at": "start", + "signature_request_id": "sr-1", + "signature_state": "SUBMITTED", + "wait_events": [], + "expires_at": "2026-01-01T00:00:00+00:00", + "offer_amount": 5000, + "request_amount": 7500, + "side": "sell", + }, + cloud_wallet_wait_offer_artifact_phase_fn=lambda **kwargs: "offer1runtime", + log_signed_offer_artifact_fn=lambda **kwargs: None, + verify_offer_text_for_dexie_fn=lambda _offer_text: None, + cloud_wallet_post_offer_phase_fn=lambda **kwargs: {"success": True, "id": "offer-1"}, + dexie_offer_view_url_fn=lambda **kwargs: "https://dexie.space/offers/offer-1", + ) + + assert exit_code == 0 + assert payload["dry_run"] is True + assert payload["publish_failures"] == 0 + assert payload["resolved_base_asset_id"] == "Asset_base" + assert payload["results"] == [] + assert payload["built_offers_preview"] == [ + {"offer_prefix": "offer1runtime", "offer_length": str(len("offer1runtime"))} + ] diff --git a/tests/test_config_models.py b/tests/test_config_models.py index c918150..24aa85f 100644 --- a/tests/test_config_models.py +++ b/tests/test_config_models.py @@ -140,6 +140,18 @@ def test_parse_markets_config_accepts_strategy_expiry_override() -> None: assert out.markets[0].pricing["strategy_offer_expiry_value"] == 2 +def test_parse_markets_config_stable_quote_validates_present_strategy_fields() -> None: + row = _base_market_row() + row["quote_asset_type"] = "stable" + row["pricing"] = { + "strategy_target_spread_bps": 0, + "strategy_min_xch_price_usd": -1, + "strategy_max_xch_price_usd": "invalid", + } + with pytest.raises(ValueError, match="strategy_target_spread_bps"): + parse_markets_config({"markets": [row]}) + + def test_parse_markets_config_reads_cloud_wallet_global_ids() -> None: row = _base_market_row() row["cloud_wallet_base_global_id"] = "Asset_base123" @@ -151,6 +163,44 @@ def test_parse_markets_config_reads_cloud_wallet_global_ids() -> None: assert out.markets[0].cloud_wallet_quote_global_id == "Asset_quote456" +def test_parse_markets_config_defaults_cat_unit_multipliers_to_1000() -> None: + row = _base_market_row() + row["base_asset"] = "BYC" + row["quote_asset"] = "wUSDC.b" + + 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"] == 1000 + + +def test_parse_markets_config_rejects_noncanonical_cat_base_multiplier() -> None: + row = _base_market_row() + row["base_asset"] = "BYC" + row["pricing"] = {"base_unit_mojo_multiplier": 10} + + with pytest.raises(ValueError, match="base_unit_mojo_multiplier must be 1000 for CAT assets"): + parse_markets_config({"markets": [row]}) + + +def test_parse_markets_config_rejects_noncanonical_cat_quote_multiplier() -> None: + row = _base_market_row() + row["quote_asset"] = "wUSDC.b" + row["pricing"] = {"quote_unit_mojo_multiplier": 10} + + with pytest.raises(ValueError, match="quote_unit_mojo_multiplier must be 1000 for CAT assets"): + parse_markets_config({"markets": [row]}) + + +def test_parse_markets_config_preserves_xch_multiplier_override() -> None: + row = _base_market_row() + row["pricing"] = {"quote_unit_mojo_multiplier": 1_000_000_000_000} + + out = parse_markets_config({"markets": [row]}) + + assert out.markets[0].pricing["quote_unit_mojo_multiplier"] == 1_000_000_000_000 + + # --------------------------------------------------------------------------- # parse_program_config: happy path # --------------------------------------------------------------------------- diff --git a/tests/test_daemon_offer_execution.py b/tests/test_daemon_offer_execution.py index 38dcc73..fbf7c78 100644 --- a/tests/test_daemon_offer_execution.py +++ b/tests/test_daemon_offer_execution.py @@ -2,6 +2,7 @@ import threading from datetime import UTC, datetime, timedelta +from pathlib import Path from typing import Any, cast from greenfloor.config.models import MarketConfig, MarketInventoryConfig @@ -9,7 +10,9 @@ from greenfloor.daemon import main as daemon_main from greenfloor.daemon.main import ( _active_offer_counts_by_size, + _active_offer_counts_by_size_and_side, _build_dexie_size_by_offer_id, + _execute_coin_ops_cloud_wallet_kms_only, _execute_strategy_actions, _inject_reseed_action_if_no_active_offers, _match_watched_coin_ids, @@ -562,6 +565,68 @@ def test_active_offer_counts_by_size_counts_cli_posted_offer() -> None: assert unmapped == 0, "CLI-posted offer must not appear in unmapped" +def test_active_offer_counts_by_size_and_side_unknown_metadata_stays_unmapped() -> None: + store = _FakeStore() + now = datetime.now(UTC) + store.offer_states = [ + {"offer_id": "offer-unknown-side", "market_id": "m1", "state": "open"}, + ] + # No strategy_offer_execution audit event metadata for this active offer. + store.audit_events = [] + + counts_by_side, state_counts, unmapped = _active_offer_counts_by_size_and_side( + store=cast(Any, store), + market_id="m1", + clock=now, + ) + + assert counts_by_side["buy"] == {1: 0, 10: 0, 100: 0} + assert counts_by_side["sell"] == {1: 0, 10: 0, 100: 0} + assert state_counts["open"] == 1 + assert unmapped == 1 + + +def test_active_offer_counts_by_size_and_side_malformed_side_stays_unmapped() -> None: + store = _FakeStore() + now = datetime.now(UTC) + store.offer_states = [ + {"offer_id": "offer-bad-side", "market_id": "m1", "state": "open"}, + {"offer_id": "offer-missing-side", "market_id": "m1", "state": "open"}, + ] + store.audit_events = [ + { + "event_type": "strategy_offer_execution", + "market_id": "m1", + "payload": { + "items": [ + { + "offer_id": "offer-bad-side", + "size": 10, + "status": "executed", + "side": "not-a-side", + }, + { + "offer_id": "offer-missing-side", + "size": 10, + "status": "executed", + }, + ] + }, + } + ] + + counts_by_side, state_counts, unmapped = _active_offer_counts_by_size_and_side( + store=cast(Any, store), + market_id="m1", + clock=now, + ) + + assert counts_by_side["buy"] == {1: 0, 10: 0, 100: 0} + assert counts_by_side["sell"] == {1: 0, 10: 0, 100: 0} + assert state_counts["open"] == 2 + assert unmapped == 2 + + def test_update_market_coin_watchlist_from_dexie_tracks_coins_for_owned_offers() -> None: store = _FakeStore() now = datetime.now(UTC) @@ -931,7 +996,7 @@ def list_coins(self, *, include_pending: bool = True): monkeypatch.setattr( daemon_main, "_resolve_cloud_wallet_offer_asset_ids_for_reservation", - lambda **_kwargs: ("asset", "xch_asset"), + lambda **_kwargs: ("asset", "quote_asset", "xch_asset"), ) monkeypatch.setattr( daemon_main, @@ -1018,7 +1083,7 @@ def list_coins(self, *, include_pending: bool = True): monkeypatch.setattr( daemon_main, "_resolve_cloud_wallet_offer_asset_ids_for_reservation", - lambda **_kwargs: ("asset", "xch_asset"), + lambda **_kwargs: ("asset", "quote_asset", "xch_asset"), ) monkeypatch.setattr( daemon_main, @@ -1124,7 +1189,7 @@ def list_coins(self, *, include_pending: bool = True): monkeypatch.setattr( daemon_main, "_resolve_cloud_wallet_offer_asset_ids_for_reservation", - lambda **_kwargs: ("asset", "xch_asset"), + lambda **_kwargs: ("asset", "quote_asset", "xch_asset"), ) monkeypatch.setattr( daemon_main, @@ -1198,7 +1263,7 @@ def try_acquire(self, **_kwargs): monkeypatch.setattr( daemon_main, "_resolve_cloud_wallet_offer_asset_ids_for_reservation", - lambda **_kwargs: ("asset", "xch_asset"), + lambda **_kwargs: ("asset", "quote_asset", "xch_asset"), ) monkeypatch.setattr( daemon_main, @@ -1267,7 +1332,7 @@ def list_coins(self, *, include_pending: bool = True): monkeypatch.setattr( daemon_main, "_resolve_cloud_wallet_offer_asset_ids_for_reservation", - lambda **_kwargs: ("asset_global", "xch_asset"), + lambda **_kwargs: ("asset_global", "quote_asset", "xch_asset"), ) monkeypatch.setattr( daemon_main, @@ -1328,8 +1393,8 @@ def list_coins( self, *, asset_id: str | None = None, include_pending: bool = True ) -> list[dict[str, Any]]: _ = include_pending - # Simulate the wallet behavior observed on John-Deere where a broad - # unfiltered query reports pending-only inventory. + # Simulate the wallet behavior that motivated asset-scoped filtering: + # a broad unfiltered query reports pending-only inventory. if not asset_id: return [ { @@ -1352,7 +1417,7 @@ def list_coins( monkeypatch.setattr( daemon_main, "_resolve_cloud_wallet_offer_asset_ids_for_reservation", - lambda **_kwargs: ("asset_global", "xch_asset"), + lambda **_kwargs: ("asset_global", "quote_asset", "xch_asset"), ) monkeypatch.setattr( daemon_main, @@ -1406,6 +1471,197 @@ class _Program: ) +def test_execute_strategy_actions_parallel_prefers_single_input_offer( + monkeypatch, tmp_path +) -> None: + daemon_main._POST_COOLDOWN_UNTIL.clear() + + class _FakeCloudWallet: + def list_coins( + self, *, asset_id: str | None = None, include_pending: bool = True + ) -> list[dict[str, Any]]: + _ = include_pending + if asset_id == "asset_global": + return [ + { + "id": "c1", + "amount": 600, + "state": "SETTLED", + "asset": {"id": "asset_global"}, + }, + { + "id": "c2", + "amount": 600, + "state": "SETTLED", + "asset": {"id": "asset_global"}, + }, + ] + if asset_id == "xch_asset": + return [ + {"id": "x1", "amount": 1000, "state": "SETTLED", "asset": {"id": "xch_asset"}} + ] + return [] + + 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_global", "quote_asset", "xch_asset"), + ) + monkeypatch.setattr( + daemon_main, + "_cloud_wallet_offer_post_fallback", + 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 + + market = _market() + market.base_asset = "asset-local-only" + market.pricing = {"fixed_quote_per_base": 1.0, "base_unit_mojo_multiplier": 1000} + db_path = tmp_path / "reservations.sqlite" + coordinator = AssetReservationCoordinator(db_path=db_path, lease_seconds=300) + dexie = _FakeDexie(post_result={"success": True, "id": "offer-should-not-post"}) + store = _FakeStore() + actions = [ + PlannedAction( + size=1, + repeat=1, + pair="usdc", + expiry_unit="minutes", + expiry_value=10, + cancel_after_create=True, + reason="no_active_offer_reseed", + side="sell", + ) + ] + result = _execute_strategy_actions( + market=market, + strategy_actions=actions, + runtime_dry_run=False, + xch_price_usd=30.0, + dexie=cast(Any, dexie), + store=cast(Any, store), + publish_venue="dexie", + program=_Program(), + reservation_coordinator=coordinator, + ) + assert result["executed_count"] == 0 + assert any( + "single_input_preferred_requires_combine" in str(item["reason"]) for item in result["items"] + ) + + +def test_cloud_wallet_spendable_base_unit_coin_amounts_filters_and_converts() -> None: + class _FakeCloudWallet: + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + {"amount": 10000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"amount": 20000, "state": "SETTLED", "asset": None}, + {"amount": 999, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"amount": 20000, "state": "PENDING", "asset": {"id": "Asset_byc"}}, + {"amount": 30000, "state": "SETTLED", "asset": {"id": "Asset_other"}}, + ] + + got = daemon_main._cloud_wallet_spendable_base_unit_coin_amounts( + wallet=cast(Any, _FakeCloudWallet()), + resolved_asset_id="Asset_byc", + base_unit_mojo_multiplier=1000, + canonical_asset_id="BYC", + ) + assert got == [10, 20] + + +def test_cloud_wallet_spendable_base_unit_coin_amounts_revalidates_direct_coin_lookup() -> None: + class _FakeCloudWallet: + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + {"id": "good", "amount": 10000, "state": "SETTLED", "asset": None}, + {"id": "wrong-asset", "amount": 10000, "state": "SETTLED", "asset": None}, + {"id": "locked", "amount": 10000, "state": "SETTLED", "asset": None}, + ] + + def get_coin_record(self, *, coin_id: str): + records = { + "good": { + "id": "good", + "state": "SETTLED", + "isLocked": False, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_byc"}, + }, + "wrong-asset": { + "id": "wrong-asset", + "state": "SETTLED", + "isLocked": False, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_xch"}, + }, + "locked": { + "id": "locked", + "state": "SETTLED", + "isLocked": True, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_byc"}, + }, + } + return records[coin_id] + + got = daemon_main._cloud_wallet_spendable_base_unit_coin_amounts( + wallet=cast(Any, _FakeCloudWallet()), + resolved_asset_id="Asset_byc", + base_unit_mojo_multiplier=1000, + canonical_asset_id="BYC", + ) + assert got == [10] + + +def test_select_spendable_coins_for_target_amount_prefers_exact() -> None: + coins = [ + {"id": "c5", "amount": 5000}, + {"id": "c3", "amount": 3000}, + {"id": "c2", "amount": 2000}, + {"id": "c3b", "amount": 3000}, + ] + coin_ids, total, exact = daemon_main._select_spendable_coins_for_target_amount( + coins=coins, + target_amount=10_000, + ) + assert exact is True + assert total == 10_000 + assert set(coin_ids) == {"c5", "c3", "c2"} + + +def test_select_spendable_coins_for_target_amount_uses_change_when_needed() -> None: + coins = [ + {"id": "c5", "amount": 5000}, + {"id": "c3a", "amount": 3000}, + {"id": "c3b", "amount": 3000}, + ] + coin_ids, total, exact = daemon_main._select_spendable_coins_for_target_amount( + coins=coins, + target_amount=10_000, + ) + assert exact is False + assert total == 11_000 + assert set(coin_ids) == {"c5", "c3a", "c3b"} + + def test_reservation_coordinator_cross_instance_contention_allows_single_winner( tmp_path, ) -> None: @@ -1440,3 +1696,771 @@ def _attempt(coordinator: AssetReservationCoordinator) -> None: assert success_count == 1 assert failure_count == 1 assert any("reservation_insufficient_asset" in str(error) for ok, error in results if not ok) + + +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" + cloud_wallet_kms_key_id = "" + coin_ops_split_fee_mojos = 0 + coin_ops_combine_fee_mojos = 0 + + class _Signer: + key_id = "key-main-2" + + 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=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"] == "cloud_wallet_kms_required_for_coin_ops" + + +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 + + 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-small", + "amount": 15_000, + "state": "CONFIRMED", + "asset": {"id": "Asset_byc"}, + }, + { + "id": "coin-big", + "amount": 80_000, + "state": "SPENDABLE", + "asset": {"id": "Asset_byc"}, + }, + ] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + assert coin_ids == ["coin-big"] + assert amount_per_coin == 10_000 + assert number_of_coins == 4 + assert fee == 0 + return {"signature_request_id": "sig-123", "status": "SUBMITTED"} + + 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 + + result = _execute_coin_ops_cloud_wallet_kms_only( + market=_market(), + program=_Program(), + plans=[CoinOpPlan(op_type="split", size_base_units=10, op_count=4, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert result["executed_count"] == 1 + assert result["items"][0]["status"] == "executed" + assert result["items"][0]["reason"] == "cloud_wallet_kms_split_submitted" + assert result["items"][0]["operation_id"] == "sig-123" + + +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 + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def __init__(self) -> None: + self.split_calls: list[list[str]] = [] + + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + { + "id": "coin-a", + "amount": 100_000, + "state": "SETTLED", + "asset": {"id": "Asset_byc"}, + }, + { + "id": "coin-b", + "amount": 90_000, + "state": "SETTLED", + "asset": {"id": "Asset_byc"}, + }, + ] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + _ = amount_per_coin, number_of_coins, fee + self.split_calls.append(list(coin_ids)) + if coin_ids == ["coin-a"]: + raise RuntimeError( + "cloud_wallet_graphql_error:Some selected coins are not spendable" + ) + return {"signature_request_id": "sig-retry-ok", "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"), + ) + + 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=4, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert fake.split_calls == [["coin-a"], ["coin-b"]] + assert result["executed_count"] == 1 + assert result["items"][0]["status"] == "executed" + assert result["items"][0]["operation_id"] == "sig-retry-ok" + + +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 + + 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": "wrong-1", "amount": 50, "state": "SETTLED", "asset": {"id": "Asset_other"}}, + {"id": "wrong-2", "amount": 75, "state": "SETTLED", "asset": {"id": "Asset_nope"}}, + ] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + raise AssertionError("split_coins should not be called for mismatched assets") + + 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 + + result = _execute_coin_ops_cloud_wallet_kms_only( + market=_market(), + program=_Program(), + plans=[CoinOpPlan(op_type="split", size_base_units=10, 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"] == "no_spendable_split_coin_available" + + +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 + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def __init__(self) -> None: + self.split_calls: list[list[str]] = [] + + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + {"id": "wrong-asset", "amount": 100_000, "state": "SETTLED", "asset": None}, + {"id": "locked", "amount": 90_000, "state": "SETTLED", "asset": None}, + {"id": "good", "amount": 80_000, "state": "SETTLED", "asset": None}, + ] + + def get_coin_record(self, *, coin_id: str): + records = { + "wrong-asset": { + "id": "wrong-asset", + "state": "SETTLED", + "isLocked": False, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_xch"}, + }, + "locked": { + "id": "locked", + "state": "SETTLED", + "isLocked": True, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_byc"}, + }, + "good": { + "id": "good", + "state": "SETTLED", + "isLocked": False, + "isLinkedToOpenOffer": False, + "asset": {"id": "Asset_byc"}, + }, + } + return records[coin_id] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + _ = amount_per_coin, number_of_coins, fee + self.split_calls.append(list(coin_ids)) + return {"signature_request_id": "sig-direct-lookup", "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"), + ) + + 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=4, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert fake.split_calls == [["good"]] + assert result["executed_count"] == 1 + assert result["items"][0]["status"] == "executed" + assert result["items"][0]["operation_id"] == "sig-direct-lookup" + + +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 + + 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": "small-1", "amount": 10, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "small-2", "amount": 20, "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 insufficient-value coins") + + 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 + + result = _execute_coin_ops_cloud_wallet_kms_only( + market=_market(), + program=_Program(), + plans=[CoinOpPlan(op_type="split", size_base_units=10, 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"] == "no_spendable_split_coin_available" + + +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 + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + combine_calls: list[dict[str, Any]] + + def __init__(self) -> None: + self.combine_calls = [] + + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + {"id": "s1", "amount": 15_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "s2", "amount": 7_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + ] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + raise AssertionError("split should not be called before combine in this cycle") + + def combine_coins( + self, + *, + number_of_coins, + fee, + asset_id, + largest_first, + input_coin_ids=None, + target_amount=None, + ): + assert fee == 0 + assert asset_id == "Asset_byc" + assert largest_first is True + self.combine_calls.append( + { + "number_of_coins": int(number_of_coins), + "input_coin_ids": list(input_coin_ids or []), + "target_amount": int(target_amount or 0), + } + ) + return {"signature_request_id": "sig-combine-1", "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"), + ) + + 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=2, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert len(fake.combine_calls) == 1 + assert fake.combine_calls[0]["number_of_coins"] == 2 + assert fake.combine_calls[0]["target_amount"] == 20_000 + assert result["executed_count"] == 1 + assert result["items"][0]["op_type"] == "combine" + assert ( + result["items"][0]["reason"] + == "cloud_wallet_kms_combine_submitted_for_split_prereq_with_change" + ) + + +def test_execute_coin_ops_cloud_wallet_kms_only_split_combine_cap_submits_progress( + monkeypatch, +) -> 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 + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def __init__(self) -> None: + self.combine_calls: list[dict[str, Any]] = [] + + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + return [ + {"id": "s1", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "s2", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "s3", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "s4", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "s5", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + {"id": "s6", "amount": 1_000, "state": "SETTLED", "asset": {"id": "Asset_byc"}}, + ] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + raise AssertionError("split should not be called before capped progress combine") + + def combine_coins( + self, + *, + number_of_coins, + fee, + asset_id, + largest_first, + input_coin_ids=None, + target_amount=None, + ): + _ = fee, asset_id, largest_first + self.combine_calls.append( + { + "number_of_coins": int(number_of_coins), + "input_coin_ids": list(input_coin_ids or []), + "target_amount": int(target_amount or 0), + } + ) + return {"signature_request_id": "sig-cap-progress", "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"), + ) + + 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=1, op_count=6, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert len(fake.combine_calls) == 1 + assert fake.combine_calls[0]["number_of_coins"] == 5 + assert fake.combine_calls[0]["target_amount"] == 5_000 + assert result["executed_count"] == 1 + assert result["items"][0]["op_type"] == "combine" + assert result["items"][0]["data"]["input_coin_cap_applied"] is True + assert result["items"][0]["data"]["selected_coin_count_before_cap"] == 6 + assert result["items"][0]["data"]["selected_coin_count_after_cap"] == 5 + assert ( + "next cycle likely needs only 2-coin combine" + in result["items"][0]["data"]["next_step_note"] + ) + + +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 + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def __init__(self) -> None: + self.combine_calls: list[dict[str, Any]] = [] + + def list_coins(self, *, asset_id: str | None = None, include_pending: bool = True): + _ = asset_id, include_pending + dust_rows = [ + {"id": f"dust-{idx}", "amount": 10, "state": "SETTLED", "asset": None} + 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": "stray-310", "amount": 310, "state": "SETTLED", "asset": None}, + *dust_rows, + ] + + def split_coins(self, *, coin_ids, amount_per_coin, number_of_coins, fee): + raise AssertionError("split should not be called before combine in this cycle") + + def combine_coins( + self, + *, + number_of_coins, + fee, + asset_id, + largest_first, + input_coin_ids=None, + target_amount=None, + ): + _ = fee, asset_id, largest_first + self.combine_calls.append( + { + "number_of_coins": int(number_of_coins), + "input_coin_ids": list(input_coin_ids or []), + "target_amount": int(target_amount or 0), + } + ) + return {"signature_request_id": "sig-dust-filtered", "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"), + ) + + 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": 30} + result = _execute_coin_ops_cloud_wallet_kms_only( + market=market, + program=_Program(), + plans=[CoinOpPlan(op_type="split", size_base_units=10, op_count=4, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + 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 result["executed_count"] == 1 + assert ( + result["items"][0]["reason"] + == "cloud_wallet_kms_combine_submitted_for_split_prereq_with_change" + ) + + +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 + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def __init__(self) -> None: + self.combine_calls = 0 + + def combine_coins(self, **_kwargs): + self.combine_calls += 1 + if self.combine_calls < 3: + raise RuntimeError("Status not ok: 429") + return {"signature_request_id": "sig-combine-retry", "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"), + ) + + 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=10, op_count=2, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert fake.combine_calls == 3 + assert result["executed_count"] == 1 + assert result["items"][0]["status"] == "executed" + assert result["items"][0]["operation_id"] == "sig-combine-retry" + + +def test_execute_coin_ops_cloud_wallet_kms_only_combine_applies_input_coin_cap( + monkeypatch, +) -> 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 + + class _Signer: + key_id = "key-main-2" + + class _FakeCloudWallet: + def __init__(self) -> None: + self.last_number_of_coins: int | None = None + + def combine_coins(self, **kwargs): + self.last_number_of_coins = int(kwargs.get("number_of_coins", 0)) + return {"signature_request_id": "sig-combine-cap", "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"), + ) + + 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=10, op_count=100, reason="r")], + wallet=cast(Any, object()), + signer_selection=_Signer(), + state_dir=Path("/tmp"), + ) + + assert fake.last_number_of_coins == 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 diff --git a/tests/test_daemon_strategy_integration.py b/tests/test_daemon_strategy_integration.py index be12969..00e4f84 100644 --- a/tests/test_daemon_strategy_integration.py +++ b/tests/test_daemon_strategy_integration.py @@ -1,7 +1,10 @@ from __future__ import annotations +from datetime import UTC, datetime + from greenfloor.config.models import MarketConfig, MarketInventoryConfig, MarketLadderEntry from greenfloor.daemon.main import ( + _evaluate_two_sided_market_actions, _normalize_strategy_pair, _strategy_config_from_market, _strategy_state_from_bucket_counts, @@ -81,3 +84,36 @@ def test_strategy_state_from_bucket_counts_includes_xch_price() -> None: assert state.tens == 1 assert state.hundreds == 0 assert state.xch_price_usd == 32.5 + + +def test_evaluate_two_sided_market_actions_uses_side_targets_from_ladders() -> None: + market = _market_with_quote("wUSDC.b") + market.mode = "two_sided" + market.quote_asset_type = "stable" + market.ladders = { + "buy": [ + MarketLadderEntry( + size_base_units=10, + target_count=1, + split_buffer_count=0, + combine_when_excess_factor=2.0, + ) + ], + "sell": [ + MarketLadderEntry( + size_base_units=10, + target_count=3, + split_buffer_count=0, + combine_when_excess_factor=2.0, + ) + ], + } + actions = _evaluate_two_sided_market_actions( + market=market, + counts_by_side={"buy": {10: 0}, "sell": {10: 1}}, + xch_price_usd=None, + now=datetime.now(UTC), + ) + by_side = {(a.side, a.size): int(a.repeat) for a in actions} + assert by_side[("buy", 10)] == 1 + assert by_side[("sell", 10)] == 2 diff --git a/tests/test_manager_post_offer.py b/tests/test_manager_post_offer.py index 1616637..7a38378 100644 --- a/tests/test_manager_post_offer.py +++ b/tests/test_manager_post_offer.py @@ -549,6 +549,43 @@ def _fake_set_log_level(*, program_path: Path, log_level: str) -> int: assert captured["log_level"] == "ERROR" +def test_main_dispatches_coin_status_command(monkeypatch, tmp_path: Path) -> None: + import pytest + + program = tmp_path / "program.yaml" + _write_program(program) + captured: dict[str, object] = {} + + def _fake_coin_status( + *, program_path: Path, asset: str | None, vault_id: str | None, cat_id: str | None + ) -> int: + captured["program_path"] = program_path + captured["asset"] = asset + captured["vault_id"] = vault_id + captured["cat_id"] = cat_id + return 0 + + monkeypatch.setattr("greenfloor.cli.manager._coin_status", _fake_coin_status) + monkeypatch.setattr( + "sys.argv", + [ + "greenfloor-manager", + "--program-config", + str(program), + "coin-status", + "--asset", + "BYC", + ], + ) + with pytest.raises(SystemExit) as exc: + manager_mod.main() + assert exc.value.code == 0 + assert captured["program_path"] == program + assert captured["asset"] == "BYC" + assert captured["vault_id"] is None + assert captured["cat_id"] is None + + def _write_markets(path: Path) -> None: path.write_text( "\n".join( @@ -865,7 +902,7 @@ class _FakeWallet: vault_id = "wallet-1" @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return { "offers": [ { @@ -921,7 +958,7 @@ class _FakeWallet: vault_id = "wallet-1" @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return { "offers": [ { @@ -979,7 +1016,7 @@ class _FakeWallet: vault_id = "wallet-1" @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return { "offers": [ { @@ -1768,6 +1805,295 @@ def list_coins(*, asset_id=None, include_pending=True): assert payload["count"] == 0 +def test_coins_list_keeps_asset_scoped_rows_when_wallet_reports_mixed_asset_metadata( + monkeypatch, tmp_path: Path, capsys +) -> None: + program = tmp_path / "program.yaml" + _write_program_with_cloud_wallet(program) + warning_calls: list[tuple[object, ...]] = [] + + class _FakeWallet: + vault_id = "wallet-1" + network = "mainnet" + + def __init__(self, _config): + pass + + @staticmethod + def list_coins(*, asset_id=None, include_pending=True): + _ = asset_id, include_pending + return [ + { + "name": "coin-byc-1", + "amount": 10, + "state": "SETTLED", + "asset": {"id": "Asset_kg8byr1jz72w12g9tjchiypp"}, + }, + { + "name": "coin-xch-1", + "amount": 10000, + "state": "SETTLED", + "asset": {"id": "Asset_huun64oh7dbt9f1f9ie8khuw"}, + }, + ] + + monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) + monkeypatch.setattr( + "greenfloor.cli.manager._manager_logger.warning", + lambda *args, **kwargs: warning_calls.append(args), + ) + monkeypatch.setattr( + "greenfloor.cli.manager._resolve_cloud_wallet_asset_id", + lambda *, wallet, canonical_asset_id, symbol_hint=None: "Asset_kg8byr1jz72w12g9tjchiypp", + ) + code = _coins_list(program_path=program, asset="BYC", vault_id=None) + assert code == 0 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["count"] == 2 + assert {item["coin_id"] for item in payload["items"]} == {"coin-byc-1", "coin-xch-1"} + assert {item["asset"] for item in payload["items"]} == {"Asset_kg8byr1jz72w12g9tjchiypp"} + assert {item["reported_asset"] for item in payload["items"]} == { + "Asset_kg8byr1jz72w12g9tjchiypp", + "Asset_huun64oh7dbt9f1f9ie8khuw", + } + assert {item["scoped_asset"] for item in payload["items"]} == {"Asset_kg8byr1jz72w12g9tjchiypp"} + assert payload["asset_total_amount"] is None + assert payload["asset_spendable_amount"] is None + assert payload["asset_locked_amount"] is None + assert payload["asset_totals_withheld_reason"] == "mixed_reported_asset_ids_detected" + assert payload["warnings"][0]["code"] == "mixed_reported_asset_ids_detected" + assert warning_calls + assert "coins_list_mixed_asset_metadata" in str(warning_calls[0][0]) + + +def test_coins_list_keeps_asset_totals_when_scoped_rows_omit_reported_asset( + monkeypatch, tmp_path: Path, capsys +) -> None: + program = tmp_path / "program.yaml" + _write_program_with_cloud_wallet(program) + warning_calls: list[tuple[object, ...]] = [] + + class _FakeWallet: + vault_id = "wallet-1" + network = "mainnet" + + def __init__(self, _config): + pass + + @staticmethod + def list_coins(*, asset_id=None, include_pending=True): + _ = asset_id, include_pending + return [ + { + "name": "coin-byc-1", + "amount": 20000, + "state": "SETTLED", + }, + { + "name": "coin-byc-2", + "amount": 30000, + "state": "SETTLED", + }, + ] + + @staticmethod + def _graphql(*, query, variables): + _ = query, variables + return { + "wallet": { + "assets": { + "edges": [ + { + "node": { + "assetId": "Asset_kg8byr1jz72w12g9tjchiypp", + "totalAmount": 50000, + "spendableAmount": 50000, + "lockedAmount": 0, + } + } + ] + } + } + } + + monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) + monkeypatch.setattr( + "greenfloor.cli.manager._manager_logger.warning", + lambda *args, **kwargs: warning_calls.append(args), + ) + monkeypatch.setattr( + "greenfloor.cli.manager._resolve_cloud_wallet_asset_id", + lambda *, wallet, canonical_asset_id, symbol_hint=None: "Asset_kg8byr1jz72w12g9tjchiypp", + ) + code = _coins_list(program_path=program, asset="BYC", vault_id=None) + assert code == 0 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["count"] == 2 + assert {item["asset"] for item in payload["items"]} == {"Asset_kg8byr1jz72w12g9tjchiypp"} + assert {item["reported_asset"] for item in payload["items"]} == {None} + assert {item["scoped_asset"] for item in payload["items"]} == {"Asset_kg8byr1jz72w12g9tjchiypp"} + assert payload["asset_total_amount"] == 50000 + assert payload["asset_spendable_amount"] == 50000 + assert payload["asset_locked_amount"] == 0 + assert payload["asset_totals_withheld_reason"] is None + assert payload["warnings"] == [] + assert warning_calls == [] + + +def test_coins_list_keeps_row_level_spendability_separate_from_wallet_asset_totals( + monkeypatch, tmp_path: Path, capsys +) -> None: + program = tmp_path / "program.yaml" + _write_program_with_cloud_wallet(program) + + class _FakeWallet: + vault_id = "wallet-1" + network = "mainnet" + + def __init__(self, _config): + pass + + @staticmethod + def list_coins(*, asset_id=None, include_pending=True): + _ = asset_id, include_pending + return [ + { + "name": "coin-a", + "amount": 10000, + "state": "SETTLED", + "asset": {"id": "Asset_kg8"}, + }, + { + "name": "coin-b", + "amount": 10000, + "state": "SETTLED", + "asset": {"id": "Asset_kg8"}, + }, + { + "name": "coin-c", + "amount": 480, + "state": "SETTLED", + "asset": {"id": "Asset_kg8"}, + }, + ] + + @staticmethod + def _graphql(*, query, variables): + _ = query, variables + return { + "wallet": { + "assets": { + "edges": [ + { + "node": { + "assetId": "Asset_kg8", + "totalAmount": 20480, + "spendableAmount": 10480, + "lockedAmount": 10000, + } + } + ] + } + } + } + + 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_kg8", + ) + code = _coins_list(program_path=program, asset="BYC", vault_id=None) + assert code == 0 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["asset_total_amount"] == 20480 + assert payload["asset_spendable_amount"] == 10480 + assert payload["asset_locked_amount"] == 10000 + assert payload["asset_totals_withheld_reason"] is None + assert payload["warnings"] == [] + spendable_total = sum(int(item["amount"]) for item in payload["items"] if item["spendable"]) + assert spendable_total == 20480 + + +def test_coins_list_warns_when_item_amount_sum_differs_from_wallet_asset_total( + monkeypatch, tmp_path: Path, capsys +) -> None: + program = tmp_path / "program.yaml" + _write_program_with_cloud_wallet(program) + warning_calls: list[tuple[object, ...]] = [] + + class _FakeWallet: + vault_id = "wallet-1" + network = "mainnet" + + def __init__(self, _config): + pass + + @staticmethod + def list_coins(*, asset_id=None, include_pending=True): + _ = asset_id, include_pending + return [ + { + "name": "coin-a", + "amount": 10000, + "state": "SETTLED", + "asset": {"id": "Asset_kg8"}, + }, + { + "name": "coin-b", + "amount": 10000, + "state": "SETTLED", + "asset": {"id": "Asset_kg8"}, + }, + ] + + @staticmethod + def _graphql(*, query, variables): + _ = query, variables + return { + "wallet": { + "assets": { + "edges": [ + { + "node": { + "assetId": "Asset_kg8", + "totalAmount": 20480, + "spendableAmount": 20480, + "lockedAmount": 0, + } + } + ] + } + } + } + + monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) + monkeypatch.setattr( + "greenfloor.cli.manager._manager_logger.warning", + lambda *args, **kwargs: warning_calls.append(args), + ) + monkeypatch.setattr( + "greenfloor.cli.manager._resolve_cloud_wallet_asset_id", + lambda *, wallet, canonical_asset_id, symbol_hint=None: "Asset_kg8", + ) + code = _coins_list(program_path=program, asset="BYC", vault_id=None) + assert code == 0 + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["item_amount_sum"] == 20000 + assert payload["asset_total_amount"] == 20480 + assert payload["warnings"] == [ + { + "code": "item_amount_sum_mismatch", + "message": "sum(items.amount) does not match wallet asset total amount", + "resolved_asset_id": "Asset_kg8", + "items_amount_sum": 20000, + "wallet_asset_total_amount": 20480, + "difference_amount": -480, + } + ] + assert warning_calls + assert "coins_list_amount_mismatch" in str(warning_calls[0][0]) + + def test_coins_list_cat_id_uses_wallet_resolution_without_dexie( monkeypatch, tmp_path: Path, capsys ) -> None: @@ -1921,7 +2247,7 @@ def list_coins(*, asset_id=None, include_pending=True): "name": "coin-override-1", "amount": 77, "state": "SETTLED", - "asset": {"id": "Asset_override"}, + "asset": {"id": "Asset_resolved"}, } ] @@ -2128,15 +2454,15 @@ def get_fee_estimate(*, target_times=None): raise AssertionError("expected _CoinsetFeeLookupPreflightError") -def test_effective_coin_split_fee_for_cat_is_temporarily_zero() -> None: +def test_effective_coin_split_fee_for_cat_keeps_default_fee() -> None: fee, source = manager_mod._effective_coin_split_fee_for_asset( canonical_asset_id="a1", resolved_asset_id="Asset_cat_a1", fee_mojos=42, fee_source="coinset_conservative", ) - assert fee == 0 - assert source == "temporary_cat_split_zero_fee" + assert fee == 42 + assert source == "coinset_conservative" def test_effective_coin_split_fee_for_xch_keeps_default_fee() -> None: @@ -2201,12 +2527,12 @@ def split_coins(*, coin_ids, amount_per_coin, number_of_coins, fee): no_wait=True, ) assert code == 0 - assert calls["split"] == (["Coin_abc123"], 10, 2, 0) + assert calls["split"] == (["Coin_abc123"], 10, 2, 42) payload = json.loads(capsys.readouterr().out.strip()) assert payload["venue"] is None assert payload["waited"] is False - assert payload["fee_mojos"] == 0 - assert payload["fee_source"] == "temporary_cat_split_zero_fee" + assert payload["fee_mojos"] == 42 + assert payload["fee_source"] == "coinset_conservative" assert payload["coin_selection_mode"] == "explicit" assert payload["resolved_asset_id"] == "Asset_split_base" @@ -2233,7 +2559,8 @@ def list_coins(*, include_pending=True, asset_id=None): if asset_id == "Asset_split_base": return [ {"id": "Coin_small", "name": "small", "amount": 100, "state": "SETTLED"}, - {"id": "Coin_big", "name": "big", "amount": 500, "state": "SETTLED"}, + {"id": "Coin_big", "name": "big", "amount": 1500, "state": "SETTLED"}, + {"id": "Coin_reserve", "name": "reserve", "amount": 1100, "state": "SETTLED"}, {"id": "Coin_pending", "name": "pending", "amount": 999, "state": "PENDING"}, ] return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] @@ -2265,7 +2592,7 @@ def split_coins(*, coin_ids, amount_per_coin, number_of_coins, fee): no_wait=True, ) assert code == 0 - assert calls["split"] == (["Coin_big"], 10, 10, 0) + assert calls["split"] == (["Coin_big"], 10, 10, 42) payload = json.loads(capsys.readouterr().out.strip()) assert payload["coin_selection_mode"] == "adapter_auto_select" assert payload["resolved_asset_id"] == "Asset_split_base" @@ -2291,7 +2618,7 @@ def __init__(self, _config): def list_coins(*, include_pending=True, asset_id=None): _ = include_pending if asset_id == "Asset_split_base": - return [{"id": "Coin_only", "name": "only", "amount": 500, "state": "SETTLED"}] + return [{"id": "Coin_only", "name": "only", "amount": 1500, "state": "SETTLED"}] return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] @staticmethod @@ -2349,7 +2676,7 @@ def __init__(self, _config): def list_coins(*, include_pending=True, asset_id=None): _ = include_pending if asset_id == "Asset_split_base": - return [{"id": "Coin_only", "name": "only", "amount": 500, "state": "SETTLED"}] + return [{"id": "Coin_only", "name": "only", "amount": 1500, "state": "SETTLED"}] return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] @staticmethod @@ -2380,7 +2707,7 @@ def split_coins(*, coin_ids, amount_per_coin, number_of_coins, fee): allow_lock_all_spendable=True, ) assert code == 0 - assert calls["split"] == (["Coin_only"], 10, 10, 0) + assert calls["split"] == (["Coin_only"], 10, 10, 42) payload = json.loads(capsys.readouterr().out.strip()) assert payload["coin_selection_mode"] == "adapter_auto_select" assert payload["resolved_asset_id"] == "Asset_split_base" @@ -2406,7 +2733,7 @@ def __init__(self, _config): def list_coins(*, include_pending=True, asset_id=None): _ = include_pending if asset_id == "Asset_split_base": - return [{"id": "Coin_only", "name": "only", "amount": 500, "state": "SETTLED"}] + return [{"id": "Coin_only", "name": "only", "amount": 1500, "state": "SETTLED"}] return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] @staticmethod @@ -2438,7 +2765,7 @@ def split_coins(*, coin_ids, amount_per_coin, number_of_coins, fee): prompt_for_override=True, ) assert code == 0 - assert calls["split"] == (["Coin_only"], 10, 10, 0) + assert calls["split"] == (["Coin_only"], 10, 10, 42) payload = json.loads(capsys.readouterr().out.strip()) assert payload["resolved_asset_id"] == "Asset_split_base" @@ -2859,7 +3186,7 @@ def split_coins(*, coin_ids, amount_per_coin, number_of_coins, fee): size_base_units=10, ) assert code == 0 - assert calls["split"] == (["Coin_abc123"], 10, 4, 0) + assert calls["split"] == (["Coin_abc123"], 10, 4, 42) payload = json.loads(capsys.readouterr().out.strip()) assert payload["venue"] == "splash" assert payload["denomination_target"]["required_count"] == 4 @@ -2882,8 +3209,16 @@ def __init__(self, _config): @staticmethod def list_coins(*, include_pending=True, asset_id=None): - _ = include_pending, asset_id - return [{"id": "Coin_abc123", "name": "coin-1"}] + _ = include_pending + if asset_id == "a1": + return [ + {"id": f"Coin_{i}", "name": f"coin-{i}", "amount": 1500 + i, "state": "SETTLED"} + for i in range(6) + ] + [ + {"id": "Coin_dust_1", "name": "dust-1", "amount": 100, "state": "SETTLED"}, + {"id": "Coin_dust_2", "name": "dust-2", "amount": 999, "state": "SETTLED"}, + ] + return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] @staticmethod def combine_coins(*, number_of_coins, fee, largest_first, asset_id, input_coin_ids=None): @@ -2909,7 +3244,13 @@ def combine_coins(*, number_of_coins, fee, largest_first, asset_id, input_coin_i size_base_units=10, ) assert code == 0 - assert calls["combine"] == (6, 77, True, "a1", None) + assert calls["combine"] == ( + 6, + 77, + True, + "a1", + ["Coin_5", "Coin_4", "Coin_3", "Coin_2", "Coin_1", "Coin_0"], + ) payload = json.loads(capsys.readouterr().out.strip()) assert payload["venue"] == "splash" assert payload["denomination_target"]["combine_threshold_count"] == 6 @@ -2957,8 +3298,13 @@ def __init__(self, _config): @staticmethod def list_coins(*, include_pending=True, asset_id=None): - _ = include_pending, asset_id - return [{"id": "Coin_abc123", "name": "coin-1"}] + _ = include_pending + if asset_id == "a1": + return [ + {"id": f"Coin_{i}", "name": f"coin-{i}", "amount": 2000 + i, "state": "SETTLED"} + for i in range(5) + ] + return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] @staticmethod def combine_coins(*, number_of_coins, fee, largest_first, asset_id, input_coin_ids=None): @@ -2986,6 +3332,71 @@ def combine_coins(*, number_of_coins, fee, largest_first, asset_id, input_coin_i assert calls["combine"][0] == 5 +def test_coin_combine_auto_selection_ignores_cat_dust_under_one_unit( + 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_big_1", "name": "big-1", "amount": 2000, "state": "SETTLED"}, + {"id": "Coin_big_2", "name": "big-2", "amount": 1500, "state": "SETTLED"}, + {"id": "Coin_dust_1", "name": "dust-1", "amount": 999, "state": "SETTLED"}, + {"id": "Coin_dust_2", "name": "dust-2", "amount": 100, "state": "SETTLED"}, + ] + return [{"id": "Coin_old", "name": "old", "amount": 1, "state": "SETTLED"}] + + @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_big_1", "Coin_big_2"], + ) + payload = json.loads(capsys.readouterr().out.strip()) + assert payload["coin_selection_mode"] == "adapter_auto_select" + assert payload["resolved_asset_id"] == "Asset_split_base" + + def test_coin_split_until_ready_ignores_unknown_states_and_string_asset( monkeypatch, tmp_path: Path, capsys ) -> None: @@ -3167,7 +3578,7 @@ def get_signature_request(signature_request_id): network="mainnet", market_id="m1", pair=None, - coin_ids=[], + coin_ids=["coin-a"], amount_per_coin=0, number_of_coins=0, no_wait=False, @@ -3179,9 +3590,9 @@ def get_signature_request(signature_request_id): assert code == 2 payload = json.loads(capsys.readouterr().out.strip()) assert payload["until_ready"] is True - assert payload["stop_reason"] == "max_iterations_reached" + assert payload["stop_reason"] == "requires_new_coin_selection" assert payload["denomination_readiness"]["ready"] is False - assert len(payload["operations"]) == 2 + assert len(payload["operations"]) == 1 # --------------------------------------------------------------------------- @@ -3217,6 +3628,12 @@ def test_is_spendable_coin_missing_state_is_not_spendable() -> None: assert _is_spendable_coin({"state": ""}) is False +def test_is_spendable_coin_locked_flag_is_not_spendable() -> None: + from greenfloor.cli.manager import _is_spendable_coin + + assert _is_spendable_coin({"state": "SETTLED", "isLocked": True}) is False + + # --------------------------------------------------------------------------- # _resolve_coin_global_ids unit tests # --------------------------------------------------------------------------- @@ -4020,6 +4437,7 @@ def test_build_and_post_offer_cloud_wallet_happy_path_dexie( monkeypatch, tmp_path: Path, capsys ) -> None: from greenfloor.cli.manager import _build_and_post_offer_cloud_wallet + from greenfloor.storage.sqlite import SqliteStore program_path = tmp_path / "program.yaml" markets_path = tmp_path / "markets.yaml" @@ -4027,7 +4445,6 @@ def test_build_and_post_offer_cloud_wallet_happy_path_dexie( _write_markets_with_ladder(markets_path) prog, mkt = _load_program_and_market(program_path, markets_path) prog.home_dir = str(tmp_path) - prog.home_dir = str(tmp_path) reset_concurrent_log_handlers(module=manager_mod) class _FakeWallet: @@ -4051,7 +4468,7 @@ def create_offer( return {"signature_request_id": "sr-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": [{"bech32": "offer1testartifact"}]} posted = {} @@ -4106,6 +4523,20 @@ def get_offer(offer_id: str) -> dict[str, object]: payload["results"][0]["result"]["offer_view_url"] == "https://dexie.space/offers/dexie-99" ) assert payload["offer_fee_mojos"] == 0 + db_path = (tmp_path / "db" / "greenfloor.sqlite").resolve() + store = SqliteStore(db_path) + try: + events = store.list_recent_audit_events( + event_types=["strategy_offer_execution"], + market_id="m1", + limit=1, + ) + finally: + store.close() + assert len(events) == 1 + event_items = list((events[0].get("payload") or {}).get("items") or []) + assert len(event_items) == 1 + assert event_items[0]["side"] == "sell" assert captured.err == "" log_text = (tmp_path / "logs" / "debug.log").read_text(encoding="utf-8") assert "signed_offer_file:offer1testartifact" in log_text @@ -4124,6 +4555,7 @@ def test_build_and_post_offer_cloud_wallet_uses_market_configured_expiry_overrid _write_program_with_cloud_wallet(program_path) _write_markets_with_ladder(markets_path) 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 @@ -4153,7 +4585,7 @@ def create_offer( return {"signature_request_id": "sr-expiry-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": [{"bech32": "offer1cwexpiry"}]} class _FakeDexie: @@ -4208,6 +4640,103 @@ def get_offer(offer_id: str) -> dict[str, object]: assert payload["publish_failures"] == 0 +def test_build_and_post_offer_cloud_wallet_records_buy_side_in_audit_event( + monkeypatch, tmp_path: Path, capsys +) -> None: + from greenfloor.cli.manager import _build_and_post_offer_cloud_wallet + from greenfloor.storage.sqlite import SqliteStore + + program_path = tmp_path / "program.yaml" + markets_path = tmp_path / "markets.yaml" + _write_program_with_cloud_wallet(program_path) + _write_markets_with_ladder(markets_path) + prog, mkt = _load_program_and_market(program_path, markets_path) + prog.home_dir = str(tmp_path) + + class _FakeWallet: + vault_id = "wallet-1" + network = "mainnet" + + def __init__(self, _config): + pass + + @staticmethod + def create_offer( + *, + offered, + requested, + fee, + expires_at_iso, + split_input_coins=True, + split_input_coins_fee=0, + ): + _ = offered, requested, fee, expires_at_iso, split_input_coins, split_input_coins_fee + return {"signature_request_id": "sr-buy-audit-1", "status": "UNSIGNED"} + + @staticmethod + def get_wallet(*, is_creator=None, states=None, first=100): + return {"offers": [{"bech32": "offer1buyaudit"}]} + + class _FakeDexie: + def __init__(self, _base_url: str): + pass + + @staticmethod + def post_offer(_offer: str, *, drop_only: bool, claim_rewards: bool | None): + _ = drop_only, claim_rewards + return {"success": True, "id": "dexie-buy-audit-1"} + + @staticmethod + def get_offer(offer_id: str) -> dict[str, object]: + return {"success": True, "offer": {"id": str(offer_id), "status": 0}} + + monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) + monkeypatch.setattr( + "greenfloor.cli.manager._poll_signature_request_until_not_unsigned", + lambda **kwargs: ("SUBMITTED", []), + ) + monkeypatch.setattr( + "greenfloor.cli.manager._poll_offer_artifact_until_available", + lambda **kwargs: "offer1buyaudit", + ) + monkeypatch.setattr("greenfloor.cli.manager._verify_offer_text_for_dexie", lambda _offer: None) + monkeypatch.setattr("greenfloor.cli.manager.DexieAdapter", _FakeDexie) + monkeypatch.setattr( + "greenfloor.cli.manager._initialize_manager_file_logging", lambda *a, **k: None + ) + + code, _ = _build_and_post_offer_cloud_wallet( + program=prog, + market=mkt, + size_base_units=10, + repeat=1, + publish_venue="dexie", + dexie_base_url="https://api.dexie.space", + splash_base_url="http://localhost:4000", + drop_only=True, + claim_rewards=False, + quote_price=0.003, + dry_run=False, + action_side="buy", + ) + assert code == 0 + _ = capsys.readouterr() + db_path = (tmp_path / "db" / "greenfloor.sqlite").resolve() + store = SqliteStore(db_path) + try: + events = store.list_recent_audit_events( + event_types=["strategy_offer_execution"], + market_id="m1", + limit=1, + ) + finally: + store.close() + assert len(events) == 1 + event_items = list((events[0].get("payload") or {}).get("items") or []) + assert len(event_items) == 1 + assert event_items[0]["side"] == "buy" + + def test_build_and_post_offer_cloud_wallet_fails_when_dexie_offer_not_visible( monkeypatch, tmp_path: Path, capsys ) -> None: @@ -4240,7 +4769,7 @@ def create_offer( return {"signature_request_id": "sr-visibility-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": [{"bech32": "offer1cwvisibility"}]} class _FakeDexie: @@ -4324,7 +4853,7 @@ def create_offer( return {"signature_request_id": "sr-mismatch-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": [{"bech32": "offer1cwmismatch"}]} class _FakeDexie: @@ -4344,7 +4873,7 @@ def get_offer(offer_id: str) -> dict[str, object]: "id": str(offer_id), "offered": [ { - "id": "a1", + "id": "unexpected_asset", "amount": 10, } ], @@ -4384,7 +4913,7 @@ def get_offer(offer_id: str) -> dict[str, object]: assert code == 2 payload = json.loads(capsys.readouterr().out.strip()) assert payload["publish_failures"] == 1 - assert "dexie_offer_base_amount_mismatch" in payload["results"][0]["result"]["error"] + assert "dexie_offer_offered_asset_missing" in payload["results"][0]["result"]["error"] def test_build_and_post_offer_cloud_wallet_returns_error_when_no_offer_artifact( @@ -4419,7 +4948,7 @@ def create_offer( return {"signature_request_id": "sr-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": []} # no offer1... bech32 monkeypatch.setattr("greenfloor.cli.manager.CloudWalletAdapter", _FakeWallet) @@ -4483,7 +5012,7 @@ def create_offer( return {"signature_request_id": "sr-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": [{"bech32": "offer1badoffer"}]} post_called = [False] @@ -4562,7 +5091,7 @@ def create_offer( return {"signature_request_id": "sr-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": [{"bech32": "offer1dryruncloudwallet"}]} class _FailDexie: @@ -4616,6 +5145,7 @@ def test_build_and_post_offer_cloud_wallet_uses_bootstrap_fallback_split_fee( _write_program_with_cloud_wallet(program_path) _write_markets_with_ladder(markets_path) prog, mkt = _load_program_and_market(program_path, markets_path) + prog.home_dir = str(tmp_path) create_offer_calls: list[int] = [] @@ -4641,7 +5171,7 @@ def create_offer( return {"signature_request_id": "sr-1", "status": "UNSIGNED"} @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": [{"bech32": "offer1bootstrapfee"}]} class _FakeDexie: @@ -4761,8 +5291,10 @@ class _Plan: market=_Market(), wallet=cast(CloudWalletAdapter, _Wallet()), resolved_base_asset_id="xch", + resolved_quote_asset_id="wusdc", key_id="key-main-1", keyring_yaml_path=str(keyring_path), + quote_price=0.999, ) assert result["status"] == "failed" assert result["reason"] == "bootstrap_wait_failed" @@ -4829,8 +5361,10 @@ class _Plan: market=_Market(), wallet=cast(CloudWalletAdapter, _Wallet()), resolved_base_asset_id="xch", + resolved_quote_asset_id="wusdc", key_id="key-main-1", keyring_yaml_path=str(keyring_path), + quote_price=0.999, ) assert result["status"] == "failed" assert "insufficient_xch_fee_balance_for_mixed_split" in str(result["reason"]) @@ -4839,30 +5373,121 @@ class _Plan: ) +def test_ensure_offer_bootstrap_denominations_buy_waits_on_quote_asset( + monkeypatch, tmp_path: Path +) -> None: + keyring_path = tmp_path / "keyring.yaml" + keyring_path.write_text("keys: []\n", encoding="utf-8") + wait_asset_ids: list[str] = [] + list_asset_ids: list[str | None] = [] + + class _Program: + app_network = "mainnet" + coin_ops_minimum_fee_mojos = 0 + cloud_wallet_base_url = "https://api.vault.chia.net" + cloud_wallet_user_key_id = "k" + cloud_wallet_private_key_pem_path = "/tmp/key.pem" + cloud_wallet_vault_id = "Wallet_abc" + cloud_wallet_kms_key_id = "" + cloud_wallet_kms_region = "" + cloud_wallet_kms_public_key_hex = "" + + class _LadderEntry: + size_base_units = 10 + target_count = 1 + split_buffer_count = 0 + + class _Market: + ladders = {"buy": [_LadderEntry()]} + receive_address = "xch1test" + base_asset = "base_asset" + quote_asset = "quote_asset" + pricing = {"quote_unit_mojo_multiplier": 1000} + + class _Wallet: + @staticmethod + def list_coins(*, asset_id=None, include_pending=False): + _ = include_pending + list_asset_ids.append(asset_id) + return [{"id": "coin_big", "amount": 50_000, "state": "CONFIRMED"}] + + class _Plan: + source_coin_id = "coin_big" + source_amount = 50_000 + output_amounts_base_units = [10_000] + total_output_amount = 10_000 + change_amount = 40_000 + deficits = [] + + monkeypatch.setattr("greenfloor.cli.manager.plan_bootstrap_mixed_outputs", lambda **_k: _Plan()) + monkeypatch.setattr( + "greenfloor.cli.manager._resolve_bootstrap_split_fee", + lambda **_k: (0, "coinset_conservative", None), + ) + monkeypatch.setattr( + "greenfloor.cli.manager.sign_and_broadcast_mixed_split", + lambda _payload: {"status": "executed", "operation_id": "tx-1"}, + ) + monkeypatch.setattr( + "greenfloor.cli.manager._wait_for_mempool_then_confirmation", + lambda **kwargs: wait_asset_ids.append(str(kwargs.get("asset_id"))) or [], + ) + + result = manager_mod._ensure_offer_bootstrap_denominations( + program=_Program(), + market=_Market(), + wallet=cast(CloudWalletAdapter, _Wallet()), + resolved_base_asset_id="Asset_base", + resolved_quote_asset_id="Asset_quote", + key_id="key-main-1", + keyring_yaml_path=str(keyring_path), + quote_price=1.0, + action_side="buy", + ) + assert result["status"] == "executed" + assert wait_asset_ids == ["Asset_quote"] + assert list_asset_ids[0] == "Asset_quote" + + def test_poll_offer_artifact_until_available_returns_new_offer(monkeypatch) -> None: wallets = [ { "offers": [ - {"offerId": "old-1", "bech32": "offer1old", "expiresAt": "2026-01-01T00:00:00Z"} + { + "offerId": "old-1", + "state": "OPEN", + "bech32": "offer1old", + "expiresAt": "2026-01-01T00:00:00Z", + } ] }, { "offers": [ - {"offerId": "new-1", "bech32": "offer1new", "expiresAt": "2026-01-02T00:00:00Z"} + { + "offerId": "new-1", + "state": "OPEN", + "bech32": "offer1new", + "expiresAt": "2026-01-02T00:00:00Z", + } ] }, ] - monotonic_values = iter([0.0, 1.0, 1.0]) class _FakeWallet: @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): if wallets: return wallets.pop(0) return {"offers": []} + monotonic_tick = {"value": 0.0} + + def _mono() -> float: + monotonic_tick["value"] += 1.0 + return float(monotonic_tick["value"]) + monkeypatch.setattr("greenfloor.cli.manager.time.sleep", lambda _seconds: None) - monkeypatch.setattr("greenfloor.cli.manager.time.monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr("greenfloor.cli.manager.time.monotonic", _mono) offer = manager_mod._poll_offer_artifact_until_available( wallet=cast(CloudWalletAdapter, _FakeWallet()), @@ -4878,12 +5503,14 @@ def test_poll_offer_artifact_until_available_filters_out_stale_created_at(monkey "offers": [ { "offerId": "stale-1", + "state": "OPEN", "bech32": "offer1stale", "expiresAt": "2026-01-03T00:00:00Z", "createdAt": "2026-01-01T00:00:00Z", }, { "offerId": "new-1", + "state": "OPEN", "bech32": "offer1new", "expiresAt": "2026-01-04T00:00:00Z", "createdAt": "2026-01-02T00:00:00Z", @@ -4891,17 +5518,22 @@ def test_poll_offer_artifact_until_available_filters_out_stale_created_at(monkey ] } ] - monotonic_values = iter([0.0, 0.5, 0.5]) class _FakeWallet: @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): if wallets: return wallets.pop(0) return {"offers": []} + monotonic_tick = {"value": 0.0} + + def _mono() -> float: + monotonic_tick["value"] += 0.5 + return float(monotonic_tick["value"]) + monkeypatch.setattr("greenfloor.cli.manager.time.sleep", lambda _seconds: None) - monkeypatch.setattr("greenfloor.cli.manager.time.monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr("greenfloor.cli.manager.time.monotonic", _mono) offer = manager_mod._poll_offer_artifact_until_available( wallet=cast(CloudWalletAdapter, _FakeWallet()), @@ -4917,7 +5549,7 @@ def test_poll_offer_artifact_until_available_times_out(monkeypatch) -> None: class _FakeWallet: @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): return {"offers": []} monkeypatch.setattr("greenfloor.cli.manager.time.sleep", lambda _seconds: None) @@ -4937,7 +5569,6 @@ def get_wallet(): def test_poll_offer_artifact_until_available_requests_creator_open_pending(monkeypatch) -> None: calls: list[tuple[bool | None, list[str] | None, int]] = [] - monotonic_values = iter([0.0, 0.5, 0.5]) class _FakeWallet: @staticmethod @@ -4945,12 +5576,23 @@ def get_wallet(*, is_creator=None, states=None, first=0): calls.append((is_creator, states, first)) return { "offers": [ - {"offerId": "new-1", "bech32": "offer1new", "expiresAt": "2026-01-02T00:00:00Z"} + { + "offerId": "new-1", + "state": "OPEN", + "bech32": "offer1new", + "expiresAt": "2026-01-02T00:00:00Z", + } ] } + monotonic_tick = {"value": 0.0} + + def _mono() -> float: + monotonic_tick["value"] += 0.5 + return float(monotonic_tick["value"]) + monkeypatch.setattr("greenfloor.cli.manager.time.sleep", lambda _seconds: None) - monkeypatch.setattr("greenfloor.cli.manager.time.monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr("greenfloor.cli.manager.time.monotonic", _mono) offer = manager_mod._poll_offer_artifact_until_available( wallet=cast(CloudWalletAdapter, _FakeWallet()), @@ -4992,7 +5634,7 @@ def test_poll_offer_artifact_until_available_requires_open_state(monkeypatch) -> class _FakeWallet: @staticmethod - def get_wallet(): + def get_wallet(*, is_creator=None, states=None, first=100): if wallets: return wallets.pop(0) return {"offers": []} @@ -5176,8 +5818,10 @@ def get_offer(offer_id: str) -> dict[str, object]: "greenfloor.cli.manager._initialize_manager_file_logging", lambda *a, **k: None ) + program = manager_mod.load_program_config(program_path) + program.home_dir = str(tmp_path) code, _ = manager_mod._build_and_post_offer_cloud_wallet( - program=manager_mod.load_program_config(program_path), + program=program, market=market, size_base_units=1, repeat=1, @@ -5466,6 +6110,47 @@ def create_offer(self, **_kwargs): assert wallet.calls == 1 +def test_cloud_wallet_create_offer_phase_buy_side_swaps_offer_legs(monkeypatch) -> None: + captured: dict[str, Any] = {} + + class _Wallet: + def create_offer(self, **kwargs): + captured.update(kwargs) + return {"signature_request_id": "sr-buy", "status": "UNSIGNED"} + + monkeypatch.setattr( + manager_mod, + "_wallet_get_wallet_offers", + lambda *_args, **_kwargs: {"offers": []}, + ) + monkeypatch.setattr( + manager_mod, + "_poll_signature_request_until_not_unsigned", + lambda **_kwargs: ("SUBMITTED", []), + ) + market = type( + "Market", + (), + {"pricing": {"base_unit_mojo_multiplier": 1000, "quote_unit_mojo_multiplier": 1000}}, + )() + payload = manager_mod._cloud_wallet_create_offer_phase( + wallet=cast(CloudWalletAdapter, _Wallet()), + market=market, + size_base_units=10, + quote_price=0.999, + 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=30, + action_side="buy", + ) + assert payload["side"] == "buy" + assert captured["offered"] == [{"assetId": "Asset_quote", "amount": 9990}] + assert captured["requested"] == [{"assetId": "Asset_base", "amount": 10000}] + + def test_cloud_wallet_post_offer_phase_verifies_dexie_visibility(monkeypatch) -> None: class _Dexie: pass @@ -5490,7 +6175,10 @@ class _Dexie: drop_only=False, claim_rewards=False, market=market, - size_base_units=1, + expected_offered_asset_id="asset", + expected_offered_symbol="asset", + expected_requested_asset_id="xch", + expected_requested_symbol="xch", ) assert result["success"] is False assert "dexie_offer_not_visible_after_publish" in str(result["error"])