Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
50 changes: 50 additions & 0 deletions docs/decisions/0023-canonical-cat-outer-puzzle-hash.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` →
Expand Down
6 changes: 3 additions & 3 deletions greenfloor-engine/src/coinset/cats/list.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,8 +17,8 @@ pub(crate) async fn coin_records_for_cat_outer_puzzle_hash(
asset_id: Bytes32,
) -> SignerResult<Vec<CoinRecord>> {
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.
Expand Down
2 changes: 2 additions & 0 deletions greenfloor-engine/src/coinset/cats/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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<String> {
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")
);
}
}
1 change: 1 addition & 0 deletions greenfloor-engine/src/coinset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions greenfloor-engine/src/coinset/wallet_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> {
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).
Expand Down
12 changes: 3 additions & 9 deletions greenfloor-engine/src/vault/cat_create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = &Cat> {
outputs.cats.values().flat_map(|cats| cats.iter())
Expand All @@ -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;
Expand Down Expand Up @@ -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!(
Expand Down
7 changes: 3 additions & 4 deletions greenfloor-engine/src/vault/mixed_split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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);
Expand Down
13 changes: 9 additions & 4 deletions greenfloor-engine/src/vault_coinset_scan/cat_detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,14 @@ pub fn prelabel_known_cat_outers(
let mut outer_to_asset: HashMap<String, String> = 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());
}
}
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 0 additions & 1 deletion greenfloor-engine/src/vault_coinset_scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions greenfloor-engine/src/vault_coinset_scan/state/nonce_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
Expand Down
Loading