diff --git a/docs/README.md b/docs/README.md index 388671a8..4a087bd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ accepted decision** when onboarding. | ADR | Topic | | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | +| [0023](decisions/0023-canonical-cat-outer-puzzle-hash.md) | **CAT outer hash** — one `coinset/cats/outer` primitive + Coinset hex | | [0021](decisions/0021-three-ownership-simplifications.md) | **Ownership spines** — expired maker, reconcile prep, `coin_ops::shape` | | [0020](decisions/0020-soft-expiry-stable-makers.md) | **Soft listing expiry** — stable makers, `ensure_size_n_offer` | | [0018](decisions/0018-coinset-parse-decomposition.md) | **Coinset submodule layout** — parse, pagination, rpc_result, json_util | diff --git a/docs/decisions/0023-canonical-cat-outer-puzzle-hash.md b/docs/decisions/0023-canonical-cat-outer-puzzle-hash.md new file mode 100644 index 00000000..805c9420 --- /dev/null +++ b/docs/decisions/0023-canonical-cat-outer-puzzle-hash.md @@ -0,0 +1,50 @@ +# ADR 0023: Canonical CAT outer puzzle-hash module + +## Status + +Accepted (2026-08-10). + +## Context + +`cat(asset_id, p2)` outer puzzle hashes were computed with the same +`CatArgs::curry_tree_hash` formula in four places: + +- `vault/cat_create.rs` (`receive_cat_outer_puzzle_hash`) +- `vault_coinset_scan/cat_outer.rs` (Bytes32 + hex adapters) +- `coinset/wallet_io.rs` (`cat_outer_puzzle_hash_hex`) +- `coinset/cats/list.rs` (unspent CAT listing) + +Callers learned “how to curry,” not “give me the outer.” Hex format differences +justified a thin Coinset adapter; the hash formula did not. + +## Decision + +One deep module owns the Bytes32 primitive: + +| Module | Responsibility | +| ----------------------- | ---------------------------------------------------------------------------------- | +| `coinset/cats/outer.rs` | `cat_outer_puzzle_hash(asset, p2) → Bytes32` once; `cat_outer_coinset_hex` adapter | + +**Ownership split:** + +- **`coinset/cats/outer`** owns the curry formula and the Coinset `0x` hex adapter. +- **`vault/cat_create`** keeps double-wrap assert policy and calls `cat_outer_puzzle_hash` + directly (no alias). +- **`wallet_io` / `cats/list` / vault scan** adapt inputs (address decode, Coinset queries) + and call the shared primitive — they do not re-curry. +- Bare-hex compare sites use `normalize_hex_id` on the Coinset form; there is no second + outer-hash helper. + +`vault_coinset_scan/cat_outer.rs` is deleted; scan paths import +`crate::coinset::{cat_outer_coinset_hex, cat_outer_puzzle_hash}`. + +## Consequences + +- Outer-hash bugs and CAT puzzle upgrades localize to one module. +- Create / scan / WS inventory / list share one `(asset, p2)` interface. +- No behavior change: adapters preserve `0x` Coinset hex; compare paths normalize that form. + +## References + +- Architecture review 2026-08-10 (Canonical CAT outer puzzle-hash module) +- [0018](0018-coinset-parse-decomposition.md) — Coinset submodule ownership pattern diff --git a/docs/progress.md b/docs/progress.md index 2c9cf038..d3dd6e28 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -16,6 +16,12 @@ Pre-Rust migration detail lives in git history and ## Milestones +### 2026-08-10 — Canonical CAT outer puzzle-hash (ADR 0023) + +`cat(asset_id, p2)` outer hashes curry once in `coinset/cats/outer`; vault create-assert +calls that primitive directly; scan / wallet_io / list use thin adapters (`cat_outer_coinset_hex` +plus `normalize_hex_id` where bare hex is needed). Deleted shallow `vault_coinset_scan/cat_outer`. + ### 2026-08-10 — Unique maker pin session deepen ADR 0022 pin protocol lives behind `UniqueMakerPinSession` (`begin` → diff --git a/greenfloor-engine/src/coinset/cats/list.rs b/greenfloor-engine/src/coinset/cats/list.rs index 684f412b..feb7598b 100644 --- a/greenfloor-engine/src/coinset/cats/list.rs +++ b/greenfloor-engine/src/coinset/cats/list.rs @@ -1,9 +1,9 @@ use chia_protocol::Bytes32; -use chia_puzzle_types::cat::CatArgs; use chia_sdk_coinset::{ChiaRpcClient, CoinRecord, CoinsetClient}; use chia_sdk_driver::Cat; use futures_util::future::try_join_all; +use super::outer::cat_outer_puzzle_hash; use super::resolve; use crate::bech32m::decode_address; use crate::coinset::pagination::coin_records_by_puzzle_hash; @@ -17,8 +17,8 @@ pub(crate) async fn coin_records_for_cat_outer_puzzle_hash( asset_id: Bytes32, ) -> SignerResult> { let p2_puzzle_hash = decode_address(receive_address)?; - let cat_outer_puzzle_hash = CatArgs::curry_tree_hash(asset_id, p2_puzzle_hash.into()).into(); - coin_records_by_puzzle_hash(client, cat_outer_puzzle_hash, None, None, Some(false)).await + let outer = cat_outer_puzzle_hash(asset_id, p2_puzzle_hash); + coin_records_by_puzzle_hash(client, outer, None, None, Some(false)).await } /// Resolve spendable [`Cat`] values with lineage proofs for coin records. diff --git a/greenfloor-engine/src/coinset/cats/mod.rs b/greenfloor-engine/src/coinset/cats/mod.rs index 0f82726e..41325aa4 100644 --- a/greenfloor-engine/src/coinset/cats/mod.rs +++ b/greenfloor-engine/src/coinset/cats/mod.rs @@ -1,8 +1,10 @@ mod list; +mod outer; mod resolve; pub(crate) use list::{coin_records_for_cat_outer_puzzle_hash, coin_records_for_coin_ids}; pub use list::{list_unspent_cats, list_unspent_cats_by_ids}; +pub(crate) use outer::{cat_outer_coinset_hex, cat_outer_puzzle_hash}; pub(crate) use resolve::cat_from_record; pub use resolve::{ cat_child_p2_create_coin_memos, cat_from_parent_spend, child_cat_asset_ids_from_parent_spend, diff --git a/greenfloor-engine/src/vault_coinset_scan/cat_outer.rs b/greenfloor-engine/src/coinset/cats/outer.rs similarity index 52% rename from greenfloor-engine/src/vault_coinset_scan/cat_outer.rs rename to greenfloor-engine/src/coinset/cats/outer.rs index 745680a9..3dbe5cf1 100644 --- a/greenfloor-engine/src/vault_coinset_scan/cat_outer.rs +++ b/greenfloor-engine/src/coinset/cats/outer.rs @@ -1,10 +1,15 @@ -//! CAT outer puzzle-hash helpers shared by discovery and prelabel. +//! Canonical CAT outer puzzle-hash primitive. +//! +//! All `GreenFloor` `cat(asset_id, p2)` outer hashes go through [`cat_outer_puzzle_hash`]. +//! Coinset hex is a thin format adapter; vault double-wrap assert policy stays in +//! `vault::cat_create`. Callers that need bare hex compare via +//! [`crate::hex::normalize_hex_id`]. use chia_protocol::Bytes32; use chia_puzzle_types::cat::CatArgs; use crate::coinset::to_coinset_hex; -use crate::hex::{hex_to_bytes32, normalize_hex_id}; +use crate::hex::hex_to_bytes32; /// `cat(asset_id, p2)` outer puzzle hash. #[must_use] @@ -20,26 +25,24 @@ pub(crate) fn cat_outer_coinset_hex(asset_id_hex: &str, p2_hex: &str) -> Option< Some(to_coinset_hex(cat_outer_puzzle_hash(asset, p2).as_ref())) } -/// Normalized outer puzzle hash for comparing against scanned coin rows. -#[must_use] -pub(crate) fn cat_outer_normalized_hex(asset_id_hex: &str, p2_hex: &str) -> Option { - let asset = hex_to_bytes32(asset_id_hex).ok()?; - let p2 = hex_to_bytes32(p2_hex).ok()?; - Some(normalize_hex_id(&hex::encode(cat_outer_puzzle_hash( - asset, p2, - )))) -} - #[cfg(test)] mod tests { use super::*; + use crate::hex::normalize_hex_id; #[test] - fn outer_helpers_agree_on_payload() { + fn coinset_hex_normalizes_to_bare_id() { let asset = "aa".repeat(32); let p2 = "11".repeat(32); let coinset = cat_outer_coinset_hex(&asset, &p2).expect("coinset"); - let normalized = cat_outer_normalized_hex(&asset, &p2).expect("normalized"); - assert_eq!(normalize_hex_id(&coinset), normalized); + let normalized = normalize_hex_id(&coinset); + assert_eq!(normalized.len(), 64); + assert_eq!( + cat_outer_puzzle_hash( + hex_to_bytes32(&asset).expect("asset"), + hex_to_bytes32(&p2).expect("p2"), + ), + hex_to_bytes32(&normalized).expect("round-trip") + ); } } diff --git a/greenfloor-engine/src/coinset/mod.rs b/greenfloor-engine/src/coinset/mod.rs index 4a8c7bdb..15f37962 100644 --- a/greenfloor-engine/src/coinset/mod.rs +++ b/greenfloor-engine/src/coinset/mod.rs @@ -43,6 +43,7 @@ pub use cats::{ fetch_parent_coin_spend, list_unspent_cats, list_unspent_cats_by_ids, require_cat_from_parent_spend, }; +pub(crate) use cats::{cat_outer_coinset_hex, cat_outer_puzzle_hash}; pub use coin_select::{select_cats_smallest_first, SelectedCats, MIN_CAT_OUTPUT_MOJOS}; pub use direct_api::{ effective_coinset_base_url, explicit_coinset_url_override, normalize_coinset_network, diff --git a/greenfloor-engine/src/coinset/wallet_io.rs b/greenfloor-engine/src/coinset/wallet_io.rs index 6a2a8b84..94712c7a 100644 --- a/greenfloor-engine/src/coinset/wallet_io.rs +++ b/greenfloor-engine/src/coinset/wallet_io.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use crate::bech32m::{decode_address, decode_offer}; use chia_protocol::SpendBundle; -use chia_puzzle_types::cat::CatArgs; use chia_traits::Streamable; use serde::Serialize; @@ -136,8 +135,9 @@ pub fn puzzle_hash_hex_for_receive_address(receive_address: &str) -> SignerResul pub fn cat_outer_puzzle_hash_hex(receive_address: &str, asset_id: &str) -> SignerResult { let puzzle_hash = decode_address(receive_address)?; let asset_bytes = hex_to_bytes32(asset_id)?; - let cat_outer: [u8; 32] = CatArgs::curry_tree_hash(asset_bytes, puzzle_hash.into()).into(); - Ok(to_coinset_hex(&cat_outer)) + Ok(to_coinset_hex( + cats::cat_outer_puzzle_hash(asset_bytes, puzzle_hash).as_ref(), + )) } /// Inventory puzzle hashes for one market receive address (inner + optional CAT outer). diff --git a/greenfloor-engine/src/vault/cat_create.rs b/greenfloor-engine/src/vault/cat_create.rs index 7d4d9b28..e05bd3e7 100644 --- a/greenfloor-engine/src/vault/cat_create.rs +++ b/greenfloor-engine/src/vault/cat_create.rs @@ -10,17 +10,11 @@ //! construction time; do not pass the CAT outer as the send destination. use chia_protocol::Bytes32; -use chia_puzzle_types::cat::CatArgs; use chia_sdk_driver::{Cat, Outputs}; +use crate::coinset::cat_outer_puzzle_hash; use crate::error::{SignerError, SignerResult}; -/// Outer puzzle hash for a single-wrapped CAT on `receive_p2`. -#[must_use] -pub(crate) fn receive_cat_outer_puzzle_hash(asset_id: Bytes32, receive_p2: Bytes32) -> Bytes32 { - CatArgs::curry_tree_hash(asset_id, receive_p2.into()).into() -} - /// CAT creates recorded on a [`Outputs`] map (after `Spends::prepare`). pub(crate) fn created_cats(outputs: &Outputs) -> impl Iterator { outputs.cats.values().flat_map(|cats| cats.iter()) @@ -41,7 +35,7 @@ pub(crate) fn assert_cat_creates<'a>( receive_p2: Bytes32, allowed_non_receive_p2s: &[Bytes32], ) -> SignerResult<()> { - let receive_outer = receive_cat_outer_puzzle_hash(asset_id, receive_p2); + let receive_outer = cat_outer_puzzle_hash(asset_id, receive_p2); for cat in cats { if cat.info.asset_id != asset_id { continue; @@ -93,7 +87,7 @@ mod tests { fn rejects_outer_as_p2() { let asset = Bytes32::new([0xaa; 32]); let receive = Bytes32::new([0x11; 32]); - let outer = receive_cat_outer_puzzle_hash(asset, receive); + let outer = cat_outer_puzzle_hash(asset, receive); let cat = sample_cat(asset, outer, 1_000); let err = assert_cat_creates([&cat], asset, receive, &[]).unwrap_err(); assert!(matches!( diff --git a/greenfloor-engine/src/vault/mixed_split.rs b/greenfloor-engine/src/vault/mixed_split.rs index 44499a3c..586bf591 100644 --- a/greenfloor-engine/src/vault/mixed_split.rs +++ b/greenfloor-engine/src/vault/mixed_split.rs @@ -285,11 +285,10 @@ mod tests { #[tokio::test] async fn build_mixed_split_rejects_double_wrap_and_keeps_receive_outputs() { + use crate::coinset::cat_outer_puzzle_hash; use crate::test_support::simulator::harness::SimulatorVaultHarness; use crate::test_support::simulator::SimulatorOfferCoinset; - use crate::vault::cat_create::{ - assert_cat_creates, created_cats, receive_cat_outer_puzzle_hash, - }; + use crate::vault::cat_create::{assert_cat_creates, created_cats}; use chia_puzzle_types::Memos; use chia_sdk_driver::{Action, Id, SpendContext, Spends}; @@ -314,7 +313,7 @@ mod tests { assert!(!spend_bundle.coin_spends.is_empty()); // Regression: Action::send to the CAT outer fails the create assert. - let expected_outer = receive_cat_outer_puzzle_hash(asset_id, receive_puzzle_hash); + let expected_outer = cat_outer_puzzle_hash(asset_id, receive_puzzle_hash); let mut bad_ctx = SpendContext::new(); let mut bad_spends = Spends::new(receive_puzzle_hash); bad_spends.add(cat); diff --git a/greenfloor-engine/src/vault_coinset_scan/cat_detect.rs b/greenfloor-engine/src/vault_coinset_scan/cat_detect.rs index c1009e04..addd3529 100644 --- a/greenfloor-engine/src/vault_coinset_scan/cat_detect.rs +++ b/greenfloor-engine/src/vault_coinset_scan/cat_detect.rs @@ -67,11 +67,14 @@ pub fn prelabel_known_cat_outers( let mut outer_to_asset: HashMap = HashMap::new(); for asset_id in requested_cat_ids { for p2_hex in &inner_p2s { - let Some(outer) = - crate::vault_coinset_scan::cat_outer::cat_outer_normalized_hex(asset_id, p2_hex) + let Some(coinset_outer) = crate::coinset::cat_outer_coinset_hex(asset_id, p2_hex) else { continue; }; + let outer = normalize_hex_id(&coinset_outer); + if outer.is_empty() { + continue; + } outer_to_asset.insert(outer, asset_id.clone()); } } @@ -439,11 +442,13 @@ mod tests { fn prelabel_known_cat_outers_labels_matching_receive_outer() { use std::collections::{BTreeMap, HashSet}; - use crate::vault_coinset_scan::cat_outer::cat_outer_normalized_hex; + use crate::coinset::cat_outer_coinset_hex; + use crate::hex::normalize_hex_id; let asset_id = "aa".repeat(32); let receive_p2 = "bb".repeat(32); - let outer = cat_outer_normalized_hex(&asset_id, &receive_p2).expect("outer"); + let outer = + normalize_hex_id(&cat_outer_coinset_hex(&asset_id, &receive_p2).expect("outer")); let coin_id = "cc".repeat(32); let mut rows = HashMap::from([( coin_id.clone(), diff --git a/greenfloor-engine/src/vault_coinset_scan/mod.rs b/greenfloor-engine/src/vault_coinset_scan/mod.rs index 810f20ef..79cc659f 100644 --- a/greenfloor-engine/src/vault_coinset_scan/mod.rs +++ b/greenfloor-engine/src/vault_coinset_scan/mod.rs @@ -2,7 +2,6 @@ pub mod asset_trace; pub mod cat_detect; -pub(crate) mod cat_outer; pub mod checkpoint; pub mod cli; pub mod dust; diff --git a/greenfloor-engine/src/vault_coinset_scan/state/nonce_scan.rs b/greenfloor-engine/src/vault_coinset_scan/state/nonce_scan.rs index ae2bc033..7e0d3f7b 100644 --- a/greenfloor-engine/src/vault_coinset_scan/state/nonce_scan.rs +++ b/greenfloor-engine/src/vault_coinset_scan/state/nonce_scan.rs @@ -2,11 +2,11 @@ use std::collections::{HashMap, HashSet}; use serde_json::Value; +use crate::coinset::cat_outer_coinset_hex; use crate::coinset::{coin_id_from_record, to_coinset_hex, u64_from_value}; use crate::error::SignerResult; use crate::hex::{hex_to_bytes32, normalize_hex_id}; use crate::vault::members::nonce_member_puzzle_hash_hex; -use crate::vault_coinset_scan::cat_outer::cat_outer_coinset_hex; use crate::vault_coinset_scan::types::{ AssetTypeFilter, CoinKind, CoinRow, DiscoverySource, ScanStopReason, }; @@ -377,7 +377,7 @@ mod tests { #[tokio::test] async fn hint_only_discovery_skips_member_nonce_walk() { - use crate::vault_coinset_scan::cat_outer::cat_outer_coinset_hex; + use crate::coinset::cat_outer_coinset_hex; let mut server = mockito::Server::new_async().await; let launcher_id = "11".repeat(32);