Skip to content

Commit 4723330

Browse files
Decompose coinset parse into focused submodules (#158)
* Decompose coinset parse into focused modules with unified RPC checks. Split parse, pagination, and utility concerns into dedicated modules so JSON parsing, cursor pagination, and RPC success handling each have a clear home without changing the public coinset API. Co-authored-by: Cursor <cursoragent@cursor.com> * Tighten coinset decomposition and document layout in ADR 0018. Inline unspent record filtering, fold pagination response parsing into cursor, adopt to_coinset_hex across in-crate callers, and record the submodule ownership split. Co-authored-by: Cursor <cursoragent@cursor.com> * Record coinset parse decomposition milestone in progress log. Document the #158 submodule refactor and ADR 0018 reference alongside other 2026-06-29 decomposition milestones. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4e1ef2b commit 4723330

27 files changed

Lines changed: 734 additions & 570 deletions

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ accepted decision** when onboarding.
77

88
| ADR | Topic |
99
| ---------------------------------------------------------------- | ----------------------------------------------------------------------- |
10+
| [0018](decisions/0018-coinset-parse-decomposition.md) | **Coinset submodule layout** — parse, pagination, rpc_result, json_util |
1011
| [0017](decisions/0017-offer-submodule-decompositions.md) | **Offer submodule layout** — bootstrap planner/phase + presplit |
1112
| [0015](decisions/0015-on-chain-offer-cancel.md) | **On-chain offer cancel** — reclaim spend, `cancel_submitted` lifecycle |
1213
| [0014](decisions/0014-offer-publish-module-decomposition.md) | **Offer publish decomposition** — bootstrap gate + publish assets |
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# ADR 0018: Coinset parse and pagination submodule decomposition
2+
3+
## Status
4+
5+
Accepted (2026-06-29)
6+
7+
## Context
8+
9+
`coinset/parse.rs` (~440 lines) mixed JSON payload parsing, typed coin-record helpers,
10+
RPC success checks, pagination field extraction, and generic utilities (`chunk_values`,
11+
`to_coinset_hex`). `pagination.rs` mixed cursor parsing with async page orchestration.
12+
Callers imported from a single `parse` module name that no longer matched its contents.
13+
14+
## Decision
15+
16+
### Coinset layout (`greenfloor-engine/src/coinset/`)
17+
18+
| Module | Responsibility |
19+
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
20+
| `parse/mod.rs` | Barrel re-exports for JSON → protocol parsing only |
21+
| `parse/payload.rs` | `coin_records_from_payload`, `record_from_payload` |
22+
| `parse/record.rs` | `coin_id_from_record`, `coin_from_record`, `coin_spend_from_solution_payload` |
23+
| `parse/tests.rs` | Parse unit tests |
24+
| `rpc_result.rs` | `ensure_coinset_success` (typed SDK responses), `ensure_coinset_rpc_success` (JSON payloads) |
25+
| `pagination/mod.rs` | Async cursor page orchestration (`fetch_all_coinset_pages`, endpoint wrappers) |
26+
| `pagination/cursor.rs` | `CoinsetRecordsPagination`, `pagination_from_*`, `ensure_complete_page`, `coin_records_page_from_response` |
27+
| `pagination/tests.rs` | Pagination unit tests |
28+
| `json_util.rs` | Coinset JSON scan helpers: `to_coinset_hex`, `u64_from_value` |
29+
| `batch.rs` | Generic `chunk_values` batching for scan/lineage queries |
30+
31+
**Ownership split:**
32+
33+
- **`rpc_result`** owns Coinset RPC success/failure mapping for typed and JSON responses.
34+
- **`parse`** owns JSON coin-record and spend decoding only.
35+
- **`pagination`** owns cursor pagination types, page parsing, and multi-page fetch loops.
36+
- **`json_util`** is the canonical in-crate `0x`-prefixed hex helper for Coinset IO (`wallet_io`, scan paths).
37+
38+
Unspent typed `CoinRecord` filtering (`!record.spent`) is inlined at call sites in `xch`,
39+
`coin_select`, and `cats/list` — a one-line filter does not warrant a shared module.
40+
41+
**Public API:** `greenfloor_engine::coinset::*` re-exports are unchanged (`chunk_values`,
42+
`to_coinset_hex`, `u64_from_value`, `ensure_coinset_rpc_success`, parse fns).
43+
44+
**Behavior note:** `coin_spend_from_solution_payload` uses `hex_to_bytes` (normalize + strip
45+
non-hex) instead of raw `hex::decode(trim_start_matches("0x"))`; tests document the broader
46+
acceptance of prefixed/mixed-case hex.
47+
48+
## Consequences
49+
50+
- Import `ensure_coinset_success` from `coinset::rpc_result` (crate-internal), not `parse`.
51+
- Import pagination helpers from `coinset::pagination`, not `parse`.
52+
- Prefer `json_util::to_coinset_hex` over local `format!("0x{}", hex::encode(...))` in `coinset/`.
53+
- Historical references to monolithic `parse.rs` / `pagination.rs` are superseded by this ADR.
54+
55+
## References
56+
57+
- [0007](0007-rust-signer-and-coinset-io.md) — Rust Coinset IO baseline
58+
- [0017](0017-offer-submodule-decompositions.md) — prior submodule decomposition pattern

docs/progress.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ Pre-Rust migration detail lives in git history and
1616

1717
## Milestones
1818

19+
### 2026-06-29 — Coinset parse and pagination decomposed (#158)
20+
21+
Split monolithic `coinset/parse.rs` into `parse/{payload,record,tests}.rs`; extracted
22+
`rpc_result`, `json_util`, and `batch`. Split `pagination.rs` into `pagination/` with cursor
23+
parsing in `cursor.rs` and async page orchestration in `mod.rs`. Unified typed and JSON RPC
24+
success checks; inlined unspent `CoinRecord` filtering; adopted `to_coinset_hex` across in-crate
25+
callers. Public `greenfloor_engine::coinset::*` re-exports unchanged. See ADR 0018.
26+
1927
### 2026-06-29 — Bootstrap phase mapping refactored (#157)
2028

2129
Decomposed `offer/bootstrap/phase.rs` into `phase/mod.rs` + `phase/tests.rs`. Typed

greenfloor-engine/src/coinset/api/rpc.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use serde_json::{json, Value};
33

44
use super::super::{
55
direct_api,
6-
pagination::coin_records_from_json_endpoint,
7-
parse::{coin_records_from_payload, pagination_from_payload, record_from_payload},
6+
pagination::{coin_records_from_json_endpoint, pagination_from_payload},
7+
parse::{coin_records_from_payload, record_from_payload},
88
retry::with_script_retries,
99
};
1010
use crate::error::{SignerError, SignerResult};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
pub fn chunk_values<T: Clone>(values: &[T], chunk_size: usize) -> Vec<Vec<T>> {
2+
if chunk_size == 0 {
3+
return if values.is_empty() {
4+
Vec::new()
5+
} else {
6+
vec![values.to_vec()]
7+
};
8+
}
9+
values.chunks(chunk_size).map(<[T]>::to_vec).collect()
10+
}
11+
12+
#[cfg(test)]
13+
mod tests {
14+
use super::chunk_values;
15+
16+
#[test]
17+
fn chunk_values_respects_batch_size() {
18+
let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
19+
assert_eq!(
20+
chunk_values(&values, 2),
21+
vec![
22+
vec!["a".to_string(), "b".to_string()],
23+
vec!["c".to_string()]
24+
]
25+
);
26+
}
27+
28+
#[test]
29+
fn chunk_values_zero_batch_returns_single_chunk() {
30+
let values = vec![1, 2, 3];
31+
assert_eq!(chunk_values(&values, 0), vec![vec![1, 2, 3]]);
32+
assert!(chunk_values::<i32>(&[], 2).is_empty());
33+
}
34+
}

greenfloor-engine/src/coinset/broadcast.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use chia_protocol::SpendBundle;
22
use chia_sdk_coinset::{ChiaRpcClient, CoinsetClient};
33
use chia_traits::Streamable;
44

5-
use super::parse::ensure_coinset_typed_rpc_success;
5+
use super::rpc_result::ensure_coinset_success;
66
use crate::error::SignerResult;
77
use crate::hex::canonical_tx_id;
88

@@ -31,7 +31,11 @@ pub async fn broadcast_spend_bundle(
3131
.push_tx(spend_bundle)
3232
.await
3333
.map_err(crate::error::SignerError::from)?;
34-
ensure_coinset_typed_rpc_success(&response, "push_tx failed")?;
34+
ensure_coinset_success(
35+
response.success,
36+
response.error.as_deref(),
37+
"push_tx failed",
38+
)?;
3539
Ok(BroadcastSpendBundleResult {
3640
status: response.status,
3741
operation_id,

greenfloor-engine/src/coinset/cats/list.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use chia_sdk_coinset::{ChiaRpcClient, CoinRecord, CoinsetClient};
44
use chia_sdk_driver::Cat;
55
use futures_util::future::try_join_all;
66

7-
use super::{resolve, unspent_coin_records};
7+
use super::resolve;
88
use crate::bech32m::decode_address;
99
use crate::coinset::pagination::coin_records_by_puzzle_hash;
1010
use crate::coinset::retry::with_coinset_client_retries;
@@ -90,7 +90,7 @@ async fn unspent_cats_from_records(
9090
client: &CoinsetClient,
9191
records: Vec<CoinRecord>,
9292
) -> SignerResult<Vec<Cat>> {
93-
let records: Vec<CoinRecord> = unspent_coin_records(records).collect();
93+
let records: Vec<CoinRecord> = records.into_iter().filter(|record| !record.spent).collect();
9494
cats_with_lineage_from_records(client, &records).await
9595
}
9696

greenfloor-engine/src/coinset/cats/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
mod list;
22
mod resolve;
33

4-
pub(crate) use super::parse::unspent_coin_records;
54
pub(crate) use list::{coin_records_for_cat_outer_puzzle_hash, coin_records_for_coin_ids};
65
pub use list::{list_unspent_cats, list_unspent_cats_by_ids};
76
pub(crate) use resolve::cat_from_record;

greenfloor-engine/src/coinset/coin_select/mod.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ use chia_sdk_driver::Cat;
99
use super::cats::{
1010
cat_from_record, coin_records_for_cat_outer_puzzle_hash, coin_records_for_coin_ids,
1111
};
12-
use super::parse::unspent_coin_records;
1312
use crate::error::{SignerError, SignerResult};
1413

1514
/// Minimum CAT output amount for offer/dust policy (1000 mojos = 1 CAT unit).
@@ -186,12 +185,17 @@ pub(crate) async fn select_cats_for_spend(
186185
target_amount: u64,
187186
) -> SignerResult<SelectedCats> {
188187
let records = if explicit_coin_ids.is_empty() {
189-
unspent_coin_records(
190-
coin_records_for_cat_outer_puzzle_hash(client, receive_address, asset_id).await?,
191-
)
192-
.collect()
188+
coin_records_for_cat_outer_puzzle_hash(client, receive_address, asset_id)
189+
.await?
190+
.into_iter()
191+
.filter(|record| !record.spent)
192+
.collect()
193193
} else {
194-
unspent_coin_records(coin_records_for_coin_ids(client, explicit_coin_ids).await?).collect()
194+
coin_records_for_coin_ids(client, explicit_coin_ids)
195+
.await?
196+
.into_iter()
197+
.filter(|record| !record.spent)
198+
.collect()
195199
};
196200
select_cats_for_spend_from_records(client, records, explicit_coin_ids, target_amount).await
197201
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#[must_use]
2+
pub fn to_coinset_hex(bytes: &[u8]) -> String {
3+
format!("0x{}", hex::encode(bytes))
4+
}
5+
6+
#[must_use]
7+
pub fn u64_from_value(value: Option<&serde_json::Value>, default: u64) -> u64 {
8+
value
9+
.and_then(|raw| {
10+
raw.as_u64()
11+
.or_else(|| raw.as_i64().and_then(|v| u64::try_from(v).ok()))
12+
})
13+
.unwrap_or(default)
14+
}
15+
16+
#[cfg(test)]
17+
mod tests {
18+
use super::*;
19+
use serde_json::json;
20+
21+
#[test]
22+
fn to_coinset_hex_prefixes_0x() {
23+
assert_eq!(to_coinset_hex(&[0xab]), "0xab");
24+
}
25+
26+
#[test]
27+
fn u64_from_value_prefers_u64_and_parses_i64() {
28+
assert_eq!(u64_from_value(Some(&json!(42_u64)), 0), 42);
29+
assert_eq!(u64_from_value(Some(&json!(7_i64)), 0), 7);
30+
assert_eq!(u64_from_value(Some(&json!("bad")), 99), 99);
31+
assert_eq!(u64_from_value(None, 5), 5);
32+
}
33+
}

0 commit comments

Comments
 (0)