From a78a6de3b2b362ccefa86370256d0a9f87a34d08 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Nov 2026 23:10:59 -0500 Subject: [PATCH 1/7] Rewrite the Wallet SDK documentation --- docs/sdk/action-system/actions.md | 578 +++++++++++ docs/sdk/action-system/custom-spends.md | 287 ++++++ docs/sdk/action-system/index.md | 270 ++++++ docs/sdk/actions.md | 1157 ----------------------- docs/sdk/bindings.md | 297 ++++++ docs/sdk/clear-signing.md | 307 ++++++ docs/sdk/concepts/clvm.md | 458 +++++++++ docs/sdk/concepts/conditions.md | 327 +++++++ docs/sdk/concepts/layers.md | 398 ++++++++ docs/sdk/concepts/signing.md | 219 +++++ docs/sdk/concepts/spend-context.md | 257 +++++ docs/sdk/connectivity.md | 139 --- docs/sdk/index.md | 236 ++--- docs/sdk/networking/daemon.md | 189 ++++ docs/sdk/networking/peer.md | 492 ++++++++++ docs/sdk/networking/rpc.md | 329 +++++++ docs/sdk/offers.md | 775 +++++++-------- docs/sdk/patterns.md | 410 ++++---- docs/sdk/primitives/action-layer.md | 411 ++++++++ docs/sdk/primitives/bulletin.md | 95 ++ docs/sdk/primitives/cat.md | 990 +++++++++---------- docs/sdk/primitives/clawback.md | 298 ++++++ docs/sdk/primitives/datalayer.md | 281 ++++++ docs/sdk/primitives/did.md | 351 +++++++ docs/sdk/primitives/index.md | 240 +++++ docs/sdk/primitives/nft.md | 802 +++++++--------- docs/sdk/primitives/options.md | 303 ++++++ docs/sdk/primitives/other.md | 252 ----- docs/sdk/primitives/singletons.md | 302 ++++++ docs/sdk/primitives/standard.md | 841 ++++------------ docs/sdk/primitives/streaming.md | 190 ++++ docs/sdk/primitives/vaults.md | 567 +++++++++++ docs/sdk/quick-start.md | 224 +++++ docs/sdk/spend-context.md | 373 -------- docs/sdk/testing.md | 651 ------------- docs/sdk/testing/full-node-simulator.md | 393 ++++++++ docs/sdk/testing/index.md | 497 ++++++++++ docs/sdk/testing/peer-simulator.md | 272 ++++++ docs/sdk/troubleshooting.md | 559 ++++------- docs/sdk/utilities.md | 262 +++++ docusaurus.config.ts | 9 +- sidebars.ts | 101 +- 42 files changed, 11036 insertions(+), 5353 deletions(-) create mode 100644 docs/sdk/action-system/actions.md create mode 100644 docs/sdk/action-system/custom-spends.md create mode 100644 docs/sdk/action-system/index.md delete mode 100644 docs/sdk/actions.md create mode 100644 docs/sdk/bindings.md create mode 100644 docs/sdk/clear-signing.md create mode 100644 docs/sdk/concepts/clvm.md create mode 100644 docs/sdk/concepts/conditions.md create mode 100644 docs/sdk/concepts/layers.md create mode 100644 docs/sdk/concepts/signing.md create mode 100644 docs/sdk/concepts/spend-context.md delete mode 100644 docs/sdk/connectivity.md create mode 100644 docs/sdk/networking/daemon.md create mode 100644 docs/sdk/networking/peer.md create mode 100644 docs/sdk/networking/rpc.md create mode 100644 docs/sdk/primitives/action-layer.md create mode 100644 docs/sdk/primitives/bulletin.md create mode 100644 docs/sdk/primitives/clawback.md create mode 100644 docs/sdk/primitives/datalayer.md create mode 100644 docs/sdk/primitives/did.md create mode 100644 docs/sdk/primitives/index.md create mode 100644 docs/sdk/primitives/options.md delete mode 100644 docs/sdk/primitives/other.md create mode 100644 docs/sdk/primitives/singletons.md create mode 100644 docs/sdk/primitives/streaming.md create mode 100644 docs/sdk/primitives/vaults.md create mode 100644 docs/sdk/quick-start.md delete mode 100644 docs/sdk/spend-context.md delete mode 100644 docs/sdk/testing.md create mode 100644 docs/sdk/testing/full-node-simulator.md create mode 100644 docs/sdk/testing/index.md create mode 100644 docs/sdk/testing/peer-simulator.md create mode 100644 docs/sdk/utilities.md diff --git a/docs/sdk/action-system/actions.md b/docs/sdk/action-system/actions.md new file mode 100644 index 0000000..2f612f4 --- /dev/null +++ b/docs/sdk/action-system/actions.md @@ -0,0 +1,578 @@ +--- +title: Action Reference +--- + +# Action Reference + +Every action is created with a constructor on [`Action`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/enum.Action.html) and applied with `Spends::apply`. This page covers each constructor, grouped by what it works on, with a runnable example for each group. Read the [Action System overview](/sdk/action-system) first for how `Spends`, `Id` and `Deltas` fit together. + +All examples run against the [Simulator](/sdk/testing) and spend coins with `finish_with_keys`. + +## Summary + +| Constructor | Applies to | Creates | Delta | +| --------------------------------------------------------------- | -------------------------- | ------------------------------- | ----------------------------------------------- | +| `send(id, puzzle_hash, amount, memos)` | XCH, CAT, NFT, DID, option | A payment, or moves a singleton | `id` output `amount` | +| `burn(id, amount, memos)` | XCH, CAT, NFT, DID, option | A payment to `BURN_PUZZLE_HASH` | `id` output `amount` | +| `fee(amount)` | XCH | A `RESERVE_FEE` condition | XCH output `amount` | +| `single_issue_cat(hidden_puzzle_hash, amount)` | XCH | `Id::New(index)` CAT | XCH output, new CAT input | +| `issue_cat(tail_spend, hidden_puzzle_hash, amount)` | XCH | `Id::New(index)` CAT | XCH output, new CAT input | +| `run_tail(id, tail_spend, supply_delta)` | CAT | More supply, or melted XCH | CAT `supply_delta`, XCH the opposite | +| `mint_nft(...)` and the `mint_empty_*` variants | XCH | `Id::New(index)` NFT | XCH output, new NFT input | +| `mint_nft_from_did(...)`, `mint_nft_from_nft(...)` and variants | DID or NFT (+ XCH) | `Id::New(index)` NFT | XCH output, new NFT input | +| `update_nft(id, metadata_update_spends, transfer)` | NFT (+ DID/NFT owner) | Updated NFT | NFT needed | +| `create_did(...)`, `create_empty_did()` | XCH | `Id::New(index)` DID | XCH output, new DID input | +| `update_did(id, ...)` | DID | Updated DID | DID needed | +| `mint_option(...)` | XCH + underlying asset | `Id::New(index)` option | XCH output, underlying output, new option input | +| `melt_singleton(id, amount)` | DID or option | XCH | Singleton output, XCH input | +| `settle(id, notarized_payment)` | Settlement coins | Notarized payments | `id` output the payment amounts | +| `settle_royalty(ctx, id, launcher_id, puzzle_hash, amount)` | Settlement coins | A royalty payment | `id` output `amount` | + +The column "Delta" is what `Deltas::from_actions` records, which is what you select coins against. Singletons have a delta of 1 in and 1 out when they're updated, which nets to zero but marks them as needed. + +## Payments + +### `Action::send` + +`Action::send(id, puzzle_hash, amount, memos)` creates a coin of the asset with the given p2 puzzle hash and amount. For CATs, the puzzle hash is wrapped in the CAT layer for you (and in the revocation layer, for revocable CATs), so you pass the recipient's p2 puzzle hash, not the full CAT puzzle hash. + +The memos are used as given. Wallets find CATs and singletons by their hint, so pass `ctx.hint(puzzle_hash)?` when sending them to someone. For XCH, `Memos::None` is fine, since the coin's puzzle hash is the recipient's puzzle hash. + +For a singleton (NFT, DID or option contract), `send` sets where the singleton goes when it's recreated, and `amount` must be the singleton's amount, otherwise it fails with `DriverError::SingletonAmountMismatch`. A singleton that isn't sent anywhere goes to the change puzzle hash. + +The SDK picks which coin creates each payment. If two payments would be identical (same puzzle hash and amount from the same parent), the second one is created from an intermediate coin, so sending the same amount to the same person twice works as expected. + +### `Action::burn` + +`Action::burn(id, amount, memos)` is `send` to `BURN_PUZZLE_HASH` (`0x000...dead`), a puzzle hash with no known puzzle, so the coins can never be spent. + +### `Action::fee` + +`Action::fee(amount)` pays a fee to the farmer. The fee is the XCH left over after every output, and it's asserted with a `RESERVE_FEE` condition, which is attached to one of the spends when they're prepared. Multiple fee actions add up. `Outputs::fee` is the total, and `Outputs::reserved_fee` is the part asserted with `RESERVE_FEE`. + +To leave a fee without asserting it, construct the variant directly: `Action::Fee(FeeAction { amount, reserved: false })`. + +```rust +use chia_wallet_sdk::chia::puzzle_types::Memos; +use chia_wallet_sdk::driver::BURN_PUZZLE_HASH; +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn payments() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(100); + let bob = BlsPair::new(1); + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + let actions = [ + // Two identical XCH payments + Action::send(Id::Xch, bob.puzzle_hash, 10, Memos::None), + Action::send(Id::Xch, bob.puzzle_hash, 10, Memos::None), + // Issue a CAT, send most of it to Bob (hinted), and burn the rest + Action::single_issue_cat(None, 50), + Action::send(Id::New(2), bob.puzzle_hash, 30, bob_hint), + Action::burn(Id::New(2), 20, Memos::None), + Action::fee(1), + ]; + + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob has two separate 10 mojo coins + assert_eq!(sim.unspent_coins(bob.puzzle_hash, false).len(), 2); + + // 100 - 20 sent - 50 used to issue the CAT - 1 fee + let change = outputs + .xch + .iter() + .find(|coin| coin.puzzle_hash == alice.puzzle_hash) + .expect("missing change"); + assert_eq!(change.amount, 29); + + // The CAT outputs are Bob's coin and the burned coin + let cats = &outputs.cats[&Id::New(2)]; + assert!(cats.iter().any(|cat| cat.info.p2_puzzle_hash == bob.puzzle_hash)); + assert!(cats.iter().any(|cat| cat.info.p2_puzzle_hash == BURN_PUZZLE_HASH)); + assert_eq!(outputs.reserved_fee, 1); + + Ok(()) +} +``` + +## CATs + +See [CAT](/sdk/primitives/cat) for how CATs, TAILs and revocable CATs work on chain. + +### `Action::single_issue_cat` + +`Action::single_issue_cat(hidden_puzzle_hash, amount)` issues a CAT with the single issuance TAIL (`GenesisByCoinIdTailArgs`), which is derived from the XCH coin that issues it, so no more of the CAT can ever be issued. The asset id depends on which coin the SDK picks as the parent, so read it from the outputs afterwards. + +### `Action::issue_cat` + +`Action::issue_cat(tail_spend, hidden_puzzle_hash, amount)` issues a CAT with any TAIL. The asset id is the tree hash of `tail_spend.puzzle`, and the TAIL is run with `tail_spend.solution`, so it must allow the issuance. + +Pass `Some(hidden_puzzle_hash)` to either constructor to issue a [revocable CAT](/sdk/primitives/cat), which the holder of the hidden puzzle can revoke later. See [Custom Spends](/sdk/action-system/custom-spends#revocation) for revoking with the action system. + +The issued CAT is `Id::New(index)`, with one exception: if coins with the same asset id are already in the `Spends`, the new coin joins their ring and must be referred to as `Id::Existing(asset_id)`. + +Issuing a CAT costs `amount` mojos of XCH, since every CAT mojo is backed by an XCH mojo. + +### `Action::run_tail` + +`Action::run_tail(id, tail_spend, supply_delta)` runs the TAIL of a CAT that's already in the spends. The `Delta` is the change in supply: + +- `Delta::new(amount, 0)` issues `amount` more of the CAT, which costs `amount` XCH. +- `Delta::new(0, amount)` melts `amount` of the CAT back into XCH, which is returned as XCH change. At least one XCH coin must be in the spends to receive it (`deltas.is_needed(&Id::Xch)` is true), even one with an amount of zero. + +Each CAT spend can only run its TAIL once, so the SDK finds a CAT spend that hasn't run it yet (or creates an intermediate coin to run it from). + +```rust +use chia_wallet_sdk::chia::puzzle_types::cat::EverythingWithSignatureTailArgs; +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn issue_and_melt_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(100); + let keys = indexmap! { alice.puzzle_hash => alice.pk }; + + // A TAIL that can be run whenever Alice signs, so she can issue and melt at will + let tail = ctx.curry(EverythingWithSignatureTailArgs::new(alice.pk))?; + let tail_spend = Spend::new(tail, NodePtr::NIL); + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + let deltas = spends.apply(&mut ctx, &[Action::issue_cat(tail_spend, None, 100)])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + let cat = outputs.cats[&Id::New(0)][0]; + let id = Id::Existing(cat.info.asset_id); + assert_eq!(cat.info.asset_id, ctx.tree_hash(tail).into()); + + // Melt 40 of the CAT. The XCH comes back as change, so an XCH coin is needed to receive it. + let actions = [Action::run_tail(id, tail_spend, Delta::new(0, 40))]; + assert!(Deltas::from_actions(&actions).is_needed(&Id::Xch)); + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(cat); + spends.add(sim.new_coin(alice.puzzle_hash, 0)); + + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::AssertConcurrent, &keys)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // 60 of the CAT is left as change, and 40 mojos of XCH were released + assert_eq!(outputs.cats[&id][0].coin.amount, 60); + assert_eq!(outputs.xch[0].amount, 40); + + Ok(()) +} +``` + +## NFTs + +See [NFT](/sdk/primitives/nft) for NFT metadata, royalties and ownership on chain. + +### `Action::mint_nft` and variants + +`Action::mint_nft(metadata, metadata_updater_puzzle_hash, royalty_puzzle_hash, royalty_basis_points, amount)` mints an NFT from an XCH coin. The metadata is a `HashedPtr` (allocate it with `ctx.alloc_hashed(&NftMetadata { .. })?`), the metadata updater is usually `NFT_METADATA_UPDATER_DEFAULT_HASH`, and the royalty is in basis points (300 is 3%). The amount must be odd, and is almost always 1. Unless it's sent elsewhere, the NFT goes to the change puzzle hash. + +The other constructors are shorthands: + +| Constructor | Launcher parent | Metadata, updater | Royalty | +| -------------------------------------------------------------------- | --------------- | ----------------- | ------- | +| `mint_nft(...)` | XCH coin | Given | Given | +| `mint_nft_from_did(did_id, ...)` | The DID's spend | Given | Given | +| `mint_nft_from_nft(nft_id, ...)` | The NFT's spend | Given | Given | +| `mint_empty_nft()` | XCH coin | Empty | None | +| `mint_empty_royalty_nft(puzzle_hash, basis_points)` | XCH coin | Empty | Given | +| `mint_empty_nft_from_did(did_id)` | The DID's spend | Empty | None | +| `mint_empty_royalty_nft_from_did(did_id, puzzle_hash, basis_points)` | The DID's spend | Empty | Given | +| `mint_empty_nft_from_nft(nft_id)` | The NFT's spend | Empty | None | +| `mint_empty_royalty_nft_from_nft(nft_id, puzzle_hash, basis_points)` | The NFT's spend | Empty | Given | + +The "empty" variants use nil metadata and an amount of 1, which is mostly useful for tests. + +Minting from a DID or NFT only makes the singleton the parent of the launcher. It **doesn't** assign the new NFT to it. To mint an NFT that belongs to a DID (as in a DID-backed collection), follow the mint with `update_nft` and a `TransferNftById` that points at the DID. The NFT's value still comes from XCH, so an XCH coin is needed as well. + +### `Action::update_nft` + +`Action::update_nft(id, metadata_update_spends, transfer)` changes an NFT that's in the spends: + +- `metadata_update_spends` are spends of the metadata updater puzzle, run in order, each against the result of the previous one. Each one requires its own spend of the NFT, which the SDK adds to the NFT's lineage. For the default updater, build them with `MetadataUpdate { kind: UriKind::Data, uri }.spend(&mut ctx)?`, which prepends a URI to the data, metadata or license URI list. +- `transfer: Some(TransferNftById::new(owner_id, trade_prices))` sets the NFT's owner. `owner_id` is `Some(id)` of a DID or NFT in the same transaction (which is spent to authorize the assignment) or `None` to remove the current owner. `trade_prices` are the [trade prices](/sdk/offers#royalties) that determine royalties when the NFT is offered. + +Updating doesn't move the NFT. Combine it with `send` to update and transfer at the same time. + +```rust +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::driver::{MetadataUpdate, UriKind}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::NFT_METADATA_UPDATER_DEFAULT_HASH; +use indexmap::indexmap; + +#[test] +fn mint_update_and_send_nft() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1); + let bob = BlsPair::new(1); + let keys = indexmap! { alice.puzzle_hash => alice.pk }; + + let metadata = ctx.alloc_hashed(&NftMetadata { + data_uris: vec!["https://example.com/1.png".to_string()], + data_hash: Some(Bytes32::new([1; 32])), + ..Default::default() + })?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + let deltas = spends.apply( + &mut ctx, + &[Action::mint_nft( + metadata, + NFT_METADATA_UPDATER_DEFAULT_HASH.into(), + alice.puzzle_hash, + 300, + 1, + )], + )?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + let nft = outputs.nfts[&Id::New(0)]; + let id = Id::Existing(nft.info.launcher_id); + assert_eq!(nft.info.royalty_basis_points, 300); + + // Add a mirror URI, then send the NFT to Bob in the same transaction + let update = MetadataUpdate { + kind: UriKind::Data, + uri: "https://mirror.example.com/1.png".to_string(), + } + .spend(&mut ctx)?; + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(nft); + + let deltas = spends.apply( + &mut ctx, + &[ + Action::update_nft(id, vec![update], None), + Action::send(id, bob.puzzle_hash, 1, bob_hint), + ], + )?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &keys)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let nft = outputs.nfts[&id]; + assert_eq!(nft.info.p2_puzzle_hash, bob.puzzle_hash); + + let metadata: NftMetadata = ctx.extract(nft.info.metadata.ptr())?; + assert_eq!(metadata.data_uris.len(), 2); + assert_eq!(metadata.data_uris[0], "https://mirror.example.com/1.png"); + + Ok(()) +} +``` + +Minting an NFT that belongs to an existing DID: + +```rust +use chia_wallet_sdk::driver::TransferNftById; +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn mint_nft_owned_by_did() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1); + let keys = indexmap! { alice.puzzle_hash => alice.pk }; + + // Create the DID first + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + let deltas = spends.apply(&mut ctx, &[Action::create_empty_did()])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + let did = outputs.dids[&Id::New(0)]; + let did_id = Id::Existing(did.info.launcher_id); + + // Mint from the DID and assign the NFT to it. The NFT's mojo comes from an XCH coin. + let actions = [ + Action::mint_empty_royalty_nft_from_did(did_id, alice.puzzle_hash, 500), + Action::update_nft( + Id::New(0), + vec![], + Some(TransferNftById::new(Some(did_id), vec![])), + ), + ]; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(did); + spends.add(sim.new_coin(alice.puzzle_hash, 1)); + + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::AssertConcurrent, &keys)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let nft = outputs.nfts[&Id::New(0)]; + assert_eq!(nft.info.current_owner, Some(did.info.launcher_id)); + assert_eq!(nft.info.p2_puzzle_hash, alice.puzzle_hash); + + // The DID was spent to authorize the assignment, and is recreated + assert!(outputs.dids.contains_key(&did_id)); + + Ok(()) +} +``` + +## DIDs + +See [DID](/sdk/primitives/did) for what a DID's recovery list, verifications and metadata mean. + +### `Action::create_did` and `Action::create_empty_did` + +`Action::create_did(recovery_list_hash, num_verifications_required, metadata, amount)` creates a DID from an XCH coin. The amount must be odd. `Action::create_empty_did()` is a DID with no recovery list, one required verification, nil metadata and an amount of 1. Unless it's sent elsewhere, the DID goes to the change puzzle hash. + +### `Action::update_did` + +`Action::update_did(id, new_recovery_list_hash, new_num_verifications_required, new_metadata)` changes any combination of the DID's fields. `None` leaves a field unchanged. The recovery list hash is an `Option>`, so `Some(None)` removes it. + +### `Action::melt_singleton` + +`Action::melt_singleton(id, amount)` destroys a DID (or [option contract](#options)) for good, returning its amount to the transaction as XCH. The amount must be the singleton's amount. A singleton spend can't create an odd coin alongside the melt condition, so the XCH comes back as change through an XCH coin, which you need to add to the spends. NFTs can't be melted; burn them with `Action::burn` instead. + +```rust +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn did_lifecycle() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1); + let bob = sim.bls(0); + let alice_keys = indexmap! { alice.puzzle_hash => alice.pk }; + let bob_keys = indexmap! { bob.puzzle_hash => bob.pk }; + + let metadata = ctx.alloc_hashed(&"Alice's DID")?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + let deltas = spends.apply(&mut ctx, &[Action::create_did(None, 1, metadata, 1)])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &alice_keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + let did = outputs.dids[&Id::New(0)]; + let id = Id::Existing(did.info.launcher_id); + + // Update the metadata and send it to Bob + let new_metadata = ctx.alloc_hashed(&"Bob's DID")?; + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(did); + let deltas = spends.apply( + &mut ctx, + &[ + Action::update_did(id, None, None, Some(new_metadata)), + Action::send(id, bob.puzzle_hash, 1, bob_hint), + ], + )?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &alice_keys)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let did = outputs.dids[&id]; + assert_eq!(did.info.p2_puzzle_hash, bob.puzzle_hash); + assert_eq!(did.info.metadata, new_metadata); + + // Bob melts it, and receives its mojo through his (zero amount) XCH coin + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(did); + spends.add(bob.coin); + let deltas = spends.apply(&mut ctx, &[Action::melt_singleton(id, 1)])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &bob_keys)?; + sim.spend_coins(ctx.take(), &[bob.sk])?; + + assert!(outputs.dids.is_empty()); + assert_eq!(outputs.xch[0].amount, 1); + + Ok(()) +} +``` + +## Options + +### `Action::mint_option` + +`Action::mint_option(creator_puzzle_hash, seconds, underlying_id, underlying_amount, strike_type, amount)` mints an [option contract](/sdk/primitives/options) singleton from an XCH coin, and locks `underlying_amount` of the underlying asset (XCH, a CAT, or an NFT, by `underlying_id`) in a coin that the option controls. Until the `seconds` timestamp, the owner of the option can exercise it by paying the `strike_type` (an `OptionType`) to `creator_puzzle_hash`. After that, the creator can reclaim the underlying asset. + +The option is `Id::New(index)`, and its owner is the p2 puzzle hash of the XCH coin it was minted from. Send it elsewhere with `Action::send`. When the underlying is an NFT, the NFT is locked in the same transaction, so no other action can use it afterwards (they fail with `DriverError::NoSourceForOutput`). + +### Exercising with `Action::melt_singleton` + +Exercising an option means melting it. `Action::melt_singleton(option_id, amount)` melts the option and authorizes its underlying coin to be exercised, but spending the underlying coin and paying the strike are separate steps. See [Exercising](/sdk/primitives/options#exercising) for the full flow. + +```rust +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn mint_cat_option() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(101); + let bob = BlsPair::new(1); + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + // Issue 100 of a CAT, lock it in an option that Bob can exercise for 50 mojos until + // timestamp 1000, and give the option to Bob + let deltas = spends.apply( + &mut ctx, + &[ + Action::single_issue_cat(None, 100), + Action::mint_option( + alice.puzzle_hash, + 1000, + Id::New(0), + 100, + OptionType::Xch { amount: 50 }, + 1, + ), + Action::send(Id::New(1), bob.puzzle_hash, 1, bob_hint), + ], + )?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::None, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let option = outputs.options[&Id::New(1)]; + assert_eq!(option.info.p2_puzzle_hash, bob.puzzle_hash); + + // The underlying CAT coin is one of the CAT outputs + let underlying = outputs.cats[&Id::New(0)] + .iter() + .find(|cat| cat.coin.coin_id() == option.info.underlying_coin_id) + .expect("missing underlying"); + assert_eq!(underlying.coin.amount, 100); + + Ok(()) +} +``` + +## Settlement + +These actions spend settlement coins, which are coins locked by the settlement payments puzzle. Anyone can spend a settlement coin, but it can only make notarized payments. They're how [offers](/sdk/offers) are taken. Add the settlement coins to the spends (for an offer, `spends.add(offer.offered_coins().clone())`), and the SDK spends them as settlement spends automatically. + +### `Action::settle` + +`Action::settle(id, notarized_payment)` makes a `NotarizedPayment` (a nonce and a list of payments) from a settlement coin of the asset. For an offer, the requested payments are exactly these actions: `offer.requested_payments().actions()` returns one `settle` per notarized payment. + +If the asset's only coins aren't settlement coins (for example, you're paying an offer's request for XCH from your own wallet), the SDK creates an intermediate settlement coin and pays from it. A settle on an NFT or option moves the singleton into a settlement coin first if needed. + +### `Action::settle_royalty` + +`Action::settle_royalty(&mut ctx, id, launcher_id, royalty_puzzle_hash, royalty_amount)` is `settle` with the royalty payment an NFT's royalty puzzle asserts: the nonce is the NFT's launcher id, and the payment is hinted to the royalty puzzle hash. See [Royalties](/sdk/offers#royalties) for calculating the amounts. + +Every payment made by a settlement coin, including change created from it, is asserted by a conditions spend in the transaction, so the settlement spends can't be stripped out. That's why the example adds one of Alice's coins, even with an amount of zero. + +```rust +use chia_wallet_sdk::chia::puzzle_types::{ + Memos, + offer::{NotarizedPayment, Payment}, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use indexmap::indexmap; + +#[test] +fn settle_payments() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(0); + let bob = BlsPair::new(1); + let carol = BlsPair::new(2); + + // A settlement coin, like the one an offer maker creates + let settlement = sim.new_coin(SETTLEMENT_PAYMENT_HASH.into(), 10); + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(settlement); + spends.add(alice.coin); + + let nft_launcher_id = Bytes32::new([9; 32]); + + let actions = [ + Action::settle( + Id::Xch, + NotarizedPayment::new( + Bytes32::new([1; 32]), + vec![Payment::new(bob.puzzle_hash, 3, Memos::None)], + ), + ), + Action::settle_royalty(&mut ctx, Id::Xch, nft_launcher_id, carol.puzzle_hash, 2)?, + ]; + + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob and Carol are paid, and the remaining 5 mojos go to Alice as change + let paid = |puzzle_hash| { + outputs + .xch + .iter() + .filter(|coin| coin.puzzle_hash == puzzle_hash) + .map(|coin| coin.amount) + .sum::() + }; + assert_eq!(paid(bob.puzzle_hash), 3); + assert_eq!(paid(carol.puzzle_hash), 2); + assert_eq!(paid(alice.puzzle_hash), 5); + + Ok(()) +} +``` + +## The `SpendAction` trait + +Every action type (`SendAction`, `IssueCatAction`, `MintNftAction`, and so on) implements `SpendAction`, which has two methods: `calculate_delta` (what `Deltas::from_actions` calls) and `spend` (what `Spends::apply` calls). The `Action` enum dispatches to them. The action structs have public fields, so you can construct a variant directly (`Action::Fee(FeeAction { .. })`) when a constructor doesn't expose an option you need. diff --git a/docs/sdk/action-system/custom-spends.md b/docs/sdk/action-system/custom-spends.md new file mode 100644 index 0000000..c0d91dd --- /dev/null +++ b/docs/sdk/action-system/custom-spends.md @@ -0,0 +1,287 @@ +--- +title: Custom Spends +--- + +# Custom Spends + +`Spends::finish_with_keys` assumes that every coin you add is locked by the [standard puzzle](/sdk/primitives/standard), or is a settlement coin. When your coins are controlled by anything else (a [vault](/sdk/primitives/vaults), a custom Chialisp puzzle, a hardware wallet that signs differently), finish the transaction yourself in two steps: `prepare` tells you what each coin's p2 puzzle has to output, and `spend` wraps the p2 spends you provide in the outer layers (CAT, singleton, NFT, and so on). + +This page also covers the parts of the action system that sit between the actions and the coin spends: `SpendKind`, transaction-wide conditions (`ConditionConfig`), settlement coins, and revoking CATs. + +## `prepare`, `unspent` and `spend` + +After applying actions: + +1. `spends.prepare(&mut ctx, &deltas, relation)` creates change, attaches transaction-wide conditions, and adds the relation. It returns a `Spends`, which can no longer have actions applied to it. +2. `prepared.unspent()` returns a `Vec<(SpendableAsset, SpendKind)>`: every coin you need to provide a p2 spend for, and what that p2 puzzle must output. +3. Build a `Spend` (puzzle and solution) for each one, and collect them into a `HashMap` keyed by the coin id (`asset.coin().coin_id()`). +4. `prepared.spend(&mut ctx, coin_spends)` spends every coin, adding the outer layers, and returns the `Outputs`. A missing p2 spend fails with `DriverError::MissingSpend`. + +The list includes coins you never added yourself: intermediate coins (created and spent in the same transaction), and extra spends of a singleton, such as an NFT that's updated twice. Their p2 puzzle hash is one you control (the intermediate or change puzzle hash, or the singleton's own p2 puzzle hash), so handle them the same way as the coins you added. `spends.p2_puzzle_hashes()` returns every p2 puzzle hash the transaction will need. + +### `SpendableAsset` + +`SpendableAsset` is the coin being spent: `Xch(Coin)`, `Cat(Cat)`, `RevokedCat(Cat)`, `Did(Did)`, `Nft(Nft)` or `Option(OptionContract)`. Use `asset.coin()` for the full coin, and `asset.p2_puzzle_hash()` to find out which of your puzzles controls it. For a `RevokedCat`, `p2_puzzle_hash()` is the CAT's hidden puzzle hash, since that's the puzzle authorizing the spend (see [Revocation](#revocation)). + +### `SpendKind` + +`SpendKind` is what the p2 puzzle must output: + +- `SpendKind::Conditions(spend)`: the p2 puzzle outputs arbitrary conditions. `spend.finish()` returns the `Conditions` to output, which you pass to your puzzle (for example, as the delegated puzzle of the standard puzzle, or through `SpendWithConditions`). +- `SpendKind::Settlement(spend)`: the coin is locked by the settlement payments puzzle. `spend.finish()` returns the `Vec` for its solution. Spend it with `SettlementLayer`. + +You only decide how to authorize conditions spends. Settlement spends always look the same. + +### Example: a custom p2 puzzle + +This wallet has one coin with the standard puzzle and one locked by the `p2_delegated_conditions` puzzle, which is signed directly by a key. Both are spent in one transaction, with each spend built by its own layer. + +```rust +use std::collections::HashMap; + +use chia_wallet_sdk::chia::puzzle_types::{Memos, offer::SettlementPaymentsSolution}; +use chia_wallet_sdk::driver::{P2DelegatedConditionsLayer, SpendKind}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::P2DelegatedConditionsSolution; + +#[test] +fn custom_p2_puzzle() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(5); + let bob = BlsPair::new(1); + + // A second coin, locked by a different puzzle that Alice can also spend + let custom = P2DelegatedConditionsLayer::new(alice.pk); + let custom_puzzle_hash: Bytes32 = custom.tree_hash().into(); + let custom_coin = sim.new_coin(custom_puzzle_hash, 10); + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + spends.add(custom_coin); + + let deltas = spends.apply( + &mut ctx, + &[ + Action::send(Id::Xch, bob.puzzle_hash, 12, Memos::None), + Action::fee(1), + ], + )?; + + let prepared = spends.prepare(&mut ctx, &deltas, Relation::AssertConcurrent)?; + let mut coin_spends = HashMap::new(); + + for (asset, kind) in prepared.unspent() { + let spend = match kind { + SpendKind::Conditions(spend) => { + let conditions = spend.finish(); + + if asset.p2_puzzle_hash() == alice.puzzle_hash { + StandardLayer::new(alice.pk).spend_with_conditions(&mut ctx, conditions)? + } else if asset.p2_puzzle_hash() == custom_puzzle_hash { + let solution = P2DelegatedConditionsSolution::new(conditions.into_iter().collect()); + custom.construct_spend(&mut ctx, solution)? + } else { + anyhow::bail!("unknown p2 puzzle hash {}", asset.p2_puzzle_hash()); + } + } + SpendKind::Settlement(spend) => SettlementLayer + .construct_spend(&mut ctx, SettlementPaymentsSolution::new(spend.finish()))?, + }; + + coin_spends.insert(asset.coin().coin_id(), spend); + } + + let outputs = prepared.spend(&mut ctx, coin_spends)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // 15 in total, 12 to Bob, 1 fee, and 2 change back to Alice's standard puzzle + let change: u64 = outputs + .xch + .iter() + .filter(|coin| coin.puzzle_hash == alice.puzzle_hash) + .map(|coin| coin.amount) + .sum(); + assert_eq!(change, 2); + + Ok(()) +} +``` + +The same pattern works for CATs, NFTs, DIDs and options with a custom p2 puzzle: `spend` adds the outer layers, so the `Spend` you provide is always just the p2 spend. For [vaults](/sdk/primitives/vaults), the p2 spend is built from a MIPS spend instead of a layer. + +## Transaction-wide conditions + +Some conditions aren't tied to a particular coin, such as the assertions that an offer's requested payments were made. Put them in `spends.conditions`, a `ConditionConfig`, before calling `prepare`: + +- `conditions.required`: always included. If no spend can emit conditions (for example, only settlement coins are being spent), an intermediate coin is created with the intermediate puzzle hash to emit them. If even that isn't possible, `prepare` fails with `DriverError::CannotEmitConditions`. +- `conditions.optional`: only included if there's already a spend that can emit conditions. +- `conditions.disable_settlement_assertions`: skips the assertions of payments made by settlement coins (see below). Only set this when something else ties the settlement spends to the transaction. + +`prepare` attaches all of these (and the `RESERVE_FEE` for `Action::fee`) to the first conditions spend, so they're emitted exactly once. + +You can inspect what each spend will output without consuming the spends by preparing a clone: + +```rust +use chia_wallet_sdk::chia::puzzle_types::Memos; +use chia_wallet_sdk::driver::SpendKind; +use chia_wallet_sdk::prelude::*; + +#[test] +fn required_conditions() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(10); + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + spends.add(sim.new_coin(alice.puzzle_hash, 10)); + + // The transaction is only valid before timestamp 1000 + spends.conditions.required = Conditions::new().assert_before_seconds_absolute(1000); + + let deltas = spends.apply( + &mut ctx, + &[Action::send(Id::Xch, alice.puzzle_hash, 15, Memos::None)], + )?; + + let prepared = spends.clone().prepare(&mut ctx, &deltas, Relation::None)?; + + let mut count = 0; + for (_, kind) in prepared.unspent() { + if let SpendKind::Conditions(spend) = kind { + count += spend + .finish() + .iter() + .filter(|condition| matches!(condition, Condition::AssertBeforeSecondsAbsolute(_))) + .count(); + } + } + + // Two coins are spent, but the condition is only emitted once + assert_eq!(count, 1); + + Ok(()) +} +``` + +## Settlement coins + +A coin whose p2 puzzle is the settlement payments puzzle (`SETTLEMENT_PAYMENT_HASH`) is spent as a settlement spend automatically when you add it. That's how you take an offer: add `offer.offered_coins().clone()` to the spends, apply `offer.requested_payments().actions()`, and add your own coins to pay for them. See [Offers](/sdk/offers). + +Anyone can spend a settlement coin. If the transaction only spent it, someone could remove that spend from the bundle and spend the coin themselves, taking whatever it was going to pay you. To prevent this, every payment a settlement coin makes (including change created from it) is notarized, and a conditions spend in the transaction asserts its puzzle announcement. Change and other coins created by a settlement coin use the settlement coin's id as the nonce, so they're unique. `Action::settle` uses the nonce you give it, so if two settlement coins with the same puzzle hash would make an identical notarized payment, `apply` and `prepare` fail with `DriverError::DuplicateSettlementPayment`, since a single assertion would be satisfied by either of them. + +When the only coins in the transaction are settlement coins, there's no spend that can emit the assertions, so an intermediate coin with the intermediate puzzle hash is created from a settlement coin to emit them. You need to be able to spend it (with `finish_with_keys`, its key must be in the map). + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use indexmap::indexmap; + +#[test] +fn settlement_only() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = BlsPair::new(0); + let settlement = sim.new_coin(SETTLEMENT_PAYMENT_HASH.into(), 10); + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(settlement); + + // Claim the settlement coin as change, paying a fee + let deltas = spends.apply(&mut ctx, &[Action::fee(1)])?; + spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::None, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + + // The settlement spend, and an intermediate coin owned by Alice that asserts its payments + let coin_spends = ctx.take(); + assert_eq!(coin_spends.len(), 2); + + sim.spend_coins(coin_spends, &[alice.sk])?; + assert_eq!(sim.unspent_coins(alice.puzzle_hash, false)[0].amount, 9); + + Ok(()) +} +``` + +`spends.non_settlement_coin_ids()` returns the ids of the coins that are spent with conditions, which are the ones you need to authorize. + +## Revocation + +A [revocable CAT](/sdk/primitives/cat) has a hidden puzzle hash, chosen by the issuer, that can spend the CAT instead of its owner's p2 puzzle. To revoke CATs with the action system, add them with `spends.add_for_revocation(cat)?` instead of `spends.add(cat)`. Adding a CAT without a hidden puzzle fails with `DriverError::NotRevocable`. + +Actions apply to revoked CATs like any other CAT, so you can send them, melt them with `Action::run_tail`, or do nothing, in which case the value goes to the change puzzle hash. In `unspent()`, they appear as `SpendableAsset::RevokedCat`, whose `p2_puzzle_hash()` is the hidden puzzle hash. Spend them with the hidden puzzle: when the hidden puzzle hash is a standard puzzle hash, `finish_with_keys` handles this for you. + +When the hidden puzzle spends the CAT, the revocation layer passes its outputs through as-is. To keep the issuer's power to revoke, every coin created by a revocation spend is wrapped in the same revocation layer and hinted with its p2 puzzle hash, so the revoked value stays revocable and wallets can still find it. Revoked and normal spends of the same CAT can be mixed in one transaction, since they're all part of the same CAT ring. + +```rust +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn revoke_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let issuer = sim.bls(10); + let bob = BlsPair::new(1); + let carol = BlsPair::new(2); + let keys = indexmap! { issuer.puzzle_hash => issuer.pk }; + + // Issue a revocable CAT with the issuer's puzzle hash as the hidden puzzle hash, and give it to Bob + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + let mut spends = Spends::new(issuer.puzzle_hash); + spends.add(issuer.coin); + let deltas = spends.apply( + &mut ctx, + &[ + Action::single_issue_cat(Some(issuer.puzzle_hash), 10), + Action::send(Id::New(0), bob.puzzle_hash, 10, bob_hint), + ], + )?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&issuer.sk))?; + + let bob_cat = outputs.cats[&Id::New(0)][0]; + let id = Id::Existing(bob_cat.info.asset_id); + + // The issuer revokes Bob's CAT and sends part of it to Carol, without Bob's signature + let carol_hint = ctx.hint(carol.puzzle_hash)?; + + let mut spends = Spends::new(issuer.puzzle_hash); + spends.add_for_revocation(bob_cat)?; + let deltas = spends.apply(&mut ctx, &[Action::send(id, carol.puzzle_hash, 4, carol_hint)])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &keys)?; + sim.spend_coins(ctx.take(), &[issuer.sk])?; + + // Carol's coin and the issuer's change are both still revocable + for cat in &outputs.cats[&id] { + assert_eq!(cat.info.hidden_puzzle_hash, Some(issuer.puzzle_hash)); + } + assert!( + outputs.cats[&id] + .iter() + .any(|cat| cat.info.p2_puzzle_hash == carol.puzzle_hash && cat.coin.amount == 4) + ); + + Ok(()) +} +``` + +## Coin selection with deltas + +The deltas tell you how much of each asset to select (see [Coin selection with deltas](/sdk/action-system#coin-selection-with-deltas)). A few rules matter when you build transactions yourself: + +- Select at least `output - input` of each fungible asset. The SDK computes change from what you actually added, so overshooting is fine, and undershooting fails with `DriverError::InsufficientFunds` in `prepare`. +- If `deltas.is_needed(&id)` is true, select at least one coin, even when the difference is zero. Minting needs an XCH coin to create the launcher from, melting needs an XCH coin to receive the value, and updating a singleton needs the singleton. +- Any id in `deltas.ids()` that's a singleton (an NFT, DID or option launcher id) must have that singleton added. +- The amounts are `u128`. Convert a total to a coin amount with `coin_amount`, which fails with `DriverError::AmountOverflow` instead of truncating. +- If an action needs to emit conditions (creating a launcher, running a TAIL, asserting settlement payments) and every coin of the asset is a settlement coin, add an XCH coin you own. Even a coin with an amount of zero works. diff --git a/docs/sdk/action-system/index.md b/docs/sdk/action-system/index.md new file mode 100644 index 0000000..6553679 --- /dev/null +++ b/docs/sdk/action-system/index.md @@ -0,0 +1,270 @@ +--- +title: Action System +slug: /sdk/action-system +--- + +# Action System + +The action system is a declarative way to build transactions. Instead of writing the spend of every coin yourself, you describe what the transaction should do as a list of [`Action`](/sdk/action-system/actions)s ("send 5 XCH to Bob", "mint an NFT", "pay a fee"), add the coins you want to spend, and the SDK works out which coins create which outputs, creates change, links the spends together, and spends every coin. + +Use it whenever a transaction is made of ordinary wallet operations on XCH, CATs, NFTs, DIDs and option contracts, especially when several assets are involved at once, or when you're making or taking an [offer](/sdk/offers). Drop down to the primitives (such as [`Cat`](/sdk/primitives/cat) or [`Nft`](/sdk/primitives/nft)) when you need full control over a single spend, or when you're working with an asset the action system doesn't know about. + +Everything on this page lives in [`chia_wallet_sdk::driver`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/index.html), and the most common types (`Spends`, `Action`, `Id`, `Deltas`, `Delta`, `Relation`, `Outputs`) are in the prelude. + +## A first transaction + +This sends 5 mojos to Bob and pays a 1 mojo fee, from a coin owned by Alice's standard puzzle. The change goes back to Alice. + +```rust +use chia_wallet_sdk::chia::puzzle_types::Memos; +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn send_xch() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(10); + let bob = BlsPair::new(1); + + // Coins are added to the spends, and change is sent to Alice's puzzle hash + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + let actions = [ + Action::send(Id::Xch, bob.puzzle_hash, 5, Memos::None), + Action::fee(1), + ]; + + let deltas = spends.apply(&mut ctx, &actions)?; + + // Spends every coin with the standard puzzle, using the key for its puzzle hash + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob's payment and Alice's change + assert_eq!(outputs.xch.len(), 2); + assert_eq!(outputs.fee, 1); + assert_eq!( + sim.unspent_coins(alice.puzzle_hash, false)[0].amount, + 4 + ); + + Ok(()) +} +``` + +## The pieces + +| Type | What it is | +| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| [`Action`](/sdk/action-system/actions) | A high level operation, created with constructors like `Action::send` or `Action::mint_nft`. | +| `Id` | Identifies the asset an action applies to: `Id::Xch`, `Id::Existing(asset_id_or_launcher_id)`, or `Id::New(action_index)`. | +| `Spends` | The coins being spent and what each of them will output. Actions are applied to it, and it's finished into coin spends. | +| `Deltas` / `Delta` | How much of each asset the actions consume (`output`) and produce (`input`), used for coin selection. | +| `Relation` | How the spends are tied together so the transaction can't be split apart. | +| `Outputs` | The coins (and singletons) the transaction created, returned when it's finished. | +| [`SpendKind`](/sdk/action-system/custom-spends), `SpendableAsset` | What a coin's p2 puzzle must output, for when you spend coins with something other than the standard puzzle. | + +## Lifecycle + +A transaction goes through the same steps every time: + +1. **Calculate deltas.** `Deltas::from_actions(&actions)` tells you how much of each asset the actions need, so you can select coins before building anything. +2. **Create `Spends` and add coins.** `Spends::new(change_puzzle_hash)` and `spends.add(coin)` for every selected coin. `add` accepts XCH `Coin`s, `Cat`s, `Nft`s, `Did`s, `OptionContract`s, and an offer's `OfferCoins`. +3. **Apply the actions.** `spends.apply(&mut ctx, &actions)` applies each action in order and returns the `Deltas` (the same as step 1). +4. **Prepare.** `spends.prepare(&mut ctx, &deltas, relation)` creates change for every asset, attaches transaction-wide conditions (the reserved fee, offer assertions, settlement payment assertions) to a single spend, and adds the `Relation` conditions. +5. **Spend.** Provide the p2 spend for every coin in `prepared.unspent()` and call `prepared.spend(&mut ctx, coin_spends)`, which returns the `Outputs`. + +Steps 4 and 5 are combined by `finish_with_keys`, which spends every coin with the [standard puzzle](/sdk/primitives/standard) (looking up the synthetic key by p2 puzzle hash) and every settlement coin with the settlement payments puzzle. If a key is missing, it returns `DriverError::MissingKey`. When you need any other p2 puzzle, such as a [vault](/sdk/primitives/vaults), use `prepare`/`unspent`/`spend` directly, as described in [Custom Spends](/sdk/action-system/custom-spends). + +The coin spends end up in the `SpendContext`. Take them with `ctx.take()`, then sign them (see [Keys & Signing](/sdk/concepts/signing)) to build a `SpendBundle`. + +## Referring to assets with `Id` + +Every action that touches an asset takes an `Id`: + +- `Id::Xch` is XCH. +- `Id::Existing(id)` is a CAT (by asset id) or singleton (by launcher id) whose coins were added to the `Spends`. +- `Id::New(index)` is an asset created in the same transaction by the action at `index` in the list passed to `apply`. Issuing a CAT, minting an NFT, creating a DID and minting an option all create new assets. + +Because `Id::New` is positional, the list you pass to `Deltas::from_actions` and `Spends::apply` must be the same list in the same order. An `Id::Existing` that doesn't match anything added to the spends fails with `DriverError::InvalidAssetId`. + +This issues a CAT and immediately splits it into two coins for different people: + +```rust +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn issue_and_send() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1_000); + let bob = BlsPair::new(1); + let alice_hint = ctx.hint(alice.puzzle_hash)?; + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + let actions = [ + // Action 0 creates the CAT, so it's referred to as `Id::New(0)` + Action::single_issue_cat(None, 1_000), + Action::send(Id::New(0), bob.puzzle_hash, 300, bob_hint), + Action::send(Id::New(0), alice.puzzle_hash, 700, alice_hint), + ]; + + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Outputs are keyed by the same id the actions used + let cats = &outputs.cats[&Id::New(0)]; + assert_eq!(cats.len(), 2); + + // In later transactions, the CAT is an existing asset + let asset_id = cats[0].info.asset_id; + let _id = Id::Existing(asset_id); + + Ok(()) +} +``` + +## Coin selection with deltas + +The action system doesn't select coins for you, since it doesn't know what your wallet owns. Instead, calculate the deltas first. For each id, `Delta { input, output }` is the amount the actions add to the transaction (issuing a CAT, melting a singleton into XCH) and remove from it (sending, paying fees, locking in an option). Select at least `output - input` worth of coins, and at least one coin if `deltas.is_needed(&id)` is true, even when the difference is zero (for example, the NFT being updated, or an XCH coin to create a launcher from). + +The amounts are `u128`, since totals of many coins can exceed `u64::MAX`. Change is computed from what you actually added, so selecting more than needed is fine. Selecting less fails with `DriverError::InsufficientFunds` when the spends are prepared, rather than producing a transaction the mempool would reject. + +```rust +use chia_wallet_sdk::chia::puzzle_types::Memos; +use chia_wallet_sdk::driver::coin_amount; +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn select_coins_for_actions() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = BlsPair::new(0); + let bob = BlsPair::new(1); + + // Alice's wallet has several coins + let wallet: Vec = [5, 10, 20, 50] + .into_iter() + .map(|amount| sim.new_coin(alice.puzzle_hash, amount)) + .collect(); + + let actions = [ + Action::send(Id::Xch, bob.puzzle_hash, 25, Memos::None), + Action::fee(2), + ]; + + // Work out how much XCH the actions need before building anything + let deltas = Deltas::from_actions(&actions); + let xch = deltas.get(&Id::Xch).copied().unwrap_or_default(); + let needed = coin_amount(xch.output.saturating_sub(xch.input))?; + assert_eq!(needed, 27); + + let selected = select_coins(wallet, needed)?; + + let mut spends = Spends::new(alice.puzzle_hash); + for coin in selected { + spends.add(coin); + } + + let deltas = spends.apply(&mut ctx, &actions)?; + spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let bob_balance: u64 = sim + .unspent_coins(bob.puzzle_hash, false) + .iter() + .map(|coin| coin.amount) + .sum(); + assert_eq!(bob_balance, 25); + + Ok(()) +} +``` + +For CATs, do the same with `Id::Existing(asset_id)` and your wallet's `Cat`s. For singletons, add the singleton itself whenever its id appears in the deltas. + +## Change and intermediate coins + +`Spends::new(puzzle_hash)` uses one puzzle hash for two things: + +- The **change puzzle hash**, which receives leftover value of every fungible asset, and every singleton that an action didn't send elsewhere. +- The **intermediate puzzle hash**, for ephemeral coins the SDK creates and spends within the same transaction. A coin can't create two identical children (they'd have the same coin id), so if two payments (or a payment and the change) would be identical, one of them is made from an intermediate coin instead. Intermediate coins are also used to emit conditions when no other spend can, for example when only settlement coins are being spent. + +Use `Spends::with_separate_change_puzzle_hash(intermediate_puzzle_hash, change_puzzle_hash)` to send change somewhere else. You must be able to spend the intermediate puzzle hash, since its coins are spent in the same transaction; `spends.p2_puzzle_hashes()` lists every puzzle hash you'll need to sign for. + +## Relations + +Without anything tying them together, the spends of a transaction could be split up by a farmer or a third party, and only some of them included in a block. A `Relation` adds conditions to every spend that emits conditions so that this can't happen: + +| Relation | Conditions added | Splittable? | +| -------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------- | +| `Relation::None` | Nothing. | Yes, unless something else ties them together. | +| `Relation::AssertConcurrent` | Each spend asserts `ASSERT_CONCURRENT_SPEND` of the previous one, wrapping around. | No. | +| `Relation::CoinAnnouncementRing` | Each spend creates an empty coin announcement and asserts the next spend's. | No. | +| `Relation::CoinAnnouncementHub` | The first spend creates an announcement, and every other spend asserts it. | The first spend can be included on its own. | + +`Relation::AssertConcurrent` is the usual choice. If there's only one conditions spend, no relation conditions are added. Settlement spends never get relation conditions (they can only make payments), but the payments they make are always asserted by a conditions spend, regardless of the relation (see [Custom Spends](/sdk/action-system/custom-spends#settlement-coins)). + +## Outputs + +`finish_with_keys` and `spend` return `Outputs`, which describes what the transaction created: + +- `outputs.xch`: XCH coins created by actions, plus change. Intermediate coins aren't included. +- `outputs.cats[&id]`: CAT coins created by actions, plus change, keyed by the `Id` used in the actions. +- `outputs.dids[&id]`, `outputs.nfts[&id]`, `outputs.options[&id]`: the final coin of each singleton, unless it was melted or exercised. +- `outputs.fee` and `outputs.reserved_fee`: the total fee, and the part of it asserted with `RESERVE_FEE`. + +Singleton outputs are complete `Nft`, `Did` and `OptionContract` values with lineage proofs, so you can add them to a new `Spends` in the next transaction without fetching anything from the chain, as long as the previous transaction confirms first. + +## Guarantees + +- **Exact change.** Change is calculated from the selected coins and the deltas. Insufficient funds are an error, and value is never silently left over as a fee. +- **No stripped settlement spends.** Payments made by settlement coins are asserted by a conditions spend, so nobody can remove a settlement spend from the bundle and claim the coin. Two settlement coins with the same puzzle hash making the same notarized payment would make identical announcements, so this is rejected with `DriverError::DuplicateSettlementPayment`. +- **Determinism.** Building the same actions against the same coins always produces the same coin spends. +- **Amounts are checked.** Sending a singleton with the wrong amount fails with `DriverError::SingletonAmountMismatch`, and totals that overflow a coin amount fail with `DriverError::AmountOverflow`. + +## Common errors + +| Error | Usual cause | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `InsufficientFunds` | The coins you added don't cover the deltas. | +| `InvalidAssetId` | An `Id` refers to an asset that wasn't added (or an action that doesn't create one), or the action doesn't support that asset. | +| `NoSourceForOutput` | No coin can create the output, for example an action on a singleton that has already been locked in an option. | +| `CannotEmitConditions` | An action needs to emit conditions, but the only coins it could use are settlement coins. Add an XCH coin. | +| `SingletonAmountMismatch` | `Action::send` or `Action::melt_singleton` used an amount other than the singleton's amount. | +| `MissingKey` / `MissingSpend` | `finish_with_keys` had no key for a p2 puzzle hash, or `spend` was missing a coin spend from `unspent()`. | + +## Next steps + +- [Action Reference](/sdk/action-system/actions): every action, with examples. +- [Custom Spends](/sdk/action-system/custom-spends): spending coins with non-standard p2 puzzles, settlement coins and revocation. +- [Offers](/sdk/offers): making and taking offers with the action system. diff --git a/docs/sdk/actions.md b/docs/sdk/actions.md deleted file mode 100644 index d3653e5..0000000 --- a/docs/sdk/actions.md +++ /dev/null @@ -1,1157 +0,0 @@ ---- -slug: /sdk/actions -title: Action System ---- - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Action System - -The Action System is a high-level, declarative API for building Chia blockchain transactions. Instead of manually constructing coin spends, puzzles, and solutions, you declare _what_ you want to do using actions, and the system generates the appropriate spend bundles. - -## Overview - -The action system solves several challenges in transaction construction: - -- **Complexity reduction** - No need to understand CLVM puzzles, conditions, and solutions directly -- **Transaction composition** - Easily combine multiple operations in a single transaction -- **Asset tracking** - Automatic handling of coin IDs, amounts, and asset types -- **Delta calculation** - Automatic balancing of inputs and outputs across all assets - -## Key Types - -| Type | Description | -| --------- | ----------------------------------------------------------- | -| `Action` | A declarative operation (send, mint, issue, etc.) | -| `Spends` | Orchestrates actions and manages coin selection | -| `Deltas` | Tracks input/output requirements per asset | -| `Id` | Identifies assets (XCH, existing CAT/NFT, or newly created) | -| `Outputs` | Results of a completed transaction | - -## Asset Identification with Id - -The `Id` type identifies assets in the action system: - - - - -```rust -use chia_wallet_sdk::prelude::*; - -// Reference native XCH -let xch = Id::Xch; - -// Reference an existing asset by its ID (CAT asset ID, NFT launcher ID, etc.) -let existing_cat = Id::Existing(asset_id); - -// Reference a new asset created in the current transaction -// The index refers to the action that creates the asset -let new_asset = Id::New(0); // First action that creates an asset -``` - - - - -```typescript -import { Id } from "chia-wallet-sdk"; - -// Reference native XCH -const xch = Id.xch(); - -// Reference an existing asset by its ID -const existingCat = Id.existing(assetId); - -// Reference a new asset created in the current transaction -const newAsset = Id.new(0n); // First action that creates an asset - -// Check asset type -if (xch.isXch()) { - /* ... */ -} -const assetBytes = existingCat.asExisting(); // Returns Uint8Array or null -const index = newAsset.asNew(); // Returns bigint or null -``` - - - - -```python -from chia_wallet_sdk import Id - -# Reference native XCH -xch = Id.xch() - -# Reference an existing asset by its ID -existing_cat = Id.existing(asset_id) - -# Reference a new asset created in the current transaction -new_asset = Id.new(0) # First action that creates an asset - -# Check asset type -if xch.is_xch(): - pass -asset_bytes = existing_cat.as_existing() # Returns bytes or None -index = new_asset.as_new() # Returns int or None -``` - - - - -## Creating Actions - -Actions are created using static factory methods on the `Action` class/enum. - -### Send Action - -Send assets to a puzzle hash: - - - - -```rust -use chia_wallet_sdk::prelude::*; - -// Send XCH -let send_xch = Action::send(Id::Xch, recipient_puzzle_hash, 1000, Memos::None); - -// Send a CAT -let send_cat = Action::send(Id::Existing(asset_id), recipient_puzzle_hash, 500, memos); - -// Send a newly created asset (from action at index 0) -let send_new = Action::send(Id::New(0), recipient_puzzle_hash, 100, memos); - -// Burn assets (send to unspendable address) -let burn = Action::burn(Id::Xch, 1000, Memos::None); -``` - - - - -```typescript -import { Action, Id } from "chia-wallet-sdk"; - -// Send XCH -const sendXch = Action.send(Id.xch(), recipientPuzzleHash, 1000n); - -// Send a CAT -const sendCat = Action.send(Id.existing(assetId), recipientPuzzleHash, 500n); - -// Send a newly created asset (from action at index 0) -const sendNew = Action.send(Id.new(0n), recipientPuzzleHash, 100n); - -// With memos for hints -const sendWithMemo = Action.send( - Id.xch(), - recipientPuzzleHash, - 1000n, - memoProgram, -); -``` - - - - -```python -from chia_wallet_sdk import Action, Id - -# Send XCH -send_xch = Action.send(Id.xch(), recipient_puzzle_hash, 1000) - -# Send a CAT -send_cat = Action.send(Id.existing(asset_id), recipient_puzzle_hash, 500) - -# Send a newly created asset (from action at index 0) -send_new = Action.send(Id.new(0), recipient_puzzle_hash, 100) - -# With memos for hints -send_with_memo = Action.send(Id.xch(), recipient_puzzle_hash, 1000, memo_program) -``` - - - - -### Fee Action - -Reserve XCH for transaction fees: - - - - -```rust -let fee = Action::fee(1000); -``` - - - - -```typescript -const fee = Action.fee(1000n); -``` - - - - -```python -fee = Action.fee(1000) -``` - - - - -### CAT Issuance Actions - -Issue new Chia Asset Tokens: - - - - -```rust -// Single issuance CAT (genesis by coin ID - can only mint once) -let issue = Action::single_issue_cat(hidden_puzzle_hash, 1_000_000); - -// Multi-issuance CAT with custom TAIL -let issue_with_tail = Action::issue_cat(tail_spend, hidden_puzzle_hash, 1_000_000); -``` - - - - -```typescript -// Single issuance CAT (genesis by coin ID - can only mint once) -const issue = Action.singleIssueCat(null, 1_000_000n); - -// Multi-issuance CAT with custom TAIL -const issueWithTail = Action.issueCat(tailSpend, null, 1_000_000n); -``` - - - - -```python -# Single issuance CAT (genesis by coin ID - can only mint once) -issue = Action.single_issue_cat(None, 1_000_000) - -# Multi-issuance CAT with custom TAIL -issue_with_tail = Action.issue_cat(tail_spend, None, 1_000_000) -``` - - - - -### NFT Actions - -Mint and update NFTs: - - - - -```rust -// Mint an NFT -let mint = Action::mint_nft( - metadata, // NFT metadata (HashedPtr) - metadata_updater_puzzle, // Puzzle hash for metadata updates - royalty_puzzle_hash, // Where royalties are paid - royalty_basis_points, // Royalty percentage (300 = 3%) - amount, // Amount (usually 1) -); - -// Mint an empty NFT with defaults -let mint_empty = Action::mint_empty_nft(); - -// Update an existing NFT's metadata -let update = Action::update_nft( - Id::Existing(launcher_id), - metadata_spends, // Spends that update metadata - transfer, // Optional transfer info -); -``` - - - - -```typescript -import { Action, NftMetadata, Constants, Spend } from "chia-wallet-sdk"; - -// Define metadata -const metadata = new NftMetadata( - 1n, // edition number - 1n, // edition total - ["https://example.com/image.png"], // data URIs - null, // data hash - ["https://example.com/metadata.json"], // metadata URIs - null, // metadata hash - [], // license URIs - null, // license hash -); - -// Mint an NFT -const mint = Action.mintNft( - clvm, - clvm.nftMetadata(metadata), - Constants.nftMetadataUpdaterDefaultHash(), - royaltyPuzzleHash, - 300, // 3% royalty - 1n, // amount - null, // parent ID (optional) -); - -// Update NFT metadata -const metadataUpdate = new Spend( - clvm.nftMetadataUpdaterDefault(), - clvm.list([clvm.string("u"), clvm.string("https://example.com/new-uri")]), -); -const update = Action.updateNft(Id.existing(launcherId), [metadataUpdate]); -``` - - - - -```python -from chia_wallet_sdk import Action, NftMetadata, Constants, Spend, Id - -# Define metadata -metadata = NftMetadata( - 1, # edition number - 1, # edition total - ["https://example.com/image.png"], # data URIs - None, # data hash - ["https://example.com/metadata.json"], # metadata URIs - None, # metadata hash - [], # license URIs - None # license hash -) - -# Mint an NFT -mint = Action.mint_nft( - clvm, - clvm.nft_metadata(metadata), - Constants.nft_metadata_updater_default_hash(), - royalty_puzzle_hash, - 300, # 3% royalty - 1, # amount - None # parent ID (optional) -) - -# Update NFT metadata -metadata_update = Spend( - clvm.nft_metadata_updater_default(), - clvm.list([clvm.string("u"), clvm.string("https://example.com/new-uri")]) -) -update = Action.update_nft(Id.existing(launcher_id), [metadata_update]) -``` - - - - -## Using the Spends Orchestrator - -The `Spends` struct orchestrates actions and manages the transaction building process. - -### Basic Workflow - - - - -```rust -use chia_wallet_sdk::prelude::*; - -let ctx = &mut SpendContext::new(); - -// 1. Create Spends with a change puzzle hash -let mut spends = Spends::new(change_puzzle_hash); - -// 2. Add coins to spend -spends.add(xch_coin); -spends.add_cat(cat); - -// 3. Apply actions and get deltas -let deltas = spends.apply(ctx, &[ - Action::send(Id::Xch, recipient, 500, Memos::None), - Action::fee(100), -])?; - -// 4. Finish with signing keys -let outputs = spends.finish_with_keys( - ctx, - &deltas, - Relation::None, - &indexmap! { puzzle_hash => public_key }, -)?; - -// 5. Extract coin spends -let coin_spends = ctx.take(); -``` - - - - -```typescript -import { - Action, - Clvm, - Id, - Spends, - Simulator, - standardPuzzleHash, - BlsPair, -} from "chia-wallet-sdk"; - -const sim = new Simulator(); -const clvm = new Clvm(); - -// Create a wallet -const pair = BlsPair.fromSeed(0n); -const puzzleHash = standardPuzzleHash(pair.pk); -sim.newCoin(puzzleHash, 1000n); - -// 1. Create Spends with a change puzzle hash -const spends = new Spends(clvm, puzzleHash); - -// 2. Add coins to spend -const coins = sim.unspentCoins(puzzleHash, false); -for (const coin of coins) { - spends.addXch(coin); -} - -// 3. Apply actions and get deltas -const deltas = spends.apply([ - Action.send(Id.xch(), recipientPuzzleHash, 500n), - Action.fee(100n), -]); - -// 4. Prepare the spends -const finished = spends.prepare(deltas); - -// 5. Insert p2 spends for each pending coin -for (const spend of finished.pendingSpends()) { - finished.insert( - spend.coin().coinId(), - clvm.standardSpend(pair.pk, clvm.delegatedSpend(spend.conditions())), - ); -} - -// 6. Execute and get outputs -const outputs = finished.spend(); - -// 7. Sign and broadcast -sim.spendCoins(clvm.coinSpends(), [pair.sk]); -``` - - - - -```python -from chia_wallet_sdk import Action, Clvm, Id, Spends, Simulator, standard_puzzle_hash, BlsPair - -sim = Simulator() -clvm = Clvm() - -# Create a wallet -pair = BlsPair.from_seed(0) -puzzle_hash = standard_puzzle_hash(pair.pk) -sim.new_coin(puzzle_hash, 1000) - -# 1. Create Spends with a change puzzle hash -spends = Spends(clvm, puzzle_hash) - -# 2. Add coins to spend -coins = sim.unspent_coins(puzzle_hash, False) -for coin in coins: - spends.add_xch(coin) - -# 3. Apply actions and get deltas -deltas = spends.apply([ - Action.send(Id.xch(), recipient_puzzle_hash, 500), - Action.fee(100), -]) - -# 4. Prepare the spends -finished = spends.prepare(deltas) - -# 5. Insert p2 spends for each pending coin -for spend in finished.pending_spends(): - finished.insert( - spend.coin().coin_id(), - clvm.standard_spend(pair.pk, clvm.delegated_spend(spend.conditions())) - ) - -# 6. Execute and get outputs -outputs = finished.spend() - -# 7. Sign and broadcast -sim.spend_coins(clvm.coin_spends(), [pair.sk]) -``` - - - - -## Understanding Deltas - -Deltas track the input/output requirements for each asset type in a transaction. This enables automatic coin selection and change calculation. - - - - -```rust -// Calculate deltas from actions -let deltas = Deltas::from_actions(&actions); - -// Check requirements for a specific asset -if let Some(delta) = deltas.get(&Id::Xch) { - println!("XCH input needed: {}", delta.input); - println!("XCH output created: {}", delta.output); -} - -// Check which assets are needed -for id in deltas.needed() { - println!("Need to provide: {:?}", id); -} -``` - - - - -```typescript -import { Deltas, Delta, Id } from "chia-wallet-sdk"; - -// Calculate deltas from actions -const deltas = Deltas.fromActions(actions); - -// Iterate over all assets -for (const id of deltas.ids()) { - const delta = deltas.get(id) ?? new Delta(0n, 0n); - console.log(`Asset ${id}: input=${delta.input}, output=${delta.output}`); -} - -// Check if an asset needs to be provided -if (deltas.isNeeded(Id.xch())) { - console.log("Need to provide XCH"); -} -``` - - - - -```python -from chia_wallet_sdk import Deltas, Delta, Id - -# Calculate deltas from actions -deltas = Deltas.from_actions(actions) - -# Iterate over all assets -for id in deltas.ids(): - delta = deltas.get(id) or Delta(0, 0) - print(f"Asset {id}: input={delta.input}, output={delta.output}") - -# Check if an asset needs to be provided -if deltas.is_needed(Id.xch()): - print("Need to provide XCH") -``` - - - - -## Working with Outputs - -After completing a transaction, the `Outputs` struct provides access to the created assets: - - - - -```rust -// Get created XCH coins -for coin in &outputs.xch { - println!("Created XCH coin: {} mojos", coin.amount); -} - -// Get created CATs by asset ID -for (id, cats) in &outputs.cats { - for cat in cats { - println!("Created CAT: {} of {:?}", cat.coin.amount, id); - } -} - -// Access fee amount -println!("Fee: {} mojos", outputs.fee); -``` - - - - -```typescript -// Get all CAT asset IDs in the outputs -const catIds = outputs.cats(); - -// Get CATs for a specific asset -const cats = outputs.cat(catIds[0]); -for (const cat of cats) { - console.log(`Created CAT: ${cat.coin.amount}`); -} - -// Get all NFT IDs in the outputs -const nftIds = outputs.nfts(); - -// Get a specific NFT -const nft = outputs.nft(nftIds[0]); -console.log( - `NFT launcher ID: ${Buffer.from(nft.info.launcherId).toString("hex")}`, -); -``` - - - - -```python -# Get all CAT asset IDs in the outputs -cat_ids = outputs.cats() - -# Get CATs for a specific asset -cats = outputs.cat(cat_ids[0]) -for cat in cats: - print(f"Created CAT: {cat.coin.amount}") - -# Get all NFT IDs in the outputs -nft_ids = outputs.nfts() - -# Get a specific NFT -nft = outputs.nft(nft_ids[0]) -print(f"NFT launcher ID: {nft.info.launcher_id.hex()}") -``` - - - - -## Complete Examples - -### Send XCH - - - - -```rust -use chia_wallet_sdk::prelude::*; - -fn send_xch( - coin: Coin, - sender_pk: PublicKey, - recipient: Bytes32, - amount: u64, - fee: u64, -) -> Result, DriverError> { - let ctx = &mut SpendContext::new(); - let sender_ph = StandardLayer::puzzle_hash(sender_pk); - - let mut spends = Spends::new(sender_ph); - spends.add(coin); - - let deltas = spends.apply(ctx, &[ - Action::send(Id::Xch, recipient, amount, Memos::None), - Action::fee(fee), - ])?; - - let _outputs = spends.finish_with_keys( - ctx, - &deltas, - Relation::None, - &indexmap! { sender_ph => sender_pk }, - )?; - - Ok(ctx.take()) -} -``` - - - - -```typescript -import { - Action, - BlsPair, - Clvm, - Id, - Simulator, - Spends, - standardPuzzleHash, -} from "chia-wallet-sdk"; - -function sendXch(recipientPuzzleHash: Uint8Array, amount: bigint, fee: bigint) { - const sim = new Simulator(); - const clvm = new Clvm(); - - // Setup sender wallet - const sender = BlsPair.fromSeed(0n); - const senderPh = standardPuzzleHash(sender.pk); - sim.newCoin(senderPh, 1000n); - - // Create spends - const spends = new Spends(clvm, senderPh); - for (const coin of sim.unspentCoins(senderPh, false)) { - spends.addXch(coin); - } - - // Apply actions - const deltas = spends.apply([ - Action.send(Id.xch(), recipientPuzzleHash, amount), - Action.fee(fee), - ]); - - // Prepare and insert p2 spends - const finished = spends.prepare(deltas); - for (const spend of finished.pendingSpends()) { - finished.insert( - spend.coin().coinId(), - clvm.standardSpend(sender.pk, clvm.delegatedSpend(spend.conditions())), - ); - } - - // Execute - const outputs = finished.spend(); - sim.spendCoins(clvm.coinSpends(), [sender.sk]); - - return outputs; -} -``` - - - - -```python -from chia_wallet_sdk import ( - Action, BlsPair, Clvm, Id, Simulator, Spends, standard_puzzle_hash -) - -def send_xch(recipient_puzzle_hash: bytes, amount: int, fee: int): - sim = Simulator() - clvm = Clvm() - - # Setup sender wallet - sender = BlsPair.from_seed(0) - sender_ph = standard_puzzle_hash(sender.pk) - sim.new_coin(sender_ph, 1000) - - # Create spends - spends = Spends(clvm, sender_ph) - for coin in sim.unspent_coins(sender_ph, False): - spends.add_xch(coin) - - # Apply actions - deltas = spends.apply([ - Action.send(Id.xch(), recipient_puzzle_hash, amount), - Action.fee(fee), - ]) - - # Prepare and insert p2 spends - finished = spends.prepare(deltas) - for spend in finished.pending_spends(): - finished.insert( - spend.coin().coin_id(), - clvm.standard_spend(sender.pk, clvm.delegated_spend(spend.conditions())) - ) - - # Execute - outputs = finished.spend() - sim.spend_coins(clvm.coin_spends(), [sender.sk]) - - return outputs -``` - - - - -### Issue and Send a CAT - - - - -```rust -use chia_wallet_sdk::prelude::*; - -fn issue_and_send_cat( - coin: Coin, - sender_pk: PublicKey, - recipient: Bytes32, - issuance_amount: u64, - send_amount: u64, -) -> Result<(Bytes32, Vec), DriverError> { - let ctx = &mut SpendContext::new(); - let sender_ph = StandardLayer::puzzle_hash(sender_pk); - - let mut spends = Spends::new(sender_ph); - spends.add(coin); - - // Issue CAT at index 0, then send from it - let deltas = spends.apply(ctx, &[ - Action::single_issue_cat(None, issuance_amount), - Action::send(Id::New(0), recipient, send_amount, Memos::None), - ])?; - - let outputs = spends.finish_with_keys( - ctx, - &deltas, - Relation::None, - &indexmap! { sender_ph => sender_pk }, - )?; - - // Get the asset ID of the newly created CAT - let asset_id = outputs.cats.keys().next() - .and_then(|id| if let Id::New(0) = id { Some(*id) } else { None }) - .expect("CAT should be created"); - - Ok((asset_id.into(), ctx.take())) -} -``` - - - - -```typescript -import { - Action, - BlsPair, - Clvm, - Id, - Simulator, - Spends, - standardPuzzleHash, -} from "chia-wallet-sdk"; - -function issueAndSendCat( - recipientPuzzleHash: Uint8Array, - issuanceAmount: bigint, - sendAmount: bigint, -) { - const sim = new Simulator(); - const clvm = new Clvm(); - - // Setup wallet - const alice = BlsPair.fromSeed(0n); - const alicePh = standardPuzzleHash(alice.pk); - sim.newCoin(alicePh, 1n); // Just need 1 mojo for CAT issuance - - // Create spends - const spends = new Spends(clvm, alicePh); - for (const coin of sim.unspentCoins(alicePh, false)) { - spends.addXch(coin); - } - - // Issue CAT at index 0, then send from it - const deltas = spends.apply([ - Action.singleIssueCat(null, issuanceAmount), - Action.send(Id.new(0n), recipientPuzzleHash, sendAmount), - ]); - - // Prepare and insert p2 spends - const finished = spends.prepare(deltas); - for (const spend of finished.pendingSpends()) { - finished.insert( - spend.coin().coinId(), - clvm.standardSpend(alice.pk, clvm.delegatedSpend(spend.conditions())), - ); - } - - // Execute - const outputs = finished.spend(); - sim.spendCoins(clvm.coinSpends(), [alice.sk]); - - // Get the CAT asset ID - const catIds = outputs.cats(); - const assetId = outputs.cat(catIds[0])[0].info.assetId; - - return { assetId, outputs }; -} -``` - - - - -```python -from chia_wallet_sdk import ( - Action, BlsPair, Clvm, Id, Simulator, Spends, standard_puzzle_hash -) - -def issue_and_send_cat( - recipient_puzzle_hash: bytes, - issuance_amount: int, - send_amount: int -): - sim = Simulator() - clvm = Clvm() - - # Setup wallet - alice = BlsPair.from_seed(0) - alice_ph = standard_puzzle_hash(alice.pk) - sim.new_coin(alice_ph, 1) # Just need 1 mojo for CAT issuance - - # Create spends - spends = Spends(clvm, alice_ph) - for coin in sim.unspent_coins(alice_ph, False): - spends.add_xch(coin) - - # Issue CAT at index 0, then send from it - deltas = spends.apply([ - Action.single_issue_cat(None, issuance_amount), - Action.send(Id.new(0), recipient_puzzle_hash, send_amount), - ]) - - # Prepare and insert p2 spends - finished = spends.prepare(deltas) - for spend in finished.pending_spends(): - finished.insert( - spend.coin().coin_id(), - clvm.standard_spend(alice.pk, clvm.delegated_spend(spend.conditions())) - ) - - # Execute - outputs = finished.spend() - sim.spend_coins(clvm.coin_spends(), [alice.sk]) - - # Get the CAT asset ID - cat_ids = outputs.cats() - asset_id = outputs.cat(cat_ids[0])[0].info.asset_id - - return asset_id, outputs -``` - - - - -### Mint and Update NFT - - - - -```rust -use chia_wallet_sdk::prelude::*; - -fn mint_nft( - coin: Coin, - minter_pk: PublicKey, - metadata: NftMetadata, - royalty_puzzle_hash: Bytes32, - royalty_basis_points: u16, -) -> Result<(Bytes32, Vec), DriverError> { - let ctx = &mut SpendContext::new(); - let minter_ph = StandardLayer::puzzle_hash(minter_pk); - - let mut spends = Spends::new(minter_ph); - spends.add(coin); - - let deltas = spends.apply(ctx, &[ - Action::mint_nft( - ctx.alloc(&metadata)?.into(), - NFT_METADATA_UPDATER_PUZZLE_HASH.into(), - royalty_puzzle_hash, - royalty_basis_points, - 1, - ), - ])?; - - let outputs = spends.finish_with_keys( - ctx, - &deltas, - Relation::None, - &indexmap! { minter_ph => minter_pk }, - )?; - - let launcher_id = outputs.nfts.keys().next() - .and_then(|id| outputs.nfts.get(id)) - .map(|nft| nft.info.launcher_id) - .expect("NFT should be created"); - - Ok((launcher_id, ctx.take())) -} -``` - - - - -```typescript -import { - Action, - BlsPair, - Clvm, - Constants, - Id, - NftMetadata, - Simulator, - Spend, - Spends, - standardPuzzleHash, -} from "chia-wallet-sdk"; - -function mintAndUpdateNft() { - const sim = new Simulator(); - const clvm = new Clvm(); - - // Setup wallet - const alice = BlsPair.fromSeed(0n); - const alicePh = standardPuzzleHash(alice.pk); - sim.newCoin(alicePh, 2n); - - // Define metadata - const metadata = new NftMetadata( - 1n, - 1n, - ["https://example.com/image.png"], - null, - [], - null, - [], - null, - ); - - // Create spends - const spends = new Spends(clvm, alicePh); - for (const coin of sim.unspentCoins(alicePh, false)) { - spends.addXch(coin); - } - - // Mint NFT and update metadata in one transaction - const metadataUpdate = new Spend( - clvm.nftMetadataUpdaterDefault(), - clvm.list([ - clvm.string("u"), - clvm.string("https://example.com/updated.png"), - ]), - ); - - const deltas = spends.apply([ - Action.mintNft( - clvm, - clvm.nftMetadata(metadata), - Constants.nftMetadataUpdaterDefaultHash(), - alicePh, - 300, // 3% royalty - 1n, - null, - ), - Action.updateNft(Id.new(0n), [metadataUpdate]), - ]); - - // Prepare and insert p2 spends - const finished = spends.prepare(deltas); - for (const spend of finished.pendingSpends()) { - finished.insert( - spend.coin().coinId(), - clvm.standardSpend(alice.pk, clvm.delegatedSpend(spend.conditions())), - ); - } - - // Execute - const outputs = finished.spend(); - sim.spendCoins(clvm.coinSpends(), [alice.sk]); - - const nftId = outputs.nfts()[0]; - const nft = outputs.nft(nftId); - - return { launcherId: nft.info.launcherId, outputs }; -} -``` - - - - -```python -from chia_wallet_sdk import ( - Action, BlsPair, Clvm, Constants, Id, NftMetadata, - Simulator, Spend, Spends, standard_puzzle_hash -) - -def mint_and_update_nft(): - sim = Simulator() - clvm = Clvm() - - # Setup wallet - alice = BlsPair.from_seed(0) - alice_ph = standard_puzzle_hash(alice.pk) - sim.new_coin(alice_ph, 2) - - # Define metadata - metadata = NftMetadata( - 1, 1, - ["https://example.com/image.png"], - None, [], None, [], None - ) - - # Create spends - spends = Spends(clvm, alice_ph) - for coin in sim.unspent_coins(alice_ph, False): - spends.add_xch(coin) - - # Mint NFT and update metadata in one transaction - metadata_update = Spend( - clvm.nft_metadata_updater_default(), - clvm.list([clvm.string("u"), clvm.string("https://example.com/updated.png")]) - ) - - deltas = spends.apply([ - Action.mint_nft( - clvm, - clvm.nft_metadata(metadata), - Constants.nft_metadata_updater_default_hash(), - alice_ph, - 300, # 3% royalty - 1, - None - ), - Action.update_nft(Id.new(0), [metadata_update]), - ]) - - # Prepare and insert p2 spends - finished = spends.prepare(deltas) - for spend in finished.pending_spends(): - finished.insert( - spend.coin().coin_id(), - clvm.standard_spend(alice.pk, clvm.delegated_spend(spend.conditions())) - ) - - # Execute - outputs = finished.spend() - sim.spend_coins(clvm.coin_spends(), [alice.sk]) - - nft_id = outputs.nfts()[0] - nft = outputs.nft(nft_id) - - return nft.info.launcher_id, outputs -``` - - - - -## Action Types Reference - -| Action | Description | Creates Asset | -| ------------------ | ---------------------------------------- | --------------- | -| `send` | Transfer assets to a puzzle hash | No | -| `burn` | Send assets to unspendable address | No | -| `fee` | Reserve XCH for transaction fees | No | -| `single_issue_cat` | Issue a single-issuance CAT | Yes (`Id::New`) | -| `issue_cat` | Issue a multi-issuance CAT with TAIL | Yes (`Id::New`) | -| `run_tail` | Execute CAT TAIL logic | No | -| `mint_nft` | Mint a new NFT | Yes (`Id::New`) | -| `update_nft` | Update NFT metadata or transfer | No | -| `create_did` | Create a DID | Yes (`Id::New`) | -| `update_did` | Update DID metadata | No | -| `settle` | Settle a payment with notarized payments | No | -| `melt_singleton` | Destroy a singleton | No | - -## Best Practices - -1. **Use `Id::New(index)` for chained operations** - When one action creates an asset and another action uses it, reference it by its action index -2. **Check deltas before applying** - Use `Deltas::from_actions()` to verify you have sufficient coins before committing -3. **Handle change automatically** - The action system calculates and creates change coins for you -4. **Batch related operations** - Combine multiple actions in a single transaction to reduce fees -5. **Test with Simulator** - Always validate transactions in the simulator before mainnet - -## Comparison with Low-Level API - -| Feature | Action System | Low-Level (SpendContext) | -| --------------- | ----------------------- | ------------------------ | -| Complexity | Declarative, high-level | Imperative, detailed | -| Flexibility | Covers common patterns | Full control | -| Coin management | Automatic | Manual | -| Change handling | Automatic | Manual | -| Learning curve | Lower | Higher | - -Choose the action system for standard operations and the low-level API when you need precise control over puzzle construction. diff --git a/docs/sdk/bindings.md b/docs/sdk/bindings.md new file mode 100644 index 0000000..e3456a2 --- /dev/null +++ b/docs/sdk/bindings.md @@ -0,0 +1,297 @@ +--- +title: Bindings +--- + +# Bindings + +The Wallet SDK is written in Rust, but most of it is also available from JavaScript and Python. The +bindings are generated from one shared description of the API (the `chia-sdk-bindings` crate and its +`bindings/*.json` files), so the three packages expose the same classes and functions, adjusted to each +language's naming conventions. They are released together with the Rust crate and share its version +number. + +| Package | Language | Built with | Install | +| ---------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------- | ---------------------------------- | +| [`chia-wallet-sdk`](https://www.npmjs.com/package/chia-wallet-sdk) | Node.js (TypeScript types) | [napi-rs](https://napi.rs), native addon | `npm install chia-wallet-sdk` | +| [`chia-wallet-sdk-wasm`](https://www.npmjs.com/package/chia-wallet-sdk-wasm) | Browsers and bundlers (TypeScript types) | [wasm-bindgen](https://rustwasm.github.io/docs/wasm-bindgen/) | `npm install chia-wallet-sdk-wasm` | +| [`chia-wallet-sdk`](https://pypi.org/project/chia-wallet-sdk/) | Python 3.8+ (`.pyi` stubs) | [PyO3](https://pyo3.rs) and maturin | `pip install chia-wallet-sdk` | + +The Node.js package ships prebuilt binaries for Windows x64, macOS (x64 and Apple silicon) and Linux +(x64 and arm64). The Python package has prebuilt wheels for common platforms and imports as +`chia_wallet_sdk`. The WASM package runs anywhere WebAssembly does, including browsers, but it can't open +raw sockets, so it has a smaller networking surface. + +Choose the Rust crate when you can. It has the complete API, including everything that isn't exported +to the bindings. Use the bindings when your application is already in JavaScript or Python and you need +to build, sign, parse or validate spends there. + +## How the bindings map to Rust + +The bindings follow the Rust API closely, so the rest of these docs apply once you know the +translation: + +| Rust | Bindings | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`SpendContext`](/sdk/concepts/spend-context) | `Clvm`. It owns the CLVM allocator and collects coin spends. `coinSpends()` / `coin_spends()` returns and clears them, like `ctx.take()` | +| `NodePtr` | `Program`, a value allocated in a `Clvm`. Use `serialize()` for bytes and `treeHash()` / `tree_hash()` for its hash | +| `ctx.alloc(&value)` (`ToClvm`) | `clvm.alloc(value)`, which converts numbers, strings, bytes, keys, arrays/lists and condition objects to a `Program` | +| `Conditions` builder | An array/list of condition `Program`s from methods like `clvm.createCoin(...)` and `clvm.reserveFee(...)`. Parse them back with `program.parseCreateCoin()` and similar | +| `StandardLayer::new(pk).spend(ctx, coin, conditions)` | `clvm.spendStandardCoin(coin, pk, clvm.delegatedSpend(conditions))` | +| `StandardLayer::new(pk).spend_with_conditions(...)` | `clvm.standardSpend(pk, clvm.delegatedSpend(conditions))`, which returns a `Spend` to use as an inner spend (for example in `clvm.spendNft(nft, spend)`) | +| Primitive methods (`Cat::spend_all`, `Nft` minting, ...) | Methods on `Clvm`: `spendCats`, `mintNfts`, `spendNft`, `createEveDid`, `spendDid`, `mintVault`, `spendOption`, ... | +| [Action system](/sdk/action-system) (`Spends`, `Action`, `Id`, `Deltas`) | The same names, with `spends.apply(actions)`, `spends.prepare(deltas)` and `finished.spend()` | +| `Simulator`, `BlsPair`, `K1Pair`, `R1Pair` | The same names. Key pairs are created with `BlsPair.fromSeed(seed)` | +| `CoinsetClient` and `FullNodeClient` | One `RpcClient` class: `new RpcClient(url)`, `RpcClient.mainnet()`, `RpcClient.testnet11()`, `RpcClient.local(cert, key)` | + +Types are converted at the boundary: + +| Rust | Node.js | WASM | Python | +| ------------------------- | ------------------------------ | ------------------- | -------------------------------- | +| `Bytes32`, `Bytes` | `Uint8Array` in, `Buffer` out | `Uint8Array` | `bytes` | +| `u64`, `u128`, `BigInt` | `bigint` (`1000n`) | `bigint` | `int` | +| `u8`, `u16`, `u32` | `number` | `number` | `int` | +| `Option` | `T`, `null` or `undefined` | `T` or `undefined` | `T` or `None` | +| `Vec` | Array | Array | `list` | +| `Result` | Returns `T`, throws an `Error` | Returns `T`, throws | Returns `T`, raises `ValueError` | +| `async fn` | Returns a `Promise` | Returns a `Promise` | Awaitable (use with `asyncio`) | +| `snake_case` method names | `camelCase` | `camelCase` | `snake_case` | + +Structs such as `Coin`, `CoinSpend`, `SpendBundle` and `CatInfo` become classes with a constructor and +properties for their fields. Passing an object to a method doesn't consume it, so you can keep using it +afterwards. + +## Node.js + +```ts +import { + Address, + BlsPair, + Clvm, + Simulator, + standardPuzzleHash, +} from "chia-wallet-sdk"; + +const sim = new Simulator(); +const clvm = new Clvm(); + +// A key pair with a 1,000 mojo standard coin, and a recipient. +const alice = sim.bls(1000n); +const bob = BlsPair.fromSeed(1n); +const bobPuzzleHash = standardPuzzleHash(bob.pk); + +// 900 mojos to Bob, hinted so his wallet can find it, and a 100 mojo fee. +clvm.spendStandardCoin( + alice.coin, + alice.pk, + clvm.delegatedSpend([ + clvm.createCoin(bobPuzzleHash, 900n, clvm.alloc([bobPuzzleHash])), + clvm.reserveFee(100n), + ]), +); + +// Sign with Alice's key and validate the transaction. +sim.spendCoins(clvm.coinSpends(), [alice.sk]); + +const [coin] = sim.unspentCoins(bobPuzzleHash, false); +console.log(coin.amount); // 900n +console.log(new Address(bobPuzzleHash, "txch").encode()); +``` + +Networking calls are asynchronous. This example starts the HTTP +[full node simulator](/sdk/testing/full-node-simulator) and talks to it with `RpcClient`, exactly as you +would talk to Coinset or a full node: + +```ts +import { + Clvm, + CoinSpend, + FullNodeSimulator, + RpcClient, + Signature, + SpendBundle, +} from "chia-wallet-sdk"; + +const sim = new FullNodeSimulator(); +const server = await sim.startServer(); + +try { + // Against mainnet you'd use `RpcClient.mainnet()` instead. + const rpc = new RpcClient(server.url); + + // An anyone-can-spend coin: the puzzle `1` returns its solution as conditions. + const clvm = new Clvm(); + const puzzle = clvm.parse("1"); + const coin = sim.newCoin(puzzle.treeHash(), 100n); + + const solution = clvm.list([clvm.createCoin(puzzle.treeHash(), 99n)]); + const spendBundle = new SpendBundle( + [new CoinSpend(coin, puzzle.serialize(), solution.serialize())], + Signature.infinity(), + ); + + const pushed = await rpc.pushTx(spendBundle); + if (!pushed.success) throw new Error(pushed.error ?? "push_tx failed"); + + sim.farmBlock(1); + + const { coinRecord } = await rpc.getCoinRecordByName(coin.coinId()); + console.log(coinRecord?.spent, coinRecord?.spentBlockIndex); // true 2 +} finally { + server.close(); +} +``` + +The Node.js package also has a `Peer` class for the [peer protocol](/sdk/networking/peer), using a +`Connector` built from a `Certificate` (`Certificate.load(certPath, keyPath)` or +`Certificate.generate()`), with native TLS: + +```ts +import { Certificate, Connector, Peer, PeerOptions } from "chia-wallet-sdk"; + +const connector = new Connector(Certificate.generate()); +const peer = await Peer.connect( + "mainnet", + "203.0.113.10:8444", + connector, + new PeerOptions(), +); + +// Events (new peaks and coin state updates) are read one at a time. +const event = await peer.next(); +console.log(event?.newPeakWallet?.height); +``` + +The binding `Peer` covers state requests (`requestCoinState`, `requestPuzzleState`, +`requestPuzzleAndSolution`), subscription removal and events. It throws when the node rejects a request +instead of returning the rejection. It has no `sendTransaction`, so submit transactions with +`RpcClient.pushTx`. + +## WASM + +The WASM package has the same API as the Node.js package, minus anything that needs sockets or native +TLS. Call `setPanicHook()` once at startup so that a Rust panic shows up as a readable JavaScript error +instead of `unreachable`. + +```ts +import { + Address, + BlsPair, + Clvm, + Simulator, + setPanicHook, + standardPuzzleHash, +} from "chia-wallet-sdk-wasm"; + +// Turns Rust panics into readable JavaScript errors. +setPanicHook(); + +const sim = new Simulator(); +const clvm = new Clvm(); + +const alice = sim.bls(1000n); +const bob = BlsPair.fromSeed(1n); +const bobPuzzleHash = standardPuzzleHash(bob.pk); + +clvm.spendStandardCoin( + alice.coin, + alice.pk, + clvm.delegatedSpend([ + clvm.createCoin(bobPuzzleHash, 900n, clvm.alloc([bobPuzzleHash])), + clvm.reserveFee(100n), + ]), +); + +sim.spendCoins(clvm.coinSpends(), [alice.sk]); + +const [coin] = sim.unspentCoins(bobPuzzleHash, false); +console.log(coin.amount); // 900n +console.log(new Address(bobPuzzleHash, "txch").encode()); +``` + +The differences from the Node.js package: + +- There's no `Peer`, `Connector` or `Certificate`, and no `RpcClient.local` / `localWithUrl`. + `RpcClient` works with Coinset or any other HTTPS endpoint through `fetch`, subject to the server's CORS + policy. +- `FullNodeSimulator` is available, but `startServer` isn't. +- Byte values are always plain `Uint8Array`s, never `Buffer`s, and missing values are `undefined`. +- Objects live in WebAssembly memory. Each class has a `free()` method that releases it immediately; + otherwise it's released when the JavaScript object is garbage collected. + +The published package is built for bundlers (`wasm-pack build`'s default target), so import it through a +bundler with WebAssembly support, such as Vite with `vite-plugin-wasm` or webpack with +`experiments.asyncWebAssembly`. + +## Python + +```python +from chia_wallet_sdk import Address, BlsPair, Clvm, Simulator, standard_puzzle_hash + +sim = Simulator() +clvm = Clvm() + +# A key pair with a 1,000 mojo standard coin, and a recipient. +alice = sim.bls(1000) +bob = BlsPair.from_seed(1) +bob_puzzle_hash = standard_puzzle_hash(bob.pk) + +# 900 mojos to Bob, hinted so his wallet can find it, and a 100 mojo fee. +clvm.spend_standard_coin( + alice.coin, + alice.pk, + clvm.delegated_spend( + [ + clvm.create_coin(bob_puzzle_hash, 900, clvm.alloc([bob_puzzle_hash])), + clvm.reserve_fee(100), + ] + ), +) + +# Sign with Alice's key and validate the transaction. +sim.spend_coins(clvm.coin_spends(), [alice.sk]) + +[coin] = sim.unspent_coins(bob_puzzle_hash, False) +print(coin.amount) # 900 +print(Address(bob_puzzle_hash, "txch").encode()) +``` + +Networking methods are coroutines: + +```python +import asyncio + +from chia_wallet_sdk import RpcClient + + +async def main() -> None: + rpc = RpcClient.mainnet() + response = await rpc.get_blockchain_state() + if response.blockchain_state is None: + raise RuntimeError(response.error) + print(response.blockchain_state.peak.height) + + +asyncio.run(main()) +``` + +The Python package has the same networking classes as Node.js: `RpcClient`, `Peer`, `Connector`, +`Certificate` and `FullNodeSimulator.start_server`. + +## What isn't in the bindings + +The bindings cover building, signing, parsing and validating spends for every primitive, offers, the +action system, clear signing, MIPS/vaults, the action layer registries, the simulators and the RPC +client. They don't include: + +- `DaemonClient`, `Client`, `PeerSimulator` and the rate limiter types. +- Generic Rust APIs such as the `Layer` trait, `ToClvm`/`FromClvm` derives and custom puzzle drivers. + You can still curry and run arbitrary puzzles with `Program` (`curry`, `uncurry`, `run`) and + `clvm.parse`. +- A helper that works out required signatures. `Simulator.spendCoins` signs for you; for anything else, + compute the messages from the `AGG_SIG_*` conditions and sign them with `SecretKey.sign`. + +If you need one of these from another language, call it from Rust and expose it yourself, or open an +issue on the [Wallet SDK repository](https://github.com/xch-dev/chia-wallet-sdk). + +For the complete API, read the type definitions that ship with each package: `index.d.ts` (Node.js), +`chia_wallet_sdk_wasm.d.ts` (WASM) and `chia_wallet_sdk.pyi` (Python). diff --git a/docs/sdk/clear-signing.md b/docs/sdk/clear-signing.md new file mode 100644 index 0000000..78a7cd0 --- /dev/null +++ b/docs/sdk/clear-signing.md @@ -0,0 +1,307 @@ +--- +title: Clear Signing +--- + +# Clear Signing + +When a [vault](/sdk/primitives/vaults) signs a transaction, its signers only sign the tree hash of the +vault's delegated puzzle. That hash says nothing on its own, so before asking a user (or a hardware +signer) to approve it, you want to know what the transaction will actually do: which coins it spends, +where the value goes, how much fee it pays, whether it changes the vault's custody, whether it links +an offer. + +The clear signing module (`chia_wallet_sdk::driver`, no feature flag) does this. You give it the +vault's delegated spend and the coin spends that go along with it, and `parse_vault_transaction` +returns a `VaultTransaction` describing the effects that are **guaranteed** if the signature is used. + +The guiding rule is that only verifiable information is reported. A coin spend is only included if the +vault's delegated spend commits to it (by sending it a message with its delegated puzzle hash, or by +asserting that it's spent), and only if the coin's conditions can't be changed without invalidating +that commitment. Anything that could be swapped out by whoever assembles the final spend bundle is +either left out or causes an error. + +## How it works + +1. The vault's delegated spend must be a quoted list of conditions (the format produced by + `SpendContext::delegated_spend`). Anything else fails with `DriverError::InvalidDelegatedSpendFormat`, + because a delegated puzzle that computes its conditions from the solution would be malleable. +2. From those conditions it reads the vault's own effects: the odd `CREATE_COIN` that recreates the + vault (`vault_child`), any even "drop" coins (`drop_coins`), `RESERVE_FEE`, expiration + (`ASSERT_BEFORE_SECONDS_ABSOLUTE`), and every `SEND_MESSAGE`. +3. Each `SEND_MESSAGE` must have the vault format: sender committed by puzzle, receiver committed by + coin id. The message names the coin the vault is authorizing. That coin's spend must be revealed, + its p2 puzzle must be one of the vault's (`P2Singleton`, `P2ConditionsOrSingleton`, or a clawback + around one of those), and the message must equal the tree hash of the coin's delegated conditions. +4. The children of every verified spend are parsed: XCH, CATs (including revocable CATs), NFTs, and + bulletins, with their memos, hints and clawbacks decoded. Spends of those children that are + asserted by the vault (`ASSERT_CONCURRENT_SPEND`) are verified recursively. +5. Settlement payments that the transaction asserts are matched against revealed requested payments, + which is how offers are summarized. + +## Example + +This example builds a vault with a single BLS member, has the vault spend one of its XCH coins to pay +Bob with a fee, and parses the result before signing it. It also shows that a transaction that hides +one of the authorized coin spends is rejected. + +```rust +use chia_wallet_sdk::clvm_traits::clvm_list; +use chia_wallet_sdk::driver::{ + ClawbackV2, InnerPuzzleSpend, MipsSpend, P2Singleton, ParsedAsset, Reveals, VaultOutput, + mips_puzzle_hash, parse_vault_transaction, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::{MessageFlags, MessageSide, puzzles::BlsMember}; + +struct TestVault { + vault: Vault, + member: BlsMember, + p2: P2Singleton, + sk: SecretKey, +} + +fn mint_vault(sim: &mut Simulator, ctx: &mut SpendContext) -> anyhow::Result { + let alice = sim.bls(1); + let member = BlsMember::new(alice.pk); + let custody_hash = mips_puzzle_hash(0, vec![], member.curry_tree_hash(), true); + + let (conditions, vault) = + Launcher::new(alice.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + let p2 = P2Singleton::new(vault.info.launcher_id, 0); + + Ok(TestVault { vault, member, p2, sk: alice.sk }) +} + +/// Builds an unsigned transaction in which the vault authorizes `coin` to output `conditions`. +/// Returns the coin spends and the vault's delegated spend (the thing that gets signed). +fn build_transaction( + ctx: &mut SpendContext, + v: &TestVault, + coin: Coin, + conditions: Conditions, +) -> anyhow::Result<(Vec, Spend)> { + let delegated = ctx.delegated_spend(conditions)?; + let delegated_puzzle_hash = ctx.tree_hash(delegated.puzzle); + let spend = v.p2.spend(ctx, v.vault.info.custody_hash.into(), v.vault.coin.amount, delegated)?; + ctx.spend(coin, spend)?; + + let mode = MessageFlags::PUZZLE.encode(MessageSide::Sender) + | MessageFlags::COIN.encode(MessageSide::Receiver); + let coin_id = ctx.alloc(&coin.coin_id())?; + let vault_delegated = ctx.delegated_spend( + Conditions::new() + .send_message(mode, delegated_puzzle_hash.to_vec().into(), vec![coin_id]) + .create_coin(v.vault.info.custody_hash.into(), v.vault.coin.amount, Memos::None), + )?; + + let mut mips = MipsSpend::new(vault_delegated); + let member_puzzle = ctx.curry(v.member)?; + mips.members.insert( + v.vault.info.custody_hash, + InnerPuzzleSpend::new(0, vec![], Spend::new(member_puzzle, NodePtr::NIL)), + ); + v.vault.spend(ctx, &mips)?; + + Ok((ctx.take(), vault_delegated)) +} + +#[test] +fn clear_sign_payment() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let v = mint_vault(&mut sim, ctx)?; + let coin = sim.new_coin(v.p2.tree_hash().into(), 1000); + let bob = Bytes32::new([1; 32]); + + let (coin_spends, delegated_spend) = build_transaction( + ctx, + &v, + coin, + Conditions::new() + .create_coin(bob, 900, Memos::None) + .reserve_fee(100), + )?; + + // Parse before signing. + let reveals = Reveals::from_coin_spends(ctx, &coin_spends)?; + let tx = parse_vault_transaction(reveals, ctx, v.vault.info.launcher_id, delegated_spend)?; + + // The vault keeps its custody configuration and amount. + assert_eq!( + tx.vault_child, + Some(VaultOutput::new(v.vault.info.custody_hash.into(), 1)) + ); + assert_eq!(tx.reserved_fee, 100); + assert_eq!(tx.p2_puzzle_hashes, vec![Bytes32::from(v.p2.tree_hash())]); + + // One vault-owned XCH coin is spent, creating one coin for Bob. + assert_eq!(tx.spends.len(), 1); + let spend = &tx.spends[0]; + assert!(matches!(spend.asset, ParsedAsset::Xch(_))); + assert_eq!(spend.children.len(), 1); + assert_eq!(spend.children[0].memos.p2_puzzle_hash, bob); + assert_eq!(spend.children[0].asset.coin().amount, 900); + + // Looks good, so sign and submit it. + sim.spend_coins(coin_spends, &[v.sk])?; + + Ok(()) +} + +#[test] +fn clear_sign_clawback() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let v = mint_vault(&mut sim, ctx)?; + let p2_puzzle_hash: Bytes32 = v.p2.tree_hash().into(); + let coin = sim.new_coin(p2_puzzle_hash, 1000); + let bob = Bytes32::new([1; 32]); + + // Send to Bob with a clawback, using the standard clawback memo format. + let clawback = ClawbackV2::new(p2_puzzle_hash, bob, 1_000_000, 1000, false); + let memos = ctx.memos(&clvm_list!(bob, clawback.memo()))?; + + let (coin_spends, delegated_spend) = build_transaction( + ctx, + &v, + coin, + Conditions::new().create_coin(clawback.tree_hash().into(), 1000, memos), + )?; + + let reveals = Reveals::from_coin_spends(ctx, &coin_spends)?; + let tx = parse_vault_transaction(reveals, ctx, v.vault.info.launcher_id, delegated_spend)?; + + // The child is recognized as a clawback, and its recipient is Bob. + let child = &tx.spends[0].children[0]; + assert_eq!(child.memos.clawback, Some(clawback)); + assert_eq!(child.memos.p2_puzzle_hash, bob); + + Ok(()) +} + +#[test] +fn hidden_spend_is_rejected() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let v = mint_vault(&mut sim, ctx)?; + let coin = sim.new_coin(v.p2.tree_hash().into(), 1000); + + let (coin_spends, delegated_spend) = build_transaction( + ctx, + &v, + coin, + Conditions::new().create_coin(Bytes32::new([1; 32]), 1000, Memos::None), + )?; + + // Only reveal the vault's own spend, not the coin it authorizes. + let vault_spend = coin_spends.last().unwrap().clone(); + let reveals = Reveals::from_coin_spends(ctx, &[vault_spend])?; + let result = parse_vault_transaction(reveals, ctx, v.vault.info.launcher_id, delegated_spend); + + assert!(matches!(result, Err(DriverError::MissingSpend))); + + Ok(()) +} +``` + +In a real wallet you'd build the coin spends with the [action system](/sdk/action-system) and a vault +p2 spend (see [Custom Spends](/sdk/action-system/custom-spends)), then pass the result through +`parse_vault_transaction` before collecting signatures. + +## Inputs: `Reveals` + +`Reveals` holds everything the parser is allowed to know about: + +| Method | What it reveals | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `Reveals::from_coin_spends` | All coin spends in the transaction. Coin spends whose parent is all zeros are treated as requested payments | +| `reveal_coin_spend` | A single coin spend (the puzzle reveal is checked against the coin's puzzle hash) | +| `reveal_settlement_payment` | A requested settlement payment, given its outer puzzle and settlement solution | +| `reveal_clawback` | A `ClawbackV2` p2 puzzle, so spends of coins locked in it can be recognized | +| `reveal_p2_conditions_or_singleton` | A `P2ConditionsOrSingleton`, so children locked to it are recognized as offer pre-splits | +| `reveal_vault_nonce` | An extra `P2Singleton` nonce the vault uses (nonce 0 is always included) | + +Coins created by the transaction are only recognized as returning to the vault if their puzzle hash is +one of the vault's p2 puzzle hashes. These are calculated from the revealed nonces and reported in +`VaultTransaction::p2_puzzle_hashes`. + +## Output: `VaultTransaction` + +| Field | Meaning | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `vault_child` | The new custody hash and amount of the vault, or `None` if the vault is melted. A different hash means a rekey | +| `drop_coins` | Even-amount coins created directly by the vault spend (for example to launch singletons) | +| `spends` | `VerifiedSpend`s: the asset spent, its custody (`CustodyInfo`), clawback path, children, and whether it was revoked | +| `issuances` | CAT mints and melts (`RunCatTail`) authorized by the transaction | +| `asserted_payments` | Settlement payments that are both revealed and asserted | +| `received_payments` | Asserted payment outputs to one of the vault's p2 puzzle hashes | +| `external_payments` | Asserted payment outputs to anyone else (required by the transaction, not necessarily paid by the vault) | +| `asset_flows` | Per-asset totals: input, output, issued, melted, received, paid and unaccounted amounts | +| `linked_offer` | Summary of the offer this transaction pre-splits coins for, if any | +| `reserved_fee` | Total `RESERVE_FEE` of the vault and its verified spends | +| `delegated_puzzle_hash` | The hash the signers are signing | + +Each child (`ParsedChild`) has a `transfer_type`: `Sent`, `Burned` (sent to the burn puzzle hash), +`Offered` (sent to the settlement puzzle), or `OfferPreSplit` (locked into a revealed +`P2ConditionsOrSingleton`). Its `memos` field (`ParsedMemos`) has the effective recipient p2 puzzle +hash, a decoded `ClawbackV2` if present, any human-readable memos, and the fixed conditions of an offer +pre-split. + +To validate a custody change, compare `vault_child.custody_hash` against a [`MipsMemo`](/sdk/primitives/vaults#recovering-a-vaults-configuration-from-memos) +for the new configuration. + +## Clawbacks and expiration + +Coins in a [`ClawbackV2`](/sdk/primitives/clawback) puzzle can be spent by the sender before the +timeout and by the receiver after it. If the transaction uses the sender path, it is only valid until +the clawback expires. The parser requires the vault to commit to an expiration +(`ASSERT_BEFORE_SECONDS_ABSOLUTE`) no later than that, and otherwise fails with +`DriverError::UnguaranteedClawBack`, since a transaction that could be held and replayed after the +timeout would do something different. + +## Linked offers + +A vault can't sign an offer the way a normal wallet does, because its coin spends need a message from +the vault spend. Instead, the vault transaction pre-splits coins into `P2ConditionsOrSingleton` puzzles +whose fixed path pays into the settlement puzzle and asserts the requested payments. The offer itself +is then built from those coins without another vault signature, and the vault can cancel it by +spending the coins through the singleton path. + +When the `P2ConditionsOrSingleton` puzzles are revealed, `VaultTransaction::linked_offer` summarizes +the future offer: its reserved fee and the payments it requests (split into ones paid back to the vault +and external ones). For making and taking offers in general, see [Offers](/sdk/offers). + +## Signing helpers + +`chia_wallet_sdk::driver` also exposes the messages that vault members sign, which is useful for +external signers: + +- `calculate_vault_coin_message(delegated_puzzle_hash, vault_coin_id, genesis_challenge)`: the + `AGG_SIG_ME` style message for coin-bound members. +- `calculate_vault_puzzle_message(delegated_puzzle_hash, vault_puzzle_hash)`: the message for + puzzle-asserting ("fast forward") members. +- `vault_recovery_delegated_puzzle_hash` and `calculate_vault_start_recovery_message`: the wrapped + delegated puzzle hash and message for starting a recovery, for either `VaultVersion`. + +## Errors + +Parsing is conservative, so many unusual but valid transactions are rejected. Common errors: + +| Error | Cause | +| ------------------------------------------- | -------------------------------------------------------------------------------------- | +| `InvalidDelegatedSpendFormat` | The vault's delegated puzzle isn't a quoted list of conditions | +| `InvalidVaultMessageFormat` | A `SEND_MESSAGE` from the vault doesn't use the puzzle-to-coin format with one coin id | +| `MissingSpend` | A coin the vault sends a message to isn't revealed | +| `InvalidLinkedCustody` | A messaged coin's p2 puzzle isn't a recognized vault custody puzzle | +| `UnmatchedVaultMessage` / `WrongConditions` | A message doesn't match the coin's delegated conditions | +| `DuplicateVaultMessage` | The same coin is authorized twice | +| `MissingP2ConditionsOrSingletonAssertion` | A vault spend of a `P2ConditionsOrSingleton` coin doesn't include `ASSERT_MY_COIN_ID` | +| `UnguaranteedClawBack` | A clawback sender path is used without a matching expiration | + +All of these are variants of `DriverError`. diff --git a/docs/sdk/concepts/clvm.md b/docs/sdk/concepts/clvm.md new file mode 100644 index 0000000..72e6512 --- /dev/null +++ b/docs/sdk/concepts/clvm.md @@ -0,0 +1,458 @@ +--- +title: CLVM Values +--- + +# CLVM Values + +Every puzzle, solution, condition list and curried argument on Chia is a CLVM value: either an atom (a byte string) or a pair of two values. The Wallet SDK is built on three crates that handle these values in Rust: + +| Crate | Re-exported as | What it provides | +| ------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `clvmr` | `chia_wallet_sdk::clvmr` | The `Allocator` that stores CLVM values, `NodePtr` handles, serialization and the CLVM interpreter | +| `clvm-traits` | `chia_wallet_sdk::clvm_traits` | The `ToClvm` and `FromClvm` traits, their derive macros, and the `clvm_list!` / `clvm_quote!` macros | +| `clvm-utils` | `chia_wallet_sdk::clvm_utils` | Tree hashing (`TreeHash`, `tree_hash`, `ToTreeHash`) and currying (`CurriedProgram`) | + +The SDK adds the `Mod` trait, Chialisp and Rue compilation, and `run_puzzle` in `chia_wallet_sdk::types`. The most common items from all of these are in `chia_wallet_sdk::prelude`. + +This page covers the CLVM building blocks. Most application code doesn't use an `Allocator` directly; it goes through [SpendContext](/sdk/concepts/spend-context), which wraps an allocator, caches puzzles and collects coin spends. Everything here still applies, because `SpendContext` dereferences to `Allocator`. + +For background on CLVM itself, see the [Chialisp documentation](https://chialisp.com). + +## Allocator and NodePtr + +An `Allocator` is an arena that owns CLVM values. A `NodePtr` is a small, `Copy` handle to a value inside one specific allocator. A `NodePtr` doesn't carry its allocator with it, so it's only meaningful when used with the allocator that created it. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn allocator_basics() -> anyhow::Result<()> { + let mut allocator = Allocator::new(); + + // Build the list (1 2 3) by hand. + let one = allocator.new_small_number(1)?; + let two = allocator.new_small_number(2)?; + let three = allocator.new_small_number(3)?; + let rest = allocator.new_pair(three, NodePtr::NIL)?; + let rest = allocator.new_pair(two, rest)?; + let list = allocator.new_pair(one, rest)?; + + // Walk it back. + let SExp::Pair(first, _rest) = allocator.sexp(list) else { + panic!("expected a pair"); + }; + assert_eq!(allocator.atom(first).as_ref(), &[1]); + + // Nil is the empty atom, and is also the canonical encoding of 0 and false. + assert!(allocator.atom(NodePtr::NIL).as_ref().is_empty()); + + Ok(()) +} +``` + +Building values node by node is rarely necessary. The traits in the next section convert Rust values to and from CLVM for you. + +## ToClvm and FromClvm + +`ToClvm` allocates a Rust value as CLVM, and `FromClvm` parses CLVM back into a Rust value. Both are implemented for: + +- Integers (`u8` through `u128`, `i8` through `i128`, `usize`, `isize`, and `num_bigint::BigInt`) and `bool`, using CLVM's minimal signed big-endian encoding +- `String` and `&str` (UTF-8 atoms), `Bytes`, `Bytes32` and other fixed-size byte arrays +- `PublicKey`, `Signature`, and the secp256k1/secp256r1 key and signature types +- `Vec`, `[T; N]` and slices (nil-terminated lists), tuples `(A, B)` (a single pair) and `()` (nil) +- `Option` (`None` is nil) +- `NodePtr` itself, so you can mix already-allocated values into typed structures +- `Program`, the serialized form described [below](#program-and-serialization) + +The `clvm_list!`, `clvm_tuple!` and `clvm_quote!` macros build ad hoc structures without declaring a type: + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::clvm_traits::{clvm_list, clvm_quote}; + +#[test] +fn to_and_from_clvm() -> anyhow::Result<()> { + let mut allocator = Allocator::new(); + + // ("hello" 42 0xcafe) + let ptr = clvm_list!("hello", 42, Bytes::new(vec![0xca, 0xfe])).to_clvm(&mut allocator)?; + + // Parse it back as a tuple-style list. + let (name, (number, (bytes, ()))) = + <(String, (u64, (Bytes, ())))>::from_clvm(&allocator, ptr)?; + assert_eq!(name, "hello"); + assert_eq!(number, 42); + assert_eq!(bytes.as_ref(), &[0xca, 0xfe]); + + // (q . (1 2 3)) is a program that returns (1 2 3) when run. + let quoted = clvm_quote!([1, 2, 3]).to_clvm(&mut allocator)?; + let output = run_puzzle(&mut allocator, quoted, NodePtr::NIL)?; + assert_eq!(Vec::::from_clvm(&allocator, output)?, vec![1, 2, 3]); + + Ok(()) +} +``` + +### Deriving ToClvm and FromClvm + +For puzzle solutions, curried arguments and memos, define a struct and derive both traits. The `#[clvm(...)]` attribute picks the representation: + +| Representation | CLVM shape | Typical use | +| ------------------ | -------------------------------------------------------- | ---------------------------------------- | +| `list` | `(a b c)`, extra trailing items are ignored when parsing | Solutions and on-chain data you parse | +| `proper_list` | `(a b c)`, must be nil-terminated exactly | Strict formats | +| `curry` | `(c (q . a) (c (q . b) 1))` | Curried puzzle arguments | +| `transparent` | Same as the single inner field | Newtypes | +| `atom` (enums) | The discriminant as an integer | Simple enums, combine with `#[repr(u8)]` | +| `untagged` (enums) | Tries each variant in order | Values with several possible shapes | + +Field attributes refine this: `#[clvm(rest)]` on the last field consumes the remainder of the list (no nil terminator), `#[clvm(default)]` or `#[clvm(default = ...)]` makes the last field optional, and `#[clvm(constant = ...)]` together with `#[apply_constants]` encodes a fixed value that isn't a Rust field. + +```rust +use chia_wallet_sdk::prelude::*; + +/// The solution to some puzzle: `(amount recipient . memos)`. +#[derive(Debug, Clone, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(list)] +pub struct PaymentSolution { + pub amount: u64, + pub recipient: Bytes32, + #[clvm(rest)] + pub memos: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(atom)] +#[repr(u8)] +pub enum Mode { + Pay = 1, + Refund = 2, +} + +#[test] +fn derived_types() -> anyhow::Result<()> { + let mut allocator = Allocator::new(); + + let solution = PaymentSolution { + amount: 1_000, + recipient: Bytes32::new([1; 32]), + memos: vec![Bytes::new(b"invoice-17".to_vec())], + }; + + let ptr = solution.to_clvm(&mut allocator)?; + assert_eq!(PaymentSolution::from_clvm(&allocator, ptr)?, solution); + + let mode = Mode::Refund.to_clvm(&mut allocator)?; + assert_eq!(allocator.atom(mode).as_ref(), &[2]); + + Ok(()) +} +``` + +The derive macros generate code that refers to the `clvm_traits` crate by name. `use chia_wallet_sdk::prelude::*;` brings that name into scope, so derives work without adding `clvm-traits` as a direct dependency. If you don't import the prelude, add `use chia_wallet_sdk::clvm_traits;` or pass `#[clvm(crate_name = chia_wallet_sdk::clvm_traits)]`. + +Use `NodePtr` as a field type (or a generic parameter that you later set to `NodePtr`) to leave part of a value unparsed. Most SDK solution types, like `StandardSolution` and `CatSolution`, are generic for exactly this reason. + +## Program and serialization + +On the wire and inside a `CoinSpend`, CLVM values are serialized bytes. `Program` (from `chia_protocol`) is a wrapper around those bytes. Convert between the two forms with `ToClvm` and `FromClvm`, or with the `clvmr::serde` functions directly: + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::clvmr::serde::{node_from_bytes, node_to_bytes}; + +#[test] +fn programs() -> anyhow::Result<()> { + let mut allocator = Allocator::new(); + + // (q . 42) serialized + let program = Program::from(hex::decode("ff012a")?); + + // Program -> NodePtr + let ptr = program.to_clvm(&mut allocator)?; + let output = run_puzzle(&mut allocator, ptr, NodePtr::NIL)?; + assert_eq!(u8::from_clvm(&allocator, output)?, 42); + + // NodePtr -> Program + let roundtrip = Program::from_clvm(&allocator, ptr)?; + assert_eq!(roundtrip, program); + + // The same thing with raw bytes. + let ptr = node_from_bytes(&mut allocator, &hex::decode("ff012a")?)?; + assert_eq!(hex::encode(node_to_bytes(&allocator, ptr)?), "ff012a"); + + Ok(()) +} +``` + +`clvmr::serde` also has `node_to_bytes_backrefs`, which compresses repeated subtrees. Current full nodes accept back references in puzzle reveals and solutions, and `SpendContext::serialize_with_backrefs` uses this encoding. + +## Tree hashing + +The tree hash of a CLVM value is its SHA-256 based structural hash. A puzzle's tree hash is its puzzle hash, which is what a coin commits to. `TreeHash` is a 32-byte newtype that converts to and from `Bytes32`. + +There are two ways to compute a tree hash: + +- `tree_hash(&allocator, ptr)` hashes a value that's already allocated. +- `value.tree_hash()` (the `ToTreeHash` trait) hashes any `ToClvm` value without allocating it, using the `TreeHasher` encoder. This is how the SDK computes puzzle hashes cheaply. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn tree_hashes() -> anyhow::Result<()> { + let mut allocator = Allocator::new(); + + let value = ("hello".to_string(), 42u64); + let ptr = value.to_clvm(&mut allocator)?; + + // Both methods agree. + assert_eq!(tree_hash(&allocator, ptr), value.tree_hash()); + + // A pair hashes as sha256(2, first, rest), and an atom as sha256(1, bytes). + let expected = tree_hash_pair(tree_hash_atom(b"hello"), tree_hash_atom(&[42])); + assert_eq!(value.tree_hash(), expected); + + // TreeHash and Bytes32 convert freely. + let puzzle_hash: Bytes32 = expected.into(); + assert_eq!(TreeHash::from(puzzle_hash), expected); + + Ok(()) +} +``` + +A `TreeHash` can stand in for a value it's the hash of. This is the basis for computing curried puzzle hashes: you only need the hash of the inner puzzle, not the inner puzzle itself. + +## Currying + +Currying fixes some of a puzzle's arguments ahead of time, producing a new puzzle with a different puzzle hash. See [Currying](https://docs.chia.net/guides/chialisp-currying) for how it works in CLVM. + +In Rust, a curried puzzle is a `CurriedProgram { program, args }`, where `args` is a struct with the `#[clvm(curry)]` representation. The same type works with three kinds of `program`: + +- A `NodePtr` to build the actual puzzle in an allocator +- A `TreeHash` of the mod to compute the curried puzzle hash without allocating anything +- Either one when parsing, to split a puzzle back into its mod and arguments + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::clvmr::serde::node_from_bytes; + +#[derive(Debug, Clone, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(curry)] +pub struct AddArgs { + pub amount: u64, +} + +#[test] +fn currying() -> anyhow::Result<()> { + let mut allocator = Allocator::new(); + + // (+ 2 5): adds the first and second arguments. + let mod_bytes = hex::decode("ff10ff02ff0580")?; + let mod_ptr = node_from_bytes(&mut allocator, &mod_bytes)?; + let mod_hash = tree_hash(&allocator, mod_ptr); + + // Curry in the first argument. + let args = AddArgs { amount: 100 }; + let puzzle = CurriedProgram { program: mod_ptr, args: args.clone() }.to_clvm(&mut allocator)?; + + // The puzzle hash can be computed from the mod hash alone. + let puzzle_hash = CurriedProgram { program: mod_hash, args: args.clone() }.tree_hash(); + assert_eq!(tree_hash(&allocator, puzzle), puzzle_hash); + + // Running it adds the curried amount to the solution's first item. + let solution = [5].to_clvm(&mut allocator)?; + let output = run_puzzle(&mut allocator, puzzle, solution)?; + assert_eq!(u64::from_clvm(&allocator, output)?, 105); + + // Uncurry it again. + let curried = CurriedProgram::::from_clvm(&allocator, puzzle)?; + assert_eq!(curried.args, args); + assert_eq!(tree_hash(&allocator, curried.program), mod_hash); + + Ok(()) +} +``` + +## The Mod trait + +`Mod` (in `chia_wallet_sdk::types`) ties a curried-arguments type to the puzzle it belongs to. It has two required methods, `mod_reveal()` (the serialized uncurried puzzle) and `mod_hash()`, and provides `curry_tree_hash()` to compute the puzzle hash of a set of arguments directly: + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::chia::puzzle_types::{cat::CatArgs, standard::StandardArgs}; + +#[test] +fn mod_trait() -> anyhow::Result<()> { + let ctx = &mut SpendContext::new(); + let public_key = SecretKey::from_seed(&[1; 32]).public_key(); + + // The puzzle hash of a standard XCH address for this key. + let p2_puzzle_hash = StandardArgs::new(public_key).curry_tree_hash(); + + // The puzzle hash of a CAT held by that address. + let asset_id = Bytes32::new([2; 32]); + let cat_puzzle_hash = CatArgs::new(asset_id, p2_puzzle_hash).curry_tree_hash(); + + // SpendContext::curry allocates the mod (cached by hash) and curries the arguments. + let puzzle = ctx.curry(StandardArgs::new(public_key))?; + assert_eq!(ctx.tree_hash(puzzle), p2_puzzle_hash); + + // The uncurried mod is available too. + let mod_ptr = ctx.alloc_mod::()?; + assert_eq!(ctx.tree_hash(mod_ptr), StandardArgs::mod_hash()); + assert_ne!(cat_puzzle_hash, p2_puzzle_hash); + + Ok(()) +} +``` + +`Mod` is implemented for the argument types of the standard puzzles from `chia_wallet_sdk::chia::puzzle_types` (`StandardArgs`, `CatArgs`, `SingletonArgs`, `DidArgs`, the NFT layers, and the TAILs), and for every puzzle in `chia_wallet_sdk::types::puzzles`. Puzzles without curried arguments, such as `SingletonLauncher` and `SettlementPayment`, implement `Mod` on a marker struct. The raw puzzle bytes and hashes themselves come from the `chia_puzzles` crate, re-exported as `chia_wallet_sdk::puzzles`. + +To use your own puzzle with the SDK, implement `Mod` on its arguments type. If you have the compiled bytes, embed them: + +```rust +use std::borrow::Cow; + +use chia_wallet_sdk::prelude::*; +use hex_literal::hex; + +/// `(mod (AMOUNT n) (+ AMOUNT n))` compiled. +pub const ADD_PUZZLE: [u8; 7] = hex!("ff10ff02ff0580"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(curry)] +pub struct AddArgs { + pub amount: u64, +} + +impl Mod for AddArgs { + fn mod_reveal() -> Cow<'static, [u8]> { + Cow::Borrowed(&ADD_PUZZLE) + } + + fn mod_hash() -> TreeHash { + // Precompute this in real code; computing it here keeps the example short. + chia_wallet_sdk::clvm_utils::tree_hash_from_bytes(&ADD_PUZZLE).unwrap() + } +} + +#[test] +fn custom_mod() -> anyhow::Result<()> { + let ctx = &mut SpendContext::new(); + + let puzzle = ctx.curry(AddArgs { amount: 7 })?; + assert_eq!(ctx.tree_hash(puzzle), AddArgs { amount: 7 }.curry_tree_hash()); + + let solution = ctx.alloc(&[3])?; + let output = ctx.run(puzzle, solution)?; + assert_eq!(ctx.extract::(output)?, 10); + + Ok(()) +} +``` + +## Compiling Chialisp and Rue + +The SDK can compile puzzles from source at runtime. `compile_chialisp(path, include_paths)` compiles a `.clsp` file, and `compile_rue(path, debug, export_name)` compiles a [Rue](https://rue-lang.com) file or project (a directory with `Rue.toml` or `main.rue`). Both return a `Compilation` with the serialized `reveal` and its tree `hash`. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::clvmr::serde::node_from_bytes; + +#[test] +fn compile_from_source() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join("sdk-docs-compile-example"); + std::fs::create_dir_all(&dir)?; + + let clsp = dir.join("double.clsp"); + std::fs::write(&clsp, "(mod (N) (* N 2))")?; + let chialisp = compile_chialisp(&clsp, &[])?; + + let rue = dir.join("double.rue"); + std::fs::write(&rue, "fn main(n: Int) -> Int { n * 2 }")?; + let rue = compile_rue(&rue, false, None)?; + + let mut allocator = Allocator::new(); + for compiled in [chialisp, rue] { + let puzzle = node_from_bytes(&mut allocator, &compiled.reveal)?; + assert_eq!(tree_hash(&allocator, puzzle), compiled.hash); + + let solution = [21].to_clvm(&mut allocator)?; + let output = run_puzzle(&mut allocator, puzzle, solution)?; + assert_eq!(u64::from_clvm(&allocator, output)?, 42); + } + + Ok(()) +} +``` + +The `compile_chialisp!` and `compile_rue!` macros combine compilation with a `Mod` implementation. They create a lazily-compiled static and implement `Mod` for your arguments type, so the source is compiled the first time the puzzle is used: + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::{compile_chialisp, compile_rue}; + +#[derive(Debug, Clone, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(curry)] +pub struct MultiplyArgs { + pub factor: u64, +} + +// Search paths are "." and "include", relative to the working directory. +compile_chialisp!(MultiplyArgs = MULTIPLY_MOD, "puzzles/multiply.clsp"); + +#[derive(Debug, Clone, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(curry)] +pub struct LockArgs { + pub inner_puzzle: T, +} + +// `debug` keeps debug information, and the optional last argument picks an exported function. +compile_rue!(debug LockArgs = LOCK_MOD, "puzzles/lock", "lock"); +``` + +:::warning +Paths passed to these macros are resolved against the process working directory when the puzzle is first used, not relative to the source file, and compilation errors panic at that point. Both macros also expect `Compilation` to be in scope (the prelude includes it). For published puzzles, compile once and embed the bytes and hash as constants instead. Compiling at runtime is slow, and a compiler upgrade could change the output and therefore the puzzle hash. +::: + +## Running puzzles + +`run_puzzle(&mut allocator, puzzle, solution)` runs a program with the mainnet maximum block cost and returns its output. `run_puzzle_with_cost` lets you pass a cost limit and enable mempool mode, and returns a `Reduction` with the cost and output. `SpendContext::run` is a shortcut for `run_puzzle`. + +Running a puzzle with its solution is how you find out what a spend does. The output of a coin's puzzle is its list of conditions, which you can parse with `Vec` or `Conditions` (see [Conditions](/sdk/concepts/conditions#parsing-conditions)): + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::clvm_traits::clvm_quote; + +#[test] +fn run_and_parse() -> anyhow::Result<()> { + let ctx = &mut SpendContext::new(); + + let conditions = Conditions::new().reserve_fee(100).assert_height_absolute(1_000); + let puzzle = ctx.alloc(&clvm_quote!(conditions))?; + + let output = ctx.run(puzzle, NodePtr::NIL)?; + let parsed = ctx.extract::>(output)?; + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].as_reserve_fee().map(|fee| fee.amount), Some(100)); + + Ok(()) +} +``` + +In debug builds and tests, puzzles run under Rue's debug dialect, which records debug messages emitted by Rue programs compiled with `debug`. Release builds use the normal Chia dialect unless the `DEBUG_CLVM` environment variable is set to `true`, `yes` or `1` at compile time. + +## HashedPtr + +`HashedPtr` (in `chia_wallet_sdk::driver`) is a `NodePtr` bundled with its precomputed tree hash. It implements `ToClvm`, `FromClvm` and `ToTreeHash`, and compares by hash. The SDK uses it for values like NFT metadata, where you often need both the value and its hash. Create one with `HashedPtr::from_ptr(&allocator, ptr)` or `SpendContext::alloc_hashed`. + +## Gotchas + +- **Pointers belong to one allocator.** Using a `NodePtr` with a different `Allocator` (or after `SpendContext::reset`) doesn't fail loudly; it points at whatever is at that index. Keep a single `SpendContext` per transaction you build. +- **Zero is nil.** `0u64`, `false`, `None`, `()` and an empty string all encode to the same nil atom, so `Option` can't distinguish `Some(0)` from `None`. +- **`list` is lenient, `proper_list` is strict.** `#[clvm(list)]` ignores extra trailing items when parsing. That's usually what you want for solutions and conditions, but it means a round trip doesn't preserve them. +- **Integer parsing only checks the length.** `FromClvm` for integers fails if the atom is too long for the type, but doesn't check for a minimal encoding or, for unsigned types, the sign bit. The atom `0x80` is -128 in CLVM but parses as `128u8`. When the exact value matters for security, compare against the encoding the puzzle itself would see, or parse as `Bytes`. +- **Tree hashes aren't SHA-256 of the serialized bytes.** Always use `tree_hash`, `ToTreeHash` or `tree_hash_from_bytes` to get a puzzle hash. diff --git a/docs/sdk/concepts/conditions.md b/docs/sdk/concepts/conditions.md new file mode 100644 index 0000000..9fa1cab --- /dev/null +++ b/docs/sdk/concepts/conditions.md @@ -0,0 +1,327 @@ +--- +title: Conditions +--- + +# Conditions + +A coin's puzzle returns a list of conditions when it's spent. Conditions are how a spend has any effect: creating coins, paying fees, requiring signatures, coordinating with other spends, and restricting when the spend is valid. See [Conditions](https://chialisp.com/conditions) on chialisp.com for the consensus rules behind each one. + +The SDK represents conditions in two ways, both in `chia_wallet_sdk::types` and the prelude: + +- `Condition`, an enum with one variant per condition, used for parsing and inspecting outputs. +- `Conditions`, a list of conditions with a builder method for each one, used when you construct a spend. + +Each condition also has its own struct in `chia_wallet_sdk::types::conditions` (for example `CreateCoin`, `ReserveFee`, `AggSigMe`), which the prelude re-exports. + +## Building conditions + +`Conditions::new()` starts an empty list, and every condition has a snake_case builder method that appends it and returns the list. Pass the finished list to a layer's spend method, such as `StandardLayer::spend`, or to `SpendContext::delegated_spend`. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn send_with_fee() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1_000); + let bob = BlsPair::new(1); + + // Hint the payment to Bob so his wallet can find it. + let hint = ctx.hint(bob.puzzle_hash)?; + + let conditions = Conditions::new() + .create_coin(bob.puzzle_hash, 900, hint) + .create_coin(alice.puzzle_hash, 50, Memos::None) + .reserve_fee(50); + + StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + assert_eq!(sim.unspent_coins(bob.puzzle_hash, false).len(), 1); + + Ok(()) +} +``` + +You can also build a list from condition structs with `with` (by value), `push` (by mutable reference) and `extend`, which is convenient when conditions come from several places: + +```rust +use chia_wallet_sdk::prelude::*; + +fn payout_conditions(payouts: &[(Bytes32, u64)], fee: u64) -> Conditions { + let mut conditions = Conditions::new().extend( + payouts + .iter() + .map(|&(puzzle_hash, amount)| CreateCoin::new(puzzle_hash, amount, Memos::None)), + ); + + if fee > 0 { + conditions.push(ReserveFee::new(fee)); + } + + conditions +} + +#[test] +fn build_from_structs() { + let conditions = payout_conditions(&[(Bytes32::new([1; 32]), 10), (Bytes32::new([2; 32]), 20)], 5); + assert_eq!(conditions.len(), 3); + assert!(conditions[2].is_reserve_fee()); +} +``` + +`Conditions` is `#[must_use]`, since the builder methods return a new list rather than modifying it in place. + +## Condition reference + +| Category | Builder method | Opcode | Notes | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------- | +| Coins | `create_coin(puzzle_hash, amount, memos)` | 51 | See [memos and hints](#memos-and-hints) | +| | `reserve_fee(amount)` | 52 | Asserts that at least this much of the bundle's surplus goes to fees | +| Signatures | `agg_sig_me`, `agg_sig_unsafe`, `agg_sig_parent`, `agg_sig_puzzle`, `agg_sig_amount`, `agg_sig_parent_amount`, `agg_sig_parent_puzzle`, `agg_sig_puzzle_amount` | 43-50 | `(public_key, message)`; see [Keys & Signing](/sdk/concepts/signing) | +| Announcements | `create_coin_announcement(message)`, `assert_coin_announcement(id)` | 60, 61 | | +| | `create_puzzle_announcement(message)`, `assert_puzzle_announcement(id)` | 62, 63 | | +| Messages | `send_message(mode, message, data)`, `receive_message(mode, message, data)` | 66, 67 | [CHIP-25](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0025.md) | +| Concurrency | `assert_concurrent_spend(coin_id)`, `assert_concurrent_puzzle(puzzle_hash)` | 64, 65 | Another coin must be spent in the same block | +| Self | `assert_my_coin_id`, `assert_my_parent_id`, `assert_my_puzzle_hash`, `assert_my_amount`, `assert_my_birth_height`, `assert_my_birth_seconds` | 70-75 | | +| | `assert_ephemeral()` | 76 | The coin must be created in the same block | +| Timelocks | `assert_seconds_relative`, `assert_seconds_absolute`, `assert_height_relative`, `assert_height_absolute` | 80-83 | Not valid until | +| | `assert_before_seconds_relative`, `assert_before_seconds_absolute`, `assert_before_height_relative`, `assert_before_height_absolute` | 84-87 | Not valid after | +| Other | `remark(rest)` | 1 | Ignored by consensus; useful for puzzles that read their own output | +| | `softfork(cost, rest)` | 90 | Reserved for future consensus extensions | + +Some puzzles interpret additional conditions that consensus doesn't know about. The SDK has variants for these too: + +| Builder method | Used by | +| ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `run_cat_tail(program, solution)` | Running a TAIL from a CAT inner puzzle (a `CREATE_COIN` with the magic amount -113) | +| `melt_singleton()` | Destroying a singleton (also the magic amount -113) | +| `transfer_nft(launcher_id, trade_prices, singleton_inner_puzzle_hash)` | Assigning an NFT to a DID through the NFT ownership layer (opcode -10) | +| `update_nft_metadata(updater_puzzle_reveal, updater_solution)` | Updating NFT metadata through the state layer (opcode -24) | +| `update_datastore_merkle_root(new_merkle_root, memos)` | DataLayer stores, see [DataLayer](/sdk/primitives/datalayer) (opcode -13) | + +You'll mostly reach these through the primitive APIs ([CAT](/sdk/primitives/cat), [NFT](/sdk/primitives/nft), [Singletons](/sdk/primitives/singletons)) rather than building them by hand. + +## Memos and hints + +The third argument of `create_coin` is a `Memos`: either `Memos::None` or `Memos::Some(list)`, where the list is an allocated CLVM value. Memos are stored on chain with the coin spend but aren't part of the new coin's id. + +By convention, if the first memo is 32 bytes, full nodes index it as the coin's **hint**. Wallets subscribe to hints to discover coins whose puzzle hash they wouldn't otherwise recognize. For example, a CAT sent to Bob is locked by a CAT puzzle hash, but is hinted with Bob's inner puzzle hash so his wallet can find it. See the [CAT](/sdk/primitives/cat) and [NFT](/sdk/primitives/nft) pages for how each primitive hints its children. + +`SpendContext` has two helpers for building memos: + +- `ctx.hint(puzzle_hash)` allocates the list `(puzzle_hash)`. +- `ctx.memos(&value)` allocates any `ToClvm` value as the memo list, for a hint followed by other data. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn hints_and_memos() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(2); + let bob = BlsPair::new(1); + + // A hint followed by a free-form note. + let memos = ctx.memos(&(bob.puzzle_hash, ("Thanks for lunch".to_string(), ())))?; + + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(bob.puzzle_hash, 2, memos), + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // The simulator indexes hints like a full node. + assert_eq!(sim.hinted_coins(bob.puzzle_hash).len(), 1); + + Ok(()) +} +``` + +## Announcements and messages + +Spends in a bundle are independent unless they assert something about each other. Announcements and messages let one spend depend on another, so that a bundle can't be split up and partially included. + +### Announcements + +A coin creates an announcement with `create_coin_announcement(message)` or `create_puzzle_announcement(message)`. Any other spend in the same block can assert it by its id: `sha256(coin_id + message)` for coin announcements, or `sha256(puzzle_hash + message)` for puzzle announcements. `announcement_id(coin_info, message)` computes this. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::announcement_id; + +#[test] +fn linked_by_announcement() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let bob = sim.bls(1); + + // Alice's spend is only valid if Bob's coin is spent in the same block, announcing "ready". + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new() + .assert_coin_announcement(announcement_id(bob.coin.coin_id(), b"ready")), + )?; + + StandardLayer::new(bob.pk).spend( + ctx, + bob.coin, + Conditions::new().create_coin_announcement(Bytes::new(b"ready".to_vec())), + )?; + + sim.spend_coins(ctx.take(), &[alice.sk, bob.sk])?; + + Ok(()) +} +``` + +Announcements are public: any spend in the block can assert one, and creating one doesn't require it to be asserted. When you only need to know that another coin is spent alongside yours, `assert_concurrent_spend(coin_id)` is simpler. + +### Messages + +Messages ([CHIP-25](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0025.md)) are a stricter, point-to-point alternative. A `SEND_MESSAGE` must be matched by exactly one `RECEIVE_MESSAGE` with the same mode and message in the same spend bundle, and vice versa, or the bundle fails. + +The `mode` byte says which parts of the sender and receiver coins are committed to. Build it with `MessageFlags` and `MessageSide`: the sender flags occupy the upper three bits and the receiver flags the lower three. The `data` argument identifies the other side: in `send_message`, the receiver's committed parts (for example its coin id), and in `receive_message`, the sender's. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::{MessageFlags, MessageSide}; + +#[test] +fn linked_by_message() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let bob = sim.bls(1); + + // Alice commits to her puzzle hash, and sends the message to Bob's exact coin. + let mode = MessageFlags::PUZZLE.encode(MessageSide::Sender) + | MessageFlags::COIN.encode(MessageSide::Receiver); + let message = Bytes::new(b"approved".to_vec()); + + let bob_coin_id = ctx.alloc(&bob.coin.coin_id())?; + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().send_message(mode, message.clone(), vec![bob_coin_id]), + )?; + + let alice_puzzle_hash = ctx.alloc(&alice.puzzle_hash)?; + StandardLayer::new(bob.pk).spend( + ctx, + bob.coin, + Conditions::new().receive_message(mode, message, vec![alice_puzzle_hash]), + )?; + + sim.spend_coins(ctx.take(), &[alice.sk, bob.sk])?; + + Ok(()) +} +``` + +Messages are how many newer puzzles authorize each other. For example, [vault](/sdk/primitives/vaults) coins are spent by a message from the vault singleton, and the [action system](/sdk/action-system) uses them to link spends. + +## Assertions and timelocks + +Assertion conditions don't change state; they make the spend invalid unless something is true. The `assert_my_*` conditions guard against a puzzle being spent as a different coin than intended. Timelocks restrict when a spend can be included: + +- `assert_seconds_*` and `assert_height_*` set the earliest time or block height. +- `assert_before_seconds_*` and `assert_before_height_*` set an expiry. +- The `_relative` versions count from when the coin was created, and the `_absolute` versions are timestamps or heights on the chain. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn relative_timelock() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let p2 = StandardLayer::new(alice.pk); + let conditions = Conditions::new() + .create_coin(alice.puzzle_hash, 1, Memos::None) + .assert_height_relative(10); + + // Too early: the coin was created less than 10 blocks ago. + p2.spend(ctx, alice.coin, conditions.clone())?; + assert!(sim.spend_coins(ctx.take(), &[alice.sk.clone()]).is_err()); + + // Ten blocks later, the same spend is valid. + for _ in 0..10 { + sim.create_block(); + } + p2.spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + Ok(()) +} +``` + +## Parsing conditions + +To find out what a spend does, run its puzzle with its solution and parse the output. `Vec` and `Conditions` both implement `FromClvm`. Each variant has generated helpers: `is_create_coin()`, `as_create_coin()` (a reference) and `into_create_coin()` (by value), and likewise for every other condition. `into_agg_sig()` converts any of the eight signature conditions into a common `AggSig` struct. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn parse_spend_output() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(10); + let bob = BlsPair::new(1); + + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(bob.puzzle_hash, 9, Memos::None).reserve_fee(1), + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Later, look up the spend and see what it created. + let coin_spend = sim.coin_spend(alice.coin.coin_id()).expect("coin was spent"); + let puzzle = ctx.alloc(&coin_spend.puzzle_reveal)?; + let solution = ctx.alloc(&coin_spend.solution)?; + let output = ctx.run(puzzle, solution)?; + let conditions = ctx.extract::>(output)?; + + let created: Vec = conditions + .iter() + .filter_map(Condition::as_create_coin) + .map(|create_coin| { + Coin::new(alice.coin.coin_id(), create_coin.puzzle_hash, create_coin.amount) + }) + .collect(); + assert_eq!(created, [Coin::new(alice.coin.coin_id(), bob.puzzle_hash, 9)]); + + // The standard puzzle adds an AGG_SIG_ME for its key. + let signatures: Vec<_> = conditions.into_iter().filter_map(Condition::into_agg_sig).collect(); + assert_eq!(signatures.len(), 1); + assert_eq!(signatures[0].public_key, alice.pk); + + Ok(()) +} +``` + +Conditions with an unrecognized opcode, or a recognized opcode that doesn't match the expected shape, parse as `Condition::Other(T)`, so parsing a list never fails because of a single unusual condition. The enum is also `#[non_exhaustive]`, so `match` statements need a wildcard arm. + +The type parameter `T` controls how the open-ended parts of a condition are parsed: the memo list of `CreateCoin`, the rest of `Remark` and `Softfork`, the message data, and `Other`. It defaults to `NodePtr`, which leaves them unparsed. + +## Gotchas + +- **Conditions don't sign anything.** An `AGG_SIG_*` condition only states that a signature is required. You still have to produce the signature; see [Keys & Signing](/sdk/concepts/signing). +- **Fees are implicit.** The fee is whatever input value isn't spent on outputs. `reserve_fee` only asserts that the fee is at least the given amount, and protects against a bundle being stripped of its fee-paying spend. +- **Memos must be allocated in the same allocator as the spend.** `Memos` points into a specific `SpendContext`. Build it with that context's `hint` or `memos`. +- **Only the first memo is a hint, and only if it's exactly 32 bytes.** +- **Condition order isn't significant** to consensus, but duplicate `CREATE_COIN` conditions with the same puzzle hash and amount create the same coin id and fail. Vary the amount, or use separate parents. diff --git a/docs/sdk/concepts/layers.md b/docs/sdk/concepts/layers.md new file mode 100644 index 0000000..26113c9 --- /dev/null +++ b/docs/sdk/concepts/layers.md @@ -0,0 +1,398 @@ +--- +title: Layers & Puzzles +--- + +# Layers & Puzzles + +Most Chia puzzles are built by wrapping one puzzle inside another. A CAT is the CAT puzzle wrapped around an inner puzzle that decides who owns it, usually the standard puzzle. An NFT is five puzzles deep: singleton, NFT state, NFT ownership, royalty transfer, and an owner puzzle. The SDK models each of these wrappers as a **layer**: a Rust type that knows how to build its puzzle and solution, and how to recognize itself in an existing puzzle. + +Layers are the low-level building blocks underneath the [primitives](/sdk/primitives). Use them directly when you need a puzzle combination that no primitive covers, when you write your own puzzle, or when you want to inspect an arbitrary coin spend. For ordinary XCH, CAT and NFT transactions, the primitives and the [action system](/sdk/action-system) are simpler. + +All of the types on this page are in `chia_wallet_sdk::driver`, and the most common ones are in the prelude. + +## The Layer trait + +```rust ignore +pub trait Layer { + type Solution; + + fn parse_puzzle(allocator: &Allocator, puzzle: Puzzle) -> Result, DriverError> + where + Self: Sized; + fn parse_solution(allocator: &Allocator, solution: NodePtr) -> Result; + + fn construct_puzzle(&self, ctx: &mut SpendContext) -> Result; + fn construct_solution(&self, ctx: &mut SpendContext, solution: Self::Solution) -> Result; + + // Provided: + fn construct_spend(&self, ctx: &mut SpendContext, solution: Self::Solution) -> Result; + fn construct_coin_spend(&self, ctx: &mut SpendContext, coin: Coin, solution: Self::Solution) -> Result; +} +``` + +- The layer value holds the puzzle's curried state, such as `StandardLayer { synthetic_key }` or `CatLayer { asset_id, inner_puzzle }`. +- `Solution` is the typed solution, usually a CLVM struct like `StandardSolution` or `CatSolution`. +- `construct_puzzle` and `construct_solution` build the CLVM for a spend. Puzzle mods are cached in the [SpendContext](/sdk/concepts/spend-context), so building the same puzzle repeatedly is cheap. +- `parse_puzzle` returns `Ok(None)` if the puzzle isn't this layer, and an error only if it is this layer but is malformed. That makes it safe to try several layers in turn. +- `parse_solution` decodes a solution, for example from a coin spend you fetched from the chain. + +Most layers also implement `ToTreeHash`, which computes the full puzzle hash without allocating, as long as the inner layer implements `ToTreeHash` too. + +## Spend and SpendWithConditions + +A `Spend` is a puzzle and solution pair of `NodePtr`s that hasn't been attached to a coin yet. `SpendContext::spend(coin, spend)` serializes it into a `CoinSpend` and adds it to the context. `construct_coin_spend` does both steps directly, without adding to the context. + +The intermediate `Spend` exists because outer layers need the inner layer's puzzle and solution. Primitives like `Cat` and `Nft` take an inner `Spend` and wrap it. + +`SpendWithConditions` is implemented by inner puzzles that can be spent by just outputting a list of [conditions](/sdk/concepts/conditions). `StandardLayer` implements it, and its `spend` helper uses it to spend a coin in one call: + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn standard_spend() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let p2 = StandardLayer::new(alice.pk); + + // spend_with_conditions builds the inner Spend, which an outer layer could wrap... + let spend = p2.spend_with_conditions(ctx, Conditions::new().reserve_fee(1))?; + + // ...or you can spend it as is. + ctx.spend(alice.coin, spend)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + Ok(()) +} +``` + +## Composing layers + +Outer layers are generic over their inner layer. The type spells out the puzzle structure, and constructing it builds the whole puzzle: + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::driver::CatLayer; +use chia_wallet_sdk::chia::puzzle_types::{cat::CatArgs, standard::StandardArgs}; + +#[test] +fn compose_cat_puzzle() -> anyhow::Result<()> { + let ctx = &mut SpendContext::new(); + let public_key = SecretKey::from_seed(&[1; 32]).public_key(); + let asset_id = Bytes32::new([2; 32]); + + let layer = CatLayer::new(asset_id, StandardLayer::new(public_key)); + + // Build the full puzzle. + let puzzle = layer.construct_puzzle(ctx)?; + + // The puzzle hash can be computed without allocating. + let puzzle_hash = layer.tree_hash(); + assert_eq!(ctx.tree_hash(puzzle), puzzle_hash); + + // It's the same as currying the arguments by hand. + let inner_puzzle_hash = StandardArgs::curry_tree_hash(public_key); + assert_eq!(puzzle_hash, CatArgs::curry_tree_hash(asset_id, inner_puzzle_hash)); + + Ok(()) +} +``` + +## Parsing puzzles + +`Puzzle` is the starting point for parsing. `Puzzle::parse(&allocator, ptr)` checks whether a puzzle is curried and splits it into its mod and arguments: + +- `Puzzle::Curried(CurriedPuzzle)` has the `mod_hash`, the `args` pointer, and the full `curried_puzzle_hash`. +- `Puzzle::Raw(RawPuzzle)` is a puzzle that isn't curried, with its `puzzle_hash`. + +`puzzle.mod_hash()` works for both, so you can compare it against known mod hashes from `Mod::mod_hash()` or `chia_wallet_sdk::puzzles`. Parsing a `Puzzle` is cheap: it computes tree hashes but doesn't run anything. + +`Puzzle` also implements `Layer`. Using it as the inner type parses just the outer layer and leaves the inner puzzle opaque, so you can decide how to parse it next. This is how you identify a coin you know nothing about: try the outer layers you support, then look at what's inside. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::driver::CatLayer; + +#[test] +fn parse_cat_puzzle() -> anyhow::Result<()> { + let ctx = &mut SpendContext::new(); + let public_key = SecretKey::from_seed(&[1; 32]).public_key(); + let asset_id = Bytes32::new([2; 32]); + + // Pretend this puzzle reveal came from a coin spend on chain. + let puzzle_reveal: Program = { + let ptr = CatLayer::new(asset_id, StandardLayer::new(public_key)).construct_puzzle(ctx)?; + ctx.serialize(&ptr)? + }; + + let ptr = ctx.alloc(&puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, ptr); + + // It isn't a standard puzzle... + assert!(StandardLayer::parse_puzzle(ctx, puzzle)?.is_none()); + + // ...but it is a CAT. Parse only the outer layer and keep the inner puzzle opaque. + let cat = CatLayer::::parse_puzzle(ctx, puzzle)?.expect("not a CAT"); + assert_eq!(cat.asset_id, asset_id); + + // Now check what's inside. + let p2 = StandardLayer::parse_puzzle(ctx, cat.inner_puzzle)?.expect("not a standard CAT"); + assert_eq!(p2.synthetic_key, public_key); + + // When you know the whole structure up front, parse it in one go. + let full = CatLayer::::parse_puzzle(ctx, puzzle)?.expect("not a standard CAT"); + assert_eq!(full.inner_puzzle.synthetic_key, public_key); + + Ok(()) +} +``` + +Parsing the puzzle tells you what a coin is. To track an asset like a CAT or NFT from one coin to the next, you parse the parent coin's spend instead, using the primitive's `parse_children` or `parse` methods. See [Primitives](/sdk/primitives) for that pattern. + +## Writing a custom layer + +To use your own puzzle with the SDK, give it a curried-arguments type with a [`Mod`](/sdk/concepts/clvm#the-mod-trait) implementation, a solution type, and a layer that ties them together. + +This example is a timelock layer: an outer puzzle that wraps any inner puzzle and adds `ASSERT_HEIGHT_ABSOLUTE` to its output, so the coin can't be spent before a given block height. The Chialisp source is: + +```clsp +(mod (MIN_HEIGHT INNER_PUZZLE inner_solution) + (c (list 83 MIN_HEIGHT) (a INNER_PUZZLE inner_solution)) +) +``` + +```rust +use std::borrow::Cow; + +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::chia::puzzle_types::standard::StandardSolution; +use hex_literal::hex; + +pub const TIMELOCK_PUZZLE: [u8; 27] = + hex!("ff04ffff04ffff0153ffff04ff02ff808080ffff02ff05ff0b8080"); +pub const TIMELOCK_PUZZLE_HASH: TreeHash = TreeHash::new(hex!( + "020c886c1d46396e6cd015774bb494a5728098532bd66e9fbe14af704947e51e" +)); + +/// The curried arguments. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(curry)] +pub struct TimelockArgs { + pub min_height: u32, + pub inner_puzzle: I, +} + +impl Mod for TimelockArgs { + fn mod_reveal() -> Cow<'static, [u8]> { + Cow::Borrowed(&TIMELOCK_PUZZLE) + } + + fn mod_hash() -> TreeHash { + TIMELOCK_PUZZLE_HASH + } +} + +/// The solution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ToClvm, FromClvm)] +#[clvm(list)] +pub struct TimelockSolution { + pub inner_solution: I, +} + +/// The layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimelockLayer { + pub min_height: u32, + pub inner_puzzle: I, +} + +impl Layer for TimelockLayer +where + I: Layer, +{ + type Solution = TimelockSolution; + + fn parse_puzzle(allocator: &Allocator, puzzle: Puzzle) -> Result, DriverError> { + let Some(puzzle) = puzzle.as_curried() else { + return Ok(None); + }; + + if puzzle.mod_hash != TIMELOCK_PUZZLE_HASH { + return Ok(None); + } + + let args = TimelockArgs::::from_clvm(allocator, puzzle.args)?; + + let Some(inner_puzzle) = + I::parse_puzzle(allocator, Puzzle::parse(allocator, args.inner_puzzle))? + else { + return Ok(None); + }; + + Ok(Some(Self { + min_height: args.min_height, + inner_puzzle, + })) + } + + fn parse_solution( + allocator: &Allocator, + solution: NodePtr, + ) -> Result { + let solution = TimelockSolution::::from_clvm(allocator, solution)?; + Ok(TimelockSolution { + inner_solution: I::parse_solution(allocator, solution.inner_solution)?, + }) + } + + fn construct_puzzle(&self, ctx: &mut SpendContext) -> Result { + let inner_puzzle = self.inner_puzzle.construct_puzzle(ctx)?; + ctx.curry(TimelockArgs { + min_height: self.min_height, + inner_puzzle, + }) + } + + fn construct_solution( + &self, + ctx: &mut SpendContext, + solution: Self::Solution, + ) -> Result { + let inner_solution = self + .inner_puzzle + .construct_solution(ctx, solution.inner_solution)?; + ctx.alloc(&TimelockSolution { inner_solution }) + } +} + +impl ToTreeHash for TimelockLayer +where + I: ToTreeHash, +{ + fn tree_hash(&self) -> TreeHash { + TimelockArgs { + min_height: self.min_height, + inner_puzzle: self.inner_puzzle.tree_hash(), + } + .curry_tree_hash() + } +} + +/// Lets the timelocked coin be spent with conditions, like the inner puzzle. +impl SpendWithConditions for TimelockLayer +where + I: SpendWithConditions, +{ + fn spend_with_conditions( + &self, + ctx: &mut SpendContext, + conditions: Conditions, + ) -> Result { + let inner = self.inner_puzzle.spend_with_conditions(ctx, conditions)?; + let puzzle = ctx.curry(TimelockArgs { + min_height: self.min_height, + inner_puzzle: inner.puzzle, + })?; + let solution = ctx.alloc(&TimelockSolution { + inner_solution: inner.solution, + })?; + Ok(Spend::new(puzzle, solution)) + } +} + +#[test] +fn timelock_layer() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let p2 = StandardLayer::new(alice.pk); + let timelock = TimelockLayer { + min_height: 10, + inner_puzzle: p2, + }; + + // Lock a coin with the timelock puzzle. + let timelock_puzzle_hash: Bytes32 = timelock.tree_hash().into(); + p2.spend( + ctx, + alice.coin, + Conditions::new().create_coin(timelock_puzzle_hash, 1, Memos::None), + )?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + let locked = Coin::new(alice.coin.coin_id(), timelock_puzzle_hash, 1); + + let unlock = Conditions::new().create_coin(alice.puzzle_hash, 1, Memos::None); + + // Spending it before height 10 fails. + let spend = timelock.spend_with_conditions(ctx, unlock.clone())?; + ctx.spend(locked, spend)?; + assert!(sim.spend_coins(ctx.take(), &[alice.sk.clone()]).is_err()); + + // After that it works. + while sim.height() < 10 { + sim.create_block(); + } + let spend = timelock.spend_with_conditions(ctx, unlock)?; + ctx.spend(locked, spend)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Anyone looking at the spend can parse it back into layers. + let coin_spend = sim.coin_spend(locked.coin_id()).expect("spent"); + let puzzle = ctx.alloc(&coin_spend.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + let parsed = TimelockLayer::::parse_puzzle(ctx, puzzle)?.expect("not a timelock"); + assert_eq!(parsed, timelock); + + let solution = ctx.alloc(&coin_spend.solution)?; + let solution: TimelockSolution> = + TimelockLayer::::parse_solution(ctx, solution)?; + assert_eq!(solution.inner_solution.original_public_key, None); + + Ok(()) +} +``` + +A few things to note about the pattern, which all of the built-in layers follow: + +- `parse_puzzle` checks the mod hash first and returns `Ok(None)` on a mismatch, then delegates to the inner layer through a fresh `Puzzle::parse`. +- The arguments type is generic over the inner puzzle, so the same type is used with a `NodePtr` (building and parsing) and with a `TreeHash` (computing the puzzle hash). +- Outer layers that can't be spent by conditions alone, like `CatLayer`, don't implement `SpendWithConditions`. The CAT primitive wraps an inner `Spend` instead. Choose whichever fits your puzzle. + +If you only need to curry a puzzle and don't need parsing, `ctx.curry(args)` and `Spend::new` are enough. A layer is worth writing when you want to recognize your puzzle on chain or compose it with other layers. + +## Built-in layers + +| Layer | Puzzle | Typically wraps / used by | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `StandardLayer` (alias `P2DelegatedOrHiddenLayer`) | `p2_delegated_puzzle_or_hidden_puzzle`, the standard XCH puzzle | Ownership of most coins; [Standard](/sdk/primitives/standard) | +| `P2DelegatedConditionsLayer` | `p2_delegated_conditions`: a key signs a list of conditions | Simple key ownership | +| `CatLayer` | CAT v2 | [CAT](/sdk/primitives/cat) | +| `RevocationLayer` | Revocation layer for revocable CATs | [CAT](/sdk/primitives/cat) | +| `SingletonLayer` | Singleton top layer v1.1 | [Singletons](/sdk/primitives/singletons), NFTs, DIDs, vaults | +| `NftStateLayer` | NFT state layer (metadata and updater) | [NFT](/sdk/primitives/nft) | +| `NftOwnershipLayer` | NFT ownership layer (current DID owner and transfer program) | [NFT](/sdk/primitives/nft) | +| `RoyaltyTransferLayer` | NFT royalty transfer program | [NFT](/sdk/primitives/nft) | +| `DidLayer` | DID inner puzzle (metadata and recovery) | [DID](/sdk/primitives/did) | +| `SettlementLayer` | `settlement_payments`, used by offers | [Offers](/sdk/offers) | +| `OptionContractLayer` | Option contract | [Option Contracts](/sdk/primitives/options) | +| `P2SingletonLayer` | Coin spendable only alongside a singleton | [Singletons](/sdk/primitives/singletons), [Clear Signing](/sdk/clear-signing) | +| `P2OneOfManyLayer` | Pick one of many delegated puzzles from a merkle root | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options), [MerkleTree](/sdk/utilities#merkle-trees) | +| `P2CurriedLayer` | Spend by revealing a puzzle that hashes to a curried puzzle hash | [Clawback](/sdk/primitives/clawback) | +| `AugmentedConditionLayer` | Adds a fixed condition to the inner puzzle's output | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options) | +| `StreamLayer` | Streamed payments | [Streamed Assets](/sdk/primitives/streaming) | +| `BulletinLayer` | Nonce wrapper that distinguishes bulletin coins | [Bulletins](/sdk/primitives/bulletin) | +| `DelegatedPuzzleFeederLayer`, `IndexWrapperLayer`, `SingletonMemberLayer` | Multi-issuer puzzle system (MIPS) building blocks | [Vaults & MIPS](/sdk/primitives/vaults) | +| `DelegationLayer`, `WriterLayer`, `OracleLayer` | DataLayer delegation (feature `chip-0035`) | [DataLayer](/sdk/primitives/datalayer) | +| `P2ControllerPuzzleLayer`, `P2Eip712MessageLayer` | Coins controlled by another puzzle or by an EIP-712 signature (feature `chip-0037`) | Ethereum-style (secp256k1) wallets | +| `ActionLayer`, `PrecommitLayer`, `StateSchedulerLayer`, `MOfNLayer`, `P2DelegatedBySingletonLayer`, `ConditionsLayer`, `P2ParentLayer` | Action layer singletons (feature `action-layer`) | [Action Layer](/sdk/primitives/action-layer) | + +Besides these, any type that implements both `ToClvm` and `FromClvm` is a layer, whose puzzle is the value itself and whose solution is a `NodePtr`. `Puzzle` is a layer too, as shown above. Both are useful as placeholder inner layers in tests and when parsing. + +## Gotchas + +- **`Ok(None)` and `Err` mean different things.** `None` means "not this puzzle". An error means the mod hash matched but the arguments didn't parse, which usually indicates a malformed or malicious puzzle. Don't silently treat errors as `None`. +- **Matching a layer doesn't verify the asset.** `CatLayer::parse_puzzle` tells you a coin has a CAT puzzle, not that its lineage is valid. Validate lineage by parsing from the parent spend, as the [primitives](/sdk/primitives) do. +- **Parsing an inner layer requires the full reveal.** A puzzle hash alone can't be parsed. You need the puzzle reveal from a coin spend, which for an unspent coin means parsing the parent's spend and reconstructing the child. diff --git a/docs/sdk/concepts/signing.md b/docs/sdk/concepts/signing.md new file mode 100644 index 0000000..8b86a25 --- /dev/null +++ b/docs/sdk/concepts/signing.md @@ -0,0 +1,219 @@ +--- +title: Keys & Signing +--- + +# Keys & Signing + +Chia uses BLS12-381 signatures. A puzzle requests a signature by outputting an `AGG_SIG_*` [condition](/sdk/concepts/conditions) with a public key and a message. Every signature required by every spend in a bundle is aggregated into a single signature, which goes in the `SpendBundle` alongside the coin spends. Some newer puzzles also verify secp256k1 or secp256r1 signatures inside the puzzle itself. + +The SDK splits this work into three parts: + +- Keys and derivation come from `chia-bls` (`chia_wallet_sdk::chia::bls`) and `chia-secp` (`chia_wallet_sdk::chia::secp`). `SecretKey`, `PublicKey`, `Signature` and the secp key types are in the prelude. +- `chia-sdk-signer` (`chia_wallet_sdk::signer`) runs your coin spends and tells you which signatures they need. +- You sign those messages with your keys, aggregate them, and build the `SpendBundle`. + +## BLS keys + +A wallet starts from a master `SecretKey`, normally derived from a BIP-39 mnemonic. Convert the mnemonic to its 64-byte seed with a BIP-39 library such as the `bip39` crate (with an empty passphrase, to match the Chia reference wallet), then call `SecretKey::from_seed(&seed)`. + +Wallet keys are derived from the master key using the path `m/12381/8444/2/index`. The `chia-bls` crate provides helpers for this path: + +| Function | Derives | Works on | +| ------------------------------------------------ | -------------------------------------------------------- | -------------------------- | +| `master_to_wallet_unhardened(&key, index)` | Unhardened wallet key | `SecretKey` or `PublicKey` | +| `master_to_wallet_unhardened_intermediate(&key)` | The key at `m/12381/8444/2`, to derive many indices from | `SecretKey` or `PublicKey` | +| `master_to_wallet_hardened(&sk, index)` | Hardened wallet key | `SecretKey` only | +| `master_to_wallet_hardened_intermediate(&sk)` | The hardened key at `m/12381/8444/2` | `SecretKey` only | + +Unhardened derivation works on public keys, so a watch-only wallet can generate addresses from the master public key alone, and derive the matching secret keys later when it needs to sign. Hardened keys can only be derived from the secret key. + +The `DerivableKey` trait provides `derive_unhardened(index)` on both key types, which is what the unhardened helpers use. `SecretKey::derive_hardened(index)` is the hardened equivalent. + +## Synthetic keys + +The standard puzzle doesn't curry in your wallet public key directly. It curries a **synthetic key**: your key combined with the hash of a hidden puzzle, which lets the coin optionally be spent by revealing that hidden puzzle instead of signing. Wallets use the default hidden puzzle, which can't be spent, so in practice only the key holder can spend the coin. See [the standard transaction](https://chialisp.com/standard-transactions) for how this works. + +The `DeriveSynthetic` trait from `chia_wallet_sdk::chia::puzzle_types` does the conversion. It's implemented for both `PublicKey` and `SecretKey`, and the two stay in step: the synthetic secret key signs for the synthetic public key. + +- `key.derive_synthetic()` uses the default hidden puzzle. +- `key.derive_synthetic_hidden(&hidden_puzzle_hash)` uses a custom one. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::chia::bls::{ + DerivableKey, master_to_wallet_unhardened, master_to_wallet_unhardened_intermediate, +}; +use chia_wallet_sdk::chia::puzzle_types::{DeriveSynthetic, standard::StandardArgs}; + +/// The first `count` receive addresses of a wallet, from its master public key only. +fn receive_addresses(master_pk: &PublicKey, count: u32) -> anyhow::Result> { + // Derive the intermediate key once, then each index from it. + let intermediate = master_to_wallet_unhardened_intermediate(master_pk); + + (0..count) + .map(|index| { + let synthetic_key = intermediate.derive_unhardened(index).derive_synthetic(); + let puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key); + Ok(Address::new(puzzle_hash.into(), "xch".to_string()).encode()?) + }) + .collect() +} + +#[test] +fn derive_wallet_keys() -> anyhow::Result<()> { + // In a real wallet, this seed comes from the user's mnemonic. + let master_sk = SecretKey::from_seed(&[42; 64]); + let master_pk = master_sk.public_key(); + + let addresses = receive_addresses(&master_pk, 5)?; + assert_eq!(addresses.len(), 5); + + // Deriving the secret key along the same path gives the matching signing key. + let synthetic_sk = master_to_wallet_unhardened(&master_sk, 0).derive_synthetic(); + let synthetic_pk = master_to_wallet_unhardened(&master_pk, 0).derive_synthetic(); + assert_eq!(synthetic_sk.public_key(), synthetic_pk); + + // The fingerprint identifies a wallet by its master public key. + println!("fingerprint {}", master_pk.get_fingerprint()); + + Ok(()) +} +``` + +`StandardLayer::new` takes the synthetic public key, and the action system's `finish_with_keys` takes a map from p2 puzzle hash to synthetic public key (see the [Action System](/sdk/action-system)). + +## Required signatures + +You don't need to work out signature messages yourself. `RequiredSignature::from_coin_spends` runs each coin spend, collects its `AGG_SIG_*` conditions, and returns a `RequiredSignature` for each one: + +- `RequiredSignature::Bls(RequiredBlsSignature)` has the `public_key` and the full `message()` to sign. The message is the condition's raw message, followed by the coin information the condition commits to (for `AGG_SIG_ME`, the coin id), followed by a network-specific domain string. +- `RequiredSignature::Secp(RequiredSecpSignature)` is a secp256k1 or secp256r1 signature that a puzzle verifies itself. See [Secp keys](#secp-keys). + +The domain strings come from `AggSigConstants`, which you build from the network's consensus constants. Mainnet and testnet11 have different genesis challenges, so the same spend needs a different signature on each network: + +| Network | Constants | +| --------------- | --------------------------------------------------------------------------------- | +| Mainnet | `AggSigConstants::from(&*MAINNET_CONSTANTS)` | +| Testnet11 | `AggSigConstants::from(&*TESTNET11_CONSTANTS)` | +| Another network | `AggSigConstants::new(agg_sig_me_additional_data)`, usually the genesis challenge | + +The [Simulator](/sdk/testing) validates signatures against testnet11 constants. + +## Building a SpendBundle + +Once you know the required signatures, sign each message with the matching secret key, aggregate the results, and pair the signature with the coin spends: + +```rust +use std::collections::HashMap; + +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::chia::bls::{aggregate_verify, master_to_wallet_unhardened, sign}; +use chia_wallet_sdk::chia::puzzle_types::DeriveSynthetic; + +/// Signs a transaction with whichever of `secret_keys` are needed. +fn sign_spends( + coin_spends: &[CoinSpend], + secret_keys: &[SecretKey], + constants: &AggSigConstants, +) -> anyhow::Result { + let keys: HashMap = + secret_keys.iter().map(|sk| (sk.public_key(), sk)).collect(); + + let mut allocator = Allocator::new(); + let required = RequiredSignature::from_coin_spends(&mut allocator, coin_spends, constants)?; + + let mut signature = Signature::default(); + + for required in required { + let RequiredSignature::Bls(required) = required else { + anyhow::bail!("secp signatures are provided in the solution, not the aggregate"); + }; + + let Some(sk) = keys.get(&required.public_key) else { + anyhow::bail!("missing secret key for {:?}", required.public_key); + }; + + signature += &sign(sk, required.message()); + } + + Ok(signature) +} + +#[test] +fn sign_and_submit() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + // A wallet key at index 0, and a coin locked to its standard puzzle. + let master_sk = SecretKey::from_seed(&[7; 64]); + let synthetic_sk = master_to_wallet_unhardened(&master_sk, 0).derive_synthetic(); + let synthetic_pk = synthetic_sk.public_key(); + let p2 = StandardLayer::new(synthetic_pk); + let coin = sim.new_coin(p2.tree_hash().into(), 1_000); + + p2.spend(ctx, coin, Conditions::new().reserve_fee(1_000))?; + let coin_spends = ctx.take(); + + // The simulator uses testnet11, so sign for testnet11. + let testnet = AggSigConstants::from(&*TESTNET11_CONSTANTS); + let signature = sign_spends(&coin_spends, &[synthetic_sk.clone()], &testnet)?; + + // A signature for mainnet is different, and wouldn't be valid here. + let mainnet = AggSigConstants::from(&*MAINNET_CONSTANTS); + let mainnet_signature = sign_spends(&coin_spends, &[synthetic_sk], &mainnet)?; + assert_ne!(signature, mainnet_signature); + let wrong_network = SpendBundle::new(coin_spends.clone(), mainnet_signature); + assert!(sim.new_transaction(wrong_network).is_err()); + + // You can verify the aggregate signature locally before submitting. + let mut allocator = Allocator::new(); + let messages: Vec<(PublicKey, Vec)> = + RequiredSignature::from_coin_spends(&mut allocator, &coin_spends, &testnet)? + .into_iter() + .filter_map(|required| match required { + RequiredSignature::Bls(bls) => Some((bls.public_key, bls.message())), + RequiredSignature::Secp(_) => None, + }) + .collect(); + assert!(aggregate_verify( + &signature, + messages.iter().map(|(pk, msg)| (pk, msg.as_slice())) + )); + + let spend_bundle = SpendBundle::new(coin_spends, signature); + sim.new_transaction(spend_bundle)?; + + Ok(()) +} +``` + +In tests, `Simulator::spend_coins(coin_spends, &secret_keys)` does all of this for you with testnet11 constants. In production, submit the `SpendBundle` with a [peer](/sdk/networking/peer) or an [RPC client](/sdk/networking/rpc). + +Signing works the same way for every puzzle, not only the standard puzzle, because `RequiredSignature` runs the actual puzzle. As long as you hold the secret key for each requested public key, the aggregate signature is valid. Spends that request no signatures, like the offer settlement puzzle, contribute nothing, and a bundle with no required signatures uses `Signature::default()`. + +### Signing errors + +`RequiredSignature::from_coin_spends` returns a `SignerError` rather than a list you might sign blindly when something looks wrong: + +| Error | Meaning | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PuzzleHashMismatch` | A puzzle reveal doesn't hash to its coin's puzzle hash. Someone may be trying to get a signature valid for a different coin. | +| `InfinityPublicKey` | A condition requests a signature from the identity public key. | +| `UnsafeMessageHasAdditionalData` | An `AGG_SIG_UNSAFE` message ends with one of the network's domain strings, so signing it could authorize an `AGG_SIG_ME` (or other) spend elsewhere. | +| `AggSigMessageTooLong` | The message exceeds the consensus limit of 1024 bytes. | +| `Eval`, `FromClvm`, `ToClvm` | The puzzle failed to run, or its output isn't a valid condition list. | + +## Secp keys + +Some puzzles verify secp256k1 or secp256r1 (P-256) signatures with the `secp256k1_verify` and `secp256r1_verify` operators instead of `AGG_SIG_*` conditions. This lets coins be controlled by Ethereum-style keys, hardware wallets and passkeys. The key types are `K1SecretKey`, `K1PublicKey`, `K1Signature` and the `R1` equivalents, all in the prelude. + +Unlike BLS signatures, these signatures are passed in the puzzle's solution, not aggregated into the `SpendBundle`. The message is a 32-byte hash defined by the puzzle, signed with `sign_prehashed`. [Vaults & MIPS](/sdk/primitives/vaults) covers the member puzzles that use these keys and how to sign for them. + +When `RequiredSignature` runs a spend whose puzzle verifies a secp signature, it reports a `RequiredSignature::Secp` with the `public_key`, the `message_hash` the puzzle checked, and a `placeholder_ptr` pointing at the signature value the puzzle passed to the operator (in the allocator you gave `from_coin_spends`). Verification is treated as successful while collecting, so a signer can discover which secp signatures a spend needs before the real signatures have been filled in. + +## Gotchas + +- **Sign with the synthetic key.** The standard puzzle requires a signature from the synthetic public key. Signing with the unsynthesized wallet key produces a valid-looking but useless signature. +- **Simulator key pairs aren't synthetic.** `BlsPair` and `Simulator::bls` lock coins to `StandardArgs::curry_tree_hash(pk)` with the plain key, to keep tests short. Real wallets call `derive_synthetic` first. +- **Signatures are network-specific.** A bundle signed with mainnet constants is invalid on testnet11 and vice versa. Pass the right `AggSigConstants` for the network you submit to. +- **Only sign what you've checked.** `RequiredSignature` tells you what a spend needs, not whether you want it to happen. Before signing a spend bundle someone else built, inspect what it does; see [Clear Signing](/sdk/clear-signing) for vault transactions and [Offers](/sdk/offers) for trades. diff --git a/docs/sdk/concepts/spend-context.md b/docs/sdk/concepts/spend-context.md new file mode 100644 index 0000000..c50dc7e --- /dev/null +++ b/docs/sdk/concepts/spend-context.md @@ -0,0 +1,257 @@ +--- +title: SpendContext +--- + +# SpendContext + +`SpendContext` is the workspace every transaction is built in. Almost every function in the driver crate +takes a `&mut SpendContext`, because building a spend means allocating CLVM values, and the context is +where those values live. It does three things: + +1. **Owns the CLVM memory.** It wraps a `clvmr::Allocator`, and puzzles, solutions and conditions are + allocated in it as `NodePtr` handles. See [CLVM Values](/sdk/concepts/clvm) for how values get in + and out. +2. **Caches puzzles.** The standard puzzles (the CAT layer, the singleton top layer, the NFT layers and so + on) are deserialized once per context and reused, no matter how many coins use them. +3. **Collects coin spends.** Spending a coin with a primitive adds a `CoinSpend` to the context. When + you're done, you take them out and sign them into a `SpendBundle`. + +## Creating a context + +```rust +use chia_wallet_sdk::prelude::*; + +fn build() -> Result, DriverError> { + let ctx = &mut SpendContext::new(); + + // ... spend coins ... + + Ok(ctx.take()) +} +``` + +Most code binds `ctx` as a `&mut SpendContext` up front, as above, so it can be passed straight to +functions that take `&mut SpendContext` without writing `&mut ctx` everywhere. The examples on these +pages do the same. + +`SpendContext` dereferences to `Allocator`, so you can pass it anywhere the lower-level crates expect an +`&Allocator` or `&mut Allocator`, such as `ToClvm::to_clvm`, `FromClvm::from_clvm`, or +`RequiredSignature::from_coin_spends`. If you already have an `Allocator`, `SpendContext::from(allocator)` +wraps it. + +## Spends + +A `Spend` is a puzzle and a solution, both as `NodePtr`s in the context. It isn't tied to a coin yet, which +is what lets layers wrap each other: an inner puzzle's spend becomes part of the outer puzzle's solution. +See [Layers & Puzzles](/sdk/concepts/layers) for how that composition works. + +There are three ways a coin spend ends up in the context: + +| Method | When to use it | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A primitive's spend method | Almost always. `StandardLayer::spend`, `Cat::spend_all`, `Nft::transfer` and friends build the full puzzle and solution and call `ctx.spend` for you. | +| `ctx.spend(coin, spend)` | You built a `Spend` yourself, for example with `Layer::construct_spend` or a custom puzzle. It serializes the puzzle and solution and records the `CoinSpend`. | +| `ctx.insert(coin_spend)` | You already have a serialized `CoinSpend`, for example one you received from someone else or built outside the SDK. | + +`ctx.delegated_spend(conditions)` builds a `Spend` whose puzzle is the quoted conditions and whose +solution is nil. That's the "delegated puzzle" that the standard puzzle and many other p2 puzzles run, so +it's the usual way to say "this coin should output these conditions" when you're assembling an inner +spend by hand. + +When you're done, get the coin spends back with: + +- `ctx.take()`, which removes and returns them, leaving the context ready for another transaction. +- `ctx.iter()`, to inspect them without removing them. +- `for coin_spend in ctx` (`IntoIterator`), to consume the context. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn build_two_coin_spends() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let alice = sim.bls(1_000); + let coin = sim.new_coin(alice.puzzle_hash, 500); + + let ctx = &mut SpendContext::new(); + let p2 = StandardLayer::new(alice.pk); + + // A primitive adds the coin spend for you. + p2.spend(ctx, alice.coin, Conditions::new().reserve_fee(1_000))?; + + // Building the Spend yourself, then recording it for a coin. + let delegated = ctx.delegated_spend( + Conditions::new() + .assert_concurrent_spend(alice.coin.coin_id()) + .reserve_fee(500), + )?; + let spend = p2.delegated_inner_spend(ctx, delegated)?; + ctx.spend(coin, spend)?; + + assert_eq!(ctx.iter().count(), 2); + + let coin_spends = ctx.take(); + assert_eq!(ctx.iter().count(), 0); + + sim.spend_coins(coin_spends, &[alice.sk])?; + + Ok(()) +} +``` + +## Allocating values + +Anything that implements `ToClvm` can be allocated in the context, and anything that implements +`FromClvm` can be extracted back out. The SDK's argument, solution and condition types all implement both, +as do integers, byte types, keys, tuples, `Vec`s and `Option`s. + +| Method | Does | +| --------------------------------------------- | ------------------------------------------------------------------ | +| `ctx.alloc(&value)` | Allocates a value and returns its `NodePtr` | +| `ctx.alloc_hashed(&value)` | Allocates and also computes the tree hash, returning a `HashedPtr` | +| `ctx.extract::(ptr)` | Parses a `NodePtr` back into a Rust type | +| `ctx.tree_hash(ptr)` | Computes the tree hash (the "puzzle hash" if `ptr` is a puzzle) | +| `ctx.serialize(&value)` | Allocates a value and serializes it to a `Program` | +| `ctx.serialize_with_backrefs(&value)` | The same, using back references to compress repeated subtrees | +| `ctx.run(puzzle, solution)` | Runs a puzzle with a solution and returns the output | +| `ctx.memos(&value)` / `ctx.hint(puzzle_hash)` | Builds the memos argument of `create_coin` | + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn allocate_and_run() -> anyhow::Result<()> { + let ctx = &mut SpendContext::new(); + let puzzle_hash = Bytes32::new([1; 32]); + + // Build the conditions a coin should output, and quote them as a puzzle. + let conditions = Conditions::new().create_coin(puzzle_hash, 100, Memos::None); + let spend = ctx.delegated_spend(conditions)?; + + // Running the puzzle returns the conditions, which parse back into Rust types. + let output = ctx.run(spend.puzzle, spend.solution)?; + let parsed: Vec = ctx.extract(output)?; + assert_eq!( + parsed[0].as_create_coin().map(|c| (c.puzzle_hash, c.amount)), + Some((puzzle_hash, 100)) + ); + + // The tree hash of a puzzle is its puzzle hash, and serializing it gives the puzzle reveal. + let puzzle_hash = ctx.tree_hash(spend.puzzle); + let puzzle_reveal = ctx.serialize(&spend.puzzle)?; + let reparsed = ctx.alloc(&puzzle_reveal)?; + assert_eq!(ctx.tree_hash(reparsed), puzzle_hash); + + Ok(()) +} +``` + +### Memos and hints + +`create_coin` takes a `Memos` argument: `Memos::None`, or `Memos::Some(ptr)` pointing at a list. The first +memo of a coin is its **hint**, which full nodes index so wallets can find coins sent to them even when +the coin's puzzle hash is wrapped (as with CATs and NFTs). `ctx.hint(puzzle_hash)` builds a memo list +containing just the hint, and `ctx.memos(&value)` allocates any list you like, for example a hint followed +by a message. + +```rust +use chia_wallet_sdk::prelude::*; + +fn memos(ctx: &mut SpendContext, recipient: Bytes32) -> Result { + let hint = ctx.hint(recipient)?; + let hint_and_note = ctx.memos(&(recipient, ("thanks for lunch", ())))?; + + Ok(Conditions::new() + .create_coin(recipient, 100, hint) + .create_coin(recipient, 200, hint_and_note)) +} +``` + +See [Conditions](/sdk/concepts/conditions#memos-and-hints) for more on what memos are used for. + +## Puzzles and currying + +Puzzles that the SDK knows about implement the `Mod` trait from `chia-sdk-types`, which ties an argument +type to the compiled puzzle and its hash. `ctx.curry(args)` looks up the puzzle from the argument type, +loads it into the context (once), and curries the arguments into it: + +```rust +use chia_wallet_sdk::chia::puzzle_types::standard::StandardArgs; +use chia_wallet_sdk::prelude::*; + +#[test] +fn curry_a_puzzle() -> anyhow::Result<()> { + let ctx = &mut SpendContext::new(); + let pk = BlsPair::new(0).pk; + + // Curry the standard puzzle with a public key. + let puzzle = ctx.curry(StandardArgs::new(pk))?; + + // The puzzle hash can also be calculated without allocating anything. + assert_eq!(ctx.tree_hash(puzzle), StandardArgs::curry_tree_hash(pk)); + + // Currying again reuses the cached puzzle. + let again = ctx.curry(StandardArgs::new(pk))?; + assert_eq!(ctx.tree_hash(again), ctx.tree_hash(puzzle)); + + Ok(()) +} +``` + +For puzzles the SDK doesn't define, `ctx.puzzle(puzzle_hash, &bytes)` deserializes and caches any compiled +puzzle by its hash, and `ctx.alloc_mod::()` loads a `Mod` without currying it. To curry arbitrary +arguments into a puzzle, allocate a `CurriedProgram { program, args }`. The +[CLVM Values](/sdk/concepts/clvm#currying) page covers these, and how to define your own `Mod`. + +## Lifecycle and memory + +A context only grows. Each allocation adds to the underlying `Allocator`, and nothing is freed until the +context is dropped. That's fine for building a transaction, which is short-lived, but it matters for +long-running programs: + +- **One context per transaction** is the simplest model. Create it, build, `take()` the spends, drop it. +- **Reuse a context** across transactions if you want to keep the puzzle cache warm, for example when + building many similar transactions in a loop. `take()` empties the spend list but keeps the memory. +- **Roll back** to an earlier state with a checkpoint. `ctx.checkpoint()` (from `Allocator`) records the + allocator's size, and `ctx.reset(&checkpoint)` frees everything allocated after it, and also clears the + puzzle cache and the collected spends. Use it to bound memory in a long-lived context, or to throw away + a half-built transaction after an error. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn reuse_a_context() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let checkpoint = ctx.checkpoint(); + + for amount in [100, 200, 300] { + let alice = sim.bls(amount); + StandardLayer::new(alice.pk).spend(ctx, alice.coin, Conditions::new().reserve_fee(amount))?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Free everything this transaction allocated before building the next one. + ctx.reset(&checkpoint); + } + + Ok(()) +} +``` + +:::warning +A `NodePtr` is only meaningful in the context that created it. Don't use pointers from one context in +another, and don't use pointers allocated after a checkpoint once you've reset to it. Store +`Program`s, `TreeHash`es or parsed Rust types (like `Cat` or `Nft`) instead if you need to keep data +around between transactions. Primitive types such as `Cat`, `Nft` and `Did` are built from plain data +(hashes and amounts), so they're safe to keep, except for generic parameters you've set to `NodePtr` +or `HashedPtr` (for example NFT metadata), which point into the context. +::: + +## Spend bundles + +The context doesn't sign anything. Once you've taken the coin spends, compute the required signatures and +aggregate them as described in [Keys & Signing](/sdk/concepts/signing), then pair them into a +`SpendBundle`. In tests, `Simulator::spend_coins` does this for you. To estimate a fee before +submitting, `chia_wallet_sdk::driver::spend_bundle_cost` computes the cost of the coin spends, as shown in +[Utilities](/sdk/utilities). diff --git a/docs/sdk/connectivity.md b/docs/sdk/connectivity.md deleted file mode 100644 index 8c144f3..0000000 --- a/docs/sdk/connectivity.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -slug: /sdk/connectivity -title: Connectivity ---- - -# Connectivity - -The Wallet SDK provides client crates for connecting to the Chia network. This page covers the basics of establishing connections and querying blockchain state. - -## Overview - -The SDK includes two client approaches: - -| Crate | Use Case | -| ------------------ | ------------------------------------------------------- | -| `chia-sdk-client` | Direct peer-to-peer connections using the Chia protocol | -| `chia-sdk-coinset` | HTTP/RPC client for querying coin state | - -## Peer Connections - -The `Peer` type provides direct connections to Chia full nodes using the native protocol: - -```rust -use chia_wallet_sdk::prelude::*; - -// Connect to a peer -let peer = Peer::connect( - "node.example.com:8444", - network_id, - tls_connector, -).await?; - -// Query coin state -let coin_states = peer.request_coin_state( - coin_ids, - None, // previous_height - genesis_challenge, -).await?; -``` - -### Connection Requirements - -Peer connections require: - -- Network ID (mainnet, testnet, etc.) -- TLS configuration -- Knowledge of the genesis challenge for the network - -## Coinset Client - -For simpler HTTP-based queries, use `CoinsetClient`: - -```rust -use chia_wallet_sdk::prelude::*; - -// Create client for a coinset API endpoint -let client = CoinsetClient::new( - "https://api.example.com", - network_id, -); - -// Query coins by puzzle hash -let coins = client.get_coins_by_puzzle_hash(puzzle_hash).await?; - -// Get coin state -let states = client.get_coin_state(coin_ids).await?; -``` - -## Full Node Client - -For direct full node RPC access: - -```rust -use chia_wallet_sdk::prelude::*; - -let client = FullNodeClient::new( - "https://localhost:8555", - cert_path, - key_path, -)?; - -// Use full node RPC methods -let blockchain_state = client.get_blockchain_state().await?; -``` - -## Broadcasting Transactions - -After building a spend bundle, broadcast it to the network: - -```rust -// Build your transaction -let ctx = &mut SpendContext::new(); -// ... add spends ... -let coin_spends = ctx.take(); - -// Sign the spend bundle -let spend_bundle = SpendBundle::new(coin_spends, aggregated_signature); - -// Broadcast via peer -let response = peer.send_transaction(spend_bundle).await?; - -// Or via full node client -let response = client.push_tx(spend_bundle).await?; -``` - -## Network Configuration - -Different networks require different configuration: - -| Network | Default Port | Genesis Challenge | -| ------- | ------------ | ----------------- | -| Mainnet | 8444 | See Chia docs | -| Testnet | 58444 | See Chia docs | - -:::info -For production applications, consider connecting to multiple peers for redundancy and using the coinset API for efficient queries. -::: - -## TLS Configuration - -Peer connections require TLS. The SDK supports both `native-tls` and `rustls` backends via feature flags: - -```toml -# Use native TLS (default) -chia-wallet-sdk = { version = "0.32", features = ["native-tls"] } - -# Or use rustls -chia-wallet-sdk = { version = "0.32", features = ["rustls"] } -``` - -## Beyond This Guide - -Detailed network programming with the SDK is beyond the scope of this documentation. For: - -- Production connection management -- Peer discovery -- Network protocol details - -See the [chia-sdk-client rustdocs](https://docs.rs/chia-sdk-client) and [chia-sdk-coinset rustdocs](https://docs.rs/chia-sdk-coinset). diff --git a/docs/sdk/index.md b/docs/sdk/index.md index 0353bc5..660650a 100644 --- a/docs/sdk/index.md +++ b/docs/sdk/index.md @@ -1,167 +1,119 @@ --- +title: Introduction slug: /sdk -title: Quick Start --- # Wallet SDK -The Chia Wallet SDK is a Rust library for building applications that interact with the Chia blockchain. It provides high-level abstractions for creating transactions, managing coins, and working with Chia primitives like CATs, NFTs, and Vaults. It's the same library [Sage](/sage/getting-started) is built on. +The Chia Wallet SDK is a Rust library for building applications that work with coins on the +[Chia blockchain](https://chia.net): wallets, dApps, indexers, trading bots, and services that mint or +custody assets. It knows how to build, parse and sign spends for the standard puzzles (XCH, CATs, NFTs, +DIDs, vaults, offers and more), how to talk to full nodes, and how to test all of it locally against a +simulated blockchain. [Sage](/sage/getting-started) is built on it. + +It is a library, not a prebuilt wallet. It doesn't store keys, track balances or pick coins for you +unless you ask it to. You decide where coins and keys come from, and the SDK turns your intent into +valid coin spends. :::info -This documentation assumes familiarity with Chia blockchain concepts such as coins, puzzles, conditions, and singletons. For background, see the [Chia Documentation](https://docs.chia.net) and [Chialisp Documentation](https://chialisp.com). +These docs assume you know Rust and the basics of Chia's coin set model: coins, puzzles and solutions, +conditions, and singletons. For background, see the [Chia documentation](https://docs.chia.net) and +[chialisp.com](https://chialisp.com). The API reference for every item lives on +[docs.rs](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/). ::: ## Installation -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - - - - -Add the SDK to your `Cargo.toml`: - -```toml -[dependencies] -chia-wallet-sdk = "0.32" -``` - -For the latest version and detailed API reference, see [docs.rs/chia-wallet-sdk](https://docs.rs/chia-wallet-sdk). - - - - -Install via npm: +Add the crate to your project: ```bash -npm install chia-wallet-sdk +cargo add chia-wallet-sdk ``` -The Node.js bindings provide a similar API with JavaScript/TypeScript support. Full TypeScript type definitions are included. - - - - -Install via pip: +Networking needs a TLS backend, which you choose with a feature flag: -```bash -pip install chia-wallet-sdk +```toml +[dependencies] +chia-wallet-sdk = { version = "0.37", features = ["rustls"] } ``` -The Python bindings provide a Pythonic API with full type stub support for IDE autocompletion. - - - - -## Quick Example - -Here's a minimal example that creates and spends a standard XCH coin: - - - +### Feature flags + +| Feature | Enables | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `rustls` | TLS for [peer connections](/sdk/networking/peer), [RPC clients](/sdk/networking/rpc) and the [daemon](/sdk/networking/daemon), using rustls | +| `native-tls` | The same, using the platform's native TLS library | +| `chip-0035` | [DataLayer stores](/sdk/primitives/datalayer) (CHIP-0035) | +| `chip-0037` | The CHIP-0037 controller puzzle and EIP-712 message layers used with [vaults](/sdk/primitives/vaults) | +| `action-layer` | [Action layer singletons](/sdk/primitives/action-layer) such as CATalog and XCHandles (implies `chip-0035`) | +| `offer-compression` | Compressing and decompressing [offer](/sdk/offers) strings | +| `peer-simulator` | The [peer simulator](/sdk/testing/peer-simulator), an in-process full node for networking tests | + +Everything else, including the [simulator](/sdk/testing), is always available. + +## How the crate is organized + +`chia-wallet-sdk` is a facade over a workspace of smaller crates. You can depend on just the facade and +reach everything through it, or depend on the individual crates if you want a smaller build. + +| Module | Crate | What's in it | +| -------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `chia_wallet_sdk::driver` | `chia-sdk-driver` | [SpendContext](/sdk/concepts/spend-context), [layers](/sdk/concepts/layers), [primitives](/sdk/primitives), the [action system](/sdk/action-system), [offers](/sdk/offers), [clear signing](/sdk/clear-signing) | +| `chia_wallet_sdk::types` | `chia-sdk-types` | [Conditions](/sdk/concepts/conditions), puzzle argument and solution types, the `Mod` trait, network constants, Merkle trees | +| `chia_wallet_sdk::signer` | `chia-sdk-signer` | Working out which [signatures](/sdk/concepts/signing) a transaction needs | +| `chia_wallet_sdk::client` | `chia-sdk-client` | [Peer connections](/sdk/networking/peer) over the full node wallet protocol | +| `chia_wallet_sdk::coinset` | `chia-sdk-coinset` | [RPC clients](/sdk/networking/rpc) for coinset.org and full nodes | +| `chia_wallet_sdk::daemon` | `chia-sdk-daemon` | A [daemon](/sdk/networking/daemon) websocket client | +| `chia_wallet_sdk::test` | `chia-sdk-test` | The [simulator](/sdk/testing), test key pairs and the [peer simulator](/sdk/testing/peer-simulator) | +| `chia_wallet_sdk::utils` | `chia-sdk-utils` | [Addresses, coin selection and hex helpers](/sdk/utilities) | + +The SDK builds on the [chia_rs](https://github.com/Chia-Network/chia_rs) crates and re-exports the ones you +need, so you don't have to keep their versions in sync yourself: + +| Path | Crate | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `chia_wallet_sdk::chia::protocol` | `chia-protocol`: `Coin`, `CoinSpend`, `SpendBundle`, `Bytes32`, `Program` and the wire protocol | +| `chia_wallet_sdk::chia::bls` | `chia-bls`: BLS keys, signatures and key derivation | +| `chia_wallet_sdk::chia::secp` | `chia-secp`: secp256k1 and secp256r1 keys | +| `chia_wallet_sdk::chia::puzzle_types` | `chia-puzzle-types`: argument and solution types for the standard puzzles | +| `chia_wallet_sdk::chia::consensus` | `chia-consensus`: consensus constants and validation | +| `chia_wallet_sdk::puzzles` | `chia-puzzles`: compiled puzzle bytes and their hashes | +| `chia_wallet_sdk::clvmr`, `clvm_traits`, `clvm_utils` | The CLVM runtime, `ToClvm`/`FromClvm`, and tree hashing and currying | + +Most programs only need the prelude, which collects the commonly used types: ```rust use chia_wallet_sdk::prelude::*; -// Create a spend context to build the transaction -let ctx = &mut SpendContext::new(); - -// Define the conditions for this spend: -// - Create a new coin with 900 mojos -// - Reserve 100 mojos as transaction fee -let conditions = Conditions::new() - .create_coin(puzzle_hash, 900, Memos::None) - .reserve_fee(100); - -// Create the spend using StandardLayer (p2 puzzle) -StandardLayer::new(public_key).spend(ctx, coin, conditions)?; - -// Extract the coin spends for signing and broadcast -let coin_spends = ctx.take(); +fn takes_common_types(_ctx: &mut SpendContext, _coin: Coin, _conditions: Conditions) {} ``` - - - -```typescript -import { Clvm, Coin, Simulator } from "chia-wallet-sdk"; - -// Create a CLVM instance to build the transaction -const clvm = new Clvm(); - -// Create conditions: -// - Create a new coin with 900 mojos -// - Reserve 100 mojos as transaction fee -const conditions = [ - clvm.createCoin(puzzleHash, 900n, null), - clvm.reserveFee(100n), -]; - -// Create and spend using delegated spend (p2 puzzle) -clvm.spendStandardCoin(coin, publicKey, clvm.delegatedSpend(conditions)); - -// Extract the coin spends for signing and broadcast -const coinSpends = clvm.coinSpends(); -``` - - - - -```python -from chia_wallet_sdk import Clvm, Coin, Simulator - -# Create a CLVM instance to build the transaction -clvm = Clvm() - -# Create conditions: -# - Create a new coin with 900 mojos -# - Reserve 100 mojos as transaction fee -conditions = [ - clvm.create_coin(puzzle_hash, 900, None), - clvm.reserve_fee(100), -] - -# Create and spend using delegated spend (p2 puzzle) -clvm.spend_standard_coin( - coin, - public_key, - clvm.delegated_spend(conditions) -) - -# Extract the coin spends for signing and broadcast -coin_spends = clvm.coin_spends() -``` - - - - -This example demonstrates the core pattern you'll use throughout the SDK: - -1. **Create a context** - In Rust, use `SpendContext`; in Node.js/Python, use the `Clvm` class -2. **Build conditions** - Define what the transaction should do (create coins, fees, announcements) -3. **Spend coins** - Use primitives like `StandardLayer` (Rust) or `spendStandardCoin` (bindings) -4. **Extract and broadcast** - Take the collected spends, sign them, and submit to the network - -## Core Concepts - -The SDK is organized around these key abstractions: - -| Concept | Rust | Node.js / Python | Description | -| -------------- | --------------------------- | --------------------------------- | ---------------------------------------------------------------- | -| **Context** | `SpendContext` | `Clvm` | Transaction builder that manages memory and collects coin spends | -| **Conditions** | `Conditions` builder | Method calls (`createCoin`, etc.) | Output conditions (create coin, fees, announcements) | -| **Actions** | `Action`, `Spends` | `Action`, `Spends` | High-level declarative transaction API | -| **Primitives** | `Cat`, `Nft`, `Vault`, etc. | `spendCats`, `spendNft`, etc. | High-level APIs for Chia constructs | -| **Simulator** | `Simulator` | `Simulator` | Test transaction validation locally | - -## Next Steps - -- [SpendContext](/sdk/spend-context) - Understanding the core transaction builder -- [Action System](/sdk/actions) - High-level declarative transaction API -- [Standard (XCH)](/sdk/primitives/standard) - Working with basic XCH coins -- [CAT](/sdk/primitives/cat) - Issuing and spending custom asset tokens -- [NFT](/sdk/primitives/nft) - Minting and transferring NFTs - -## Relationship to chia-rs - -The Wallet SDK builds on top of the lower-level [chia-rs](https://github.com/Chia-Network/chia_rs) crates, providing ergonomic APIs for common operations. If you need lower-level control, the underlying types from `chia-protocol`, `clvm-traits`, and `clvmr` are re-exported through the SDK. +## The big picture + +Every Chia transaction is a `SpendBundle`: a list of coin spends (each a coin, its puzzle reveal and a +solution) plus one aggregated signature. Building one with the SDK follows the same shape no matter +what assets are involved: + +1. **Allocate.** Create a [SpendContext](/sdk/concepts/spend-context). It owns the CLVM memory that puzzles + and solutions are built in, and collects the coin spends you add. +2. **Describe what should happen.** Either spend coins directly with a [primitive](/sdk/primitives) such as + `StandardLayer`, `Cat` or `Nft`, passing the [conditions](/sdk/concepts/conditions) each coin should + output, or describe the whole transaction declaratively with the [action system](/sdk/action-system) + and let it select change, link spends and build the conditions for you. +3. **Sign.** Work out which signatures the spends require with `RequiredSignature` and sign them with + your keys. See [Keys & Signing](/sdk/concepts/signing). +4. **Submit.** Send the `SpendBundle` to the network through a [peer](/sdk/networking/peer) or an + [RPC client](/sdk/networking/rpc), or validate it locally with the [simulator](/sdk/testing). + +To recognize coins you receive, you go the other way: fetch the parent coin's spend and let a +primitive parse it (for example `Cat::parse_children` or `Nft::parse_child`). The +[primitives overview](/sdk/primitives) explains this pattern. + +## Where to go next + +- [Quick Start](/sdk/quick-start) builds, signs and validates a first transaction. +- [Core Concepts](/sdk/concepts/clvm) explains the building blocks everything else uses. +- [Action System](/sdk/action-system) is the easiest way to build multi-asset transactions. +- [Primitives](/sdk/primitives) covers each asset type in depth. +- [Testing](/sdk/testing) shows how to test your code against a simulated blockchain. +- [Bindings](/sdk/bindings) covers using the SDK from Node.js, the browser and Python. diff --git a/docs/sdk/networking/daemon.md b/docs/sdk/networking/daemon.md new file mode 100644 index 0000000..182ad89 --- /dev/null +++ b/docs/sdk/networking/daemon.md @@ -0,0 +1,189 @@ +--- +title: Daemon Client +--- + +# Daemon Client + +The `chia-sdk-daemon` crate (re-exported as `chia_wallet_sdk::daemon`) connects to the daemon of a +locally installed Chia node. The daemon is the process that starts and supervises the full node, wallet, +farmer and other services, and relays messages between them and UIs over a single WebSocket at +`wss://localhost:55400`. + +`DaemonClient` gives you two things over that connection: + +- Full node RPC calls. `DaemonClient` implements `ChiaRpcClient`, so every method described in + [RPC Clients](/sdk/networking/rpc) works on it, routed through the daemon to the `chia_full_node` + service. +- Events. You can register under a service name and receive the messages the daemon forwards to it, + such as the `state_changed` notifications that drive the Chia GUI. + +Use it when your application runs next to a Chia installation and you want live events, or you'd rather +hold one daemon connection than talk to each service's RPC port. For a node elsewhere, or when you don't +need events, a [`FullNodeClient`](/sdk/networking/rpc#connecting-to-your-own-full-node) is simpler. + +:::info Feature flags +`DaemonClient` is only compiled with the `rustls` or `native-tls` feature. The message types +(`DaemonEvent`, `WebsocketRequest`, `WebsocketResponse`) and `DaemonError` are always available. +::: + +## Connecting + +The daemon uses mutual TLS with the installation's private CA, so connect with the daemon's private +certificate and key. A standard installation keeps them in +`~/.chia/mainnet/config/ssl/daemon/private_daemon.crt` and `private_daemon.key`. Read the files and build +a connector with the [TLS helpers from `chia-sdk-client`](/sdk/networking/peer#certificates-and-connectors). + +```rust +use std::{path::Path, time::Duration}; + +use chia_wallet_sdk::chia::ssl::ChiaCertificate; +use chia_wallet_sdk::client::create_rustls_connector; +use chia_wallet_sdk::daemon::DaemonClient; +use chia_wallet_sdk::prelude::*; + +async fn connect_daemon(chia_root: &Path) -> anyhow::Result { + let ssl = chia_root.join("config/ssl/daemon"); + let cert = ChiaCertificate { + cert_pem: std::fs::read_to_string(ssl.join("private_daemon.crt"))?, + key_pem: std::fs::read_to_string(ssl.join("private_daemon.key"))?, + }; + + let client = DaemonClient::connect( + "wss://localhost:55400", + create_rustls_connector(&cert)?, + // How long each request waits for its response. + Duration::from_secs(30), + ) + .await?; + + Ok(client) +} + +async fn peak_height(client: &DaemonClient) -> anyhow::Result> { + let response = client.get_blockchain_state().await?; + Ok(response.blockchain_state.map(|state| state.peak.height)) +} +``` + +:::warning +Don't load the daemon certificate with `load_ssl_cert`. When it can't read the files, it generates a +new self-signed certificate and tries to write it to those paths, and the daemon rejects that +certificate. Paths starting with `~` are never expanded, so they always take that branch. Read the files +directly as above. +::: + +On connect, the client picks a unique origin name (`chia-wallet-sdk-`) and registers it with +the daemon so that responses are routed back to it. `DaemonClient` is cheap to clone; all clones share +the connection. + +## RPC calls + +Every `ChiaRpcClient` method sends a request with the RPC endpoint as the command and `chia_full_node` as +the destination, then waits up to the timeout you passed to `connect`. Responses use the same types as +the HTTP clients, so code written against `ChiaRpcClient` works unchanged: + +```rust +use chia_wallet_sdk::daemon::{DaemonClient, DaemonError}; +use chia_wallet_sdk::prelude::*; + +/// Works with a `DaemonClient`, `CoinsetClient`, `FullNodeClient` or `MockRpcClient`. +async fn coin_is_spent(client: &C, coin_id: Bytes32) -> Result { + let response = client.get_coin_record_by_name(coin_id).await?; + Ok(response.coin_record.is_some_and(|record| record.spent)) +} + +async fn check_with_daemon(client: &DaemonClient, coin_id: Bytes32) -> Result { + coin_is_spent(client, coin_id).await +} +``` + +Requests always go to the full node. `DaemonClient` doesn't expose a way to send commands to the wallet, +farmer or daemon itself. + +## Events + +The daemon forwards messages to registered service names. `subscribe(service)` registers the client +under `service` (once per name) and returns a `tokio::sync::broadcast::Receiver`. Each +`DaemonEvent` has the `command` (for example `state_changed`), the `origin` service that sent it, and the +JSON `data`. + +Which name to register depends on what you want to hear. The Chia GUI registers as `wallet_ui` to +receive `state_changed` events from the full node and wallet. Services also send metrics events to +`metrics`. + +```rust +use chia_wallet_sdk::daemon::DaemonClient; +use tokio::sync::broadcast::error::RecvError; + +async fn watch_state_changes(client: &DaemonClient) -> anyhow::Result<()> { + let mut events = client.subscribe("wallet_ui").await?; + + loop { + match events.recv().await { + Ok(event) if event.command == "state_changed" => { + println!("{} changed: {}", event.origin, event.data["state"]); + } + Ok(_) => {} + Err(RecvError::Lagged(skipped)) => eprintln!("missed {skipped} events"), + Err(RecvError::Closed) => return Ok(()), + } + } +} +``` + +All subscriptions share one broadcast channel, which holds the last 256 events. A receiver gets every +event the daemon sends to this client, whatever service name it was registered under, so filter on +`origin` and `command`. A receiver that falls more than 256 events behind gets `RecvError::Lagged` and +skips ahead. + +## Reconnection + +If the WebSocket fails, the client reconnects on its own. It fails any requests waiting for a response +with `DaemonError::ReceiveFailed`, then retries with exponential backoff from 1 second up to 30 seconds. +After reconnecting, it registers its origin and every subscribed service again, so event receivers keep +working. While disconnected, new requests fail immediately with `DaemonError::ConnectionClosed`. + +```rust +use chia_wallet_sdk::daemon::DaemonClient; + +fn monitor_connection(client: &DaemonClient) { + // Give up after 10 failed attempts instead of retrying forever. + client.set_max_reconnect_attempts(Some(10)); + + let mut disconnected = client.on_disconnect(); + let mut reconnected = client.on_reconnect(); + + tokio::spawn(async move { + while disconnected.recv().await.is_ok() { + eprintln!("lost connection to the daemon"); + } + }); + + tokio::spawn(async move { + while reconnected.recv().await.is_ok() { + eprintln!("reconnected to the daemon"); + } + }); +} +``` + +Call `close()` to close the connection. Reconnection is triggered by read errors, so a connection that +the daemon closes cleanly is not reopened. + +## Errors + +| `DaemonError` | Meaning | +| -------------------- | ----------------------------------------------------------------------------- | +| `WebSocket` | The connection couldn't be opened or closed cleanly | +| `Json` | A request couldn't be serialized or a response didn't match the expected type | +| `Timeout(duration)` | No response arrived within the timeout given to `connect` | +| `ConnectionClosed` | The client is disconnected and waiting to reconnect | +| `SendFailed` | The request couldn't be written to the WebSocket | +| `ReceiveFailed` | The connection dropped while the request was waiting | +| `ReconnectFailed(n)` | Reconnection gave up after `n` attempts (logged, not returned to callers) | + +As with the HTTP clients, an RPC error reported by the full node isn't a `DaemonError`. It comes back as +a response with `success: false` and an `error` message. + +See the [chia-sdk-daemon rustdocs](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/daemon/index.html) +and the [Chia daemon documentation](https://docs.chia.net/) for the underlying protocol. diff --git a/docs/sdk/networking/peer.md b/docs/sdk/networking/peer.md new file mode 100644 index 0000000..62ce08d --- /dev/null +++ b/docs/sdk/networking/peer.md @@ -0,0 +1,492 @@ +--- +title: Peer Protocol +--- + +# Peer Protocol + +The `chia-sdk-client` crate (re-exported as `chia_wallet_sdk::client`) connects directly to Chia full +nodes over the peer-to-peer wallet protocol, the same protocol the reference light wallet uses. A `Peer` +is a single authenticated WebSocket connection to one full node. Through it you can look up coin state, +subscribe to puzzle hashes and coin IDs, fetch the puzzle and solution of a spent coin, and submit +transactions. + +Use the peer protocol when you want a self-contained light wallet that talks to the network with no +trusted third party. If you would rather make HTTP calls to a hosted indexer or your own full node's RPC, +use the [RPC clients](/sdk/networking/rpc) instead. Both approaches give you the same `Coin`, +`CoinState` and `CoinSpend` types, so the rest of the SDK works the same either way. + +:::info Feature flags +Connecting requires a TLS backend. Enable exactly one of the `rustls` or `native-tls` features on +`chia-wallet-sdk`. Without one, `Peer::connect`, `connect_peer`, `Client` and the connector helpers are +not compiled. `Peer`, `PeerOptions`, `Network` and the rate limiter types are always available. +::: + +```toml +[dependencies] +chia-wallet-sdk = { version = "0.36", features = ["rustls"] } +tokio = { version = "1", features = ["full"] } +``` + +## How it fits together + +| Item | Role | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `ChiaCertificate` (`chia_wallet_sdk::chia::ssl`) | The client TLS certificate and key that identify your node to the peer | +| `load_ssl_cert` | Loads a certificate from disk, or generates and saves one if the files can't be read | +| `create_rustls_connector` / `create_native_tls_connector` | Builds a `Connector` (TLS config) from a certificate | +| `Network` | Default port, genesis challenge and DNS introducers for mainnet or testnet11 | +| `connect_peer` | Opens the WebSocket, performs the wallet handshake and checks the network ID | +| `Peer` | Cloneable handle for sending requests on one connection | +| `mpsc::Receiver` | Messages the peer pushes to you unprompted: new peaks and coin state updates | +| `Client` / `ClientState` | Optional bookkeeping for many peers, with ban and trust lists | +| `ClientError` | Every error the crate returns | + +## Certificates and connectors + +Full nodes require mutual TLS, but they accept any self-signed client certificate. You don't need the +certificate of an installed Chia node. Generating one with `ChiaCertificate::generate` works, but the +certificate is part of how a peer identifies you, so a long-running wallet should generate it once and +keep it. `load_ssl_cert` does that: it reads the PEM files if they exist, and otherwise generates a new +certificate and writes both files. + +```rust +use chia_wallet_sdk::client::{create_rustls_connector, load_ssl_cert}; + +#[test] +fn persistent_certificate() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join("chia-sdk-docs-peer-cert"); + std::fs::create_dir_all(&dir)?; + let cert_path = dir.join("wallet.crt"); + let key_path = dir.join("wallet.key"); + + // The first call generates the files, later calls load the same certificate. + let cert = load_ssl_cert(cert_path.to_str().unwrap(), key_path.to_str().unwrap())?; + let again = load_ssl_cert(cert_path.to_str().unwrap(), key_path.to_str().unwrap())?; + assert_eq!(cert.cert_pem, again.cert_pem); + + // A connector is the TLS configuration used to open connections. It's cheap to clone. + let _connector = create_rustls_connector(&cert)?; + + Ok(()) +} +``` + +With the `native-tls` feature, use `create_native_tls_connector` instead. Both connectors present your +certificate and skip verification of the full node's certificate, because full nodes also use self-signed +certificates. The connection is encrypted, but the server's identity isn't authenticated, so treat data +from any single peer as untrusted. + +:::warning +`load_ssl_cert` overwrites the paths you give it when it can't read them. Don't point it at the +certificate files of an existing Chia installation unless you're sure they exist. To use existing +certificate files, read them yourself and construct `ChiaCertificate { cert_pem, key_pem }`. +::: + +## Connecting to a peer + +`connect_peer` opens a `wss://:/ws` connection, sends a `Handshake` that identifies you as a +wallet, and waits for the full node's handshake. It fails with `ClientError::WrongNetwork` if the node +is on a different network, and `ClientError::WrongNodeType` if the remote isn't a full node. The network +ID is `"mainnet"` or `"testnet11"`. + +To find peers, `Network::default_mainnet()` and `Network::default_testnet11()` include the DNS +introducers run by Chia Network and the community. `lookup_all` resolves them in batches and returns +socket addresses on the network's default port (8444 for mainnet, 58444 for testnet11). Many of the +returned addresses won't accept a connection, so try several with a timeout. + +```rust +use std::time::Duration; + +use chia_wallet_sdk::chia::protocol::Message; +use chia_wallet_sdk::chia::ssl::ChiaCertificate; +use chia_wallet_sdk::client::{Network, connect_peer, create_rustls_connector}; +use chia_wallet_sdk::prelude::*; +use tokio::sync::mpsc; + +async fn connect_to_mainnet( + cert: &ChiaCertificate, +) -> anyhow::Result<(Peer, mpsc::Receiver)> { + let connector = create_rustls_connector(cert)?; + let network = Network::default_mainnet(); + + // Resolve two introducers at a time, waiting at most 5 seconds for each. + let addrs = network.lookup_all(Duration::from_secs(5), 2).await; + + for addr in addrs { + let attempt = connect_peer( + "mainnet".to_string(), + connector.clone(), + addr, + PeerOptions::default(), + ); + + match tokio::time::timeout(Duration::from_secs(3), attempt).await { + Ok(Ok((peer, receiver))) => return Ok((peer, receiver)), + Ok(Err(error)) => eprintln!("{addr}: {error}"), + Err(_) => eprintln!("{addr}: timed out"), + } + } + + anyhow::bail!("could not connect to any mainnet peer") +} +``` + +`Peer::connect` and `Peer::connect_full_uri` open the connection without sending the handshake. Use +them only if you want to send your own `Handshake` message. For anything else, use `connect_peer`. + +A `Peer` is an `Arc` around the connection, so clone it freely and share it between tasks. Requests +from multiple tasks can be in flight at once, because each request gets a message ID and the response is +routed back to the caller that sent it. Call `peer.close()` to close the WebSocket. Dropping the last +clone stops the background task that reads incoming messages. + +## Requesting state + +All wallet protocol requests are `async` methods on `Peer`. Some requests can be rejected by the node, +so those methods return `Result, ClientError>`. The outer `Result` reports +connection and decoding failures. The inner one is the node's answer. + +| Method | Returns | Use it to | +| ----------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- | +| `request_puzzle_state` | `Result` | Fetch (and optionally subscribe to) every coin with given puzzle hashes or hints | +| `request_coin_state` | `Result` | Fetch (and optionally subscribe to) coins by ID | +| `request_puzzle_and_solution` | `Result` | Get the reveal and solution of a coin spent at a given height | +| `request_children` | `RespondChildren` | List coins created by a spent coin | +| `register_for_ph_updates` | `RespondToPhUpdates` | Older subscription API for puzzle hashes | +| `register_for_coin_updates` | `RespondToCoinUpdates` | Older subscription API for coin IDs | +| `remove_puzzle_subscriptions` / `remove_coin_subscriptions` | Removed hashes or IDs | Unsubscribe (pass `None` to remove all) | +| `send_transaction` | `TransactionAck` | Submit a `SpendBundle` to the node's mempool | +| `request_transaction` | `RespondTransaction` | Fetch a mempool transaction by ID | +| `request_peers` | `RespondPeers` | Ask the node for other peers | + +The request and response types are defined in `chia-protocol` and re-exported as +`chia_wallet_sdk::chia::protocol`. For anything not covered, `request_infallible`, `request_fallible` +and `request_raw` send any `ChiaProtocolMessage` and decode the reply into the types you name. `send` +sends a message without waiting for a reply. + +### Syncing a puzzle hash + +`request_puzzle_state` is the main way a light wallet discovers its coins. It takes a `header_hash` and +`previous_height` that describe where to resume from. For the first request, pass `None` and the +network's genesis challenge. The node returns up to a batch of coin states, the height and header hash +it got to, and `is_finished`. If it isn't finished, ask again from where it stopped. If the chain +reorganized past that point in the meantime, the node rejects the request with +`RejectStateReason::Reorg` and you have to restart from an earlier point. + +The `CoinStateFilters` select spent and unspent coins, whether to include coins hinted with the puzzle +hash (not just coins locked by it), and a minimum amount. With `subscribe_when_finished` set, the node +also subscribes you to those puzzle hashes after the last batch, so later changes arrive on the +receiver. + +```rust +use chia_wallet_sdk::chia::protocol::CoinStateFilters; +use chia_wallet_sdk::prelude::*; + +/// Returns every coin state for `puzzle_hash` and subscribes to future updates. +pub async fn sync_puzzle_hash( + peer: &Peer, + puzzle_hash: Bytes32, + genesis_challenge: Bytes32, +) -> anyhow::Result> { + let mut coin_states = Vec::new(); + let mut previous_height = None; + let mut header_hash = genesis_challenge; + + loop { + let response = peer + .request_puzzle_state( + vec![puzzle_hash], + previous_height, + header_hash, + CoinStateFilters::new(true, true, true, 0), + true, + ) + .await? + .map_err(|rejection| anyhow::anyhow!("rejected: {:?}", rejection.reason))?; + + coin_states.extend(response.coin_states); + + if response.is_finished { + return Ok(coin_states); + } + + previous_height = Some(response.height); + header_hash = response.header_hash; + } +} + +#[cfg(test)] +mod tests { + use chia_wallet_sdk::test::PeerSimulator; + + use super::*; + + #[tokio::test] + async fn sync_against_simulator() -> anyhow::Result<()> { + let sim = PeerSimulator::new().await?; + let peer = sim.connect().await?; + + let alice = sim.lock().await.bls(1_000); + + let coin_states = + sync_puzzle_hash(&peer, alice.puzzle_hash, sim.config().genesis_challenge).await?; + + assert_eq!(coin_states.len(), 1); + assert_eq!(coin_states[0].coin, alice.coin); + assert_eq!(coin_states[0].spent_height, None); + + Ok(()) + } +} +``` + +The test runs the helper against a local [Peer Simulator](/sdk/testing/peer-simulator), which speaks +the same protocol as a full node. + +### Looking up a parent spend + +To spend a CAT, NFT, DID or other primitive, you need its parent's puzzle reveal and solution (see +[Primitives](/sdk/primitives)). Look up the coin's state to find the parent, then fetch the parent's +spend at the height it was spent, which is the height the child was created. + +```rust +use chia_wallet_sdk::prelude::*; + +/// Fetches the spend that created `coin_id`. +pub async fn fetch_parent_spend( + peer: &Peer, + coin_id: Bytes32, + genesis_challenge: Bytes32, +) -> anyhow::Result> { + let response = peer + .request_coin_state(vec![coin_id], None, genesis_challenge, false) + .await? + .map_err(|rejection| anyhow::anyhow!("rejected: {:?}", rejection.reason))?; + + let Some(coin_state) = response.coin_states.first() else { + return Ok(None); + }; + let Some(created_height) = coin_state.created_height else { + return Ok(None); + }; + + let parent_id = coin_state.coin.parent_coin_info; + + let parent = peer + .request_coin_state(vec![parent_id], None, genesis_challenge, false) + .await? + .map_err(|rejection| anyhow::anyhow!("rejected: {:?}", rejection.reason))?; + + let Some(parent_state) = parent.coin_states.first() else { + return Ok(None); + }; + + let Ok(puzzle_and_solution) = peer + .request_puzzle_and_solution(parent_id, created_height) + .await? + else { + return Ok(None); + }; + + Ok(Some(CoinSpend::new( + parent_state.coin, + puzzle_and_solution.puzzle, + puzzle_and_solution.solution, + ))) +} +``` + +### Submitting a transaction + +`send_transaction` submits a signed `SpendBundle` and returns the node's `TransactionAck`. The `status` +field follows the full node's `MempoolInclusionStatus`: `1` means the transaction entered the mempool, +`2` means the node is holding it as pending but hasn't added it to the mempool yet, and `3` means it +failed. `error` holds the reason when it failed. + +```rust +use chia_wallet_sdk::prelude::*; + +pub async fn broadcast(peer: &Peer, spend_bundle: SpendBundle) -> anyhow::Result { + let ack = peer.send_transaction(spend_bundle).await?; + + match ack.status { + 1 => Ok(ack.txid), + 2 => { + eprintln!("transaction {} is pending: {:?}", ack.txid, ack.error); + Ok(ack.txid) + } + _ => anyhow::bail!("transaction failed: {}", ack.error.unwrap_or_default()), + } +} +``` + +An accepted transaction isn't confirmed yet. Wait for a coin state update that shows the spent coins +with a `spent_height`, and consider rebroadcasting to other peers in case the first one drops it. + +## Events + +`connect_peer` returns an `mpsc::Receiver` alongside the `Peer`. Responses to your requests go +to the callers that made them. Everything the node sends on its own goes to the receiver. For a wallet, +that's mainly: + +- `NewPeakWallet`: the node has a new peak (header hash, height, weight and fork point). It's sent right + after the handshake and then every time the chain advances. +- `CoinStateUpdate`: coins you subscribed to (by puzzle hash, hint or coin ID) were created, spent or + reorged. `fork_height` tells you how far back the update reaches. + +Messages are raw `Message` values. Match on `msg_type` and decode the body with `Streamable::from_bytes`. + +```rust +use chia_wallet_sdk::chia::protocol::{ + CoinStateUpdate, Message, NewPeakWallet, ProtocolMessageTypes, +}; +use chia_wallet_sdk::chia::traits::Streamable; +use tokio::sync::mpsc; + +pub async fn handle_events(mut receiver: mpsc::Receiver) -> anyhow::Result<()> { + while let Some(message) = receiver.recv().await { + match message.msg_type { + ProtocolMessageTypes::NewPeakWallet => { + let peak = NewPeakWallet::from_bytes(&message.data)?; + println!("new peak at height {}", peak.height); + } + ProtocolMessageTypes::CoinStateUpdate => { + let update = CoinStateUpdate::from_bytes(&message.data)?; + for coin_state in update.items { + println!( + "coin {} created at {:?}, spent at {:?}", + coin_state.coin.coin_id(), + coin_state.created_height, + coin_state.spent_height + ); + } + } + _ => {} + } + } + + // The channel closes when the connection does. + Ok(()) +} +``` + +The receiver buffers up to 32 messages. If you keep it but stop reading, the background reader blocks +once the buffer is full, and responses to your requests stop arriving too. Either drain it (for example +in a spawned task) or drop it, in which case unsolicited messages are discarded. When the connection +closes, the receiver returns `None`. + +:::warning +Requests that are waiting for a response when the connection drops are not failed. Their futures stay +pending, so put a timeout on requests (see [Gotchas](#gotchas)) and treat the receiver closing as the +signal that the peer is gone. New requests on a closed connection fail with `ClientError::WebSocket`. +::: + +## Managing multiple peers + +A light wallet usually connects to several peers so that one slow or dishonest node can't stall it. +`Client` is a small helper for that. It holds the network ID, a `Network` and a connector, and keeps a +`ClientState` with the connected peers keyed by IP address, plus ban and trust lists. `Client` derefs to +a `tokio::sync::Mutex`. + +```rust +use std::time::Duration; + +use chia_wallet_sdk::chia::ssl::ChiaCertificate; +use chia_wallet_sdk::client::{Client, Network, create_rustls_connector}; +use chia_wallet_sdk::prelude::*; + +pub async fn connect_many(cert: &ChiaCertificate, target: usize) -> anyhow::Result { + let network = Network::default_testnet11(); + let addrs = network.lookup_all(Duration::from_secs(5), 2).await; + + let client = Client::new( + "testnet11".to_string(), + network, + create_rustls_connector(cert)?, + ); + + for addr in addrs { + if client.lock().await.peers().count() >= target { + break; + } + + let connect = client.connect(addr, PeerOptions::default()); + if let Ok(Ok(receiver)) = tokio::time::timeout(Duration::from_secs(3), connect).await { + tokio::spawn(async move { + let mut receiver = receiver; + while receiver.recv().await.is_some() {} + }); + } + } + + // Ban a misbehaving peer. Trusted peers can't be banned. + let mut state = client.lock().await; + let first = state.peers().next().map(Peer::socket_addr); + if let Some(addr) = first { + state.ban(addr.ip()); + } + drop(state); + + Ok(client) +} +``` + +`Client::connect` refuses banned IPs with `ClientError::BannedPeer`, and `ban` also disconnects the peer. +`Client` doesn't discover peers, reconnect or pick which peer to ask; those policies are up to your +application. + +## Rate limits + +Full nodes enforce per-connection rate limits and disconnect peers that exceed them. To stay under the +limits, every `Peer` runs an outbound `RateLimiter` loaded with the node's `V2_RATE_LIMITS`. When a +message would exceed a limit, `send` waits (checking once per second) until the 60-second window resets +instead of sending it. + +`PeerOptions::rate_limit_factor` scales the limits. The default of `0.6` uses 60% of what the node +allows, leaving headroom for differences between your clock and the node's. Raise it towards `1.0` if +you control the node, or lower it if you're being disconnected. + +```rust +use chia_wallet_sdk::prelude::*; + +fn conservative_options() -> PeerOptions { + PeerOptions { + rate_limit_factor: 0.3, + } +} +``` + +`RateLimiter`, `RateLimits`, `RateLimit` and the `V1_RATE_LIMITS`/`V2_RATE_LIMITS` tables are public if +you need to apply the same limits elsewhere, for example on the server side of a test harness. + +## Errors + +Every fallible function returns `ClientError`. The variants you'll handle most often are: + +| Variant | Meaning | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `WebSocket`, `Io` | The connection couldn't be opened or was lost | +| `WrongNetwork`, `WrongNodeType` | The handshake succeeded but the peer is on another network or isn't a full node | +| `MissingHandshake` | The connection closed before the peer sent its handshake | +| `InvalidResponse`, `UnexpectedMessage` | The peer replied with a message of the wrong type | +| `Recv` | The response channel for a request was dropped | +| `Streamable` | A message couldn't be decoded | +| `Ssl`, `Rustls`, `NativeTls`, `Pem`, `MissingPkcs8Key`, `MissingCa` | Certificate or TLS setup failed | +| `BannedPeer` | `Client::connect` was called with a banned IP | + +Rejections such as `RejectPuzzleState` aren't errors. They're returned as the inner `Err` of the +request methods described above. + +## Gotchas + +- `Peer` has no request timeouts. A peer that never answers leaves the future pending forever, so wrap + requests in `tokio::time::timeout` in production code. +- The peer simulator and full nodes support different subsets of messages. Requests the + [peer simulator](/sdk/testing/peer-simulator) doesn't implement (such as `request_peers`) close the + simulated connection. +- `request_puzzle_and_solution` needs the exact height at which the coin was spent. Use + `spent_height` from the coin's `CoinState` (or the child's `created_height`). +- Coin states from a single peer are unauthenticated. For high-value decisions, confirm with more than + one peer. + +See the [chia-sdk-client rustdocs](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/client/index.html) +for the full API and the [Chia wallet protocol docs](https://docs.chia.net/) for the underlying messages. diff --git a/docs/sdk/networking/rpc.md b/docs/sdk/networking/rpc.md new file mode 100644 index 0000000..79016cf --- /dev/null +++ b/docs/sdk/networking/rpc.md @@ -0,0 +1,329 @@ +--- +title: RPC Clients +--- + +# RPC Clients + +The `chia-sdk-coinset` crate (re-exported as `chia_wallet_sdk::coinset`, and included in the prelude) +is an HTTP client for the full node RPC API. It talks to either a hosted [Coinset](https://coinset.org) +endpoint, which needs no setup, or your own full node's RPC server, which needs the node's TLS +certificate. + +Use an RPC client when you want simple request/response access to chain data: looking up coins by +puzzle hash or hint, fetching a parent's puzzle and solution, reading block records, and pushing +transactions. There are no subscriptions or push notifications; for those, use the +[peer protocol](/sdk/networking/peer). + +## Clients + +| Type | Connects to | Feature | +| ---------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | +| `CoinsetClient` | Any server with the full node RPC API over plain HTTPS (Coinset by default) | Always available | +| `FullNodeClient` | Your full node's RPC port (`https://localhost:8555` by default), authenticated with the node's private certificate | `rustls` or `native-tls`, not on `wasm32` | +| `MockRpcClient` | Nothing: returns canned JSON responses you register, for unit tests | Always available | +| `DaemonClient` | The Chia daemon's WebSocket, which forwards to the full node (see [Daemon client](/sdk/networking/daemon)) | `rustls` or `native-tls` | + +All of them implement the `ChiaRpcClient` trait, which provides every RPC method. Write your code +against the trait and you can swap a hosted endpoint for a local node, or for a mock in tests. + +## Querying Coinset + +`CoinsetClient::mainnet()` and `CoinsetClient::testnet11()` point at `https://api.coinset.org` and +`https://testnet11.api.coinset.org`. Use `CoinsetClient::new(url)` for anything else, such as a +self-hosted Coinset instance. + +```rust +use chia_wallet_sdk::prelude::*; + +async fn print_peak() -> anyhow::Result<()> { + let client = CoinsetClient::mainnet(); + + let response = client.get_blockchain_state().await?; + let Some(state) = response.blockchain_state else { + anyhow::bail!("RPC error: {}", response.error.unwrap_or_default()); + }; + + println!("peak height: {}", state.peak.height); + println!("synced: {}", state.sync.synced); + println!("mempool size: {}", state.mempool_size); + + Ok(()) +} +``` + +### Checking responses + +Each method returns `Result`. The `Err` case is a transport failure (the request +couldn't be sent, or the body couldn't be decoded). A request the node rejects still returns `Ok`, with +`success: false` and a message in `error`. The payload fields are `Option`s that are `None` in that +case, so check them rather than unwrapping. + +## Methods + +`ChiaRpcClient` implements these methods on top of a single required method, `make_post_request`, which +posts a JSON body to `/`. Each one maps to the full node RPC endpoint of the same +name (see the [full node RPC reference](https://docs.chia.net/full-node-rpc/)). + +| Area | Methods | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Chain state | `get_blockchain_state`, `get_network_info` | +| Blocks | `get_block`, `get_blocks`, `get_block_record`, `get_block_record_by_height`, `get_block_records`, `get_block_spends`, `get_additions_and_removals` | +| Coins | `get_coin_record_by_name`, `get_coin_records_by_names`, `get_coin_records_by_puzzle_hash(es)`, `get_coin_records_by_hint(s)`, `get_coin_records_by_parent_ids` | +| Spends | `get_puzzle_and_solution` | +| Mempool | `push_tx`, `get_mempool_item_by_tx_id`, `get_mempool_items_by_coin_name` | + +The response types (`BlockchainStateResponse`, `GetCoinRecordsResponse`, `CoinRecord`, +`PushTxResponse` and so on) are plain serde structs. `CoinRecord` has the `coin`, whether it's +`spent`, the `confirmed_block_index` and `spent_block_index` heights, `coinbase` and `timestamp`. + +## Finding coins + +The `get_coin_records_by_*` methods take optional `start_height`, `end_height` and +`include_spent_coins` filters, plus a `cursor`. Coinset caps how many records it returns per request: +when a response is cut short, it sets `truncated: Some(true)` and returns a `next_cursor` to pass back in +the next request. A Chia full node never truncates, and leaves both fields as `None`. + +The function below is generic over `ChiaRpcClient`, so it works with any client: + +```rust +use chia_wallet_sdk::prelude::*; + +/// Returns the unspent coins locked by `puzzle_hash`, following pagination cursors. +pub async fn unspent_coins( + client: &C, + puzzle_hash: Bytes32, +) -> Result, C::Error> { + let mut coins = Vec::new(); + let mut cursor = None; + + loop { + let response = client + .get_coin_records_by_puzzle_hash(puzzle_hash, None, None, Some(false), cursor) + .await?; + + coins.extend( + response + .coin_records + .unwrap_or_default() + .into_iter() + .filter(|record| !record.spent) + .map(|record| record.coin), + ); + + match response.next_cursor { + Some(next) if response.truncated == Some(true) => cursor = Some(next), + _ => return Ok(coins), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn finds_unspent_coins() -> Result<(), Box> { + let mut client = MockRpcClient::new(); + + client.mock_response( + "http://api.example.com/get_coin_records_by_puzzle_hash", + r#"{ + "coin_records": [{ + "coin": { + "parent_coin_info": "0x4dda4b8b6017c633794c2b719c3591870b4bc7682930094c11a311112c772ce6", + "puzzle_hash": "0x18cfd81a9a58d598197730b2f2a21ff3b72951577be1dcc6004080ad17069e84", + "amount": 1000 + }, + "coinbase": false, + "confirmed_block_index": 5612341, + "spent": false, + "spent_block_index": 0, + "timestamp": 1720407964 + }], + "success": true + }"#, + ); + + let puzzle_hash = Bytes32::new(hex_literal::hex!( + "18cfd81a9a58d598197730b2f2a21ff3b72951577be1dcc6004080ad17069e84" + )); + + let coins = unspent_coins(&client, puzzle_hash).await?; + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].amount, 1000); + + // The mock records every request, so you can check what was sent. + let requests = client.get_requests(); + assert_eq!( + requests[0].0, + "http://api.example.com/get_coin_records_by_puzzle_hash" + ); + assert_eq!(requests[0].1["include_spent_coins"], false); + + Ok(()) + } +} +``` + +To find CATs, NFTs and other assets sent to a wallet, use `get_coin_records_by_hint` with the wallet's +puzzle hash. Those coins are locked by an outer puzzle, so their puzzle hash differs from the wallet's, +but senders hint them with the recipient's puzzle hash (see +[Conditions](/sdk/concepts/conditions)). + +## Fetching a parent spend + +Parsing a primitive from the chain means getting its parent's coin spend (see +[Primitives](/sdk/primitives)). `get_puzzle_and_solution` returns it as a `CoinSpend`. Pass the height +the parent was spent at, which is the height the child was confirmed at. + +```rust +use chia_wallet_sdk::prelude::*; + +/// Returns the coin spend that created `coin_id`. +pub async fn parent_spend( + client: &CoinsetClient, + coin_id: Bytes32, +) -> anyhow::Result> { + let Some(record) = client.get_coin_record_by_name(coin_id).await?.coin_record else { + return Ok(None); + }; + + let response = client + .get_puzzle_and_solution( + record.coin.parent_coin_info, + Some(record.confirmed_block_index), + ) + .await?; + + Ok(response.coin_solution) +} +``` + +## Pushing a transaction + +`push_tx` submits a signed `SpendBundle`. On success, `status` is `"SUCCESS"` (or `"PENDING"` if the node +holds the transaction without adding it to the mempool yet). On failure, `success` is `false` and +`error` explains why. + +```rust +use chia_wallet_sdk::prelude::*; + +pub async fn broadcast( + client: &C, + spend_bundle: SpendBundle, +) -> anyhow::Result +where + C::Error: std::error::Error + Send + Sync + 'static, +{ + let transaction_id = spend_bundle.name(); + let response = client.push_tx(spend_bundle).await?; + + if !response.success { + anyhow::bail!("push_tx failed: {}", response.error.unwrap_or_default()); + } + + Ok(transaction_id) +} +``` + +Once pushed, poll `get_mempool_item_by_tx_id` to see whether it's still in the mempool, and +`get_coin_record_by_name` on one of the spent coins to see when it confirms. + +## Connecting to your own full node + +`FullNodeClient` talks to a full node's RPC server, which uses mutual TLS. Pass the PEM contents of the +node's private full node certificate and key, which a standard installation keeps in +`~/.chia/mainnet/config/ssl/full_node/private_full_node.crt` and `private_full_node.key`. +`FullNodeClient::new` connects to `https://localhost:8555`; use `with_base_url` for another host or +port. The server's certificate isn't verified, since nodes use self-signed certificates. + +```rust +use std::path::Path; + +use chia_wallet_sdk::prelude::*; + +fn local_full_node(chia_root: &Path) -> anyhow::Result { + let ssl = chia_root.join("config/ssl/full_node"); + let cert = std::fs::read(ssl.join("private_full_node.crt"))?; + let key = std::fs::read(ssl.join("private_full_node.key"))?; + + Ok(FullNodeClient::new(&cert, &key)?) +} + +async fn local_network_name(chia_root: &Path) -> anyhow::Result> { + let client = local_full_node(chia_root)?; + Ok(client.get_network_info().await?.network_name) +} +``` + +:::info Feature flags +`FullNodeClient` is only compiled with the `rustls` or `native-tls` feature, and never on `wasm32`. +`CoinsetClient` and `MockRpcClient` are always available. In the browser, `CoinsetClient` uses `fetch` +through `reqwest`'s WASM support. +::: + +## Implementing your own client + +To route RPC calls through something else (a proxy, a client with custom headers or timeouts, a +recording layer), implement `ChiaRpcClient` yourself. You only need `base_url` and `make_post_request`; +every RPC method comes with the trait. The method's bounds use `serde`'s `Serialize` and +`DeserializeOwned`, so your crate needs a `serde` dependency to implement it. + +This wrapper logs every call and then forwards it to another client: + +```rust ignore +use chia_wallet_sdk::prelude::*; +use serde::{Serialize, de::DeserializeOwned}; + +struct Logged(C); + +impl ChiaRpcClient for Logged { + type Error = C::Error; + + fn base_url(&self) -> &str { + self.0.base_url() + } + + async fn make_post_request(&self, endpoint: &str, body: B) -> Result + where + B: Serialize + Send, + R: DeserializeOwned + Send, + { + println!("POST {}/{endpoint}", self.base_url()); + self.0.make_post_request(endpoint, body).await + } +} + +// Every trait method now goes through the wrapper. +async fn logged_peak() -> anyhow::Result<()> { + let client = Logged(CoinsetClient::mainnet()); + let state = client.get_blockchain_state().await?; + println!("{:?}", state.blockchain_state.map(|state| state.peak.height)); + Ok(()) +} +``` + +## Testing + +There are two ways to test RPC code without a network: + +- `MockRpcClient` returns a fixed JSON string per URL (`http://api.example.com/`), as in the + [example above](#finding-coins), and records the requests it received. It suits unit tests of parsing + and request construction. It returns the same response every time, so it can't model state changes. +- The [Full Node Simulator](/sdk/testing/full-node-simulator) is an in-memory full node with a mempool + and block farming that returns the same response types. With the `full-node-simulator-http` feature of + `chia-sdk-test` it also serves them over HTTP, so a `CoinsetClient` pointed at it exercises your real + client code. + +## Gotchas + +- Hashes are sent as `0x`-prefixed hex, and amounts as JSON numbers. If you build request bodies yourself + through `make_post_request`, follow the same format. +- `get_coin_records_by_*` ignores spent coins unless `include_spent_coins` is `Some(true)`. +- `get_block` and `get_blocks` return full blocks, which can be large. Use `get_block_record(s)` when you + only need headers and heights. +- Public Coinset endpoints are rate limited. Batch lookups with the plural methods + (`get_coin_records_by_names`, `get_coin_records_by_puzzle_hashes`) instead of many single requests. + +See the [chia-sdk-coinset rustdocs](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/coinset/index.html) +for every request and response type. diff --git a/docs/sdk/offers.md b/docs/sdk/offers.md index 11b3fb5..d9cd13b 100644 --- a/docs/sdk/offers.md +++ b/docs/sdk/offers.md @@ -1,256 +1,366 @@ --- -slug: /sdk/offers title: Offers +slug: /sdk/offers --- # Offers -Offers enable trustless, atomic swaps of assets on the Chia blockchain. Both parties' transactions execute together or not at all. +An offer is a partially complete transaction that trades assets without trusting the other party. The **maker** spends the assets they're giving up into settlement coins, and makes their spend conditional on receiving what they're asking for. They sign it and share it, usually as a bech32 `offer1...` string. A **taker** completes it by spending those settlement coins to themselves and paying the requested assets, and the combined spend bundle is either included in a block as a whole or not at all. See [Offers](https://chialisp.com/offers) on chialisp.com for background on the settlement payments puzzle and the offer format. + +The SDK represents an offer as [`Offer`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/struct.Offer.html), and both sides are built with the [action system](/sdk/action-system), which is how the SDK's own tests make and take offers. The offer types are in the prelude; the royalty helpers and compression functions are in `chia_wallet_sdk::driver`. + +## How offers work + +- **Settlement payments puzzle.** `SETTLEMENT_PAYMENT_HASH` (in `chia_wallet_sdk::puzzles`) is a puzzle that anyone can spend, but that can only make notarized payments. For each notarized payment it makes, it creates a puzzle announcement of the payment's hash. Offered assets are sent to this puzzle (inside their CAT, NFT or option layers), and requested payments are made by it. +- **Notarized payments.** A `NotarizedPayment` is a nonce and a list of `Payment`s (puzzle hash, amount, memos). The maker asserts the puzzle announcement for each payment they request, from the settlement puzzle of the requested asset, so their spend is only valid if the taker makes exactly those payments. +- **The nonce.** `Offer::nonce(coin_ids)` is the tree hash of the sorted ids of the coins the maker is spending. Each coin can only be spent once, so no two offers that can both be taken share a nonce, and a taker can't satisfy two offers with one payment. +- **The offer file.** The offer's spend bundle contains the maker's coin spends, plus one spend per requested asset of a placeholder settlement coin with a parent coin id of zero, whose solution lists the requested notarized payments. The placeholder spends are how a taker learns what's requested, and they're removed when the offer is taken. -## Overview +| Type | What it is | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `Offer` | The maker's spend bundle, the parsed `OfferCoins`, `RequestedPayments` and `AssetInfo`. | +| `OfferCoins` | The settlement coins the maker created: `xch`, `cats` (by asset id), `nfts` and `options` (by launcher id), and the `fee` they reserved. | +| `RequestedPayments` | The notarized payments the maker asks for: `xch`, `cats`, `nfts` and `options`. | +| `AssetInfo` | What's needed to build the settlement puzzle hash of each CAT (`CatAssetInfo`), NFT (`NftAssetInfo`) and option (`OptionAssetInfo`). | +| `OfferAmounts` | Total XCH and CAT amounts, as `u128`. | +| `Arbitrage` | The difference between what's offered and requested, from the taker's point of view. | +| `RoyaltyInfo` | An NFT's launcher id, royalty puzzle hash and basis points. | -Chia offers work by: +## Making an offer -1. **Maker** creates an offer specifying what they give and what they want -2. Offer is shared off-chain (file, URL, marketplace) -3. **Taker** accepts by completing the transaction with their assets -4. Both sides execute atomically - either both succeed or neither does +To make an offer with the action system: -## Key Concepts +1. Build the `RequestedPayments`, with payments to your own puzzle hash, notarized with `Offer::nonce` of the coins you're offering. Hint CAT and NFT payments with `ctx.hint(puzzle_hash)?`. +2. Build the `AssetInfo` for every requested CAT, NFT or option, since the settlement puzzle hash of a requested asset depends on it (for example, whether a CAT is revocable). +3. Add your coins to `Spends`, and add `requested_payments.assertions(&mut ctx, &asset_info)?` to `spends.conditions.required`. +4. Apply actions that send the offered assets to `SETTLEMENT_PAYMENT_HASH`, finish, and sign. +5. `Offer::from_input_spend_bundle(&mut ctx, spend_bundle, requested_payments, asset_info)` parses the settlement coins you created, and `offer.to_spend_bundle(&mut ctx)?` produces the offer spend bundle, which you can encode with `encode_offer`. -### Settlement Layer +## Taking an offer -The settlement layer is a special puzzle (`SETTLEMENT_PAYMENT_HASH`) that locks assets until the offer is completed. Assets sent to this puzzle hash can only be claimed by satisfying the offer's requirements. +To take an offer: + +1. Decode it with `decode_offer`, and parse it with `Offer::from_spend_bundle(&mut ctx, &spend_bundle)?`. This checks every puzzle reveal against its coin, and separates the maker's spends from the requested payments. +2. Inspect what's offered and requested (see [Inspecting offers](#inspecting-offers)) before going any further. +3. Add `offer.offered_coins().clone()` and your own coins to `Spends`. The settlement coins are spent as settlement spends automatically. +4. Apply `offer.requested_payments().actions()`, which is an `Action::settle` for each requested notarized payment, plus anything else you want (a fee, royalties). The offered assets go to your change puzzle hash, unless you send them elsewhere. +5. Finish and sign, then combine your spend bundle with the maker's with `offer.take(spend_bundle)`. + +Here's a complete trade of a CAT for XCH, including encoding the offer as a string and parsing it on the taker's side: ```rust +use chia_wallet_sdk::chia::puzzle_types::{ + Memos, + offer::{NotarizedPayment, Payment}, +}; +use chia_wallet_sdk::driver::{decode_offer, encode_offer}; use chia_wallet_sdk::prelude::*; -use chia_puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::test::sign_transaction; +use indexmap::indexmap; + +#[test] +fn cat_for_xch() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(100); + let bob = sim.bls(51); + let alice_keys = indexmap! { alice.puzzle_hash => alice.pk }; + let alice_hint = ctx.hint(alice.puzzle_hash)?; + + // Alice issues a CAT to offer + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + let deltas = spends.apply(&mut ctx, &[Action::single_issue_cat(None, 100)])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &alice_keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + let cat = outputs.cats[&Id::New(0)][0]; + let asset_id = cat.info.asset_id; + + // Maker: offer 100 of the CAT for 50 mojos of XCH + let mut requested_payments = RequestedPayments::new(); + requested_payments.xch.push(NotarizedPayment::new( + Offer::nonce(vec![cat.coin.coin_id()]), + vec![Payment::new(alice.puzzle_hash, 50, alice_hint)], + )); + let asset_info = AssetInfo::new(); -// Assets locked to settlement can only be spent when -// the corresponding requested payments are satisfied -let settlement_puzzle_hash: Bytes32 = SETTLEMENT_PAYMENT_HASH.into(); -``` + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(cat); + spends.conditions.required = spends + .conditions + .required + .extend(requested_payments.assertions(&mut ctx, &asset_info)?); -### Offer Structure + let deltas = spends.apply( + &mut ctx, + &[Action::send( + Id::Existing(asset_id), + SETTLEMENT_PAYMENT_HASH.into(), + 100, + Memos::None, + )], + )?; + spends.finish_with_keys(&mut ctx, &deltas, Relation::AssertConcurrent, &alice_keys)?; -An offer contains: + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[alice.sk])?; + let offer = Offer::from_input_spend_bundle( + &mut ctx, + SpendBundle::new(coin_spends, signature), + requested_payments, + asset_info, + )?; -| Component | Description | -| -------------------- | ----------------------------------------- | -| `spend_bundle` | Maker's pre-signed spends | -| `offered_coins` | Assets being given (locked to settlement) | -| `requested_payments` | What the maker wants in return | -| `asset_info` | Metadata about the assets involved | + let offer_text = encode_offer(&offer.to_spend_bundle(&mut ctx)?)?; + assert!(offer_text.starts_with("offer1")); -### Notarized Payments + // Taker: parse the untrusted offer and check what it does + let offer = Offer::from_spend_bundle(&mut ctx, &decode_offer(&offer_text)?)?; + assert_eq!(offer.offered_coins().amounts().cats[&asset_id], 100); + assert_eq!(offer.requested_payments().amounts().xch, 50); -Requested payments use a nonce (derived from offered coin IDs) to link the maker's and taker's spends: + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(offer.offered_coins().clone()); + spends.add(bob.coin); -```rust -use chia_puzzle_types::offer::{NotarizedPayment, Payment}; + let mut actions = offer.requested_payments().actions(); + actions.push(Action::fee(1)); + + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { bob.puzzle_hash => bob.pk }, + )?; -// The nonce links this payment request to specific offered coins -let nonce = Offer::nonce(vec![offered_coin.coin_id()]); + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[bob.sk])?; + sim.new_transaction(offer.take(SpendBundle::new(coin_spends, signature)))?; + + // Bob received the CAT, and Alice received the XCH + let bob_cat = outputs.cats[&Id::Existing(asset_id)][0]; + assert_eq!(bob_cat.info.p2_puzzle_hash, bob.puzzle_hash); + assert_eq!(bob_cat.coin.amount, 100); + assert_eq!(sim.unspent_coins(alice.puzzle_hash, false)[0].amount, 50); -let notarized_payment = NotarizedPayment::new( - nonce, - vec![Payment::new(recipient_puzzle_hash, amount, memos)], -); + Ok(()) +} ``` -## XCH for CAT Offer +Requesting a CAT works the same way, with the payment in `requested_payments.cats` and `asset_info.insert_cat(asset_id, CatAssetInfo::new(hidden_puzzle_hash))?`. Requesting an NFT uses `requested_payments.nfts` and `NftAssetInfo` (the NFT's metadata, metadata updater puzzle hash and royalty), since the NFT's settlement puzzle hash commits to them. When taking an offer that requests an asset, add your coins of that asset to the spends: the settle actions pay from an intermediate settlement coin, and the rest comes back to you as change. -### Maker: Offering XCH for CAT +## Inspecting offers -```rust -use chia_wallet_sdk::prelude::*; -use chia_puzzles::SETTLEMENT_PAYMENT_HASH; -use chia_puzzle_types::offer::{NotarizedPayment, Payment}; - -fn create_xch_for_cat_offer( - xch_coin: Coin, - maker_pk: PublicKey, - maker_sk: &SecretKey, - cat_asset_id: Bytes32, - xch_amount: u64, // XCH to offer - cat_amount: u64, // CAT to receive - maker_puzzle_hash: Bytes32, - agg_sig_data: Bytes32, -) -> Result { - let ctx = &mut SpendContext::new(); - - // Step 1: Lock XCH to the settlement puzzle - let conditions = Conditions::new() - .create_coin(SETTLEMENT_PAYMENT_HASH.into(), xch_amount, Memos::None); - - StandardLayer::new(maker_pk).spend(ctx, xch_coin, conditions)?; - - // Step 2: Define what we want in return (CAT tokens) - let nonce = Offer::nonce(vec![xch_coin.coin_id()]); - let memos = ctx.hint(maker_puzzle_hash)?; +Before taking an offer (or showing it to a user), look at what it does: - let mut requested_payments = RequestedPayments::new(); - requested_payments.cats.insert( - cat_asset_id, - vec![NotarizedPayment::new( - nonce, - vec![Payment::new(maker_puzzle_hash, cat_amount, memos)], - )], - ); +- `offer.offered_coins()`: the settlement coins you'll receive. `.amounts()` totals XCH and each CAT, and `.nfts` and `.options` are the singletons. +- `offer.requested_payments()`: what you'll pay. `.amounts()` totals XCH and CATs, and `.nfts` and `.options` are keyed by launcher id. +- `offer.asset_info()`: the CAT, NFT and option info for every asset involved, including whether each CAT is revocable (`CatAssetInfo::hidden_puzzle_hash`). +- `offer.arbitrage()`: an `Arbitrage` with `offered` and `requested` sides, each an `ArbitrageSide` with `xch`, `cats`, `nfts` and `options`. `requested` is what the offer gives you beyond what it asks for of the same asset, and `offered` is what you need to supply. +- `offer.fee()`: the fee the maker's spends leave for the farmer, as an `i128` (negative if the spends create more than they spend). `offered_coins().fee` is the fee they asserted with `RESERVE_FEE`. +- `offer.offered_royalties()` and `offer.requested_royalties()`: the `RoyaltyInfo` of requested and offered NFTs (see below). - // Step 3: Sign the maker's spends - let coin_spends = ctx.take(); - let mut allocator = Allocator::new(); - let required = RequiredSignature::from_coin_spends( - &mut allocator, - &coin_spends, - &AggSigConstants::new(agg_sig_data), - )?; +To cancel an offer you made, spend any of the coins it spends. `offer.cancellable_coin_spends()` returns the maker's spends of coins that weren't created within the offer, which are the coins to spend. Several offers can be combined into one with `offer.extend(other)`, as long as they don't spend the same coins (`DriverError::ConflictingOfferInputs`). - let mut signature = Signature::default(); - for req in required { - let RequiredSignature::Bls(bls_req) = req else { continue }; - signature += &sign(maker_sk, bls_req.message()); - } +## Royalties - // Step 4: Create the offer - let spend_bundle = SpendBundle::new(coin_spends, signature); - let offer = Offer::from_input_spend_bundle( - &mut ctx, - spend_bundle, - requested_payments, - AssetInfo::new(), // No special asset info needed for simple CAT - )?; +NFTs with a royalty require a royalty payment whenever they're traded through an offer. The NFT's transfer program asserts that, for each trade price it's told about, the royalty was paid by a settlement coin, with the NFT's launcher id as the nonce. A trade price is an amount and the settlement puzzle hash of the asset it's paid in (`TradePrice`). - Ok(offer) -} -``` +When several royalty NFTs are traded together, the price is split evenly between them: + +| Function | What it does | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `calculate_nft_trade_price(amount, royalty_nft_count)` | `amount / royalty_nft_count`, or zero if there are no royalty NFTs. | +| `calculate_trade_price_amounts(&amounts, royalty_nft_count)` | The per-NFT trade price of each asset in an `OfferAmounts`. | +| `calculate_trade_prices(&trade_price_amounts, &asset_info)` | Converts them into `TradePrice`s for `TransferNftById`. Needs `AssetInfo` for CATs. | +| `calculate_nft_royalty(trade_price, basis_points)` | `trade_price * basis_points / 10000`. | +| `calculate_royalty_amounts(&trade_prices, &royalties)` | The total royalty owed in each asset, for a list of `RoyaltyInfo`. | +| `calculate_royalty_payments(&mut ctx, &trade_prices, &royalties)` | The royalty payments as `RequestedPayments`, whose `.actions()` are `settle` actions that pay them. | -### Taker: Accepting with CAT +There are two cases, depending on which side the NFT is on. + +### Offering an NFT + +The maker offering an NFT tells it the trade prices with `Action::update_nft(nft_id, vec![], Some(TransferNftById::new(None, trade_prices)))`, computed from the requested amounts. The taker pays the royalty from what they're paying anyway: `offer.requested_royalty_amounts()?` is the total, and `Action::settle_royalty` pays each NFT's royalty. If the taker doesn't pay it, the NFT's spend fails and the offer can't be taken. ```rust +use chia_wallet_sdk::chia::puzzle_types::{ + Memos, + offer::{NotarizedPayment, Payment}, +}; +use chia_wallet_sdk::driver::{ + TransferNftById, calculate_trade_price_amounts, calculate_trade_prices, coin_amount, +}; use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::test::sign_transaction; +use indexmap::indexmap; + +#[test] +fn nft_for_xch_with_royalty() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1); + let bob = sim.bls(1_030); + let carol = BlsPair::new(2); + let alice_keys = indexmap! { alice.puzzle_hash => alice.pk }; + let alice_hint = ctx.hint(alice.puzzle_hash)?; + + // Alice mints an NFT with a 3% royalty to Carol + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + let deltas = spends.apply(&mut ctx, &[Action::mint_empty_royalty_nft(carol.puzzle_hash, 300)])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &alice_keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + let nft = outputs.nfts[&Id::New(0)]; + let nft_id = Id::Existing(nft.info.launcher_id); + + // Maker: offer the NFT for 1000 mojos, telling the NFT its trade price + let mut requested_payments = RequestedPayments::new(); + requested_payments.xch.push(NotarizedPayment::new( + Offer::nonce(vec![nft.coin.coin_id()]), + vec![Payment::new(alice.puzzle_hash, 1_000, alice_hint)], + )); + let asset_info = AssetInfo::new(); -fn accept_xch_for_cat_offer( - offer: Offer, - cat: Cat, - taker_pk: PublicKey, - taker_sk: &SecretKey, - taker_puzzle_hash: Bytes32, - agg_sig_data: Bytes32, -) -> Result { - let ctx = &mut SpendContext::new(); - - // Step 1: Get the offered XCH coins from the offer - let offered_xch = &offer.offered_coins().xch; - - // Step 2: Spend the offered XCH to ourselves - // (The settlement puzzle allows this once we provide the CAT) - for xch_coin in offered_xch { - let conditions = Conditions::new() - .create_coin(taker_puzzle_hash, xch_coin.amount, ctx.hint(taker_puzzle_hash)?); - - // Spend using SettlementLayer - let spend = SettlementLayer.construct_spend( - ctx, - SettlementPaymentsSolution::new(vec![]), - )?; - ctx.spend(*xch_coin, spend)?; - } + let trade_prices = calculate_trade_prices( + &calculate_trade_price_amounts(&requested_payments.amounts(), 1), + &asset_info, + )?; - // Step 3: Send CAT to satisfy the maker's request - let requested = offer.requested_payments(); - let p2 = StandardLayer::new(taker_pk); - - for (asset_id, payments) in &requested.cats { - // Create inner spend that outputs to maker's requested destination - let mut conditions = Conditions::new(); - for notarized in payments { - for payment in ¬arized.payments { - conditions = conditions.create_coin( - payment.puzzle_hash, - payment.amount, - Memos::from(payment.memos.clone()), - ); - } - } - - let inner_spend = p2.spend_with_conditions(ctx, conditions)?; - let cat_spends = [CatSpend::new(cat, inner_spend)]; - Cat::spend_all(ctx, &cat_spends)?; - } + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(nft); + spends.conditions.required = spends + .conditions + .required + .extend(requested_payments.assertions(&mut ctx, &asset_info)?); + + let deltas = spends.apply( + &mut ctx, + &[ + Action::update_nft(nft_id, vec![], Some(TransferNftById::new(None, trade_prices))), + Action::send(nft_id, SETTLEMENT_PAYMENT_HASH.into(), 1, Memos::None), + ], + )?; + spends.finish_with_keys(&mut ctx, &deltas, Relation::AssertConcurrent, &alice_keys)?; - // Step 4: Sign taker's spends let coin_spends = ctx.take(); - let mut allocator = Allocator::new(); - let required = RequiredSignature::from_coin_spends( - &mut allocator, - &coin_spends, - &AggSigConstants::new(agg_sig_data), + let signature = sign_transaction(&coin_spends, &[alice.sk])?; + let offer = Offer::from_input_spend_bundle( + &mut ctx, + SpendBundle::new(coin_spends, signature), + requested_payments, + asset_info, )?; - let mut signature = Signature::default(); - for req in required { - let RequiredSignature::Bls(bls_req) = req else { continue }; - signature += &sign(taker_sk, bls_req.message()); + // Taker: pay the price and the 30 mojo royalty + let royalties = offer.requested_royalty_amounts()?; + assert_eq!(royalties.xch, 30); + + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(offer.offered_coins().clone()); + spends.add(bob.coin); + + let mut actions = offer.requested_payments().actions(); + for royalty in offer.requested_royalties() { + actions.push(Action::settle_royalty( + &mut ctx, + Id::Xch, + royalty.launcher_id, + royalty.puzzle_hash, + coin_amount(royalties.xch)?, + )?); } - // Step 5: Combine with maker's spend bundle - let taker_bundle = SpendBundle::new(coin_spends, signature); - let final_bundle = offer.take(taker_bundle); + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { bob.puzzle_hash => bob.pk }, + )?; + + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[bob.sk])?; + sim.new_transaction(offer.take(SpendBundle::new(coin_spends, signature)))?; + + assert_eq!(outputs.nfts[&nft_id].info.p2_puzzle_hash, bob.puzzle_hash); + assert_eq!(sim.unspent_coins(carol.puzzle_hash, false)[0].amount, 30); - Ok(final_bundle) + Ok(()) } ``` -## NFT Offers +With several royalty NFTs, compute the royalty for each one separately (`calculate_nft_royalty(trade_price, royalty.basis_points)` with the per-NFT trade price), and pay one `settle_royalty` per NFT and asset. -NFTs have built-in methods for offer settlement that handle royalties. +### Requesting an NFT -### Maker: Offering NFT +When the maker requests an NFT, they pay its royalty up front, since the taker can't be trusted to. The maker adds `NftAssetInfo` for the NFT (which includes its royalty), and adds the royalty payments with `calculate_royalty_payments(...)?.actions()` alongside the offered XCH. The taker reveals the trade prices to the NFT when they spend it, with `update_nft`, computed from the offered amounts: ```rust +use chia_wallet_sdk::chia::puzzle_types::{ + Memos, + offer::{NotarizedPayment, Payment}, +}; +use chia_wallet_sdk::driver::{ + TransferNftById, calculate_royalty_payments, calculate_trade_price_amounts, + calculate_trade_prices, +}; use chia_wallet_sdk::prelude::*; -use chia_puzzle_types::offer::{NotarizedPayment, Payment}; - -fn create_nft_offer( - nft: Nft, - maker_pk: PublicKey, - maker_sk: &SecretKey, - requested_xch: u64, - maker_puzzle_hash: Bytes32, - agg_sig_data: Bytes32, -) -> Result { - let ctx = &mut SpendContext::new(); - let p2 = StandardLayer::new(maker_pk); - - // Step 1: Lock NFT to settlement with trade prices (for royalty calculation) - let trade_prices = vec![TradePrice { - amount: requested_xch, - puzzle_hash: SETTLEMENT_PAYMENT_HASH.into(), // XCH - }]; - - let locked_nft = nft.lock_settlement( - ctx, - &p2, - trade_prices, - Conditions::new(), +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::test::sign_transaction; +use indexmap::{IndexMap, indexmap}; + +#[test] +fn xch_for_royalty_nft() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1_030); + let bob = sim.bls(1); + let carol = BlsPair::new(2); + let bob_keys = indexmap! { bob.puzzle_hash => bob.pk }; + let alice_hint = ctx.hint(alice.puzzle_hash)?; + + // Bob owns an NFT with a 3% royalty to Carol + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(bob.coin); + let deltas = spends.apply(&mut ctx, &[Action::mint_empty_royalty_nft(carol.puzzle_hash, 300)])?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &bob_keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&bob.sk))?; + + let nft = outputs.nfts[&Id::New(0)]; + let launcher_id = nft.info.launcher_id; + + // Maker: offer 1000 mojos for the NFT, and pay the royalty up front + let royalties = [RoyaltyInfo::new(launcher_id, carol.puzzle_hash, 300)]; + let offered = OfferAmounts { xch: 1_000, cats: IndexMap::new() }; + let royalty_payments = calculate_royalty_payments( + &mut ctx, + &calculate_trade_price_amounts(&offered, royalties.len()), + &royalties, )?; - // Step 2: Define requested payment (XCH) - let nonce = Offer::nonce(vec![nft.coin.coin_id()]); - let memos = ctx.hint(maker_puzzle_hash)?; + let mut actions = vec![Action::send(Id::Xch, SETTLEMENT_PAYMENT_HASH.into(), 1_000, Memos::None)]; + actions.extend(royalty_payments.actions()); let mut requested_payments = RequestedPayments::new(); - requested_payments.xch.push(NotarizedPayment::new( - nonce, - vec![Payment::new(maker_puzzle_hash, requested_xch, memos)], - )); - - // Step 3: Build asset info for the NFT + requested_payments.nfts.insert( + launcher_id, + vec![NotarizedPayment::new( + Offer::nonce(vec![alice.coin.coin_id()]), + vec![Payment::new(alice.puzzle_hash, 1, alice_hint)], + )], + ); let mut asset_info = AssetInfo::new(); asset_info.insert_nft( - nft.info.launcher_id, + launcher_id, NftAssetInfo::new( nft.info.metadata, nft.info.metadata_updater_puzzle_hash, @@ -259,226 +369,119 @@ fn create_nft_offer( ), )?; - // Step 4: Sign and create offer - let coin_spends = ctx.take(); - let signature = sign_spends(&coin_spends, maker_sk, agg_sig_data)?; + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + spends.conditions.required = spends + .conditions + .required + .extend(requested_payments.assertions(&mut ctx, &asset_info)?); + let deltas = spends.apply(&mut ctx, &actions)?; + spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[alice.sk])?; let offer = Offer::from_input_spend_bundle( &mut ctx, SpendBundle::new(coin_spends, signature), requested_payments, asset_info, )?; + assert_eq!(offer.offered_royalty_amounts()?.xch, 30); - Ok(offer) -} -``` + // Taker: reveal the trade price to the NFT, and send it to Alice + let trade_prices = calculate_trade_prices( + &calculate_trade_price_amounts(&offer.offered_coins().amounts(), 1), + offer.asset_info(), + )?; -### Taker: Accepting NFT Offer + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(offer.offered_coins().clone()); + spends.add(nft); -```rust -use chia_wallet_sdk::prelude::*; - -fn accept_nft_offer( - offer: Offer, - xch_coin: Coin, - taker_pk: PublicKey, - taker_sk: &SecretKey, - taker_puzzle_hash: Bytes32, - agg_sig_data: Bytes32, -) -> Result { - let ctx = &mut SpendContext::new(); - let p2 = StandardLayer::new(taker_pk); - - // Step 1: Get the locked NFT from the offer - let offered_nfts = &offer.offered_coins().nfts; - let (launcher_id, locked_nft) = offered_nfts.iter().next() - .ok_or(DriverError::MissingChild)?; - - // Step 2: Calculate royalties - let royalty_amounts = offer.requested_royalty_amounts(); - let royalty_info = offer.requested_royalties(); - - // Step 3: Unlock the NFT to ourselves - let nonce = Offer::nonce(vec![locked_nft.coin.coin_id()]); - let memos = ctx.hint(taker_puzzle_hash)?; - - let notarized_payments = vec![NotarizedPayment::new( - nonce, - vec![Payment::new(taker_puzzle_hash, 1, memos)], + let mut actions = vec![Action::update_nft( + Id::Existing(launcher_id), + vec![], + Some(TransferNftById::new(None, trade_prices)), )]; + actions.extend(offer.requested_payments().actions()); - locked_nft.unlock_settlement(ctx, notarized_payments)?; - - // Step 4: Pay the maker (XCH) plus royalties - let requested_xch = offer.requested_payments().amounts().xch; - let royalty_xch = royalty_amounts.xch; - let total_needed = requested_xch + royalty_xch; - - let mut conditions = Conditions::new(); - - // Pay the maker - for notarized in &offer.requested_payments().xch { - for payment in ¬arized.payments { - conditions = conditions.create_coin( - payment.puzzle_hash, - payment.amount, - Memos::from(payment.memos.clone()), - ); - } - } - - // Pay royalties - for royalty in &royalty_info { - let amount = royalty_amounts.xch / royalty_info.len() as u64; - if amount > 0 { - conditions = conditions.create_coin( - royalty.puzzle_hash, - amount, - Memos::None, - ); - } - } - - // Change back to taker - let change = xch_coin.amount - total_needed; - if change > 0 { - conditions = conditions.create_coin( - taker_puzzle_hash, - change, - ctx.hint(taker_puzzle_hash)?, - ); - } - - p2.spend(ctx, xch_coin, conditions)?; + let deltas = spends.apply(&mut ctx, &actions)?; + let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::AssertConcurrent, &bob_keys)?; - // Step 5: Combine and sign let coin_spends = ctx.take(); - let signature = sign_spends(&coin_spends, taker_sk, agg_sig_data)?; + let signature = sign_transaction(&coin_spends, &[bob.sk])?; + sim.new_transaction(offer.take(SpendBundle::new(coin_spends, signature)))?; - let final_bundle = offer.take(SpendBundle::new(coin_spends, signature)); + assert_eq!(outputs.nfts[&Id::Existing(launcher_id)].info.p2_puzzle_hash, alice.puzzle_hash); + assert_eq!(sim.unspent_coins(bob.puzzle_hash, false)[0].amount, 1_000); + assert_eq!(sim.unspent_coins(carol.puzzle_hash, false)[0].amount, 30); - Ok(final_bundle) + Ok(()) } ``` -## Parsing Existing Offers - -To parse an offer received from elsewhere: +The royalty math on its own: ```rust +use chia_wallet_sdk::driver::{ + calculate_nft_royalty, calculate_nft_trade_price, calculate_royalty_amounts, + calculate_trade_price_amounts, +}; use chia_wallet_sdk::prelude::*; - -fn parse_offer(spend_bundle: &SpendBundle) -> Result { - let mut allocator = Allocator::new(); - - // Parse the offer from a complete spend bundle - let offer = Offer::from_spend_bundle(&mut allocator, spend_bundle)?; - - // Inspect what's being offered - let offered = offer.offered_coins(); - println!("Offered XCH: {} mojos", offered.amounts().xch); - for (asset_id, amount) in &offered.amounts().cats { - println!("Offered CAT {}: {} mojos", asset_id, amount); - } - for launcher_id in offered.nfts.keys() { - println!("Offered NFT: {}", launcher_id); - } - - // Inspect what's requested - let requested = offer.requested_payments(); - println!("Requested XCH: {} mojos", requested.amounts().xch); - for (asset_id, amount) in &requested.amounts().cats { - println!("Requested CAT {}: {} mojos", asset_id, amount); - } - - Ok(offer) -} -``` - -## Royalties - -NFT royalties are automatically calculated based on trade prices: - -```rust -// Get royalty info from an offer -let royalties = offer.requested_royalties(); // For NFTs being offered -let royalty_amounts = offer.requested_royalty_amounts(); - -for royalty in &royalties { - println!( - "NFT {} requires {}% royalty to {}", - royalty.launcher_id, - royalty.basis_points as f64 / 100.0, - royalty.puzzle_hash - ); -} - -println!("Total royalty XCH: {} mojos", royalty_amounts.xch); -``` - -## Offer Compression - -For sharing offers efficiently, enable compression: - -```toml -chia-wallet-sdk = { version = "0.32", features = ["offer-compression"] } -``` - -```rust -use chia_wallet_sdk::driver::{compress_offer, decompress_offer}; - -// Compress for sharing -let compressed = compress_offer(&offer_bytes)?; - -// Decompress when receiving -let decompressed = decompress_offer(&compressed)?; -``` - -## Helper Function - -A utility for signing spends used in the examples above: - -```rust -fn sign_spends( - coin_spends: &[CoinSpend], - secret_key: &SecretKey, - agg_sig_data: Bytes32, -) -> Result { - let mut allocator = Allocator::new(); - let required = RequiredSignature::from_coin_spends( - &mut allocator, - coin_spends, - &AggSigConstants::new(agg_sig_data), - )?; - - let mut signature = Signature::default(); - for req in required { - let RequiredSignature::Bls(bls_req) = req else { continue }; - signature += &sign(secret_key, bls_req.message()); - } - - Ok(signature) +use indexmap::indexmap; + +#[test] +fn royalty_math() -> anyhow::Result<()> { + let cat = Bytes32::new([1; 32]); + + // Two royalty NFTs are traded for 1000 XCH mojos and 200 CAT mojos + let amounts = OfferAmounts { xch: 1_000, cats: indexmap! { cat => 200 } }; + let trade_prices = calculate_trade_price_amounts(&amounts, 2); + assert_eq!(trade_prices.xch, calculate_nft_trade_price(1_000, 2)); + assert_eq!(trade_prices.cats[&cat], 100); + + // 3% and 5% royalties + let royalties = [ + RoyaltyInfo::new(Bytes32::new([2; 32]), Bytes32::new([3; 32]), 300), + RoyaltyInfo::new(Bytes32::new([4; 32]), Bytes32::new([5; 32]), 500), + ]; + let owed = calculate_royalty_amounts(&trade_prices, &royalties)?; + assert_eq!(owed.xch, calculate_nft_royalty(500, 300) + calculate_nft_royalty(500, 500)); + assert_eq!(owed.xch, 15 + 25); + assert_eq!(owed.cats[&cat], 3 + 5); + + Ok(()) } ``` -## Security Considerations +## Compression -:::warning -Always verify offer terms before accepting: +Offers are shared as bech32m strings with the `offer` prefix, containing a zlib-compressed spend bundle. The compression uses a dictionary of common puzzles (the standard puzzle, CAT, settlement payments, singleton and NFT puzzles), with a two byte version prefix. These functions require the `offer-compression` feature: -- Confirm the assets being offered match expectations -- Verify the requested amounts are acceptable -- Check NFT royalty terms and amounts -- Ensure you understand the full transaction -- Validate with the simulator before mainnet - ::: +| Function | Converts | +| ------------------------------------------------- | ------------------------------------- | +| `encode_offer(&spend_bundle)` | `SpendBundle` to `offer1...` string | +| `decode_offer(&text)` | `offer1...` string to `SpendBundle` | +| `compress_offer(&spend_bundle)` | `SpendBundle` to compressed bytes | +| `decompress_offer(&bytes)` | Compressed bytes to `SpendBundle` | +| `compress_offer_bytes` / `decompress_offer_bytes` | Raw serialized bytes, without parsing | -## API Reference +Decompression supports every version of the format the reference wallet has used, and output is capped at 6 MB (`DriverError::DecompressionTooLarge`), matching chia-blockchain. Encode the result of `offer.to_spend_bundle(&mut ctx)?`, not the maker's input spend bundle, since the offer file needs the requested payments. -For offer-related APIs, see: +## Security considerations -- [Offer](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.Offer.html) -- [OfferCoins](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.OfferCoins.html) -- [RequestedPayments](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.RequestedPayments.html) -- [SettlementLayer](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.SettlementLayer.html) +- **Treat offers from others as untrusted.** Parse them with `Offer::from_spend_bundle`, which rejects puzzle reveals that don't match their coin's puzzle hash (`DriverError::WrongPuzzleHash`). `from_input_spend_bundle` skips this check, so only use it for spends you built yourself. +- **Check what you're getting.** Compare `offered_coins()` and `requested_payments()` (or `arbitrage()`) with what the user agreed to, and check that the coins the maker's spends consume are still unspent on chain. An offer can reference coins that have already been spent, in which case it can't be taken. +- **Old offers are rejected.** Offers using the retired, insecure settlement payments puzzle (`OLD_SETTLEMENT_PAYMENT_HASH`) fail to parse with `DriverError::OldSettlementPayment`. +- **Revocable CATs are a different asset.** A revocable CAT can be clawed back by its issuer, and has a different settlement puzzle hash than a CAT with the same asset id but no revocation layer. Check `asset_info().cat(asset_id)?.hidden_puzzle_hash` before treating an offered CAT as the one you expect. Mixing both kinds under one asset id fails with `DriverError::IncompatibleAssetInfo`. +- **Settlement spends are asserted.** When taking an offer, every payment a settlement coin makes is asserted by one of your conditions spends, so a third party can't strip the settlement spends out of the bundle and claim the coins. Don't set `disable_settlement_assertions` unless something else ties them to your spend. +- **Amounts can overflow.** Offered and requested totals are `u128`. A malicious offer can have totals that don't fit in a coin, which fail with `DriverError::AmountOverflow` (or `InsufficientFunds`) rather than wrapping around. Use `coin_amount` to convert totals. +- **Royalties are enforced on chain.** An NFT's royalty must be paid for an offer involving it to succeed. Use `requested_royalty_amounts` and `offered_royalty_amounts` to show users the real cost. `offered_royalties` fails with `DriverError::MissingAssetInfo` if a requested NFT has no asset info, rather than silently skipping its royalty. +- **Verify option contracts.** An offered option only commits to hashes of its terms. Check it with `OptionInfo::is_backed_by` before paying for it. See [Option Contracts](/sdk/primitives/options). +- **Relate your spends.** Use `Relation::AssertConcurrent` for both sides, so neither the maker's nor the taker's spends can be split apart. diff --git a/docs/sdk/patterns.md b/docs/sdk/patterns.md index 362b846..20bcd87 100644 --- a/docs/sdk/patterns.md +++ b/docs/sdk/patterns.md @@ -1,295 +1,253 @@ --- -slug: /sdk/patterns title: Application Patterns --- # Application Patterns -This page covers common patterns for structuring applications that use the Wallet SDK. +The other pages explain individual pieces of the SDK. This one is about putting them together in an +application: how to organize keys, how to structure transaction building, and the habits that keep +transactions safe. -## SpendContext Lifecycle +## Keep keys indexed by puzzle hash -### Single Transaction Pattern - -For simple operations, create a SpendContext, use it, and discard: +Everything the SDK hands you identifies ownership by **p2 puzzle hash**: `Spends::finish_with_keys` takes +a map from p2 puzzle hash to synthetic key, `SpendableAsset::p2_puzzle_hash` tells you which key a coin +needs, and parsed primitives expose `info.p2_puzzle_hash`. So the most useful structure for a wallet is a +map from each derived puzzle hash to the key behind it. ```rust -fn send_payment(/* params */) -> Result { - let ctx = &mut SpendContext::new(); - - // Build transaction - StandardLayer::new(pk).spend(ctx, coin, conditions)?; - - // Extract, sign, return - let spends = ctx.take(); - sign_and_bundle(spends) +use chia_wallet_sdk::chia::bls::master_to_wallet_unhardened; +use chia_wallet_sdk::chia::puzzle_types::{DeriveSynthetic, Memos}; +use chia_wallet_sdk::prelude::*; +use indexmap::IndexMap; + +/// The keys for the first `count` addresses of a wallet. +struct Wallet { + secret_keys: Vec, + synthetic_keys: IndexMap, } -``` -### Reusable Context Pattern +impl Wallet { + fn new(master_sk: &SecretKey, count: u32) -> Self { + let mut secret_keys = Vec::new(); + let mut synthetic_keys = IndexMap::new(); -For multiple transactions, reuse the context to benefit from puzzle caching: - -```rust -struct TransactionBuilder { - ctx: SpendContext, -} - -impl TransactionBuilder { - fn new() -> Self { - Self { - ctx: SpendContext::new(), + for index in 0..count { + let sk = master_to_wallet_unhardened(master_sk, index).derive_synthetic(); + let pk = sk.public_key(); + synthetic_keys.insert(StandardLayer::new(pk).tree_hash().into(), pk); + secret_keys.push(sk); } + + Self { secret_keys, synthetic_keys } } - fn build_payment(&mut self, /* params */) -> Result> { - // Use self.ctx for building - StandardLayer::new(pk).spend(&mut self.ctx, coin, conditions)?; + fn puzzle_hash(&self, index: usize) -> Bytes32 { + *self.synthetic_keys.get_index(index).unwrap().0 + } - // take() empties the context but preserves puzzle cache - Ok(self.ctx.take()) + fn owns(&self, p2_puzzle_hash: Bytes32) -> bool { + self.synthetic_keys.contains_key(&p2_puzzle_hash) } } -``` -## Batching Transactions +#[test] +fn spend_coins_from_several_addresses() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -### Multiple Independent Spends + let wallet = Wallet::new(&SecretKey::from_seed(&[1; 64]), 5); + let bob = BlsPair::new(1); -When spending multiple coins, batch them in one transaction and link them with `assert_concurrent_spend`: + // Coins received on two different addresses. + let first = sim.new_coin(wallet.puzzle_hash(0), 300); + let second = sim.new_coin(wallet.puzzle_hash(3), 400); -```rust -let ctx = &mut SpendContext::new(); -let coins: Vec<(Coin, PublicKey)> = /* your coins */; + let mut spends = Spends::new(wallet.puzzle_hash(0)); + spends.add(first); + spends.add(second); -// Collect all coin IDs for concurrent spend assertions -let coin_ids: Vec = coins.iter().map(|(c, _)| c.coin_id()).collect(); + let deltas = spends.apply( + ctx, + &[ + Action::send(Id::Xch, bob.puzzle_hash, 600, Memos::None), + Action::fee(50), + ], + )?; -// Spend multiple coins in one transaction -for (i, (coin, pk)) in coins.iter().enumerate() { - let mut conditions = Conditions::new() - .create_coin(destination, coin.amount, Memos::None); + // Each coin is spent with the key for its own puzzle hash. + spends.finish_with_keys(ctx, &deltas, Relation::AssertConcurrent, &wallet.synthetic_keys)?; + sim.spend_coins(ctx.take(), &wallet.secret_keys)?; - // Link to all other coins in the transaction - for (j, other_id) in coin_ids.iter().enumerate() { - if i != j { - conditions = conditions.assert_concurrent_spend(*other_id); - } - } + assert!(wallet.owns(wallet.puzzle_hash(0))); + assert_eq!(sim.unspent_coins(wallet.puzzle_hash(0), false)[0].amount, 50); - StandardLayer::new(*pk).spend(ctx, *coin, conditions)?; + Ok(()) } - -let spends = ctx.take(); ``` -:::warning -Always use `assert_concurrent_spend` to link coins in a multi-spend transaction. Without it, an attacker could extract individual spends from your signed bundle. -::: +Real wallets derive keys in batches and keep deriving while new addresses are being used (a "gap +limit"), and also index hardened keys if they use them. See [Keys & Signing](/sdk/concepts/signing). -### Dependent Spends +## Separate building, signing and submitting -When spends depend on each other (e.g., parent-child), ensure proper ordering: +Building a transaction doesn't need secret keys, only public keys. Keeping the three steps apart makes +it easy to support watch-only wallets, hardware signers, multi-party signing and offline review: -```rust -let ctx = &mut SpendContext::new(); - -// First spend creates a coin -let conditions = Conditions::new() - .create_coin(intermediate_ph, 1000, Memos::None); -StandardLayer::new(pk1).spend(ctx, parent_coin, conditions)?; - -// Calculate the created coin -let child_coin = Coin::new(parent_coin.coin_id(), intermediate_ph, 1000); - -// Second spend uses the created coin (ephemeral spend) -let conditions = Conditions::new() - .create_coin(final_destination, 900, Memos::None) - .reserve_fee(100); -StandardLayer::new(pk2).spend(ctx, child_coin, conditions)?; - -let spends = ctx.take(); -``` - -## Coin Management - -### Coin Selection - -When you have multiple coins, select appropriately: +1. **Build** the coin spends with public keys (the action system or primitives). +2. **Sign**: compute `RequiredSignature::from_coin_spends` for the target network, hand the messages + to whoever holds the keys, and aggregate the signatures that come back. Because every signer signs a + specific `(public key, message)` pair, partial signatures from different parties can be added together + in any order. +3. **Submit** the `SpendBundle` through a [peer](/sdk/networking/peer) or [RPC client](/sdk/networking/rpc). ```rust -fn select_coins( - available: &[Coin], - target_amount: u64, -) -> Vec { - let mut selected = Vec::new(); - let mut total = 0; - - // Simple greedy selection - for coin in available { - if total >= target_amount { - break; +use chia_wallet_sdk::chia::bls::sign; +use chia_wallet_sdk::prelude::*; + +/// Signs the messages that belong to one party's key. Other parties do the same with their keys. +fn partial_signature(required: &[RequiredSignature], sk: &SecretKey) -> Signature { + let pk = sk.public_key(); + let mut signature = Signature::default(); + for required in required { + if let RequiredSignature::Bls(required) = required { + if required.public_key == pk { + signature += &sign(sk, required.message()); + } } - selected.push(*coin); - total += coin.amount; } - - selected -} -``` - -### Change Handling - -Always account for change when the input exceeds the output: - -```rust -fn build_with_change( - ctx: &mut SpendContext, - coin: Coin, - pk: PublicKey, - send_amount: u64, - recipient: Bytes32, - fee: u64, -) -> Result<()> { - let sender_ph = StandardLayer::puzzle_hash(pk); - let change = coin.amount.saturating_sub(send_amount).saturating_sub(fee); - - let mut conditions = Conditions::new() - .create_coin(recipient, send_amount, ctx.hint(recipient)?) - .reserve_fee(fee); - - if change > 0 { - conditions = conditions.create_coin(sender_ph, change, ctx.hint(sender_ph)?); - } - - StandardLayer::new(pk).spend(ctx, coin, conditions)?; - Ok(()) + signature } -``` - -## Error Handling -### Graceful Error Recovery - -```rust -fn try_build_transaction(/* params */) -> Result> { +#[test] +fn two_party_signing() -> anyhow::Result<()> { + let mut sim = Simulator::new(); let ctx = &mut SpendContext::new(); - // Attempt to build - match build_complex_spend(ctx, /* params */) { - Ok(()) => Ok(ctx.take()), - Err(e) => { - // Context can be safely dropped - // No cleanup needed - Err(e) - } - } -} -``` - -## Signing Patterns + let alice = sim.bls(100); + let bob = sim.bls(100); -### Collecting Required Signatures + // Both coins are spent together, and each needs its owner's signature. + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().assert_concurrent_spend(bob.coin.coin_id()).reserve_fee(100), + )?; + StandardLayer::new(bob.pk).spend( + ctx, + bob.coin, + Conditions::new().assert_concurrent_spend(alice.coin.coin_id()), + )?; + let coin_spends = ctx.take(); -```rust -fn sign_spends( - coin_spends: &[CoinSpend], - secret_keys: &[SecretKey], - agg_sig_data: &[u8], -) -> Result { - // Calculate what needs to be signed + // The builder shares the required signatures; each party signs only its own. let required = RequiredSignature::from_coin_spends( - coin_spends, - agg_sig_data, + ctx, + &coin_spends, + &AggSigConstants::from(&*TESTNET11_CONSTANTS), )?; + let signature = partial_signature(&required, &alice.sk) + &partial_signature(&required, &bob.sk); - // Sign each requirement - let mut signatures = Vec::new(); - for req in required { - let sk = find_key_for_pk(&req.public_key, secret_keys)?; - signatures.push(sign(&sk, &req.message)); - } + sim.new_transaction(SpendBundle::new(coin_spends, signature))?; - // Aggregate signatures - Ok(aggregate(&signatures)) + Ok(()) } ``` -### Multi-Party Signing +If the signer is a separate device or person, they shouldn't sign blindly. For vaults, the +[clear signing](/sdk/clear-signing) module parses a transaction into what it actually does, so the signer +can show and check it before signing. -When multiple parties need to sign: +## Link spends that belong together -```rust -// Party 1 builds and partially signs -let spends = build_transaction()?; -let sig1 = sign_my_portion(&spends, &my_keys)?; - -// Serialize and send to Party 2 -let partial = PartialTransaction { spends, signatures: vec![sig1] }; +A spend bundle's coin spends aren't bound to each other by default. Anyone who sees your bundle in the +mempool can drop some of the spends and submit the rest, if the rest are still valid on their own. For +example, they could keep the spend that pays a fee and drop the one that pays you. Always tie the spends +of one transaction together: -// Party 2 adds their signature -let sig2 = sign_my_portion(&partial.spends, &their_keys)?; -partial.signatures.push(sig2); - -// Combine and broadcast -let final_sig = aggregate(&partial.signatures); -let bundle = SpendBundle::new(partial.spends, final_sig); -``` +- With the action system, choose a [`Relation`](/sdk/action-system#relations). + `Relation::AssertConcurrent` makes every spend assert every other spend, and is the right default. +- By hand, add `assert_concurrent_spend(other_coin_id)` (or announcements, as described in + [Conditions](/sdk/concepts/conditions)) to each spend, as in the example above. -## State Tracking +## Spend coins you create in the same transaction -### Tracking Coin State +A coin created by one spend can be spent in the same block, as long as both spends are in the bundle. +The child's `Coin` can be computed before anything is on chain, from the parent coin id, the puzzle hash +and the amount. This is how intermediate coins work: launchers, the eve spends of singletons, and the +intermediate coins the action system creates when it needs a spend to emit conditions. ```rust -struct WalletState { - coins: HashMap, - pending_spends: HashSet, -} +use chia_wallet_sdk::prelude::*; -impl WalletState { - fn mark_spent(&mut self, coin_id: Bytes32) { - self.pending_spends.insert(coin_id); - } +#[test] +fn spend_a_coin_created_in_the_same_transaction() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); - fn confirm_spent(&mut self, coin_id: Bytes32) { - self.coins.remove(&coin_id); - self.pending_spends.remove(&coin_id); - } + let alice = sim.bls(1_000); + let bob = BlsPair::new(1); + let p2 = StandardLayer::new(alice.pk); + + // Alice's coin creates an intermediate coin owned by herself... + p2.spend(ctx, alice.coin, Conditions::new().create_coin(alice.puzzle_hash, 1_000, Memos::None))?; + let intermediate = Coin::new(alice.coin.coin_id(), alice.puzzle_hash, 1_000); + + // ...which is spent in the same bundle. + p2.spend( + ctx, + intermediate, + Conditions::new() + .create_coin(bob.puzzle_hash, 900, Memos::None) + .reserve_fee(100), + )?; - fn available_coins(&self) -> impl Iterator { - self.coins.values() - .filter(|c| !self.pending_spends.contains(&c.coin_id())) - } + sim.spend_coins(ctx.take(), &[alice.sk])?; + assert_eq!(sim.unspent_coins(bob.puzzle_hash, false).len(), 1); + + Ok(()) } ``` -### Tracking NFT/CAT State +Relative timelocks (`assert_seconds_relative`, `assert_height_relative`) can't be satisfied by a coin +created in the same block, since it has no confirmed height or timestamp yet. -For singletons and CATs, track the current coin after each spend: +## Track what you've already spent -```rust -struct NftTracker { - nft: Nft, -} +A coin stays unspent on chain until the block that spends it is farmed, which usually takes under a +minute but can take longer under load. If your application builds a second transaction before the first +confirms, it must not select the same coins again, or one of the two transactions will fail. -impl NftTracker { - fn after_transfer(&mut self, new_nft: Nft) { - self.nft = new_nft; - } +- Keep a set of coin ids used by your pending transactions, and exclude them from coin selection until + their spends confirm or the transaction is dropped. +- Subscribe to coin state updates for your puzzle hashes with the [peer protocol](/sdk/networking/peer), + or poll an [RPC client](/sdk/networking/rpc), to learn when coins are spent and created. +- Treat a transaction as failed if its coins are spent by something else, and as dropped if it hasn't + confirmed after some time and isn't in the mempool any more. Then release its coins. - fn current_coin(&self) -> &Coin { - &self.nft.coin - } -} -``` +The action system can help here too. `Deltas::from_actions` tells you how much of each asset a set of +actions needs before you select coins, so you only reserve what you need. See +[coin selection with deltas](/sdk/action-system#coin-selection-with-deltas). + +## Recognize the coins you receive + +To find out what a coin is, fetch its parent's spend and let the primitives parse it. Check the +parsers in order (CAT, NFT, DID and so on), and treat the coin as XCH if its puzzle hash is one of yours. +The [primitives overview](/sdk/primitives#parsing-from-the-parent-coin-spend) has a complete example. Always +confirm that the parsed p2 puzzle hash is one of yours before you show it as a balance: anyone can send +or hint anything to your address. + +## Estimate fees from cost + +The mempool prioritizes transactions by fee per cost. `spend_bundle_cost` computes the cost of a set of +coin spends locally, so you can multiply it by a fee rate before submitting. See +[estimating cost](/sdk/utilities#estimating-cost) for an example. The SDK's RPC clients don't wrap a fee +estimate endpoint, so get current fee rates from your full node's `get_fee_estimate` RPC or another +source. -## Best Practices +## Test against the simulator first -1. **Link multi-coin spends** - Always use `assert_concurrent_spend` when spending multiple coins -2. **Validate locally first** - Use the simulator before mainnet -3. **Handle change** - Never lose funds to missing change outputs -4. **Use hints** - Include memos for wallet discovery -5. **Batch when possible** - Reduce fees by combining spends -6. **Track state** - Keep your local view synchronized -7. **Reuse SpendContext** - Benefit from puzzle caching -8. **Handle errors gracefully** - SpendContext cleanup is automatic +Everything above can be tested without a network. The [simulator](/sdk/testing) validates spend +bundles the same way a full node does (puzzles, conditions, signatures and timelocks), so most bugs in +transaction building show up as test failures instead of rejected mainnet transactions. The +[peer simulator](/sdk/testing/peer-simulator) covers code that talks to a full node. diff --git a/docs/sdk/primitives/action-layer.md b/docs/sdk/primitives/action-layer.md new file mode 100644 index 0000000..acd8995 --- /dev/null +++ b/docs/sdk/primitives/action-layer.md @@ -0,0 +1,411 @@ +--- +title: Action Layer Singletons +--- + +# Action Layer Singletons + +The **action layer** is a pattern for building on-chain applications as singletons that anyone can +interact with. Instead of a single owner deciding how the singleton is spent, the singleton's inner +puzzle commits to a merkle tree of **actions**. Each spend runs one or more actions, which update the +singleton's **state**, and a **finalizer** recreates the singleton with the new state. Data that doesn't +fit in the state is stored in **slots**: 0-amount coins created by the singleton that commit to a value +and can only be spent by the singleton. + +The SDK includes drivers for several applications built this way: the CATalog and XCHandles registries, +the reward distributor, plus supporting pieces such as the medieval vault, state scheduler, precommit +coins and reserves. All of it is behind the `action-layer` feature, which also enables `chip-0035`: + +```toml +chia-wallet-sdk = { version = "...", features = ["action-layer"] } +``` + +:::note +This is unrelated to the SDK's [action system](/sdk/action-system) (`Action`, `Spends`), which is a +transaction builder. The action layer is an on-chain puzzle pattern. +::: + +## Architecture + +```text +SingletonLayer + ActionLayer { merkle_root, state, finalizer } + action 1 puzzle (revealed with a merkle proof) + action 2 puzzle + ... +``` + +| Concept | SDK type | Description | +| -------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| Action layer | `ActionLayer`, `ActionLayerSolution` | Inner puzzle that runs a list of action spends, each proven to be in the merkle tree of allowed actions | +| State | e.g. `CatalogRegistryState` | Curried into the action layer. Each action receives the current state and returns the new one | +| Finalizer | `Finalizer::Default` / `Finalizer::Reserve` | Recreates the singleton with the new state. The reserve finalizer also manages a reserve coin | +| Actions | `SingletonAction` | Each action is its own puzzle, curried with the singleton's constants | +| Slots | `Slot`, `SlotInfo` | 0-amount coins that store values (for example a sorted linked list of registered CATs) | +| Precommit coin | `PrecommitCoin` | A CAT coin that commits to a value and payment before an action can use it, to prevent front running | +| Reserve | `Reserve` | A CAT coin controlled by the singleton through the finalizer, which holds funds (such as rewards) | + +Because anyone can run actions, an action spend often returns **secure conditions** (usually announcement +assertions). These must be included in some other spend in the same transaction, which ties that spend +to the singleton spend so that the action can't be stripped out or reused. + +### Lifecycle of a spend + +Each application singleton (`CatalogRegistry`, `XchandlesRegistry`, `RewardDistributor`) follows the +same pattern: + +1. Get an action with `registry.new_action::()`. Actions are built from the singleton's + constants, so the SDK can compute their puzzle hashes. +2. Call the action's `spend(...)` method. This creates any coin spends the action needs (precommit + coins, slots, NFT launchers), appends the action spend to `registry.pending_spend`, and returns the + conditions that must be asserted elsewhere. +3. Repeat for more actions. `pending_spend` tracks the latest state, created and spent slots, and logs. +4. Call `registry.finish_spend(ctx)`, which spends the singleton with all pending actions and returns + the child singleton. + +To follow a singleton on chain, use `from_parent_spend` / `from_spend` on its coin spends, and +`from_mempool_item` to apply actions that are pending in the mempool. + +## Applications + +| Application | Types | What it does | +| ------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| CATalog | `CatalogRegistry`, `CatalogRegistryInfo`, `CatalogRegistryState`, `CatalogRegistryConstants` | A registry of CATs. Registering a CAT (by TAIL hash) mints an NFT that holds its metadata (`CatNftMetadata`). Each TAIL can only be registered once, enforced by slots | +| XCHandles | `XchandlesRegistry`, `XchandlesRegistryInfo`, `XchandlesRegistryState`, `XchandlesConstants` | A registry of handles (names) with expiration, priced by a pricing puzzle. Handles can be registered, extended, expired and re-registered, and updated | +| Reward distributor | `RewardDistributor`, `RewardDistributorInfo`, `RewardDistributorState`, `RewardDistributorConstants` | Distributes rewards (a CAT held in a `Reserve`) to entries over epochs. Entries are managed by a manager singleton, NFT stakers or CAT stakers (`RewardDistributorType`) | +| Medieval vault | `MedievalVault`, `MedievalVaultInfo` | A plain m-of-n BLS multisig singleton, used to administer other singletons | +| State scheduler | `StateScheduler`, `StateSchedulerInfo` | A singleton that sends a pre-committed sequence of state updates to another singleton at given times (for example to schedule price changes) | + +Each registry's actions are separate types: + +- CATalog: `CatalogRegisterAction`, `CatalogRefundAction`, `DelegatedStateAction`. +- XCHandles: `XchandlesRegisterAction`, `XchandlesExtendAction`, `XchandlesExpireAction`, + `XchandlesOracleAction`, `XchandlesRefundAction`, `XchandlesInitiateUpdateAction`, + `XchandlesExecuteUpdateAction`, `DelegatedStateAction`. +- Reward distributor: `RewardDistributorAddEntryAction`, `RewardDistributorRemoveEntryAction`, + `RewardDistributorStakeAction`, `RewardDistributorUnstakeAction`, `RewardDistributorAddIncentivesAction`, + `RewardDistributorCommitIncentivesAction`, `RewardDistributorWithdrawIncentivesAction`, + `RewardDistributorNewEpochAction`, `RewardDistributorSyncAction`, `RewardDistributorInitiatePayoutAction`, + `RewardDistributorRefreshAction`. + +`DelegatedStateAction` lets another singleton (the "price singleton", whose launcher id is in the +registry's constants) replace the registry's state by sending it a message. This is how registration +prices are updated. + +### Launching + +Launching an action layer singleton involves several coins (a launcher, the eve spend that creates the +initial slots, and a "security coin" that ties it together), so the SDK provides launch helpers: +`launch_catalog_registry`, `launch_xchandles_registry` and `launch_reward_distributor`. They take an +[offer](/sdk/offers) that provides the funds, and return the new singleton, its initial slots, and the +secret key of a freshly generated security coin that you need to sign with. + +## Example: registering a CAT in a CATalog + +This example (adapted from the SDK's `test_catalog`) launches a CATalog registry, then registers a CAT +in it. Registration is a two-step process: + +1. The registrant creates a **precommit coin**: a CAT coin of the payment asset, holding the + registration price, that commits to the TAIL being registered and the inner puzzle of the NFT that + will be minted for it. It must wait `relative_block_height` blocks, so nobody can see the registration + in the mempool and front run it. +2. Anyone spends the registry with `CatalogRegisterAction`, which spends the precommit coin (paying the + registration fee), inserts the TAIL hash between its neighbors in the sorted list of slots, and mints + the CAT's NFT. + +```rust +use chia_wallet_sdk::chia::puzzle_types::{cat::GenesisByCoinIdTailArgs, singleton::SingletonStruct}; +use chia_wallet_sdk::clvm_traits::clvm_quote; +use chia_wallet_sdk::driver::{ + CatalogPrecommitValue, CatalogRegisterAction, CatalogRegistryConstants, PrecommitCoin, + launch_catalog_registry, sign_standard_transaction, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; + +#[test] +fn register_cat_in_catalog() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + // The launcher id is filled in when the registry is launched. + let constants = CatalogRegistryConstants { + launcher_id: Bytes32::default(), + royalty_address: Bytes32::new([7; 32]), + royalty_basis_points: 100, + precommit_payout_puzzle_hash: Bytes32::new([8; 32]), + relative_block_height: 1, + price_singleton_launcher_id: Bytes32::new([9; 32]), + }; + let registration_price = 2000; + + // Issue the CAT that registrations are paid with. + let minter = sim.bls(10_000); + let minter_p2 = StandardLayer::new(minter.pk); + let (issue_cat, cats) = Cat::single_issuance( + ctx, + minter.coin.coin_id(), + None, + 10_000, + Conditions::new().create_coin(minter.puzzle_hash, 10_000, Memos::None), + )?; + minter_p2.spend(ctx, minter.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[minter.sk.clone()])?; + let payment_cat = cats[0]; + + // The launch is funded by an offer of 1 mojo. + let funder = sim.bls(1); + let offer_spend = StandardLayer::new(funder.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(SETTLEMENT_PAYMENT_HASH.into(), 1, Memos::None), + )?; + let puzzle_reveal = ctx.serialize(&offer_spend.puzzle)?; + let solution = ctx.serialize(&offer_spend.solution)?; + let signature = + sign_standard_transaction(ctx, funder.coin, offer_spend, &funder.sk, &TESTNET11_CONSTANTS)?; + let offer = Offer::from_spend_bundle( + ctx, + &SpendBundle::new( + vec![CoinSpend::new(funder.coin, puzzle_reveal, solution)], + signature, + ), + )?; + + // Launch the registry, which starts with two slots: the left and right ends of the list. + let (_signature, security_sk, mut catalog, slots, _security_coin) = launch_catalog_registry( + ctx, + &offer, + registration_price, + |_ctx, _launcher_id, _security_coin, (constants, payment_asset_id)| { + Ok((Conditions::new(), constants, payment_asset_id)) + }, + &TESTNET11_CONSTANTS, + (constants, payment_cat.info.asset_id), + )?; + sim.spend_coins(ctx.take(), &[funder.sk, security_sk])?; + assert_eq!(catalog.info.state.registration_price, registration_price); + + // Step 1: create a precommit coin for the CAT being registered. + let tail = ctx.curry(GenesisByCoinIdTailArgs::new(Bytes32::new([1; 32])))?; + let tail_hash = ctx.tree_hash(tail); + let eve_nft_inner_puzzle = clvm_quote!(Conditions::new().create_coin( + minter.puzzle_hash, + 1, + Memos::None, + )) + .to_clvm(ctx)?; + let eve_nft_inner_puzzle_hash = ctx.tree_hash(eve_nft_inner_puzzle); + + let value = CatalogPrecommitValue::with_default_cat_maker( + payment_cat.info.asset_id.tree_hash(), + eve_nft_inner_puzzle_hash.into(), + tail, + ); + let precommit_coin = PrecommitCoin::new( + ctx, + payment_cat.coin.coin_id(), + payment_cat.child_lineage_proof(), + payment_cat.info.asset_id, + SingletonStruct::new(catalog.info.constants.launcher_id) + .tree_hash() + .into(), + constants.relative_block_height, + constants.precommit_payout_puzzle_hash, + minter.puzzle_hash, // refund puzzle hash, if the registration can't go through + value, + registration_price, + )?; + + let inner_spend = minter_p2.spend_with_conditions( + ctx, + Conditions::new() + .create_coin(precommit_coin.inner_puzzle_hash, registration_price, Memos::None) + .create_coin(minter.puzzle_hash, 10_000 - registration_price, Memos::None), + )?; + Cat::spend_all(ctx, &[CatSpend::new(payment_cat, inner_spend)])?; + sim.spend_coins(ctx.take(), &[minter.sk])?; + + // Step 2: register. The new TAIL goes between the two end slots. + let mut slots = slots.to_vec(); + slots.sort_unstable_by_key(|slot| slot.info.value); + let (left_slot, right_slot) = (slots[0].clone(), slots[1].clone()); + + let secure_conditions = catalog.new_action::().spend( + ctx, + &mut catalog, + tail_hash.into(), + left_slot, + right_slot, + &precommit_coin, + Spend::new(eve_nft_inner_puzzle, NodePtr::NIL), + )?; + assert_eq!(catalog.pending_spend.spent_slots.len(), 2); + + let (catalog, _signature) = catalog.finish_spend(ctx)?; + + // Another spend in the transaction must assert the secure conditions. Here a coin with a + // quoted conditions puzzle does it; in practice it's one of the registrant's coins. + let puzzle = clvm_quote!(secure_conditions).to_clvm(ctx)?; + let coin = sim.new_coin(ctx.tree_hash(puzzle).into(), 1); + ctx.spend(coin, Spend::new(puzzle, NodePtr::NIL))?; + + sim.spend_coins(ctx.take(), &[])?; + assert!(sim.coin_state(catalog.coin.coin_id()).is_some()); + + Ok(()) +} +``` + +If the precommit coin can't be used (the price changed, the payment CAT is wrong, or the TAIL is +already registered), `CatalogRefundAction` spends it back to its refund puzzle hash instead. + +## Slots + +A slot is a 0-amount coin whose puzzle is curried with the singleton's launcher id, a nonce (which +distinguishes slot types) and the hash of its value. Only the singleton can spend it, by revealing its +own inner puzzle hash. `Slot::new(lineage_proof, SlotInfo::from_value(...))` describes a slot created +by a singleton spend; drivers create these for you (`created_slot_value_to_slot`). + +Registries use slots as a sorted linked list: each `CatalogSlotValue` (or XCHandles handle slot) +records its neighbors, so inserting a new value means spending the two neighboring slots and creating +three new ones. This proves on chain that a value isn't already registered, without the singleton +having to store the whole list. Keep track of the current slots (for example by indexing slot coins +created by the singleton) so you can supply the right neighbors; `actual_neigbors` and `actual_slot` +adjust for slots that were already spent and recreated earlier in the same pending spend. + +## Medieval vault + +`MedievalVault` is a singleton with a simple m-of-n BLS multisig inner puzzle +(`P2MOfNDelegateDirectArgs`). It's used as the administrator of action layer singletons, for example as +a price singleton or a reward distributor manager. Its info can be recovered from the launcher's +`MedievalVaultHint` and from the memos of rekey spends. + +```rust +use chia_wallet_sdk::driver::{MedievalVault, MedievalVaultHint, MedievalVaultInfo}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn medieval_vault_rekey() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let funder = sim.bls(1); + let [alice, bob] = BlsPair::range(); + + // Launch a 1-of-2 vault. The hint lets wallets recover its configuration. + let launcher = Launcher::new(funder.coin.coin_id(), 1); + let launcher_id = launcher.coin().coin_id(); + let info = MedievalVaultInfo::new(launcher_id, 1, vec![alice.pk, bob.pk]); + let (conditions, _) = launcher.spend(ctx, info.inner_puzzle_hash().into(), info.to_hint())?; + StandardLayer::new(funder.pk).spend(ctx, funder.coin, conditions)?; + sim.spend_coins(ctx.take(), &[funder.sk])?; + + let launcher_spend = sim.coin_spend(launcher_id).unwrap(); + let vault = MedievalVault::from_parent_spend(ctx, &launcher_spend)?.unwrap(); + assert_eq!(vault.info, info); + + // Rekey to 2-of-2, signed by Alice alone. + let memos = ctx.alloc(&MedievalVaultHint { + my_launcher_id: launcher_id, + m: 2, + public_key_list: vec![alice.pk, bob.pk], + })?; + let new_info = MedievalVaultInfo::new(launcher_id, 2, vec![alice.pk, bob.pk]); + let conditions = + Conditions::new().create_coin(new_info.inner_puzzle_hash().into(), 1, Memos::Some(memos)); + vault + .clone() + .spend(ctx, &[alice.pk], conditions, TESTNET11_CONSTANTS.genesis_challenge)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // The child is parsed from the rekey spend. + let vault_spend = sim.coin_spend(vault.coin.coin_id()).unwrap(); + let vault = MedievalVault::from_parent_spend(ctx, &vault_spend)?.unwrap(); + assert_eq!(vault.info, new_info); + + Ok(()) +} +``` + +`MedievalVault::spend` makes the delegated puzzle non-replayable by asserting the coin id and the +network's genesis challenge (`delegated_conditions`), so pass the genesis challenge of the network you +are on. `spend_sunsafe` skips that, and `delegated_puzzle_for_rekey` / +`delegated_puzzle_for_flexible_send_message` build delegated puzzles for signers that sign offline. + +## State scheduler + +A `StateScheduler` is a singleton launched with a strictly increasing list of `(timestamp, state)` +pairs (`StateSchedulerInfo::state_schedule`) and a receiver singleton. Each spend asserts that the next +timestamp has passed and sends the next state to the receiver as a message, advancing its `generation`. +After the last state, the singleton becomes `final_puzzle_hash`. Pair it with a registry's +`DelegatedStateAction` (using the scheduler as the price singleton) to schedule price changes in +advance. `StateScheduler::from_launcher_spend` parses one from its launcher spend, and `child()` +computes the next generation. + +## Supporting primitives + +- **`PrecommitCoin`**: a CAT coin locked by the `PrecommitLayer`. It commits to a value (such as + `CatalogPrecommitValue` or `XchandlesPrecommitValue`), a payout puzzle hash, a refund puzzle hash and + the controlling singleton, and must be at least `relative_block_height` blocks old before it can be + used. The singleton spends it in either register or refund mode. +- **`Reserve`**: a CAT coin locked by `P2DelegatedBySingletonLayer`, spent by the singleton through the + reserve finalizer. The reward distributor keeps its rewards here; `Reserveful` is implemented by + states that track a reserve amount. +- **`UniquenessPrelauncher`**: a coin whose puzzle hash commits to a value and which creates a + singleton launcher. Since there's only one possible prelauncher coin per parent and value, it + guarantees that only one launcher (and so one NFT) exists per registration. +- **`CatMaker`** and `DefaultCatMakerArgs`: small puzzles that compute a CAT puzzle hash from an inner + puzzle hash, so actions can verify payments in a CAT chosen by the registry's state. +- **`P2ParentCoin`**: a coin (XCH or CAT) that can be spent by whoever controls its parent's inner + puzzle. Its solution reveals the parent's inner puzzle with a new solution, the puzzle checks it + against the parent coin id, and the conditions it outputs become the coin's conditions. + +```rust +use chia_wallet_sdk::driver::P2ParentCoin; +use chia_wallet_sdk::prelude::*; + +#[test] +fn p2_parent_coin() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1000); + let alice_p2 = StandardLayer::new(alice.pk); + + // Alice creates a p2 parent coin. + let conditions = + Conditions::new().create_coin(P2ParentCoin::puzzle_hash(None).into(), 1000, Memos::None); + alice_p2.spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + let parent_spend = sim.coin_spend(alice.coin.coin_id()).unwrap(); + let puzzle = ctx.alloc(&parent_spend.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + let solution = ctx.alloc(&parent_spend.solution)?; + let (coin, _memos) = P2ParentCoin::parse_child(ctx, alice.coin, puzzle, solution)?.unwrap(); + + // Spending it requires Alice's puzzle (and so Alice's signature) again. + let bob = Bytes32::new([1; 32]); + let delegated_spend = alice_p2.spend_with_conditions( + ctx, + Conditions::new().create_coin(bob, 1000, Memos::None), + )?; + coin.spend(ctx, delegated_spend, ())?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + assert_eq!(sim.unspent_coins(bob, false).len(), 1); + + Ok(()) +} +``` + +## Gotchas + +- The SDK doesn't ship production constants for these registries yet (`CatalogRegistryConstants::get` + panics with `todo!`). Construct the constants for the deployment you're targeting yourself. +- Action spends add coin spends to the `SpendContext` immediately, but the singleton itself is only spent + by `finish_spend`. Don't forget to call it, and don't run two pending spends of the same singleton + at once. +- Always include the returned secure conditions in another spend of the same transaction. +- Keep your slot index up to date. Using a slot that was already spent (or the wrong neighbors) makes + the spend invalid. +- Precommit coins can't be used until `relative_block_height` blocks after they're created. diff --git a/docs/sdk/primitives/bulletin.md b/docs/sdk/primitives/bulletin.md new file mode 100644 index 0000000..1a58925 --- /dev/null +++ b/docs/sdk/primitives/bulletin.md @@ -0,0 +1,95 @@ +--- +title: Bulletins +--- + +# Bulletins + +A **bulletin** is a way to publish short, structured messages on chain. Each message is a +`BulletinMessage { topic, content }` (two strings), emitted as a `REMARK` condition by a special +0-amount coin. Anyone indexing the blockchain can find bulletin coins by their puzzle and read their +messages, and the messages are attributable to whoever controls the bulletin's inner puzzle. + +Bulletins are ephemeral: the parent creates the 0-amount bulletin coin and the bulletin is spent in the +same transaction, so nothing is left behind except the messages in the spend. + +The types are `Bulletin`, `BulletinMessage` and `BulletinLayer`, all in `chia_wallet_sdk::driver` (no +feature flag). + +## How it works + +The bulletin's puzzle is the MIPS index wrapper (see [Vaults & MIPS](/sdk/primitives/vaults)) around +the publisher's own puzzle, with the fixed nonce `("b" . ())` (`BulletinNonce`). This doesn't change how +the inner puzzle is spent, it only gives the bulletin coin a different puzzle hash from the publisher's +normal coins, so indexers can tell bulletins apart from regular XCH. The `hidden_puzzle_hash` field of +`Bulletin` is the hash of that inner puzzle. + +To spend the bulletin, the inner puzzle outputs one `REMARK` per message. `Bulletin::conditions` +builds them, and `Bulletin::parse` reads them back from a spend. + +## Publishing and reading a bulletin + +```rust +use chia_wallet_sdk::driver::{Bulletin, BulletinMessage}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn publish_bulletin() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(0); + let p2 = StandardLayer::new(alice.pk); + + // The bulletin is created by Alice's coin and wraps Alice's own puzzle. + let (parent_conditions, bulletin) = Bulletin::create( + alice.coin.coin_id(), + alice.puzzle_hash, + vec![BulletinMessage::new( + "animals/rabbit/vienna-blue".to_string(), + "The Vienna Blue rabbit breed originally comes from Austria.".to_string(), + )], + )?; + p2.spend(ctx, alice.coin, parent_conditions)?; + + // Spend the bulletin with Alice's puzzle, outputting the messages as remarks. + let conditions = bulletin.conditions(ctx)?; + let inner_spend = p2.spend_with_conditions(ctx, conditions)?; + bulletin.spend(ctx, inner_spend)?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Anyone can parse it from the coin spend. + let coin_spend = sim.coin_spend(bulletin.coin.coin_id()).unwrap(); + let puzzle = ctx.alloc(&coin_spend.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + let solution = ctx.alloc(&coin_spend.solution)?; + + let (parsed, _inner_puzzle, _inner_solution) = + Bulletin::parse(ctx, coin_spend.coin, puzzle, solution)?.expect("not a bulletin"); + + assert_eq!(parsed, bulletin); + assert_eq!(parsed.messages[0].topic, "animals/rabbit/vienna-blue"); + assert_eq!(parsed.hidden_puzzle_hash, alice.puzzle_hash); + + Ok(()) +} +``` + +`Bulletin::parse` returns `None` if the puzzle isn't a bulletin. Along with the `Bulletin`, it returns +the inner puzzle and solution so you can inspect the publisher's spend further. `REMARK` conditions that +don't decode as a `BulletinMessage` are ignored. + +## Publishing from a vault + +The bulletin's inner puzzle can be any p2 puzzle, including a vault's `P2Singleton`. In that case the +bulletin spend's delegated conditions are the remarks, and the vault authorizes the bulletin coin with a +message like any other coin it owns. [Clear signing](/sdk/clear-signing) recognizes this and reports the +bulletin (with its messages) as a `ParsedAsset::Bulletin` in the transaction summary. + +## Gotchas + +- `Bulletin::create` only returns the `CREATE_COIN` for the parent; you still have to spend the + bulletin coin in the same transaction, or a 0-amount coin is left behind with no messages. +- Topics and contents are arbitrary strings. Nothing on chain enforces a topic format, so agree on a + convention with whoever reads them. +- Messages are public and permanent. Remarks add to the transaction's cost, so keep them short. diff --git a/docs/sdk/primitives/cat.md b/docs/sdk/primitives/cat.md index 1798a38..c3cb858 100644 --- a/docs/sdk/primitives/cat.md +++ b/docs/sdk/primitives/cat.md @@ -1,658 +1,580 @@ --- -slug: /sdk/primitives/cat title: CAT --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +# CAT -# CAT (Chia Asset Tokens) +CATs (Chia Asset Tokens) are fungible tokens. A CAT coin is an ordinary coin whose puzzle is the CAT +outer puzzle wrapped around an inner ("p2") puzzle, usually the [standard puzzle](/sdk/primitives/standard) +of the owner. The outer puzzle guarantees that the total amount of a CAT can only change when its +**TAIL** (Token and Asset Issuance Limitations) program allows it. The tree hash of the TAIL is the +CAT's **asset ID**. -CATs (Chia Asset Tokens) are fungible tokens on the Chia blockchain. Each CAT has a unique asset ID derived from its TAIL (Token and Asset Issuance Limiter) program, which controls how the token can be minted. +The SDK represents a spendable CAT coin with the `Cat` primitive. This page covers issuing CATs, +spending one or many CAT coins, lineage, parsing CATs from the blockchain, melting, and revocable CATs. +For background on the puzzle itself see [CATs on chialisp.com](https://chialisp.com/cats). -## Overview - -CATs work by wrapping an inner puzzle (typically a standard p2 puzzle) with the CAT layer. The CAT layer: - -- Enforces that the total CAT amount is preserved across spends (no creation/destruction) -- Identifies the token by its asset ID -- Requires lineage proofs to verify the CAT's history - -## Key Types - -| Type | Description | -| -------------- | --------------------------------------------------- | -| `Cat` | A CAT coin with its info and optional lineage proof | -| `CatInfo` | Asset ID and inner puzzle hash | -| `CatSpend` | Combines a CAT with an inner spend | -| `LineageProof` | Proof of the CAT's parent for validation | - -## Issuing CATs +:::tip +The [Action System](/sdk/action-system) handles CAT issuance, sends, change, melting and fees with +`Action::single_issue_cat`, `Action::issue_cat`, `Action::send`, `Action::run_tail` and `Action::burn`, +including multi-asset transactions. Use it unless you need the control the low-level API on this page +gives you. +::: -The simplest way to issue a new CAT is using the single-issuance TAIL (genesis by coin ID): +## The `Cat` type - - +```rust ignore +pub struct Cat { + pub coin: Coin, + pub lineage_proof: Option, + pub info: CatInfo, +} -```rust -use chia_wallet_sdk::prelude::*; +pub struct CatInfo { + pub asset_id: Bytes32, + pub hidden_puzzle_hash: Option, // Some for revocable CATs + pub p2_puzzle_hash: Bytes32, // the owner's puzzle hash +} +``` -let ctx = &mut SpendContext::new(); +- `info` is everything needed to construct the outer puzzle. `CatInfo::puzzle_hash` computes the full + puzzle hash, which must equal `coin.puzzle_hash`. +- `lineage_proof` proves that the coin's parent was also a CAT of the same asset ID. It is only `None` + for an "eve" coin whose spend runs the TAIL. +- The p2 puzzle itself is not stored. You provide its spend each time you spend the CAT, so any p2 + puzzle (standard, vault, clawback, custom) works. -// The p2 layer for ownership -let p2 = StandardLayer::new(public_key); +A `Cat` together with knowledge of how to spend its `p2_puzzle_hash` is everything you need to store in +a database to spend the coin later. -// Create hint memos for coin discovery -let memos = ctx.hint(puzzle_hash)?; +## Issuing a CAT -// Conditions for the newly created CAT coins -let conditions = Conditions::new() - .create_coin(puzzle_hash, 1_000, memos); +Issuance happens in two steps in the same transaction: a parent coin creates an **eve** CAT coin, and +the eve coin is spent immediately with a `RUN_CAT_TAIL` condition that reveals the TAIL. The issue +helpers do both. They add the eve spend to the context, and return the conditions the parent must output +plus the resulting CAT coins. -// Issue the CAT - this returns: -// - issue_cat: Conditions to add to the parent spend -// - cats: Vector of created Cat objects -let (issue_cat, cats) = Cat::issue_with_coin( - ctx, - parent_coin_id, // The coin ID that will be spent to issue - 1_000, // Total amount to issue - conditions, // Output conditions -)?; +The eve coin's outputs are the `extra_conditions` you pass in. They must create exactly `amount` worth of +coins (the TAIL is checked against the delta), and those coins become the issued CATs. -// Spend the parent coin with the issuance conditions -p2.spend(ctx, parent_coin, issue_cat)?; +### Single issuance -// The asset ID is derived from the parent coin ID -let asset_id = cats[0].info.asset_id; -``` +`Cat::single_issuance` uses the `genesis_by_coin_id` TAIL, curried with the ID of the parent coin. Since a +coin can only be spent once, the supply is fixed forever after this transaction. - - +```rust +use chia_wallet_sdk::chia::puzzle_types::cat::GenesisByCoinIdTailArgs; +use chia_wallet_sdk::prelude::*; -```typescript -import { Clvm, Coin, CatInfo, Cat, CatSpend, Simulator } from "chia-wallet-sdk"; +#[test] +fn single_issuance() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -const clvm = new Clvm(); -const sim = new Simulator(); -const alice = sim.bls(1000n); + let alice = sim.bls(1_000); + let alice_p2 = StandardLayer::new(alice.pk); -// Create a simple TAIL (genesis by coin ID uses nil TAIL for single issuance) -const tail = clvm.nil(); -const assetId = tail.treeHash(); + let memos = ctx.hint(alice.puzzle_hash)?; + let (issue_cat, cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, // not revocable + 1_000, + Conditions::new().create_coin(alice.puzzle_hash, 1_000, memos), + )?; -// Create CAT info with the asset ID and inner puzzle hash -const catInfo = new CatInfo(assetId, null, alice.puzzleHash); + // The parent (genesis) coin creates the eve CAT. + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; -// Issue the CAT by spending the parent coin -clvm.spendStandardCoin( - alice.coin, - alice.pk, - clvm.delegatedSpend([clvm.createCoin(catInfo.puzzleHash(), 1000n)]), -); + let cat = cats[0]; + assert_eq!( + cat.info.asset_id, + GenesisByCoinIdTailArgs::curry_tree_hash(alice.coin.coin_id()).into() + ); + assert_eq!(cat.info.p2_puzzle_hash, alice.puzzle_hash); + assert!(sim.coin_state(cat.coin.coin_id()).is_some()); -// Create the eve CAT (first CAT coin) -const eveCat = new Cat( - new Coin(alice.coin.coinId(), catInfo.puzzleHash(), 1000n), - null, // No lineage proof for eve - catInfo, -); + Ok(()) +} ``` - - +You can split the supply among several outputs, or send it straight to other people, by adding more +`create_coin` conditions to `extra_conditions`. -```python -from chia_wallet_sdk import Clvm, Coin, CatInfo, Cat, CatSpend, Simulator +### Multi issuance -clvm = Clvm() -sim = Simulator() -alice = sim.bls(1000) +`Cat::multi_issuance` uses the `everything_with_signature` TAIL, curried with a public key. The TAIL +allows any supply change that is signed by that key, so the issuer can mint more later (or melt) and the +asset ID stays the same because it only depends on the key. -# Create a simple TAIL (genesis by coin ID uses nil TAIL for single issuance) -tail = clvm.nil() -asset_id = tail.tree_hash() +```rust +use chia_wallet_sdk::chia::puzzle_types::cat::EverythingWithSignatureTailArgs; +use chia_wallet_sdk::prelude::*; -# Create CAT info with the asset ID and inner puzzle hash -cat_info = CatInfo(asset_id, None, alice.puzzle_hash) +#[test] +fn multi_issuance() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -# Issue the CAT by spending the parent coin -clvm.spend_standard_coin( - alice.coin, - alice.pk, - clvm.delegated_spend([clvm.create_coin(cat_info.puzzle_hash(), 1000)]) -) + let alice = sim.bls(100); + let alice_p2 = StandardLayer::new(alice.pk); + let memos = ctx.hint(alice.puzzle_hash)?; -# Create the eve CAT (first CAT coin) -eve_cat = Cat( - Coin(alice.coin.coin_id(), cat_info.puzzle_hash(), 1000), - None, # No lineage proof for eve - cat_info -) -``` + let (issue_cat, first) = Cat::multi_issuance( + ctx, + alice.coin.coin_id(), + alice.pk, + None, + 100, + Conditions::new().create_coin(alice.puzzle_hash, 100, memos), + )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; - - + // Later, issue more of the same asset from a different XCH coin. + let coin = sim.new_coin(alice.puzzle_hash, 50); + let (issue_cat, second) = Cat::multi_issuance( + ctx, + coin.coin_id(), + alice.pk, + None, + 50, + Conditions::new().create_coin(alice.puzzle_hash, 50, memos), + )?; + alice_p2.spend(ctx, coin, issue_cat)?; + // The TAIL requires a signature from `alice.pk`, provided here by the simulator. + sim.spend_coins(ctx.take(), &[alice.sk])?; -:::info -The asset ID for single-issuance CATs is deterministically derived from the parent coin ID. This means you can calculate the asset ID before issuing. -::: + let asset_id: Bytes32 = EverythingWithSignatureTailArgs::curry_tree_hash(alice.pk).into(); + assert_eq!(first[0].info.asset_id, asset_id); + assert_eq!(second[0].info.asset_id, asset_id); -## Spending CATs + Ok(()) +} +``` -CAT spends require creating a `CatSpend` that combines the CAT with its inner puzzle spend: +### Custom TAILs - - +Both helpers are thin wrappers around `Cat::issue`, which takes a `RunCatTail` condition with any TAIL +program and solution. The asset ID is the tree hash of the program you pass: ```rust +use chia_wallet_sdk::chia::puzzle_types::cat::EverythingWithSignatureTailArgs; use chia_wallet_sdk::prelude::*; -let ctx = &mut SpendContext::new(); +#[test] +fn issue_with_custom_tail() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -// Setup -let p2 = StandardLayer::new(public_key); -let memos = ctx.hint(recipient_puzzle_hash)?; + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); -// Create the inner spend (what the standard layer would do) -let inner_spend = p2.spend_with_conditions( - ctx, - Conditions::new().create_coin(recipient_puzzle_hash, 1_000, memos), -)?; + // Any curried TAIL works here. This one is equivalent to `multi_issuance`. + let tail = ctx.curry(EverythingWithSignatureTailArgs::new(alice.pk))?; + let asset_id: Bytes32 = ctx.tree_hash(tail).into(); -// Wrap it in a CatSpend -let cat_spends = [CatSpend::new(cat, inner_spend)]; + let memos = ctx.hint(alice.puzzle_hash)?; + let (issue_cat, cats) = Cat::issue( + ctx, + alice.coin.coin_id(), + None, + 1, + RunCatTail::new(tail, NodePtr::NIL), // TAIL program and its solution + Conditions::new().create_coin(alice.puzzle_hash, 1, memos), + )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; -// Execute all CAT spends -Cat::spend_all(ctx, &cat_spends)?; + assert_eq!(cats[0].info.asset_id, asset_id); -let coin_spends = ctx.take(); + Ok(()) +} ``` - - +If you write your own TAIL in Chialisp or Rue, compile it with `compile_chialisp` or `compile_rue` (see +[CLVM Values](/sdk/concepts/clvm)) and pass the resulting program. The TAIL decides when the supply may +change; the SDK doesn't validate that ahead of time, so test issuance and melting with the +[Simulator](/sdk/testing). -```typescript -import { Clvm, CatSpend } from "chia-wallet-sdk"; - -const clvm = new Clvm(); +## Spending CATs -// Create the inner spend with conditions -const innerSpend = clvm.standardSpend( - publicKey, - clvm.delegatedSpend([ - clvm.createCoin( - recipientPuzzleHash, - 1000n, - clvm.alloc([recipientPuzzleHash]), - ), - ]), -); +A CAT spend wraps a p2 spend. Build the p2 spend (for the standard puzzle, with +`StandardLayer::spend_with_conditions`), pair it with the `Cat` in a `CatSpend`, and pass every +`CatSpend` of that asset to `Cat::spend_all`: -// Wrap it in a CatSpend and execute -clvm.spendCats([new CatSpend(cat, innerSpend)]); +```rust +use chia_wallet_sdk::prelude::*; -const coinSpends = clvm.coinSpends(); -``` +#[test] +fn send_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); - - + let alice = sim.bls(1_000); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); -```python -from chia_wallet_sdk import Clvm, CatSpend + let alice_memos = ctx.hint(alice.puzzle_hash)?; + let (issue_cat, cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 1_000, + Conditions::new().create_coin(alice.puzzle_hash, 1_000, alice_memos), + )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; -clvm = Clvm() + // Send 250 to Bob and keep 750 as change. The p2 conditions use the + // recipient's p2 puzzle hash; the CAT layer wraps the outputs for you. + let bob_memos = ctx.hint(bob.puzzle_hash)?; + let p2_spend = alice_p2.spend_with_conditions( + ctx, + Conditions::new() + .create_coin(bob.puzzle_hash, 250, bob_memos) + .create_coin(alice.puzzle_hash, 750, alice_memos), + )?; -# Create the inner spend with conditions -inner_spend = clvm.standard_spend( - public_key, - clvm.delegated_spend([ - clvm.create_coin(recipient_puzzle_hash, 1000, clvm.alloc([recipient_puzzle_hash])), - ]) -) + let children = Cat::spend_all(ctx, &[CatSpend::new(cats[0], p2_spend)])?; + sim.spend_coins(ctx.take(), &[alice.sk])?; -# Wrap it in a CatSpend and execute -clvm.spend_cats([CatSpend(cat, inner_spend)]) + // `spend_all` returns the child CATs, ready to be spent again. + assert_eq!(children[0].info.p2_puzzle_hash, bob.puzzle_hash); + assert_eq!(children[0].coin.amount, 250); + assert_eq!(children[1].info.p2_puzzle_hash, alice.puzzle_hash); -coin_spends = clvm.coin_spends() + Ok(()) +} ``` - - - -### Why `spend_all`? +Inside a CAT, `CREATE_COIN` conditions refer to the **inner** puzzle hash. The CAT layer rewrites each +one to the full CAT puzzle hash of the same asset, so you send to Bob's normal address. Hint outputs with +the p2 puzzle hash so Bob's wallet can find them. -CAT spends must be validated together because the CAT layer verifies that the total amount in equals the total amount out. The `Cat::spend_all` function handles: +### Spending many CAT coins at once -- Linking CAT spends via announcements -- Ensuring amount conservation -- Validating lineage proofs - -## Computing Child Coins - -After spending a CAT, you can compute the child CAT: - - - +All CAT coins of the same asset in a transaction must be spent together in a single `spend_all` call. +The CAT puzzle links them in a ring of announcements and checks that the sum of inputs equals the sum of +outputs across the ring (unless the TAIL runs). That means one coin can create outputs worth more than +itself as long as another coin in the ring makes up the difference. `spend_all` calculates the ring, +subtotals and proofs, so you only describe the outputs. ```rust -// After spending, compute the new CAT -let child_cat = cat.child(recipient_puzzle_hash, 1_000); - -// Access the underlying coin -let child_coin = child_cat.coin; -``` +use chia_wallet_sdk::prelude::*; - - +#[test] +fn combine_cats_and_pay_fee() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -```typescript -// After spending, compute the new CAT -const childCat = cat.child(recipientPuzzleHash, 1000n); + let alice = sim.bls(300); + let alice_p2 = StandardLayer::new(alice.pk); + let memos = ctx.hint(alice.puzzle_hash)?; -// Access the underlying coin -const childCoin = childCat.coin; -``` + // Issue three separate CAT coins. + let (issue_cat, cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 300, + Conditions::new() + .create_coin(alice.puzzle_hash, 100, memos) + .create_coin(alice.puzzle_hash, 150, memos) + .create_coin(alice.puzzle_hash, 50, memos), + )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // An XCH coin to pay the transaction fee. + let fee_coin = sim.new_coin(alice.puzzle_hash, 10); + + // The first coin creates a single combined output, the rest create nothing. + let mut cat_spends = Vec::new(); + for (i, &cat) in cats.iter().enumerate() { + let mut conditions = Conditions::new(); + if i == 0 { + conditions = conditions + .create_coin(alice.puzzle_hash, 300, memos) + .assert_concurrent_spend(fee_coin.coin_id()); + } + cat_spends.push(CatSpend::new(cat, alice_p2.spend_with_conditions(ctx, conditions)?)); + } + let children = Cat::spend_all(ctx, &cat_spends)?; + + // XCH and CAT spends are separate rings, so link them explicitly. + alice_p2.spend( + ctx, + fee_coin, + Conditions::new() + .reserve_fee(10) + .assert_concurrent_spend(cats[0].coin.coin_id()), + )?; - - + sim.spend_coins(ctx.take(), &[alice.sk])?; -```python -# After spending, compute the new CAT -child_cat = cat.child(recipient_puzzle_hash, 1000) + assert_eq!(children.len(), 1); + assert_eq!(children[0].coin.amount, 300); -# Access the underlying coin -child_coin = child_cat.coin + Ok(()) +} ``` - - +Fees are always paid in XCH, so a CAT transaction that pays a fee also spends at least one XCH coin. -## Multi-Input CAT Spends +`CatSpend` doesn't care what the p2 puzzle is. You can mix coins owned by the standard puzzle, vaults or +custom puzzles in the same ring, as long as you provide a valid `Spend` for each. -When spending multiple CATs of the same asset type together: +### Lower-level spending - - +`Cat::spend` spends a single coin with an explicit `SingleCatSpend` (previous coin ID, next coin proof, +subtotal and extra delta). You rarely need it; `spend_all` calls it for each coin after computing the +ring. If you want to inspect or build the outer layers yourself, `CatInfo::construct_puzzle` and the +`CatLayer` / `RevocationLayer` types are described in [Layers & Puzzles](/sdk/concepts/layers). -```rust -let ctx = &mut SpendContext::new(); -let p2 = StandardLayer::new(public_key); - -// Build spends for multiple CAT coins -let cat_spends = [ - CatSpend::new( - cat1, - p2.spend_with_conditions(ctx, Conditions::new())?, - ), - CatSpend::new( - cat2, - p2.spend_with_conditions( - ctx, - Conditions::new() - .create_coin(recipient, combined_amount, ctx.hint(recipient)?), - )?, - ), -]; - -Cat::spend_all(ctx, &cat_spends)?; -``` +## Lineage proofs - - - -```typescript -// Build spends for multiple CAT coins -clvm.spendCats([ - new CatSpend(cat1, clvm.standardSpend(publicKey, clvm.delegatedSpend([]))), - new CatSpend( - cat2, - clvm.standardSpend( - publicKey, - clvm.delegatedSpend([ - clvm.createCoin(recipient, combinedAmount, clvm.alloc([recipient])), - ]), - ), - ), -]); -``` +The CAT puzzle needs to know that its parent was a CAT of the same asset ID, otherwise anyone could +create a coin with the CAT puzzle hash out of thin air. The `LineageProof` contains the parent's parent +coin ID, the parent's **inner** puzzle hash and the parent's amount, from which the puzzle recomputes the +parent coin ID and checks it against its own parent. - - - -```python -# Build spends for multiple CAT coins -clvm.spend_cats([ - CatSpend(cat1, clvm.standard_spend(public_key, clvm.delegated_spend([]))), - CatSpend( - cat2, - clvm.standard_spend( - public_key, - clvm.delegated_spend([ - clvm.create_coin(recipient, combined_amount, clvm.alloc([recipient])), - ]) - ) - ), -]) -``` +You can compute lineage without looking anything up when you know the parent `Cat`: - - +- `Cat::child_lineage_proof` returns the proof for any child of a CAT. +- `Cat::child(p2_puzzle_hash, amount)` returns the full child `Cat` (same asset and revocation layer). +- `Cat::spend_all` returns the children it creates, with lineage filled in. -## Paying Fees +For coins you receive from someone else, you have to look at the parent spend instead. -CATs cannot pay transaction fees directly. To pay fees when spending CATs, you must include an XCH spend in the same transaction and use `assert_concurrent_spend` to link them together: +## Parsing CATs from the blockchain - - +When your wallet sees a hinted coin (for example from a peer's puzzle hash subscription), you only have +the `Coin`. To spend it you need its `CatInfo` and lineage proof, which come from the parent coin's spend. +Fetch the parent's puzzle reveal and solution, then call `Cat::parse_children`, which runs the parent's p2 +spend and returns every CAT child it created. Pick the one that matches your coin: ```rust use chia_wallet_sdk::prelude::*; -let ctx = &mut SpendContext::new(); -let p2 = StandardLayer::new(public_key); - -// Spend the CAT -let memos = ctx.hint(recipient_puzzle_hash)?; -let inner_spend = p2.spend_with_conditions( - ctx, - Conditions::new() - .create_coin(recipient_puzzle_hash, cat.coin.amount, memos) - .assert_concurrent_spend(xch_coin.coin_id()), // Link to XCH spend -)?; - -Cat::spend_all(ctx, &[CatSpend::new(cat, inner_spend)])?; - -// Spend XCH to pay the fee -let fee = 100_000_000; // 0.0001 XCH -let change = xch_coin.amount - fee; - -let mut xch_conditions = Conditions::new() - .reserve_fee(fee) - .assert_concurrent_spend(cat.coin.coin_id()); // Link to CAT spend - -if change > 0 { - xch_conditions = xch_conditions.create_coin( - my_puzzle_hash, - change, - ctx.hint(my_puzzle_hash)?, - ); -} +#[test] +fn parse_received_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -p2.spend(ctx, xch_coin, xch_conditions)?; + let alice = sim.bls(10); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); -let coin_spends = ctx.take(); -``` + let memos = ctx.hint(bob.puzzle_hash)?; + let (issue_cat, cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 10, + Conditions::new().create_coin(bob.puzzle_hash, 10, memos), + )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob only knows the coin (e.g. from `sim.hinted_coins` or a peer subscription). + let coin = sim.coin_state(cats[0].coin.coin_id()).unwrap().coin; + + // Look up the parent spend. With a real node, use the peer or RPC client. + let parent_spend = sim.coin_spend(coin.parent_coin_info).unwrap(); + let parent_puzzle = ctx.alloc(&parent_spend.puzzle_reveal)?; + let parent_puzzle = Puzzle::parse(ctx, parent_puzzle); + let parent_solution = ctx.alloc(&parent_spend.solution)?; + + let children = Cat::parse_children(ctx, parent_spend.coin, parent_puzzle, parent_solution)? + .expect("parent is not a CAT"); + let cat = children + .into_iter() + .find(|child| child.coin == coin) + .expect("coin not found among the children"); + + assert_eq!(cat, cats[0]); + + // Bob can now spend it. + let bob_p2 = StandardLayer::new(bob.pk); + let p2_spend = bob_p2.spend_with_conditions( + ctx, + Conditions::new().create_coin(bob.puzzle_hash, 10, memos), + )?; + Cat::spend_all(ctx, &[CatSpend::new(cat, p2_spend)])?; + sim.spend_coins(ctx.take(), &[bob.sk])?; - - - -```typescript -const clvm = new Clvm(); - -// Spend the CAT (linked to XCH spend) -clvm.spendCats([ - new CatSpend( - cat, - clvm.standardSpend( - publicKey, - clvm.delegatedSpend([ - clvm.createCoin( - recipientPuzzleHash, - cat.coin.amount, - clvm.alloc([recipientPuzzleHash]), - ), - clvm.assertConcurrentSpend(xchCoin.coinId()), // Link to XCH spend - ]), - ), - ), -]); - -// Spend XCH to pay the fee -const fee = 100_000_000n; // 0.0001 XCH -const change = xchCoin.amount - fee; - -const xchConditions = [ - clvm.reserveFee(fee), - clvm.assertConcurrentSpend(cat.coin.coinId()), // Link to CAT spend -]; - -if (change > 0n) { - xchConditions.push( - clvm.createCoin(myPuzzleHash, change, clvm.alloc([myPuzzleHash])), - ); + Ok(()) } - -clvm.spendStandardCoin(xchCoin, publicKey, clvm.delegatedSpend(xchConditions)); - -const coinSpends = clvm.coinSpends(); ``` - - - -```python -clvm = Clvm() - -# Spend the CAT (linked to XCH spend) -clvm.spend_cats([ - CatSpend( - cat, - clvm.standard_spend( - public_key, - clvm.delegated_spend([ - clvm.create_coin(recipient_puzzle_hash, cat.coin.amount, clvm.alloc([recipient_puzzle_hash])), - clvm.assert_concurrent_spend(xch_coin.coin_id()), # Link to XCH spend - ]) - ) - ), -]) - -# Spend XCH to pay the fee -fee = 100_000_000 # 0.0001 XCH -change = xch_coin.amount - fee - -xch_conditions = [ - clvm.reserve_fee(fee), - clvm.assert_concurrent_spend(cat.coin.coin_id()), # Link to CAT spend -] - -if change > 0: - xch_conditions.append( - clvm.create_coin(my_puzzle_hash, change, clvm.alloc([my_puzzle_hash])) - ) - -clvm.spend_standard_coin(xch_coin, public_key, clvm.delegated_spend(xch_conditions)) - -coin_spends = clvm.coin_spends() -``` +`parse_children` returns `Ok(None)` if the parent isn't a CAT, and an error if it looks like a CAT but +is malformed. Always compare the parsed child with the coin you actually have: parsing returns every +child of the parent, and it trusts the parent's outputs, not the hint. - - - -:::warning -Always use `assert_concurrent_spend` to link CAT and XCH spends together. Without this, an attacker could extract and submit only the XCH spend (with the fee) while discarding your CAT spend. -::: - -## Lineage Proofs - -CATs require lineage proofs to verify their authenticity. When parsing CATs from the blockchain, you'll need to track the parent information: - -```rust -// A CAT with its lineage proof -let cat = Cat { - coin, - info: CatInfo { - asset_id, - inner_puzzle_hash, - }, - lineage_proof: Some(LineageProof { - parent_parent_coin_info: parent_coin.parent_coin_info, - parent_inner_puzzle_hash: parent_inner_puzzle_hash, - parent_amount: parent_coin.amount, - }), -}; -``` +Related parsing functions: -When issuing new CATs, the lineage proof is handled automatically by the SDK. +| Function | Input | Output | +| --------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `Cat::parse_children` | parent coin, puzzle and solution | every child `Cat` created by the parent spend | +| `Cat::parse` | a CAT coin's own puzzle and solution | `ParsedCat`: the `Cat`, its p2 puzzle and solution, and whether it was revoked | +| `CatInfo::parse` | a puzzle only | `CatInfo` and the p2 puzzle (`None` for revocable CATs, whose p2 puzzle is only revealed in the solution) | -## Complete Example +See [Peer Protocol](/sdk/networking/peer) and [RPC Clients](/sdk/networking/rpc) for fetching coin spends +from a real node, and the [Primitives overview](/sdk/primitives) for the general pattern. -Here's a full example of issuing a CAT and then spending it: +## Melting - - +Melting removes CAT supply by making the outputs of the ring less than its inputs. That's only allowed +when one of the spends reveals the TAIL with `RUN_CAT_TAIL` and the TAIL approves the negative delta. +With the `everything_with_signature` TAIL, the issuer's signature is enough: ```rust +use chia_wallet_sdk::chia::puzzle_types::cat::EverythingWithSignatureTailArgs; use chia_wallet_sdk::prelude::*; -fn issue_and_spend_cat( - issuer_coin: Coin, - issuer_public_key: PublicKey, - recipient_puzzle_hash: Bytes32, - amount: u64, -) -> Result<(Bytes32, Vec), DriverError> { +#[test] +fn melt_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); let ctx = &mut SpendContext::new(); - let p2 = StandardLayer::new(issuer_public_key); - let issuer_puzzle_hash = StandardLayer::puzzle_hash(issuer_public_key); - // Step 1: Issue the CAT - let memos = ctx.hint(issuer_puzzle_hash)?; - let issue_conditions = Conditions::new() - .create_coin(issuer_puzzle_hash, amount, memos); + let alice = sim.bls(10_000); + let alice_p2 = StandardLayer::new(alice.pk); + let memos = ctx.hint(alice.puzzle_hash)?; - let (issue_cat, cats) = Cat::issue_with_coin( + let (issue_cat, cats) = Cat::multi_issuance( ctx, - issuer_coin.coin_id(), - amount, - issue_conditions, + alice.coin.coin_id(), + alice.pk, + None, + 10_000, + Conditions::new().create_coin(alice.puzzle_hash, 10_000, memos), )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; - p2.spend(ctx, issuer_coin, issue_cat)?; - - let asset_id = cats[0].info.asset_id; - let cat = cats[0]; - - // Step 2: Immediately spend the CAT to the recipient - let transfer_memos = ctx.hint(recipient_puzzle_hash)?; - let inner_spend = p2.spend_with_conditions( + // Keep 7,000 and melt 3,000 by revealing the TAIL. + let tail = ctx.curry(EverythingWithSignatureTailArgs::new(alice.pk))?; + let p2_spend = alice_p2.spend_with_conditions( ctx, - Conditions::new().create_coin(recipient_puzzle_hash, amount, transfer_memos), + Conditions::new() + .create_coin(alice.puzzle_hash, 7_000, memos) + .run_cat_tail(tail, NodePtr::NIL), )?; + let children = Cat::spend_all(ctx, &[CatSpend::new(cats[0], p2_spend)])?; + sim.spend_coins(ctx.take(), &[alice.sk])?; - Cat::spend_all(ctx, &[CatSpend::new(cat, inner_spend)])?; + assert_eq!(children[0].coin.amount, 7_000); - Ok((asset_id, ctx.take())) + Ok(()) } ``` - - - -```typescript -import { Clvm, Coin, CatInfo, Cat, CatSpend, Simulator } from "chia-wallet-sdk"; - -function issueAndSpendCat(recipientPuzzleHash: Uint8Array, amount: bigint) { - const clvm = new Clvm(); - const sim = new Simulator(); - const alice = sim.bls(amount); - - // Step 1: Create TAIL and issue CAT - const tail = clvm.nil(); - const assetId = tail.treeHash(); - const catInfo = new CatInfo(assetId, null, alice.puzzleHash); - - // Issue the CAT - clvm.spendStandardCoin( - alice.coin, - alice.pk, - clvm.delegatedSpend([clvm.createCoin(catInfo.puzzleHash(), amount)]), - ); - - // Create eve CAT - const eveCat = new Cat( - new Coin(alice.coin.coinId(), catInfo.puzzleHash(), amount), - null, - catInfo, - ); - - // Step 2: Spend the CAT with TAIL reveal, then transfer - clvm.spendCats([ - new CatSpend( - eveCat, - clvm.standardSpend( - alice.pk, - clvm.delegatedSpend([ - clvm.createCoin( - recipientPuzzleHash, - amount, - clvm.alloc([recipientPuzzleHash]), - ), - clvm.runCatTail(tail, clvm.nil()), - ]), - ), - ), - ]); - - // Sign and validate - sim.spendCoins(clvm.coinSpends(), [alice.sk]); - - return assetId; -} -``` +The same mechanism with a positive delta issues more supply from existing CAT coins. `spend_all` puts +the required `extra_delta` on whichever spend in the ring outputs `RUN_CAT_TAIL`, so the TAIL can be +revealed by any coin. The melted mojos stay in the transaction as XCH value: they can go to the fee, or +an XCH spend in the same bundle can output more than its input to reclaim them. A `genesis_by_coin_id` +CAT can never be melted, because its TAIL only allows the genesis spend. - - +## Revocable CATs -```python -from chia_wallet_sdk import Clvm, Coin, CatInfo, Cat, CatSpend, Simulator +A revocable CAT has a `RevocationLayer` between the CAT layer and the p2 puzzle. It is curried with a +**hidden puzzle hash** chosen by the issuer. Each spend either runs the owner's p2 puzzle as usual, or +reveals the hidden puzzle instead, which lets the issuer take back (revoke) the coins. The asset ID still +comes from the TAIL, so a revocable CAT is identified by its asset ID plus `hidden_puzzle_hash`. -def issue_and_spend_cat(recipient_puzzle_hash: bytes, amount: int): - clvm = Clvm() - sim = Simulator() - alice = sim.bls(amount) +Pass `Some(hidden_puzzle_hash)` to any issuance function to make the issued coins revocable. Normal spends +keep the revocation layer: outputs of a non-revoking spend are wrapped in the same layer automatically, +so `Cat::child` and `spend_all` preserve it. - # Step 1: Create TAIL and issue CAT - tail = clvm.nil() - asset_id = tail.tree_hash() - cat_info = CatInfo(asset_id, None, alice.puzzle_hash) +To revoke, use `CatSpend::revoke` with a spend of the hidden puzzle (here the issuer's standard puzzle). +In a revocation spend, outputs are **not** wrapped automatically. A plain output becomes an ordinary, +non-revocable CAT. To keep an output revocable, create it at the revocation-wrapped puzzle hash and hint +it with the p2 puzzle hash; the parsing code uses that hint to recognize it. - # Issue the CAT - clvm.spend_standard_coin( - alice.coin, - alice.pk, - clvm.delegated_spend([clvm.create_coin(cat_info.puzzle_hash(), amount)]) - ) +```rust +use chia_wallet_sdk::chia::puzzle_types::cat::EverythingWithSignatureTailArgs; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::RevocationArgs; - # Create eve CAT - eve_cat = Cat( - Coin(alice.coin.coin_id(), cat_info.puzzle_hash(), amount), - None, - cat_info - ) - - # Step 2: Spend the CAT with TAIL reveal, then transfer - clvm.spend_cats([ - CatSpend( - eve_cat, - clvm.standard_spend( - alice.pk, - clvm.delegated_spend([ - clvm.create_coin(recipient_puzzle_hash, amount, clvm.alloc([recipient_puzzle_hash])), - clvm.run_cat_tail(tail, clvm.nil()), - ]) - ) - ), - ]) - - # Sign and validate - sim.spend_coins(clvm.coin_spends(), [alice.sk]) - - return asset_id +#[test] +fn revocable_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let issuer = sim.bls(10); + let issuer_p2 = StandardLayer::new(issuer.pk); + let bob = sim.bls(0); + let bob_p2 = StandardLayer::new(bob.pk); + + // The issuer's own puzzle hash is the hidden puzzle hash. + let bob_memos = ctx.hint(bob.puzzle_hash)?; + let (issue_cat, cats) = Cat::multi_issuance( + ctx, + issuer.coin.coin_id(), + issuer.pk, + Some(issuer.puzzle_hash), + 10, + Conditions::new().create_coin(bob.puzzle_hash, 10, bob_memos), + )?; + issuer_p2.spend(ctx, issuer.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[issuer.sk.clone()])?; + assert_eq!(cats[0].info.hidden_puzzle_hash, Some(issuer.puzzle_hash)); + + // Bob spends normally. The child stays revocable. + let p2_spend = bob_p2.spend_with_conditions( + ctx, + Conditions::new().create_coin(bob.puzzle_hash, 10, bob_memos), + )?; + let cats = Cat::spend_all(ctx, &[CatSpend::new(cats[0], p2_spend)])?; + sim.spend_coins(ctx.take(), &[bob.sk])?; + assert_eq!(cats[0].info.hidden_puzzle_hash, Some(issuer.puzzle_hash)); + + // The issuer revokes: 5 become a plain CAT, 5 stay revocable. + let issuer_memos = ctx.hint(issuer.puzzle_hash)?; + let revocable_puzzle_hash: Bytes32 = + RevocationArgs::new(issuer.puzzle_hash, issuer.puzzle_hash) + .curry_tree_hash() + .into(); + + let hidden_spend = issuer_p2.spend_with_conditions( + ctx, + Conditions::new() + .create_coin(issuer.puzzle_hash, 5, issuer_memos) + .create_coin(revocable_puzzle_hash, 5, issuer_memos), + )?; + let revoked = Cat::spend_all(ctx, &[CatSpend::revoke(cats[0], hidden_spend)])?; + sim.spend_coins(ctx.take(), &[issuer.sk])?; + + assert_eq!(revoked[0].info.hidden_puzzle_hash, None); + assert_eq!(revoked[1].info.hidden_puzzle_hash, Some(issuer.puzzle_hash)); + assert_eq!(revoked[1].info.p2_puzzle_hash, issuer.puzzle_hash); + + Ok(()) +} ``` - - +`Cat::unrevocable_child` and `Cat::child_with` let you construct children with a different revocation +setting when you build outputs by hand. When parsing, `Cat::parse_children` applies the same rules, and +`ParsedCat::revoked` tells you whether a spend was a revocation. + +:::warning +For revocable CATs the p2 puzzle hash is **not** the inner puzzle hash of the CAT layer, because the +revocation layer sits in between. Use `CatInfo::inner_puzzle_hash` (not `p2_puzzle_hash`) whenever you +need the hash the CAT layer sees, for example in lineage proofs. The `Cat` methods already do this. +::: + +## Gotchas -## API Reference +- Never call `spend_all` twice for the same asset in one transaction. Each call forms a separate ring + that must balance on its own, so value can't move between the two groups. +- Spend different asset IDs in separate `spend_all` calls; they can't share a ring. +- Every output inside a CAT spend becomes a CAT of the same asset. To pay a fee or send XCH, spend an XCH + coin in the same bundle and link it (for example with `assert_concurrent_spend`). +- The issuance outputs must sum to exactly the issued amount. Too much or too little makes the TAIL fail + with a `clvm raise` error. +- The eve coin used during issuance has no hint and is spent immediately, so you don't need to track it. -For the complete CAT API, see [docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.Cat.html). +See the [`Cat` rustdoc](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/) for the complete +API, and [Offers](/sdk/offers) for trading CATs. diff --git a/docs/sdk/primitives/clawback.md b/docs/sdk/primitives/clawback.md new file mode 100644 index 0000000..1646815 --- /dev/null +++ b/docs/sdk/primitives/clawback.md @@ -0,0 +1,298 @@ +--- +title: Clawback +--- + +# Clawback + +A clawback is a p2 puzzle that sends a coin to a receiver while letting the sender take it back for a +limited time. Until the timeout, the sender can claw the coin back; after it, the receiver can claim +it. This protects against sending to the wrong address, since the coin isn't final until the receiver +has had a chance to claim it. + +The SDK has two versions, both in `chia_wallet_sdk::driver` (no feature flag): + +| Type | Timeout | Discovery | Used by | +| ------------ | ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------- | +| `ClawbackV2` | Absolute timestamp (`seconds`) | Hint plus memo on the `CREATE_COIN` | Newer wallets and [clear signing](/sdk/clear-signing). Works for XCH and CATs | +| `Clawback` | Relative seconds (`timelock`) | A `REMARK` condition next to the `CREATE_COIN` | The reference Chia wallet's clawback format | + +Both are built on `P2OneOfManyLayer`: a merkle tree of spend paths where each spend reveals one path. +Because they're p2 puzzles, you can wrap them in a [CAT](/sdk/primitives/cat) layer like any other +inner puzzle. + +## ClawbackV2 + +`ClawbackV2::new(sender_puzzle_hash, receiver_puzzle_hash, seconds, amount, hinted)` describes the +clawback. Its puzzle hash (`clawback.tree_hash()`) commits to all five fields. There are three paths: + +| Path | Who | When | Methods | +| ------------ | ------------- | --------------------- | ------------------------------------------------------------------------------------------------------ | +| Sender | Sender's p2 | Before `seconds` | `recover_spend` (send back to sender), `force_spend` (send to receiver early), `sender_spend` (custom) | +| Receiver | Receiver's p2 | At or after `seconds` | `finish_spend` (claim), `receiver_spend` (custom) | +| Push through | Anyone | At or after `seconds` | `push_through_spend`: pays `amount` to the receiver with no signature | + +The `*_coin_spend` variants (`recover_coin_spend`, `finish_coin_spend`, `force_coin_spend`, +`push_through_coin_spend`) insert the spend into the `SpendContext` directly. Use the `*_spend` +variants when you need the `Spend` to wrap in another layer, for example with `Cat::spend_all`. + +`hinted` controls whether the coin created by these paths includes a hint for its recipient. Set it to +`true` for CATs (so the recipient's wallet can find the CAT) and usually `false` for XCH. + +### Discovering clawbacks + +To let the receiver's wallet find and decode the clawback, create the coin with memos of the form +`(receiver_puzzle_hash, (clawback.memo(), ...))`. The first memo is a hint, so the coin shows up when the +receiver looks up coins hinted to their puzzle hash. The second is `(sender_puzzle_hash, (seconds, ()))`, +which together with the coin's amount and the hint lets anyone reconstruct the `ClawbackV2` with +`ClawbackV2::from_memo`. `from_memo` checks the reconstructed puzzle hash against the coin, so a bad +memo just returns `None`. + +This example sends a clawback from Alice to Bob, has Bob discover it from the chain, and then claims it +after the timeout. + +```rust +use chia_wallet_sdk::clvm_traits::clvm_list; +use chia_wallet_sdk::prelude::*; + +#[test] +fn clawback_v2_send_and_claim() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1000); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); + let bob_p2 = StandardLayer::new(bob.pk); + + // Bob can claim it 100 seconds after the next block. + let seconds = sim.next_timestamp() + 100; + let clawback = ClawbackV2::new(alice.puzzle_hash, bob.puzzle_hash, seconds, 1000, false); + let clawback_puzzle_hash: Bytes32 = clawback.tree_hash().into(); + + let memos = ctx.memos(&clvm_list!(bob.puzzle_hash, clawback.memo()))?; + alice_p2.spend( + ctx, + alice.coin, + Conditions::new().create_coin(clawback_puzzle_hash, 1000, memos), + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob finds the coin by its hint, and decodes the clawback from the parent's conditions. + let coin = sim + .unspent_coins(bob.puzzle_hash, true) + .into_iter() + .find(|coin| coin.puzzle_hash != bob.puzzle_hash) + .unwrap(); + let parent = sim.coin_spend(coin.parent_coin_info).unwrap(); + let puzzle = ctx.alloc(&parent.puzzle_reveal)?; + let solution = ctx.alloc(&parent.solution)?; + let output = ctx.run(puzzle, solution)?; + let conditions: Vec = ctx.extract(output)?; + + let create_coin = conditions + .into_iter() + .filter_map(Condition::into_create_coin) + .find(|cc| cc.puzzle_hash == coin.puzzle_hash && cc.amount == coin.amount) + .unwrap(); + let Memos::Some(memos) = create_coin.memos else { + anyhow::bail!("missing memos"); + }; + let (hint, (memo, _rest)) = ctx.extract::<(Bytes32, (NodePtr, NodePtr))>(memos)?; + + let found = ClawbackV2::from_memo(ctx, memo, hint, coin.amount, false, coin.puzzle_hash) + .expect("not a valid clawback"); + assert_eq!(found, clawback); + + // Claiming before the timeout fails. + found.finish_coin_spend(ctx, coin, &bob_p2, Conditions::new())?; + assert!(sim.spend_coins(ctx.take(), &[bob.sk.clone()]).is_err()); + + // After the timeout, Bob can claim it. + sim.set_next_timestamp(seconds)?; + found.finish_coin_spend(ctx, coin, &bob_p2, Conditions::new())?; + sim.spend_coins(ctx.take(), &[bob.sk])?; + + assert!(sim.unspent_coins(bob.puzzle_hash, false).iter().any(|c| c.amount == 1000)); + + Ok(()) +} +``` + +### Clawing back + +The sender's paths assert that the timeout hasn't passed (`ASSERT_BEFORE_SECONDS_ABSOLUTE`), so they +stop working once the receiver can claim. If nobody claims the coin, anyone can push it through to the +receiver after the timeout without a signature. + +```rust +use chia_wallet_sdk::clvm_traits::clvm_list; +use chia_wallet_sdk::prelude::*; + +#[test] +fn clawback_v2_recover_and_push_through() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(3); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); + + // Send two clawback coins to Bob. The amount is part of the puzzle hash. + let seconds = sim.next_timestamp() + 100; + let first = ClawbackV2::new(alice.puzzle_hash, bob.puzzle_hash, seconds, 1, false); + let second = ClawbackV2::new(alice.puzzle_hash, bob.puzzle_hash, seconds, 2, false); + + let first_memos = ctx.memos(&clvm_list!(bob.puzzle_hash, first.memo()))?; + let second_memos = ctx.memos(&clvm_list!(bob.puzzle_hash, second.memo()))?; + alice_p2.spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(first.tree_hash().into(), 1, first_memos) + .create_coin(second.tree_hash().into(), 2, second_memos), + )?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + let first_coin = Coin::new(alice.coin.coin_id(), first.tree_hash().into(), 1); + let second_coin = Coin::new(alice.coin.coin_id(), second.tree_hash().into(), 2); + + // Alice changes her mind about the first one, and takes it back before the timeout. + first.recover_coin_spend(ctx, first_coin, &alice_p2, Conditions::new())?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Nobody claims the second one, so after the timeout anyone can push it through to Bob. + sim.set_next_timestamp(seconds)?; + second.push_through_coin_spend(ctx, second_coin)?; + sim.spend_coins(ctx.take(), &[])?; + + assert!(sim.unspent_coins(alice.puzzle_hash, false).iter().any(|c| c.amount == 1)); + assert!(sim.unspent_coins(bob.puzzle_hash, false).iter().any(|c| c.amount == 2)); + + Ok(()) +} +``` + +### CATs + +For CATs, the clawback is the CAT's inner puzzle. Create the CAT with the clawback puzzle hash as its +p2 puzzle hash and the same memos, set `hinted` to `true`, and spend it by passing the clawback's +`Spend` to `Cat::spend_all`: + +```rust +use chia_wallet_sdk::clvm_traits::clvm_list; +use chia_wallet_sdk::prelude::*; + +#[test] +fn clawback_v2_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); + + let hint = ctx.hint(alice.puzzle_hash)?; + let (issue_cat, cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 1, + Conditions::new().create_coin(alice.puzzle_hash, 1, hint), + )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + + let seconds = sim.next_timestamp() + 100; + let clawback = ClawbackV2::new(alice.puzzle_hash, bob.puzzle_hash, seconds, 1, true); + let clawback_puzzle_hash: Bytes32 = clawback.tree_hash().into(); + let memos = ctx.memos(&clvm_list!(bob.puzzle_hash, clawback.memo()))?; + + let inner_spend = alice_p2.spend_with_conditions( + ctx, + Conditions::new().create_coin(clawback_puzzle_hash, 1, memos), + )?; + Cat::spend_all(ctx, &[CatSpend::new(cats[0], inner_spend)])?; + let clawback_cat = cats[0].child(clawback_puzzle_hash, 1); + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // Claw the CAT back. + let spend = clawback.recover_spend(ctx, &alice_p2, Conditions::new())?; + Cat::spend_all(ctx, &[CatSpend::new(clawback_cat, spend)])?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + Ok(()) +} +``` + +## Clawback (v1) + +`Clawback { timelock, sender_puzzle_hash, receiver_puzzle_hash }` is the format used by the reference +wallet. The receiver path asserts a relative timelock (`ASSERT_SECONDS_RELATIVE`), and the sender path +can be spent at any time, so the sender can claw back even after the timelock has passed if the receiver +hasn't claimed yet. + +Instead of memos, v1 clawbacks are discovered through a `REMARK` condition emitted by the parent spend +alongside the `CREATE_COIN`. `get_remark_condition` builds it, and `Clawback::parse_children` (or +`parse_child`) finds every clawback created by a parent spend. + +```rust +use chia_wallet_sdk::driver::Clawback; +use chia_wallet_sdk::prelude::*; + +#[test] +fn clawback_v1() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); + let bob_p2 = StandardLayer::new(bob.pk); + + let clawback = Clawback { + timelock: 1, + sender_puzzle_hash: alice.puzzle_hash, + receiver_puzzle_hash: bob.puzzle_hash, + }; + let clawback_puzzle_hash: Bytes32 = clawback.tree_hash().into(); + + let remark = clawback.get_remark_condition(ctx)?; + alice_p2.spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(clawback_puzzle_hash, 1, Memos::None) + .with(remark), + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Parse the clawback back out of the parent spend. + let parent = sim.coin_spend(alice.coin.coin_id()).unwrap(); + let puzzle = ctx.alloc(&parent.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + let solution = ctx.alloc(&parent.solution)?; + let found = Clawback::parse_child(ctx, puzzle, solution, clawback_puzzle_hash)? + .expect("clawback not found"); + assert_eq!(found, clawback); + + // Bob claims it once the relative timelock has passed. + let coin = Coin::new(alice.coin.coin_id(), clawback_puzzle_hash, 1); + sim.pass_time(1); + found.claim_coin_spend(ctx, coin, &bob_p2, Conditions::new())?; + sim.spend_coins(ctx.take(), &[bob.sk])?; + + Ok(()) +} +``` + +The sender's counterpart is `clawback_coin_spend`. Both create the full coin amount for the recipient +with a hint. For custom spends, use `receiver_spend` and `sender_spend`. + +## Gotchas + +- `ClawbackV2::seconds` is an absolute Unix timestamp, while `Clawback::timelock` is relative to the + coin's creation. In the simulator, use `sim.next_timestamp()`, `set_next_timestamp` and `pass_time` + to control block timestamps (see [Simulator](/sdk/testing)). +- `amount` and `hinted` are part of a `ClawbackV2`'s puzzle hash. The push-through path pays exactly + `amount`, so the coin's amount must match it. +- If the receiver is a vault, the paths are spent with the vault's p2 spend as the inner spend. Clear + signing requires vault transactions that use the sender path to expire before the clawback does; see + [Clear Signing](/sdk/clear-signing#clawbacks-and-expiration). diff --git a/docs/sdk/primitives/datalayer.md b/docs/sdk/primitives/datalayer.md new file mode 100644 index 0000000..082a462 --- /dev/null +++ b/docs/sdk/primitives/datalayer.md @@ -0,0 +1,281 @@ +--- +title: DataLayer Stores +--- + +# DataLayer Stores + +A **DataLayer store** is a singleton that commits to the root hash of an off-chain key-value store, +used by [Chia DataLayer](https://docs.chia.net/datalayer/). The store's owner (and optionally a set of +delegated admins, writers and oracles) can publish a new root hash, label, description and size by +spending the singleton. Anyone who has the data can then prove it matches what's on chain. + +The SDK's `Datastore` primitive implements the DataLayer singleton format described in +[CHIP-0035](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0035.md). It requires the +`chip-0035` feature (also enabled by `action-layer`): + +```toml +chia-wallet-sdk = { version = "...", features = ["chip-0035"] } +``` + +## Structure + +A store is built like an NFT without the ownership layer: + +```text +SingletonLayer + NftStateLayer (metadata = DatastoreMetadata, updater = DL metadata updater) + DelegationLayer (only if there are delegated puzzles) + owner puzzle, or one of the delegated puzzles +``` + +| Type | Purpose | +| ----------------------------------------------- | ------------------------------------------------------------------------------- | +| `Datastore` | The store coin, its lineage proof, and its `DatastoreInfo` | +| `DatastoreInfo` | Launcher id, metadata, owner puzzle hash and delegated puzzles | +| `DatastoreMetadata` | `root_hash`, plus optional `label`, `description`, `bytes` and `size_proof` | +| `DelegatedPuzzle` | `Admin(puzzle_hash)`, `Writer(inner_puzzle_hash)` or `Oracle(puzzle_hash, fee)` | +| `DelegationLayer`, `WriterLayer`, `OracleLayer` | The layers that implement delegation | + +The metadata type is generic: `DatastoreMetadata` is the standard format, but any type that implements +`ToClvm`, `FromClvm` and `MetadataWithRootHash` works. + +### Delegated puzzles + +When a store has delegated puzzles, its inner puzzle is a `DelegationLayer` that can be spent either by +the owner or by any delegated puzzle in a merkle tree: + +| Role | Can do | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Owner | Anything: update metadata, change the delegated puzzles, transfer ownership, melt | +| Admin | Update metadata and replace the set of delegated puzzles (`UpdateDatastoreMerkleRoot`) | +| Writer | Only update metadata. The `WriterLayer` filters out every other condition | +| Oracle | Anyone can spend it, as long as they pay `fee` mojos to the oracle puzzle hash. The spend makes an announcement that the payer can assert, so it can be used to prove the store's current state on chain | + +## Launching and updating a store + +`Launcher::mint_datastore(ctx, metadata, owner_puzzle_hash, delegated_puzzles)` returns the conditions to +spend from the parent coin and the eve `Datastore`. To update a store, have the owner's inner puzzle +output a new metadata condition (`Datastore::new_metadata_condition`) and recreate the store +(`Datastore::owner_create_coin_condition`), then call `Datastore::spend`, which returns the +`CoinSpend` for you to insert. + +`Datastore::from_spend(ctx, &coin_spend, &parent_delegated_puzzles)` parses the child store from a coin +spend, whether it's the launcher spend or a store spend. It reads the new metadata and, from the +`CREATE_COIN` memos, any new owner and delegated puzzles. + +```rust +use chia_wallet_sdk::driver::{Datastore, DatastoreMetadata, MetadataWithRootHash}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn launch_and_update_store() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + + let (launch, datastore) = Launcher::new(alice.coin.coin_id(), 1).mint_datastore( + ctx, + DatastoreMetadata::root_hash_only(Bytes32::default()), + alice.puzzle_hash.into(), + vec![], + )?; + alice_p2.spend(ctx, alice.coin, launch)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // Parse the store from the launcher spend, as a wallet syncing from chain would. + let launcher_spend = sim.coin_spend(datastore.info.launcher_id).unwrap(); + let parsed = Datastore::::from_spend(ctx, &launcher_spend, &[])?.unwrap(); + assert_eq!(parsed, datastore); + + // Publish a new root hash, and recreate the store with the same owner. + let new_metadata = DatastoreMetadata { + root_hash: Bytes32::new([1; 32]), + label: Some("my store".to_string()), + description: Some("An example DataLayer store".to_string()), + bytes: Some(1024), + size_proof: None, + }; + let conditions = Conditions::new() + .with(Datastore::new_metadata_condition(ctx, new_metadata.clone())?) + .with(Datastore::::owner_create_coin_condition( + ctx, + datastore.info.launcher_id, + alice.puzzle_hash, + vec![], + false, + )?); + let inner_spend = alice_p2.spend_with_conditions(ctx, conditions)?; + let coin_spend = datastore.spend(ctx, inner_spend)?; + + let updated = Datastore::::from_spend(ctx, &coin_spend, &[])?.unwrap(); + assert_eq!(updated.info.metadata, new_metadata); + + ctx.insert(coin_spend); + sim.spend_coins(ctx.take(), &[alice.sk])?; + + Ok(()) +} +``` + +## Using delegated puzzles + +This example (adapted from the SDK's tests) launches a store with an admin, a writer and an oracle, +and then exercises each role before transferring ownership: + +```rust +use chia_wallet_sdk::chia::puzzle_types::standard::StandardArgs; +use chia_wallet_sdk::chia::sha2::Sha256; +use chia_wallet_sdk::driver::{ + Datastore, DatastoreMetadata, DelegatedPuzzle, MetadataWithRootHash, OracleLayer, WriterLayer, + get_merkle_tree, +}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn delegated_store() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let [owner, new_owner, admin, writer] = BlsPair::range(); + let owner_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(owner.pk).into(); + let new_owner_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(new_owner.pk).into(); + + let admin_puzzle = DelegatedPuzzle::Admin(StandardArgs::curry_tree_hash(admin.pk)); + let writer_puzzle = DelegatedPuzzle::Writer(StandardArgs::curry_tree_hash(writer.pk)); + let oracle_puzzle_hash = Bytes32::new([7; 32]); + let oracle_fee = 1000; + let oracle_puzzle = DelegatedPuzzle::Oracle(oracle_puzzle_hash, oracle_fee); + + let coin = sim.new_coin(owner_puzzle_hash, 1); + let (launch, datastore) = Launcher::new(coin.coin_id(), 1).mint_datastore( + ctx, + DatastoreMetadata::default(), + owner_puzzle_hash.into(), + vec![admin_puzzle, writer_puzzle, oracle_puzzle], + )?; + StandardLayer::new(owner.pk).spend(ctx, coin, launch)?; + + // Writer: update the metadata. The writer layer only allows metadata updates. + let new_metadata = DatastoreMetadata::root_hash_only(Bytes32::new([1; 32])); + let condition = Datastore::new_metadata_condition(ctx, new_metadata.clone())?; + let inner_spend = + WriterLayer::new(StandardLayer::new(writer.pk)).spend(ctx, Conditions::new().with(condition))?; + let coin_spend = datastore.clone().spend(ctx, inner_spend)?; + let datastore = Datastore::::from_spend( + ctx, + &coin_spend, + &datastore.info.delegated_puzzles, + )? + .unwrap(); + ctx.insert(coin_spend); + assert_eq!(datastore.info.metadata, new_metadata); + + // Admin: remove the writer, keeping the admin and oracle. + let delegated_puzzles = vec![admin_puzzle, oracle_puzzle]; + let new_merkle_root = get_merkle_tree(ctx, delegated_puzzles.clone())?.root(); + let condition = ctx.alloc(&UpdateDatastoreMerkleRoot { + new_merkle_root, + memos: Datastore::::get_recreation_memos( + datastore.info.launcher_id, + owner_puzzle_hash.into(), + delegated_puzzles.clone(), + ), + })?; + let inner_spend = StandardLayer::new(admin.pk) + .spend_with_conditions(ctx, Conditions::new().with(Condition::Other(condition)))?; + let coin_spend = datastore.clone().spend(ctx, inner_spend)?; + let datastore = Datastore::::from_spend( + ctx, + &coin_spend, + &datastore.info.delegated_puzzles, + )? + .unwrap(); + ctx.insert(coin_spend); + assert_eq!(datastore.info.delegated_puzzles, delegated_puzzles); + + // Oracle: anyone can spend the store by paying the oracle fee. + let inner_spend = OracleLayer::new(oracle_puzzle_hash, oracle_fee) + .unwrap() + .construct_spend(ctx, ())?; + let coin_spend = datastore.clone().spend(ctx, inner_spend)?; + let oracle_store = datastore.clone(); + let datastore = Datastore::::from_spend( + ctx, + &coin_spend, + &datastore.info.delegated_puzzles, + )? + .unwrap(); + ctx.insert(coin_spend); + + // The payer funds the fee and asserts the oracle's announcement. + let mut hasher = Sha256::new(); + hasher.update(oracle_store.coin.puzzle_hash); + hasher.update(b"$"); + let payer = sim.new_coin(owner_puzzle_hash, oracle_fee); + StandardLayer::new(owner.pk).spend( + ctx, + payer, + Conditions::new().assert_puzzle_announcement(Bytes32::new(hasher.finalize())), + )?; + + // Owner: transfer the store to a new owner, keeping the delegated puzzles. + let condition = Datastore::::owner_create_coin_condition( + ctx, + datastore.info.launcher_id, + new_owner_puzzle_hash, + delegated_puzzles.clone(), + true, + )?; + let inner_spend = + StandardLayer::new(owner.pk).spend_with_conditions(ctx, Conditions::new().with(condition))?; + let coin_spend = datastore.clone().spend(ctx, inner_spend)?; + let datastore = Datastore::::from_spend( + ctx, + &coin_spend, + &datastore.info.delegated_puzzles, + )? + .unwrap(); + ctx.insert(coin_spend); + assert_eq!(datastore.info.owner_puzzle_hash, new_owner_puzzle_hash); + assert_eq!(datastore.info.delegated_puzzles, delegated_puzzles); + + // All of the spends above happen in a single block. + sim.spend_coins(ctx.take(), &[owner.sk, admin.sk, writer.sk])?; + assert!(sim.coin_state(datastore.coin.coin_id()).is_some()); + + Ok(()) +} +``` + +## Hints and memos + +Store children are found and decoded using the memos on their `CREATE_COIN`: + +- The first memo is the launcher id, so a wallet can look up all coins for a store by hint. +- If the owner or delegated puzzles change, the next memos are the new owner puzzle hash followed by a + `(type, puzzle_hash[, fee])` group per delegated puzzle (`HintType::AdminPuzzle`, `WriterPuzzle`, + `OraclePuzzle`). `Datastore::get_recreation_memos` builds these. + +That's why `owner_create_coin_condition` has a `hint_delegated_puzzles` flag: when recreating the store +with the same owner and delegated puzzles, you can pass `false` and only the launcher id is written. When +anything changes, pass `true` so `from_spend` (and other wallets) can recover the new configuration. + +Because the memos only describe changes, `from_spend` needs the parent's delegated puzzles as its last +argument to know the current set when they aren't re-hinted. + +## Melting + +The owner can melt the store by outputting `Conditions::new().melt_singleton()` from the owner puzzle, +which destroys the singleton. Admins and writers can't melt it. + +## Gotchas + +- `Datastore::spend` consumes the store and returns a `CoinSpend`. It doesn't insert it into the + `SpendContext`; call `ctx.insert(coin_spend)` yourself. +- A writer spend can only update metadata, and an admin spend can't change the owner. Conditions they + aren't allowed to output make the spend fail. +- `Datastore::from_spend` verifies that the memos are consistent with the created puzzle hash, so you + can't be tricked into tracking a different owner than the one actually on chain. +- For NFT metadata in general (which uses the same state layer), see [NFT](/sdk/primitives/nft). diff --git a/docs/sdk/primitives/did.md b/docs/sdk/primitives/did.md new file mode 100644 index 0000000..a3aa52e --- /dev/null +++ b/docs/sdk/primitives/did.md @@ -0,0 +1,351 @@ +--- +title: DID +--- + +# DID + +A DID (decentralized identifier) is a [singleton](/sdk/primitives/singletons) that represents an +on-chain identity. Its launcher ID is the identifier (encoded as a `did:chia:1...` address), and its +inner puzzle is the DID layer around the owner's p2 puzzle. DIDs are mostly used as the creator and +assigned owner of [NFTs](/sdk/primitives/nft), but because a DID can output arbitrary conditions it can +also authorize other spends. The DID standard is +[CHIP-0004](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0004.md). + +In the SDK, `Did` is `Singleton`. + +:::tip +The [Action System](/sdk/action-system) can create and update DIDs (`Action::create_did`, +`Action::create_empty_did`, `Action::update_did`) and mint NFTs from them (`Action::mint_nft_from_did`) +as part of a larger transaction. This page covers the low-level `Did` API. +::: + +## The `DidInfo` type + +```rust ignore +pub struct DidInfo { + pub launcher_id: Bytes32, + pub recovery_list_hash: Option, + pub num_verifications_required: u64, + pub metadata: HashedPtr, + pub p2_puzzle_hash: Bytes32, +} +``` + +- `metadata` is an arbitrary CLVM value stored in the puzzle, held as a `HashedPtr` (a `NodePtr` plus its + tree hash). Allocate it with `ctx.alloc_hashed(&value)`. `HashedPtr::NIL` means no metadata. +- `recovery_list_hash` and `num_verifications_required` belong to the DID recovery feature, which is + rarely used and not fully supported. Use `None` and `1` unless you need compatibility with a specific + wallet; `create_simple_did` does this for you. +- `p2_puzzle_hash` is the owner's puzzle hash, typically a [standard](/sdk/primitives/standard) puzzle. + +Because `metadata` points into an `Allocator`, a `Did` is only meaningful together with the +`SpendContext` (or `Allocator`) it was created in. To store a DID long term, store the metadata +separately (for example as a serialized `Program`) and allocate it again when you load it. + +## Creating a DID + +DIDs are created from a [`Launcher`](/sdk/primitives/singletons#how-a-singleton-is-launched). +`create_simple_did` creates a DID with no metadata and no recovery list; `create_did` lets you choose +both. They return the conditions the parent coin must output, plus the new `Did`. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn create_did() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(2); + let alice_p2 = StandardLayer::new(alice.pk); + + // A DID with no metadata. + let (create_did, did) = + Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?; + + // A second DID with custom metadata, from an intermediate coin of the same parent + // (each parent can only create one launcher of a given amount). + let intermediate = Coin::new(alice.coin.coin_id(), alice.puzzle_hash, 1); + let metadata = ctx.alloc_hashed(&("name", "Alice"))?; + let (create_named_did, named_did) = Launcher::new(intermediate.coin_id(), 1) + .create_did(ctx, None, 1, metadata, &alice_p2)?; + + alice_p2.spend( + ctx, + alice.coin, + create_did.create_coin(alice.puzzle_hash, 1, Memos::None), + )?; + alice_p2.spend(ctx, intermediate, create_named_did)?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + assert_eq!(did.info.p2_puzzle_hash, alice.puzzle_hash); + assert_eq!(named_did.info.metadata, metadata); + assert!(sim.coin_state(named_did.coin.coin_id()).is_some()); + + Ok(()) +} +``` + +The `inner` argument must implement both `SpendWithConditions` and `ToTreeHash`, because the DID's p2 +puzzle hash is taken from it and it's used to spend the eve coin. + +`create_did` actually performs two singleton spends: the launcher creates the eve DID, and the eve DID is +immediately spent once (an "update" with no changes) to create the DID you get back. That extra spend +puts a DID spend with a hint in the parent position, which is what wallets need to discover and parse the +DID. `create_eve_did` creates only the eve coin and returns it with an `EveProof`, if you want to spend it +yourself. + +## Spending a DID + +All DID spends go through a p2 spend, like other primitives. The convenience methods take any +`SpendWithConditions` inner layer and extra conditions to emit from the DID spend: + +| Method | Effect | +| ---------------------------------------------------------- | -------------------------------------------------------------- | +| `did.update(ctx, &p2, conditions)` | recreate the DID unchanged, emitting `conditions` | +| `did.update_with_metadata(ctx, &p2, metadata, conditions)` | recreate it with new metadata | +| `did.transfer(ctx, &p2, new_p2_puzzle_hash, conditions)` | recreate it owned by a new puzzle hash (and hinted to it) | +| `did.spend_with(ctx, &p2, conditions)` | spend with exactly the given conditions; returns `Option` | +| `did.spend(ctx, inner_spend)` | spend with an arbitrary p2 `Spend`; returns `Option` | + +`update`, `update_with_metadata` and `transfer` return the child `Did`. `spend` and `spend_with` don't +know what you intended, so they try to work out the child from your conditions: they return the child if +there's an odd-amount `CREATE_COIN` that is hinted with a p2 puzzle hash matching its puzzle hash, and +`None` otherwise (for example when the DID is melted). + +`update` is the usual way to make a DID "sign off" on something in the same transaction: for example to +assert or create announcements and messages, or to authorize NFT minting and assignment. + +### Transferring + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn transfer_did() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); + let bob_p2 = StandardLayer::new(bob.pk); + + let (create_did, did) = + Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?; + alice_p2.spend(ctx, alice.coin, create_did)?; + + // Spends can be chained in the same transaction. + let did = did.transfer(ctx, &alice_p2, bob.puzzle_hash, Conditions::new())?; + assert_eq!(did.info.p2_puzzle_hash, bob.puzzle_hash); + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob can now spend it. + let _did = did.update(ctx, &bob_p2, Conditions::new())?; + sim.spend_coins(ctx.take(), &[bob.sk])?; + + Ok(()) +} +``` + +### Updating metadata + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn update_did_metadata() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + + let (create_did, did) = + Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?; + alice_p2.spend(ctx, alice.coin, create_did)?; + + let metadata = ctx.alloc_hashed(&("name", "Alice"))?; + let did = did.update_with_metadata(ctx, &alice_p2, metadata, Conditions::new())?; + + // Settle the new metadata with one more update spend (see below). + let did = did.update(ctx, &alice_p2, Conditions::new())?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + assert_eq!(did.info.metadata, metadata); + + Ok(()) +} +``` + +:::warning +A DID's metadata is part of its puzzle, but it isn't revealed by the spend that creates it. Anyone +parsing a DID from its parent spend (including `Did::parse_child`) assumes the child has the **same** +metadata as the parent. After `update_with_metadata`, do one more `update` (in the same or a later +transaction) so the coin that wallets see has a parent whose puzzle already contains the new metadata. +Until then, wallets can't sync the DID. +::: + +### Melting + +Melt a DID with `did.spend_with(ctx, &p2, Conditions::new().melt_singleton())`. This permanently destroys +the identity. NFTs that reference it as their creator or owner keep the launcher ID, but no one can prove +ownership of it anymore. + +## Minting NFTs from a DID + +NFTs minted by a DID record the DID as their owner, which is how wallets and marketplaces attribute them +to a creator. The NFT launchers are created from the DID coin itself, and the DID spend must emit the +conditions returned by the mint so it approves the assignment. Use +[`IntermediateLauncher`](/sdk/primitives/singletons#creating-many-launchers-from-one-parent-intermediatelauncher) +so a single DID spend can create any number of launchers: + +```rust +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::driver::IntermediateLauncher; +use chia_wallet_sdk::prelude::*; + +#[test] +fn mint_from_did() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(3); + let alice_p2 = StandardLayer::new(alice.pk); + + let (create_did, did) = + Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?; + // The extra 2 mojos fund the two NFT launchers created from the DID spend below. + alice_p2.spend(ctx, alice.coin, create_did)?; + + // Assign the NFTs to the DID as they are minted. + let metadata = ctx.alloc_hashed(&NftMetadata::default())?; + let mint = NftMint::new( + metadata, + alice.puzzle_hash, + 300, // 3% royalty + Some(TransferNft::new( + Some(did.info.launcher_id), + Vec::new(), + Some(did.info.inner_puzzle_hash().into()), + )), + ); + + let mut did_conditions = Conditions::new(); + let mut nfts = Vec::new(); + for i in 0..2 { + let (conditions, nft) = IntermediateLauncher::new(did.coin.coin_id(), i, 2) + .create(ctx)? + .mint_nft(ctx, &mint)?; + did_conditions = did_conditions.extend(conditions); + nfts.push(nft); + } + + // The DID's spend creates the intermediate coins and approves the assignments. + let _did = did.update(ctx, &alice_p2, did_conditions)?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + for nft in nfts { + assert_eq!(nft.info.current_owner, Some(did.info.launcher_id)); + } + + Ok(()) +} +``` + +Two things make this work: + +- The `TransferNft` condition names the DID's launcher ID and its **current** inner puzzle hash + (`did.info.inner_puzzle_hash()` of the DID coin being spent). The NFT ownership layer uses them to + check that the DID approved the assignment. +- The mint conditions include an announcement assertion and the creation of the intermediate coins. + They have to be emitted by the DID spend, which is why they're passed to `did.update`. + +The value of the launchers comes from the transaction (the DID itself keeps its 1 mojo), so the bundle +must contain enough spare XCH, here the 2 extra mojos left over by Alice's coin. See +[NFT](/sdk/primitives/nft) for minting without a DID, assigning existing NFTs to a DID, and metadata. + +## Parsing DIDs + +When a wallet sees a hinted coin that might be a DID, it fetches the parent spend and calls +`Did::parse_child`. Unlike `Cat::parse_children`, it takes the child coin you are trying to parse. The +child's p2 puzzle hash isn't visible in the parent spend (only the DID's full inner puzzle hash is), so +`parse_child` takes it from the hint. Anyone can choose a hint, so check that the parsed DID actually +produces the coin before trusting it: + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn parse_did() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); + + let (create_did, did) = + Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?; + alice_p2.spend(ctx, alice.coin, create_did)?; + let expected = did.transfer(ctx, &alice_p2, bob.puzzle_hash, Conditions::new())?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob finds the coin by its hint. + let coin_id = sim.hinted_coins(bob.puzzle_hash)[0]; + let coin = sim.coin_state(coin_id).unwrap().coin; + + let parent_spend = sim.coin_spend(coin.parent_coin_info).unwrap(); + let parent_puzzle = ctx.alloc(&parent_spend.puzzle_reveal)?; + let parent_puzzle = Puzzle::parse(ctx, parent_puzzle); + let parent_solution = ctx.alloc(&parent_spend.solution)?; + + let did = Did::parse_child(ctx, parent_spend.coin, parent_puzzle, parent_solution, coin)? + .expect("not a DID child"); + + // The p2 puzzle hash came from the hint, so verify it against the coin. + assert_eq!(Bytes32::from(did.info.puzzle_hash()), coin.puzzle_hash); + assert_eq!(did, expected); + assert_eq!(did.info.p2_puzzle_hash, bob.puzzle_hash); + + Ok(()) +} +``` + +`parse_child` returns `Ok(None)` if the parent isn't a DID or if the DID was melted. It relies on two +assumptions: the child is hinted with its p2 puzzle hash, and its metadata is unchanged from the parent +(hence the settle spend above). Neither is verified, which is why you compare `did.info.puzzle_hash()` +with the coin's puzzle hash, and why you should also make sure `coin` really is a child of +`parent_coin`. + +The hint isn't always the p2 puzzle hash. A DID sent with a [ClawbackV2](/sdk/primitives/clawback) is +hinted to the receiver, but its p2 puzzle is the clawback. In that case, decode the clawback from the +memos and replace `did.info.p2_puzzle_hash` with the clawback's puzzle hash if that produces the coin. + +:::note +Version 0.37.0 verified the hint inside `parse_child` and returned `None` on a mismatch. 0.37.1 +reverted this, because it rejected DIDs sent with a clawback. The check above works on every version. +::: + +The other parsers: + +- `Did::parse(allocator, coin, puzzle, solution)` parses a DID coin's own spend, returning the `Did` and + its p2 puzzle and solution (or `None` for the p2 spend if the spend used the recovery path). +- `DidInfo::parse(allocator, puzzle)` parses only a puzzle into `DidInfo` and the p2 puzzle. + +See the [Primitives overview](/sdk/primitives) for the general parsing pattern and +[Peer Protocol](/sdk/networking/peer) for looking up spends on a real node. + +## Gotchas + +- `metadata` is a `HashedPtr` into the current allocator. Don't mix `Did` values between different + `SpendContext`s. +- Always hint DID outputs with the p2 puzzle hash. The convenience methods do; if you build `CREATE_COIN` + conditions yourself with `spend_with`, use `ctx.hint(p2_puzzle_hash)`, and remember that the + `CREATE_COIN` puzzle hash is the DID inner puzzle hash (`DidInfo::inner_puzzle_hash`), not the p2 + puzzle hash. +- When you chain several DID spends in one transaction, each spend must use the `Did` returned by the + previous one, since the coin and lineage proof change each time. diff --git a/docs/sdk/primitives/index.md b/docs/sdk/primitives/index.md new file mode 100644 index 0000000..40df26b --- /dev/null +++ b/docs/sdk/primitives/index.md @@ -0,0 +1,240 @@ +--- +title: Primitives +slug: /sdk/primitives +--- + +# Primitives + +A **primitive** is a Rust type that represents a spendable on-chain asset: a CAT coin, an NFT, a DID, a +vault, and so on. Each one knows how to compute its puzzle hash, build a spend, compute the children that +spend creates, and parse itself back out of the blockchain. Primitives are the main low-level API of the +SDK. The [Action System](/sdk/action-system) is built on top of them, and you use them directly when you +need exact control over a spend or are working with an asset the action system doesn't cover. + +## Primitives and layers + +A [layer](/sdk/concepts/layers) is a single puzzle building block, such as `CatLayer`, `SingletonLayer` or +`NftOwnershipLayer`. It knows how to construct and parse one curried puzzle and its solution, and nothing +else: it doesn't know which coin it belongs to or how that coin came to exist. + +A primitive combines one or more layers with the coin and the proof that it's legitimate: + +| Primitive | Coin | Proof | Info (curried values) | Layers | +| --------- | ------ | ------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `Cat` | `coin` | `lineage_proof: Option` | `CatInfo`: asset ID, hidden puzzle hash, p2 puzzle hash | `CatLayer`, optionally `RevocationLayer` | +| `Nft` | `coin` | `proof: Proof` | `NftInfo`: metadata, owner, royalty, p2 puzzle hash | `SingletonLayer`, `NftStateLayer`, `NftOwnershipLayer`, `RoyaltyTransferLayer` | +| `Did` | `coin` | `proof: Proof` | `DidInfo`: metadata, recovery settings, p2 puzzle hash | `SingletonLayer`, `DidLayer` | + +`Nft` and `Did` are aliases for the generic `Singleton` with `NftInfo` and `DidInfo`. Vaults, option +contracts and the action layer singletons follow the same pattern (see +[Singletons & Launchers](/sdk/primitives/singletons)). + +### The p2 puzzle is separate + +Most primitives store only the **hash** of their innermost puzzle, the p2 ("pay to") puzzle that decides +who can spend it. When you spend the primitive you pass the p2 spend in, usually as a `Spend` or as any +type implementing `SpendWithConditions`, like `StandardLayer`: + +```rust ignore +cat_spend = CatSpend::new(cat, alice_p2.spend_with_conditions(ctx, conditions)?); +nft.transfer(ctx, &alice_p2, bob_puzzle_hash, Conditions::new())?; +``` + +This is what makes the same `Cat` or `Nft` type work regardless of whether it's owned by a standard +wallet, a [vault](/sdk/primitives/vaults), a [clawback](/sdk/primitives/clawback) or your own puzzle. It +also means the primitive value (plus a way to spend its `p2_puzzle_hash`) is exactly what a wallet needs +to persist. + +## Lifecycle + +Every primitive goes through the same steps: + +1. **Create** it, with a constructor that returns the conditions the funding coin must output (for + example `Cat::single_issuance` or `Launcher::mint_nft`) together with the new primitive. +2. **Spend** it. Spend methods add the coin spends to the [SpendContext](/sdk/concepts/spend-context) and + return the children, already carrying the right lineage, so you can keep spending them in the same + transaction or store them. +3. **Parse** it, when you didn't create it yourself, from the spend of its parent coin. + +## Parsing from the parent coin spend + +A wallet usually discovers coins by their hint (with the [Peer Protocol](/sdk/networking/peer) or an +[RPC client](/sdk/networking/rpc)), so all it has is a `Coin`. The coin's puzzle hash alone doesn't tell +you what it is, and a coin's puzzle isn't revealed until it's spent. But the **parent** coin has been +spent, and its puzzle reveal and solution contain everything needed: the parent's layers tell you the +asset type and its curried values, and running the parent's p2 spend tells you what children it created. + +So the pattern is: + +1. Look up the parent's coin spend (puzzle reveal and solution). +2. Allocate the puzzle and solution, and wrap the puzzle with `Puzzle::parse`. +3. Try the parsers for the asset types you support. Each returns `Ok(None)` if the parent isn't that + type, and an error only if it looks like that type but is malformed. +4. Make sure the parsed child is the coin you have. + +| Parser | Returns | Notes | +| -------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------- | +| `Cat::parse_children(alloc, parent_coin, puzzle, solution)` | `Option>` | every CAT child of the parent; find yours | +| `Nft::parse_child(alloc, parent_coin, puzzle, solution)` | `Option` | applies ownership and metadata changes from the spend | +| `Did::parse_child(alloc, parent_coin, puzzle, solution, coin)` | `Option` | takes your coin and the p2 puzzle hash from the hint; check `did.info.puzzle_hash()` against the coin | + +The same functions work whether the spend data comes from the `Simulator`, a peer +(`request_puzzle_and_solution`) or a full node RPC (`get_puzzle_and_solution`). This example identifies +two received coins: + +```rust +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::prelude::*; + +#[derive(Debug)] +enum Asset { + Cat(Cat), + Nft(Nft), + Did(Did), + Unknown, +} + +/// Works out what `coin` is from its parent's coin spend. +fn identify(ctx: &mut SpendContext, parent_spend: &CoinSpend, coin: Coin) -> anyhow::Result { + let puzzle = ctx.alloc(&parent_spend.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + let solution = ctx.alloc(&parent_spend.solution)?; + + if let Some(cats) = Cat::parse_children(ctx, parent_spend.coin, puzzle, solution)? { + if let Some(cat) = cats.into_iter().find(|cat| cat.coin == coin) { + return Ok(Asset::Cat(cat)); + } + } + + if let Some(nft) = Nft::parse_child(ctx, parent_spend.coin, puzzle, solution)? { + if nft.coin == coin { + return Ok(Asset::Nft(nft)); + } + } + + if let Some(did) = Did::parse_child(ctx, parent_spend.coin, puzzle, solution, coin)? { + // The p2 puzzle hash comes from the hint, so make sure it produces this coin. + if Bytes32::from(did.info.puzzle_hash()) == coin.puzzle_hash { + return Ok(Asset::Did(did)); + } + } + + Ok(Asset::Unknown) +} + +#[test] +fn identify_received_coins() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(101); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); + let bob_memos = ctx.hint(bob.puzzle_hash)?; + + // Alice issues a CAT directly to Bob... + let (issue_cat, _cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 100, + Conditions::new().create_coin(bob.puzzle_hash, 100, bob_memos), + )?; + + // ...and mints an NFT from an intermediate coin, then sends it to Bob. + let intermediate = Coin::new(alice.coin.coin_id(), alice.puzzle_hash, 1); + alice_p2.spend( + ctx, + alice.coin, + issue_cat.create_coin(alice.puzzle_hash, 1, Memos::None), + )?; + let metadata = ctx.alloc_hashed(&NftMetadata::default())?; + let mint = NftMint::new(metadata, alice.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(intermediate.coin_id(), 1).mint_nft(ctx, &mint)?; + alice_p2.spend(ctx, intermediate, mint_nft)?; + let _nft = nft.transfer(ctx, &alice_p2, bob.puzzle_hash, Conditions::new())?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob syncs his hinted coins and identifies each one. + let mut found = Vec::new(); + for coin_id in sim.hinted_coins(bob.puzzle_hash) { + let coin = sim.coin_state(coin_id).unwrap().coin; + let parent_spend = sim.coin_spend(coin.parent_coin_info).unwrap(); + found.push(identify(ctx, &parent_spend, coin)?); + } + + assert!(found.iter().any(|asset| matches!(asset, Asset::Cat(cat) if cat.coin.amount == 100))); + assert!(found.iter().any(|asset| matches!(asset, Asset::Nft(_)))); + + Ok(()) +} +``` + +A few things to keep in mind: + +- Parsing from the parent trusts the parent's spend, not the hint. Hints are just memos and anyone can + hint anything to your puzzle hash, so always check that the parsed child's `p2_puzzle_hash` is one you + can spend before treating it as yours. +- `Cat::parse_children` and `Nft::parse_child` don't take your coin, so compare the result with it, as + above. `Did::parse_child` takes your coin but trusts the hint for the p2 puzzle hash, so check that the + parsed DID's puzzle hash matches the coin. +- Some primitives can't be fully reconstructed from the parent alone. For example DID metadata is assumed + unchanged from the parent, and revocable CAT children of a revocation spend rely on a hint (see + [DID](/sdk/primitives/did#updating-metadata) and [CAT](/sdk/primitives/cat#revocable-cats)). +- If you need to inspect a coin's **own** spend instead (for example to see what a transaction did), use + `Cat::parse`, `Nft::parse` or `Did::parse`, which also return the p2 puzzle and solution, or the + `*Info::parse` functions for a bare puzzle. + +## Lineage proofs + +Anyone can create a coin with any puzzle hash, so an outer puzzle like the CAT or singleton layer has to +prove the coin was created by a legitimate parent of the same kind. It does this with a proof of the +parent, passed in the solution: + +```rust ignore +pub struct LineageProof { + pub parent_parent_coin_info: Bytes32, + pub parent_inner_puzzle_hash: Bytes32, + pub parent_amount: u64, +} +``` + +The outer puzzle rebuilds the parent's full puzzle hash from `parent_inner_puzzle_hash` and its own +curried values (asset ID or launcher ID), hashes it with the other two fields to get the parent's coin +ID, and checks that against its own parent coin ID. Note that the inner puzzle hash is the hash of +everything inside the outer layer, which is not always the p2 puzzle hash (the NFT, DID and revocable CAT +layers sit in between). The `child_lineage_proof` methods and the `*Info::inner_puzzle_hash` methods get +this right for you. + +Singletons use `Proof`, which is either a `LineageProof` or, for the first coin after the launcher, an +`EveProof` (the launcher's parent coin ID and amount). CATs use `Option`, where `None` is +only valid for a spend that runs the TAIL. + +In practice you rarely build proofs by hand: + +- Spend methods return children with the proof filled in. +- `child_lineage_proof()`, `Cat::child` and `Singleton::child_with` compute them from the parent + primitive. +- The parsing functions above compute them from the parent spend. + +## All primitives + +| Page | Types | Feature | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------- | +| [Standard (XCH)](/sdk/primitives/standard) | `StandardLayer` | | +| [CAT](/sdk/primitives/cat) | `Cat`, `CatInfo`, `CatSpend` | | +| [Singletons & Launchers](/sdk/primitives/singletons) | `Launcher`, `IntermediateLauncher`, `Singleton`, `SingletonInfo` | | +| [NFT](/sdk/primitives/nft) | `Nft`, `NftInfo`, `NftMint`, `MetadataUpdate` | | +| [DID](/sdk/primitives/did) | `Did`, `DidInfo` | | +| [Option Contracts](/sdk/primitives/options) | `OptionContract`, `OptionLauncher`, `OptionUnderlying` | | +| [Vaults & MIPS](/sdk/primitives/vaults) | `Vault`, `VaultInfo`, `MipsSpend`, `MofN`, `P2Singleton` | | +| [Clawback](/sdk/primitives/clawback) | `Clawback`, `ClawbackV2` | | +| [Streamed Assets](/sdk/primitives/streaming) | `StreamedAsset`, `StreamingPuzzleInfo` | | +| [Bulletins](/sdk/primitives/bulletin) | `Bulletin`, `BulletinMessage` | | +| [DataLayer](/sdk/primitives/datalayer) | `Datastore`, `DatastoreInfo` | `chip-0035` | +| [Action Layer](/sdk/primitives/action-layer) | `CatalogRegistry`, `XchandlesRegistry`, `RewardDistributor`, `MedievalVault`, `StateScheduler`, `Slot` | `action-layer` | + +All primitive types live in `chia_wallet_sdk::driver`, and the most common ones are re-exported from +`chia_wallet_sdk::prelude`. See the +[driver rustdoc](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/) for the full API. diff --git a/docs/sdk/primitives/nft.md b/docs/sdk/primitives/nft.md index c40fffd..e170993 100644 --- a/docs/sdk/primitives/nft.md +++ b/docs/sdk/primitives/nft.md @@ -1,545 +1,437 @@ --- -slug: /sdk/primitives/nft title: NFT --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - # NFT -NFTs (Non-Fungible Tokens) in Chia are singleton-based assets that can hold metadata, have ownership controls, and support royalties. Each NFT has a unique launcher ID that serves as its permanent identifier. +An NFT is a [singleton](/sdk/primitives/singletons) whose inner puzzle stores metadata, an optional +assigned owner (usually a [DID](/sdk/primitives/did)), and royalty information. The launcher ID is the +NFT's permanent identifier (encoded as an `nft1...` address). The on-chain NFT1 standard is +[CHIP-0005](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0005.md), and the off-chain +metadata JSON format is [CHIP-0007](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0007.md). -## Overview +In the SDK, `Nft` is `Singleton`. This page covers minting (single and bulk), transfers, DID +assignment, metadata updates, royalties and parsing. -Chia NFTs are built on the singleton pattern, meaning: +:::tip +The [Action System](/sdk/action-system) can mint (`Action::mint_nft`, `Action::mint_nft_from_did`), +update (`Action::update_nft`) and send NFTs alongside other assets, and the [Offers](/sdk/offers) page +covers trading them with royalties. This page describes the low-level `Nft` API those are built on. +::: -- Each NFT has a unique identity that persists across spends -- Only one instance of an NFT can exist at any time -- The NFT's launcher ID remains constant even as the coin ID changes +## Puzzle structure and `NftInfo` -NFTs consist of multiple layers: +An NFT coin's puzzle is a stack of layers: -| Layer | Purpose | -| ------------- | ----------------------------------------------- | -| Singleton | Ensures uniqueness and provides the launcher ID | -| NFT State | Holds metadata URIs and hash | -| NFT Ownership | Controls transfer and royalty logic | -| Inner (p2) | Defines who can spend the NFT | +```text +SingletonLayer launcher_id +└─ NftStateLayer metadata, metadata_updater_puzzle_hash + └─ NftOwnershipLayer current_owner + ├─ RoyaltyTransferLayer launcher_id, royalty_puzzle_hash, royalty_basis_points + └─ p2 puzzle (e.g. the owner's standard puzzle) +``` -## Key Types +`NftInfo` holds the values curried into these layers. The SDK calls the full stack `StandardNftLayers`. + +```rust ignore +pub struct NftInfo { + pub launcher_id: Bytes32, + pub metadata: HashedPtr, + pub metadata_updater_puzzle_hash: Bytes32, + pub current_owner: Option, + pub royalty_puzzle_hash: Bytes32, + pub royalty_basis_points: u16, + pub p2_puzzle_hash: Bytes32, +} +``` -| Type | Description | -| ------------- | ---------------------------------------------- | -| `Nft` | An NFT with its metadata and inner puzzle info | -| `NftInfo` | Launcher ID, metadata, owner info, royalties | -| `NftMetadata` | URIs for data, metadata, and license | -| `Proof` | Singleton lineage proof for validation | +- `metadata` is a `HashedPtr` into the allocator. For standard NFTs it's an `NftMetadata` value. +- `metadata_updater_puzzle_hash` is the program allowed to change the metadata. The default updater + (`NFT_METADATA_UPDATER_DEFAULT_HASH`) only allows prepending new URIs. +- `current_owner` is the launcher ID of the singleton the NFT is assigned to, typically a DID. +- `royalty_basis_points` is in hundredths of a percent (300 is 3%), paid to `royalty_puzzle_hash` when the + NFT is traded in an offer. + +## Minting + +Minting launches a singleton and immediately spends the eve NFT to its first owner. The parameters are +collected in `NftMint`: + +```rust ignore +pub struct NftMint { + pub metadata: HashedPtr, + pub metadata_updater_puzzle_hash: Bytes32, // default updater + pub royalty_puzzle_hash: Bytes32, // defaults to p2_puzzle_hash + pub royalty_basis_points: u16, + pub p2_puzzle_hash: Bytes32, + pub transfer_condition: Option, // assign to a DID on mint +} +``` -## Minting NFTs +`NftMint::new(metadata, p2_puzzle_hash, royalty_basis_points, transfer_condition)` fills in the default +metadata updater and pays royalties to the recipient. Set the fields directly to change them. -To mint a new NFT, use the `Nft::mint` function: +### Minting a single NFT - - +`Launcher::mint_nft` returns the conditions for the parent coin and the minted `Nft`. Here the parent is +an XCH coin and the NFT has no assigned owner: ```rust +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; use chia_wallet_sdk::prelude::*; -let ctx = &mut SpendContext::new(); -let p2 = StandardLayer::new(public_key); - -// Define the NFT metadata -let metadata = NftMetadata { - data_uris: vec!["https://example.com/image.png".to_string()], - data_hash: Some(image_hash), - metadata_uris: vec!["https://example.com/metadata.json".to_string()], - metadata_hash: Some(metadata_hash), - license_uris: vec![], - license_hash: None, - edition_number: Some(1), - edition_total: Some(100), -}; - -// Mint the NFT -let (mint_conditions, nft) = Nft::mint( - ctx, - parent_coin_id, // Coin being spent to mint - owner_puzzle_hash, // Initial owner - Some(royalty_puzzle_hash), // Royalty recipient (or None) - royalty_percentage, // Royalty in basis points (e.g., 300 = 3%) - metadata, - owner_puzzle_hash, // Hint for discovery -)?; - -// Spend the parent coin with mint conditions -p2.spend(ctx, parent_coin, mint_conditions)?; - -// The NFT's permanent identifier -let launcher_id = nft.info.launcher_id; -``` +#[test] +fn mint_nft() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); - - - -```typescript -import { - Clvm, - NftMetadata, - NftMint, - Constants, - Simulator, -} from "chia-wallet-sdk"; - -const clvm = new Clvm(); -const sim = new Simulator(); -const alice = sim.bls(1n); - -// Define the NFT metadata -const metadata = new NftMetadata( - 1n, // edition number - 1n, // edition total - ["https://example.com/image.png"], // data URIs - null, // data hash (optional) - ["https://example.com/metadata.json"], // metadata URIs - null, // metadata hash (optional) - [], // license URIs - null, // license hash (optional) -); - -// Mint the NFT -const { nfts, parentConditions } = clvm.mintNfts(alice.coin.coinId(), [ - new NftMint( - clvm.nftMetadata(metadata), - Constants.nftMetadataUpdaterDefaultHash(), - alice.puzzleHash, // Royalty puzzle hash - alice.puzzleHash, // Owner p2 puzzle hash - 300, // Royalty in basis points (3%) - ), -]); - -// Spend the parent coin with mint conditions -clvm.spendStandardCoin( - alice.coin, - alice.pk, - clvm.delegatedSpend(parentConditions), -); - -// The NFT's permanent identifier -const launcherId = nfts[0].info.launcherId; -``` + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let creator = sim.bls(0); - - - -```python -from chia_wallet_sdk import Clvm, NftMetadata, NftMint, Constants, Simulator - -clvm = Clvm() -sim = Simulator() -alice = sim.bls(1) - -# Define the NFT metadata -metadata = NftMetadata( - 1, # edition number - 1, # edition total - ["https://example.com/image.png"], # data URIs - None, # data hash (optional) - ["https://example.com/metadata.json"], # metadata URIs - None, # metadata hash (optional) - [], # license URIs - None # license hash (optional) -) - -# Mint the NFT -result = clvm.mint_nfts(alice.coin.coin_id(), [ - NftMint( - clvm.nft_metadata(metadata), - Constants.nft_metadata_updater_default_hash(), - alice.puzzle_hash, # Royalty puzzle hash - alice.puzzle_hash, # Owner p2 puzzle hash - 300 # Royalty in basis points (3%) - ), -]) - -# Spend the parent coin with mint conditions -clvm.spend_standard_coin( - alice.coin, - alice.pk, - clvm.delegated_spend(result.parent_conditions) -) - -# The NFT's permanent identifier -launcher_id = result.nfts[0].info.launcher_id -``` + let metadata = ctx.alloc_hashed(&NftMetadata { + data_uris: vec!["https://example.com/image.png".to_string()], + data_hash: Some(Bytes32::new([1; 32])), + ..Default::default() + })?; + + let mut mint = NftMint::new(metadata, alice.puzzle_hash, 250, None); + // Pay the 2.5% royalty to someone other than the first owner. + mint.royalty_puzzle_hash = creator.puzzle_hash; + + let (mint_nft, nft) = Launcher::new(alice.coin.coin_id(), 1).mint_nft(ctx, &mint)?; + alice_p2.spend(ctx, alice.coin, mint_nft)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; - - + assert_eq!(nft.info.p2_puzzle_hash, alice.puzzle_hash); + assert_eq!(nft.info.royalty_puzzle_hash, creator.puzzle_hash); + assert_eq!(nft.info.current_owner, None); + assert!(sim.coin_state(nft.coin.coin_id()).is_some()); -## Transferring NFTs + Ok(()) +} +``` -To transfer an NFT to a new owner: +`mint_eve_nft` is the lower-level step: it creates the eve NFT with a p2 puzzle hash you choose and +returns it unspent, with an `EveProof`. - - +### Bulk minting + +A parent coin can only create one launcher of a given amount, so minting many NFTs at once uses +[`IntermediateLauncher`](/sdk/primitives/singletons#creating-many-launchers-from-one-parent-intermediatelauncher). +Each index gives a distinct launcher ID: ```rust +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::driver::IntermediateLauncher; use chia_wallet_sdk::prelude::*; -let ctx = &mut SpendContext::new(); +#[test] +fn bulk_mint() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -// Transfer to a new owner -let new_nft = nft.transfer( - ctx, - &p2, // Current owner's p2 layer - new_owner_puzzle_hash, // New owner - Conditions::new(), // Additional conditions (fees, etc.) -)?; + let total = 5; + let alice = sim.bls(total as u64); + let alice_p2 = StandardLayer::new(alice.pk); -let coin_spends = ctx.take(); -``` + let mut conditions = Conditions::new(); + let mut nfts = Vec::new(); - - + for i in 0..total { + let metadata = ctx.alloc_hashed(&NftMetadata { + edition_number: i as u64 + 1, + edition_total: total as u64, + data_uris: vec![format!("https://example.com/{i}.png")], + ..Default::default() + })?; + let mint = NftMint::new(metadata, alice.puzzle_hash, 300, None); -```typescript -// Transfer the NFT to a new owner -const innerSpend = clvm.standardSpend( - alice.pk, - clvm.delegatedSpend([ - clvm.createCoin(newOwnerPuzzleHash, 1n, clvm.alloc([newOwnerPuzzleHash])), - ]), -); + let (mint_nft, nft) = IntermediateLauncher::new(alice.coin.coin_id(), i, total) + .create(ctx)? + .mint_nft(ctx, &mint)?; + conditions = conditions.extend(mint_nft); + nfts.push(nft); + } -const newNft = clvm.spendNft(nft, innerSpend); + alice_p2.spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; -const coinSpends = clvm.coinSpends(); -``` + assert_eq!(nfts.len(), total); - - + Ok(()) +} +``` -```python -# Transfer the NFT to a new owner -inner_spend = clvm.standard_spend( - alice.pk, - clvm.delegated_spend([ - clvm.create_coin(new_owner_puzzle_hash, 1, clvm.alloc([new_owner_puzzle_hash])), - ]) -) +Every launcher needs 1 mojo, so the parent must provide `total` mojos. For large collections, split the +mint over several transactions to stay under the block cost limit. -new_nft = clvm.spend_nft(nft, inner_spend) +### Minting from a DID -coin_spends = clvm.coin_spends() -``` +To mint NFTs owned by a DID, create the intermediate launchers from the DID coin, set `transfer_condition` +to a `TransferNft` naming the DID, and emit the mint conditions from a DID update spend. This is covered +with a full example on the [DID page](/sdk/primitives/did#minting-nfts-from-a-did). - - +## Transferring -For more control over the transfer, use `transfer_with_metadata`: +`Nft::transfer` spends the NFT with a `SpendWithConditions` inner layer and recreates it at a new p2 +puzzle hash, hinted to the recipient: ```rust -let new_nft = nft.transfer_with_metadata( - ctx, - &p2, - new_owner_puzzle_hash, - updated_metadata, // Can update metadata during transfer - Conditions::new(), -)?; -``` +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::prelude::*; -## Spending NFTs with Custom Conditions +#[test] +fn transfer_nft() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -For complex operations beyond simple transfers: + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); - - + let metadata = ctx.alloc_hashed(&NftMetadata::default())?; + let mint = NftMint::new(metadata, alice.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(alice.coin.coin_id(), 1).mint_nft(ctx, &mint)?; + alice_p2.spend(ctx, alice.coin, mint_nft)?; -```rust -let ctx = &mut SpendContext::new(); - -// Build conditions for the NFT spend -let conditions = Conditions::new() - .create_coin_announcement(b"nft_action") - .reserve_fee(fee_amount); - -// Spend with custom conditions -let new_nft = nft.spend( - ctx, - p2.spend_with_conditions(ctx, conditions)?, -)?; -``` + // Mint and transfer can happen in the same transaction. + let nft = nft.transfer(ctx, &alice_p2, bob.puzzle_hash, Conditions::new())?; + sim.spend_coins(ctx.take(), &[alice.sk])?; - - - -```typescript -// Build conditions for the NFT spend -const innerSpend = clvm.standardSpend( - alice.pk, - clvm.delegatedSpend([ - clvm.createCoinAnnouncement(Buffer.from("nft_action")), - clvm.reserveFee(feeAmount), - clvm.createCoin(newOwnerPuzzleHash, 1n, clvm.alloc([newOwnerPuzzleHash])), - ]), -); - -// Spend with custom conditions -const newNft = clvm.spendNft(nft, innerSpend); -``` + assert_eq!(nft.info.p2_puzzle_hash, bob.puzzle_hash); + assert!(sim.coin_state(nft.coin.coin_id()).is_some()); - - - -```python -# Build conditions for the NFT spend -inner_spend = clvm.standard_spend( - alice.pk, - clvm.delegated_spend([ - clvm.create_coin_announcement(b"nft_action"), - clvm.reserve_fee(fee_amount), - clvm.create_coin(new_owner_puzzle_hash, 1, clvm.alloc([new_owner_puzzle_hash])), - ]) -) - -# Spend with custom conditions -new_nft = clvm.spend_nft(nft, inner_spend) + Ok(()) +} ``` - - +Unlike DIDs and other singletons, NFT `CREATE_COIN` conditions use the new **p2** puzzle hash. The +ownership and state layers rewrap the output with the full NFT puzzle, so you send to the recipient's +address directly. Every spend method returns the child `Nft`, which you can spend again in the same +transaction. -## Royalties +For other spends, use `spend_with(ctx, &p2, conditions)` or `spend(ctx, inner_spend)` with any p2 +`Spend`. Both require the conditions to create the odd-amount child (at its p2 puzzle hash) and return +`DriverError::MissingChild` otherwise. + +:::note +NFTs can't be melted. A p2 spend that outputs `melt_singleton()` fails when the NFT layers run it, and +`spend_with` returns `MissingChild` (after it has already added the coin spend to the context). To get rid of an NFT, transfer it to an unspendable puzzle hash (a burn +address) instead. +::: -NFTs support royalties that are enforced during trades. The royalty is specified as a percentage in basis points (1/100th of a percent): +## Assigning an owner (DID) -| Basis Points | Percentage | -| ------------ | ---------- | -| 100 | 1% | -| 250 | 2.5% | -| 500 | 5% | -| 1000 | 10% | +The `current_owner` field is changed by a `TransferNft` condition from the NFT's p2 spend. To assign an +NFT to a DID, the DID must approve in the same transaction, which `Nft::assign_owner` arranges: it returns +the conditions the DID spend has to emit, along with the child NFT. ```rust -// 5% royalty -let royalty_basis_points = 500; - -let (mint_conditions, nft) = Nft::mint( - ctx, - parent_coin_id, - owner_puzzle_hash, - Some(royalty_puzzle_hash), // Where royalties are paid - royalty_basis_points, - metadata, - owner_puzzle_hash, -)?; +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::prelude::*; + +#[test] +fn assign_to_did() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(2); + let alice_p2 = StandardLayer::new(alice.pk); + + // A DID and an unassigned NFT, both owned by Alice. + let intermediate = Coin::new(alice.coin.coin_id(), alice.puzzle_hash, 1); + let (create_did, did) = + Launcher::new(alice.coin.coin_id(), 1).create_simple_did(ctx, &alice_p2)?; + alice_p2.spend( + ctx, + alice.coin, + create_did.create_coin(alice.puzzle_hash, 1, Memos::None), + )?; + + let metadata = ctx.alloc_hashed(&NftMetadata::default())?; + let mint = NftMint::new(metadata, alice.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(intermediate.coin_id(), 1).mint_nft(ctx, &mint)?; + alice_p2.spend(ctx, intermediate, mint_nft)?; + + // Assign the NFT to the DID. The DID's current inner puzzle hash is required. + let (did_conditions, nft) = nft.assign_owner( + ctx, + &alice_p2, + alice.puzzle_hash, + TransferNft::new( + Some(did.info.launcher_id), + Vec::new(), + Some(did.info.inner_puzzle_hash().into()), + ), + Conditions::new(), + )?; + let did = did.update(ctx, &alice_p2, did_conditions)?; + assert_eq!(nft.info.current_owner, Some(did.info.launcher_id)); + + // Unassigning doesn't need the DID, so the returned conditions are empty. + let (did_conditions, nft) = nft.assign_owner( + ctx, + &alice_p2, + alice.puzzle_hash, + TransferNft::new(None, Vec::new(), None), + Conditions::new(), + )?; + assert!(did_conditions.is_empty()); + assert_eq!(nft.info.current_owner, None); + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + Ok(()) +} ``` -:::info -Royalties are enforced through the offer system. Direct transfers don't automatically pay royalties - they're applied when NFTs are traded via offers. -::: +Plain `transfer` keeps `current_owner` unchanged. Wallets typically unassign the DID when an NFT leaves +the owner's wallet; do that with `assign_owner` and `TransferNft::new(None, Vec::new(), None)` while +transferring to the new p2 puzzle hash. -## Parsing NFTs +The owner doesn't have to be a DID: any singleton that can emit the returned conditions works, such as a +[vault](/sdk/primitives/vaults). -When you need to reconstruct an NFT from blockchain data: +## Updating metadata + +Metadata changes go through the NFT's metadata updater, invoked by an `UPDATE_NFT_METADATA` condition in +the p2 spend. With the default updater you can only **prepend** a data, metadata or license URI; the hashes +and edition fields are immutable. `MetadataUpdate` builds the updater spend: ```rust -// Parse an NFT from a parent coin spend -let nft = Nft::parse_child( - ctx, - parent_coin_spend, - child_coin, -)?; - -// Or parse directly from puzzle/solution -let parsed = Nft::::parse( - &ctx.allocator(), - puzzle_ptr, - solution_ptr, -)?; +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::driver::{MetadataUpdate, UriKind}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn add_nft_uri() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + + let metadata = ctx.alloc_hashed(&NftMetadata { + data_uris: vec!["https://example.com/a.png".to_string()], + data_hash: Some(Bytes32::new([1; 32])), + ..Default::default() + })?; + let mint = NftMint::new(metadata, alice.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(alice.coin.coin_id(), 1).mint_nft(ctx, &mint)?; + alice_p2.spend(ctx, alice.coin, mint_nft)?; + + // Add a mirror for the image. + let update = MetadataUpdate { + kind: UriKind::Data, + uri: "https://mirror.example.com/a.png".to_string(), + } + .spend(ctx)?; + + // Keep the NFT with Alice while running the update. + let nft = nft.transfer_with_metadata(ctx, &alice_p2, alice.puzzle_hash, update, Conditions::new())?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let metadata: NftMetadata = ctx.extract(nft.info.metadata.ptr())?; + assert_eq!( + metadata.data_uris, + vec![ + "https://mirror.example.com/a.png".to_string(), + "https://example.com/a.png".to_string(), + ] + ); + + Ok(()) +} ``` -## NFT Ownership Layer +The returned `Nft` already has the new metadata, computed by running the updater locally +(`run_metadata_updater`). `UriKind::Metadata` and `UriKind::License` update the other URI lists. A +custom metadata updater can allow other changes, but wallets and indexers may not support it. + +## Royalties -The ownership layer tracks: +Royalties are enforced when an NFT is traded through an [offer](/sdk/offers), not on plain transfers. The +`RoyaltyTransferLayer` is the NFT's transfer program: when the p2 spend outputs a `TransferNft` condition +with **trade prices**, it requires a payment of `royalty_basis_points` of each price to +`royalty_puzzle_hash`, through the settlement puzzle, in the same transaction. -- Current owner (puzzle hash) -- Transfer program (for trading/offers) -- Royalty information +The relevant methods are: -When building custom NFT interactions, be aware that the ownership layer requires proper handling for the NFT to remain valid. +- `Nft::lock_settlement(ctx, &p2, trade_prices, conditions)` moves the NFT to the settlement puzzle and + reveals the trade prices, unassigning the owner. This is the maker's side of an offer. +- `Nft::unlock_settlement(ctx, notarized_payments)` spends an NFT that is locked in the settlement puzzle, + on the taker's side. -## Complete Example +The royalty amounts themselves (and the payments that satisfy them) are calculated by the offer code; see +[Offers](/sdk/offers) for the full flow, including `RoyaltyInfo` and how royalties combine for multiple +NFTs and assets. -Here's a full example of minting and transferring an NFT: +## Parsing NFTs - - +To spend an NFT you received, fetch the parent coin's spend and call `Nft::parse_child`. It runs the +parent's p2 spend, applying any `TransferNft` and metadata update it contains, so the returned `Nft` has +the correct owner and metadata: ```rust +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; use chia_wallet_sdk::prelude::*; -fn mint_and_transfer_nft( - minter_coin: Coin, - minter_public_key: PublicKey, - recipient_puzzle_hash: Bytes32, - metadata: NftMetadata, -) -> Result<(Bytes32, Vec), DriverError> { +#[test] +fn parse_nft() -> anyhow::Result<()> { + let mut sim = Simulator::new(); let ctx = &mut SpendContext::new(); - let p2 = StandardLayer::new(minter_public_key); - let minter_puzzle_hash = StandardLayer::puzzle_hash(minter_public_key); - // Step 1: Mint the NFT - let (mint_conditions, nft) = Nft::mint( - ctx, - minter_coin.coin_id(), - minter_puzzle_hash, - None, // No royalties for this example - 0, - metadata, - minter_puzzle_hash, - )?; + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = sim.bls(0); - p2.spend(ctx, minter_coin, mint_conditions)?; - let launcher_id = nft.info.launcher_id; + let metadata = ctx.alloc_hashed(&NftMetadata::default())?; + let mint = NftMint::new(metadata, alice.puzzle_hash, 300, None); + let (mint_nft, nft) = Launcher::new(alice.coin.coin_id(), 1).mint_nft(ctx, &mint)?; + alice_p2.spend(ctx, alice.coin, mint_nft)?; + let expected = nft.transfer(ctx, &alice_p2, bob.puzzle_hash, Conditions::new())?; + sim.spend_coins(ctx.take(), &[alice.sk])?; - // Step 2: Transfer to recipient - let _new_nft = nft.transfer( - ctx, - &p2, - recipient_puzzle_hash, - Conditions::new(), - )?; + // Bob finds the coin by its hint, then looks up the parent spend. + let coin_id = sim.hinted_coins(bob.puzzle_hash)[0]; + let coin = sim.coin_state(coin_id).unwrap().coin; + let parent_spend = sim.coin_spend(coin.parent_coin_info).unwrap(); - Ok((launcher_id, ctx.take())) -} -``` + let parent_puzzle = ctx.alloc(&parent_spend.puzzle_reveal)?; + let parent_puzzle = Puzzle::parse(ctx, parent_puzzle); + let parent_solution = ctx.alloc(&parent_spend.solution)?; - - - -```typescript -import { - Clvm, - NftMetadata, - NftMint, - Constants, - Simulator, -} from "chia-wallet-sdk"; - -function mintAndTransferNft(recipientPuzzleHash: Uint8Array) { - const clvm = new Clvm(); - const sim = new Simulator(); - const alice = sim.bls(2n); - - // Step 1: Mint the NFT - const metadata = new NftMetadata( - 1n, - 1n, - ["https://example.com/image.png"], - null, - [], - null, - [], - null, - ); - - const { nfts, parentConditions } = clvm.mintNfts(alice.coin.coinId(), [ - new NftMint( - clvm.nftMetadata(metadata), - Constants.nftMetadataUpdaterDefaultHash(), - alice.puzzleHash, - alice.puzzleHash, - 0, // No royalties - ), - ]); - - clvm.spendStandardCoin( - alice.coin, - alice.pk, - clvm.delegatedSpend(parentConditions), - ); - - const launcherId = nfts[0].info.launcherId; - - // Step 2: Transfer to recipient - clvm.spendNft( - nfts[0], - clvm.standardSpend( - alice.pk, - clvm.delegatedSpend([ - clvm.createCoin( - recipientPuzzleHash, - 1n, - clvm.alloc([recipientPuzzleHash]), - ), - ]), - ), - ); + let nft = Nft::parse_child(ctx, parent_spend.coin, parent_puzzle, parent_solution)? + .expect("parent is not an NFT"); - // Sign and validate - sim.spendCoins(clvm.coinSpends(), [alice.sk]); + // `parse_child` doesn't know which coin you have, so check it. + assert_eq!(nft.coin, coin); + assert_eq!(nft, expected); - return launcherId; + Ok(()) } ``` - - - -```python -from chia_wallet_sdk import Clvm, NftMetadata, NftMint, Constants, Simulator - -def mint_and_transfer_nft(recipient_puzzle_hash: bytes): - clvm = Clvm() - sim = Simulator() - alice = sim.bls(2) - - # Step 1: Mint the NFT - metadata = NftMetadata( - 1, 1, - ["https://example.com/image.png"], - None, [], None, [], None - ) - - result = clvm.mint_nfts(alice.coin.coin_id(), [ - NftMint( - clvm.nft_metadata(metadata), - Constants.nft_metadata_updater_default_hash(), - alice.puzzle_hash, - alice.puzzle_hash, - 0 # No royalties - ), - ]) +Unlike `Did::parse_child`, `Nft::parse_child` doesn't take the child coin and doesn't rely on a hint, because +the NFT child's p2 puzzle hash is visible in the parent's `CREATE_COIN`. It returns `Ok(None)` if the +parent isn't an NFT and `DriverError::MissingChild` if the parent was melted. - clvm.spend_standard_coin( - alice.coin, - alice.pk, - clvm.delegated_spend(result.parent_conditions) - ) - - launcher_id = result.nfts[0].info.launcher_id - - # Step 2: Transfer to recipient - clvm.spend_nft( - result.nfts[0], - clvm.standard_spend( - alice.pk, - clvm.delegated_spend([ - clvm.create_coin(recipient_puzzle_hash, 1, clvm.alloc([recipient_puzzle_hash])), - ]) - ) - ) - - # Sign and validate - sim.spend_coins(clvm.coin_spends(), [alice.sk]) - - return launcher_id -``` +The other parsers: + +- `Nft::parse(allocator, coin, puzzle, solution)` parses an NFT coin's own spend into the `Nft`, its p2 + puzzle and its p2 solution. +- `NftInfo::parse(allocator, puzzle)` parses just a puzzle into `NftInfo` and the p2 puzzle. +- `NftInfo::child_from_p2_spend` computes the next `NftInfo` from a p2 spend. - - +To read the metadata, extract it as `NftMetadata` with `ctx.extract(nft.info.metadata.ptr())`, as in the +metadata update example. -## API Reference +## Gotchas -For the complete NFT API, see [docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.Nft.html). +- `metadata` is a `HashedPtr`, so an `Nft` is tied to the allocator it was parsed or created in. Persist + the metadata separately if you store NFTs. +- The `TransferNft` used to assign a DID must contain the DID's inner puzzle hash at the time of the DID + spend. If you spend the DID earlier in the same transaction, use the updated `Did`. +- `NftMint::new` sets `royalty_puzzle_hash` to the first owner. Override it if royalties should go to the + creator rather than the recipient. diff --git a/docs/sdk/primitives/options.md b/docs/sdk/primitives/options.md new file mode 100644 index 0000000..3e298e1 --- /dev/null +++ b/docs/sdk/primitives/options.md @@ -0,0 +1,303 @@ +--- +title: Option Contracts +--- + +# Option Contracts + +An option contract gives its owner the right, but not the obligation, to buy an asset at a fixed price before a deadline. On Chia, the option is a [singleton](/sdk/primitives/singletons) that can be traded like an NFT, and the asset it's for (the **underlying**) is locked in a separate coin that the option controls. Until the expiration timestamp, whoever owns the option can exercise it by paying the **strike** to the option's creator, which releases the underlying to them. After expiration, the creator can take the underlying back. + +Use options for covered calls, for selling the right to buy an asset later, or anywhere one party wants to lock up an asset at a price without committing the other party to buy it. The underlying can be XCH, a CAT (including a revocable CAT) or an NFT, and the strike can be any of those as well. + +The types live in [`chia_wallet_sdk::driver`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/index.html), and are in the prelude. + +## How it works + +An option involves two coins: + +- The **option contract** (`OptionContract`, which is `Singleton`). Its inner puzzle is the option contract layer, which commits to the id of the underlying coin and the hash of the delegated puzzle used to exercise it, and wraps the owner's p2 puzzle. The owner can transfer it freely. +- The **underlying coin**, locked by a 1-of-2 puzzle (`OptionUnderlying`, built on `P2OneOfManyLayer`) with two paths: + - **Exercise**: requires a message from the option singleton, which it only sends when it's melted. The delegated puzzle for this path asserts that the timestamp is before the expiration, asserts that the strike was paid to the creator (as a notarized payment from a settlement coin, with the option's launcher id as the nonce), and sends the underlying to the settlement payments puzzle, where the exerciser can claim it in the same transaction. + - **Clawback**: the creator's puzzle, with an `ASSERT_SECONDS_ABSOLUTE` of the expiration, so the creator can only reclaim the underlying after it expires. + +Exercising is therefore atomic: the option is melted, the strike is paid, and the underlying is released in one spend bundle, or none of it happens. The strike payment is the same kind of notarized payment an [offer](/sdk/offers) uses, so options can be exercised with the action system's settlement machinery. + +| Type | What it is | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OptionContract` | The option singleton: `coin`, `proof` and `info`. Has `parse_child`, `parse`, `spend`, `transfer`, `exercise`. | +| `OptionInfo` | `launcher_id`, `underlying_coin_id`, `underlying_delegated_puzzle_hash` and the owner's `p2_puzzle_hash`. Has `is_backed_by`. | +| `OptionUnderlying` | The terms of the underlying coin's puzzle: launcher id, creator puzzle hash, expiration `seconds`, underlying `amount`, and strike type. | +| `OptionType` | The strike: `Xch { amount }`, `Cat { asset_id, amount }`, `RevocableCat { asset_id, hidden_puzzle_hash, amount }`, or `Nft { launcher_id, settlement_puzzle_hash, amount }`. | +| `OptionLauncher` | Builds a new option, in two states: before and after the underlying coin is known. | +| `OptionLauncherInfo` | The terms passed to `OptionLauncher`: creator, owner, expiration, underlying amount and strike type. | +| `OptionMetadata` | The expiration and strike type, stored in the launcher's solution so wallets can display the option's terms. | + +## Minting with the action system + +The simplest way to mint an option is `Action::mint_option`, which creates the launcher, locks the underlying asset, and mints the option in one transaction. The underlying can be XCH (`Id::Xch`), a CAT or an NFT in the spends, including one created by an earlier action in the same list. See the [Action Reference](/sdk/action-system/actions#options). + +```rust +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn mint_xch_option() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1_001); + let bob = BlsPair::new(1); + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + // Lock 1000 mojos, which Bob can buy for 600 mojos of XCH until timestamp 500 + let deltas = spends.apply( + &mut ctx, + &[ + Action::mint_option( + alice.puzzle_hash, + 500, + Id::Xch, + 1_000, + OptionType::Xch { amount: 600 }, + 1, + ), + Action::send(Id::New(0), bob.puzzle_hash, 1, bob_hint), + ], + )?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::None, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let option = outputs.options[&Id::New(0)]; + assert_eq!(option.info.p2_puzzle_hash, bob.puzzle_hash); + assert!( + outputs + .xch + .iter() + .any(|coin| coin.coin_id() == option.info.underlying_coin_id && coin.amount == 1_000) + ); + + Ok(()) +} +``` + +## Exercising + +Exercising takes three things in the same spend bundle: + +1. **Melt the option**, which sends the message that authorizes the underlying coin's exercise path. With the action system, that's `Action::melt_singleton(option_id, amount)`. At a low level, it's `option.exercise(ctx, &p2, extra_conditions)`. Melting without the message, or sending the message without melting, fails. +2. **Spend the underlying coin** with `underlying.exercise_spend(ctx, option.info.inner_puzzle_hash().into(), option.coin.amount)`, wrapping it in the asset's layers (`Cat::spend_all` for a CAT, `nft.spend` for an NFT, or `exercise_coin_spend` for XCH). This sends the underlying to a settlement coin. +3. **Pay the strike** with a notarized payment from a settlement coin: `underlying.requested_payment(ctx)` returns it. With the action system, that's `Action::settle(strike_id, payment)`, and the settlement child of the underlying can be added to the spends, so it's claimed as change. + +You need the option's terms (the `OptionUnderlying`) to build the exercise spend. The expiration and strike type are in the launcher's solution (`OptionContract::parse_metadata`), the creator's puzzle hash is the launcher's hint, and the underlying amount is the underlying coin's amount. **Always check them with `option.info.is_backed_by(&underlying, underlying_coin, underlying_p2_puzzle_hash)`** before paying for or exercising an option you didn't mint, since the option singleton only commits to hashes and anyone can mint an option with misleading metadata. + +```rust +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn exercise_cat_option() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(101); + let bob = sim.bls(50); + let bob_hint = ctx.hint(bob.puzzle_hash)?; + + // Alice issues 100 of a CAT and sells Bob an option to buy it for 50 mojos of XCH + let strike_type = OptionType::Xch { amount: 50 }; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + let deltas = spends.apply( + &mut ctx, + &[ + Action::single_issue_cat(None, 100), + Action::mint_option(alice.puzzle_hash, 1_000, Id::New(0), 100, strike_type, 1), + Action::send(Id::New(1), bob.puzzle_hash, 1, bob_hint), + ], + )?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::None, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + let option = outputs.options[&Id::New(1)]; + let underlying_cat = outputs.cats[&Id::New(0)] + .iter() + .copied() + .find(|cat| cat.coin.coin_id() == option.info.underlying_coin_id) + .expect("missing underlying"); + + // Bob reconstructs the terms and checks that the option is backed by the CAT + let underlying = OptionUnderlying::new( + option.info.launcher_id, + alice.puzzle_hash, + 1_000, + 100, + strike_type, + ); + assert!(option.info.is_backed_by( + &underlying, + underlying_cat.coin, + underlying_cat.info.p2_puzzle_hash, + )); + + // Spend the underlying CAT with the exercise path, which moves it to a settlement coin + let exercise_spend = + underlying.exercise_spend(&mut ctx, option.info.inner_puzzle_hash().into(), option.coin.amount)?; + let settlement_cats = Cat::spend_all(&mut ctx, &[CatSpend::new(underlying_cat, exercise_spend)])?; + + // Melt the option, pay the strike, and claim the released CAT as change + let strike_payment = underlying.requested_payment(&mut *ctx)?; + + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(bob.coin); + spends.add(option); + spends.add(settlement_cats[0]); + + let deltas = spends.apply( + &mut ctx, + &[ + Action::melt_singleton(Id::Existing(option.info.launcher_id), 1), + Action::settle(Id::Xch, strike_payment), + ], + )?; + let outputs = spends.finish_with_keys( + &mut ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { bob.puzzle_hash => bob.pk }, + )?; + sim.spend_coins(ctx.take(), &[bob.sk])?; + + // Bob has the CAT, and Alice was paid the strike + let bob_cat = &outputs.cats[&Id::Existing(underlying_cat.info.asset_id)][0]; + assert_eq!(bob_cat.info.p2_puzzle_hash, bob.puzzle_hash); + assert_eq!(bob_cat.coin.amount, 100); + assert_eq!(sim.unspent_coins(alice.puzzle_hash, false)[0].amount, 50); + assert!(outputs.options.is_empty()); + + Ok(()) +} +``` + +For a CAT strike, pay it with `Action::settle(Id::Existing(asset_id), payment)` from your CAT coins, and for an NFT strike, `Action::settle(Id::Existing(launcher_id), payment)` with the NFT in the spends. The payment is hinted to the creator for CAT and NFT strikes. + +## Minting at a low level + +Without the action system, minting takes three steps, because the option needs to know the underlying coin's id, and the underlying coin's puzzle needs to know the option's launcher id: + +1. Create an `OptionLauncher` from the parent coin of the launcher and an `OptionLauncherInfo`. At this point, it knows the launcher id, so `launcher.p2_puzzle_hash()` is the puzzle hash to lock the underlying with, and `launcher.underlying()` is the `OptionUnderlying`. +2. Create the underlying coin with that puzzle hash (for a CAT or NFT, as the p2 puzzle hash inside the asset's layers), then call `launcher.with_underlying(underlying_coin_id)`. +3. `launcher.mint(ctx)` returns the conditions for the parent coin to output (which create and spend the launcher) and the `OptionContract`, already sent to the owner. + +The expiration (`seconds`) is compared against the block timestamp. The Simulator starts at a timestamp of 0 and adds one second per block, and `sim.set_next_timestamp` or `sim.pass_time` moves it forward. + +## Expiring and reclaiming + +After the expiration timestamp, the exercise path stops working, and the creator can reclaim the underlying with `underlying.clawback_spend(ctx, creator_spend)`, where `creator_spend` is a spend of the creator's puzzle (whose hash is `creator_puzzle_hash`). For an XCH underlying, `clawback_coin_spend` spends the coin directly. For a CAT or NFT, wrap the returned spend in the asset's layers, the same as for exercising. The option singleton is still around after expiration, but it can no longer be exercised. + +This example mints an option at a low level, checks its metadata and lineage the way a wallet would, and then reclaims the underlying after it expires: + +```rust +use chia_wallet_sdk::chia::puzzle_types::Memos; +use chia_wallet_sdk::prelude::*; + +#[test] +fn mint_and_reclaim() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + let bob = BlsPair::new(1); + let underlying_parent = sim.new_coin(alice.puzzle_hash, 100); + + // 1. The launcher is created from Alice's 1 mojo coin, and the option is owned by Bob + let seconds = 10; + let strike_type = OptionType::Xch { amount: 60 }; + let launcher = OptionLauncher::new( + &mut ctx, + alice.coin.coin_id(), + OptionLauncherInfo::new(alice.puzzle_hash, bob.puzzle_hash, seconds, 100, strike_type), + 1, + )?; + let underlying = launcher.underlying(); + let underlying_puzzle_hash = launcher.p2_puzzle_hash(); + + // 2. Lock the underlying XCH with the option's puzzle + alice_p2.spend( + &mut ctx, + underlying_parent, + Conditions::new().create_coin(underlying_puzzle_hash, 100, Memos::None), + )?; + let underlying_coin = Coin::new(underlying_parent.coin_id(), underlying_puzzle_hash, 100); + let launcher = launcher.with_underlying(underlying_coin.coin_id()); + + // 3. Mint the option + let (mint_option, option) = launcher.mint(&mut ctx)?; + alice_p2.spend(&mut ctx, alice.coin, mint_option)?; + + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + assert_eq!(option.info.p2_puzzle_hash, bob.puzzle_hash); + assert!(option.info.is_backed_by(&underlying, underlying_coin, underlying_puzzle_hash)); + + // A wallet reads the terms from the launcher's solution... + let launcher_spend = sim.coin_spend(option.info.launcher_id).expect("missing launcher"); + let launcher_solution = ctx.alloc(&launcher_spend.solution)?; + let metadata = OptionContract::parse_metadata(&mut ctx, launcher_solution)?; + assert_eq!(metadata, OptionMetadata::new(seconds, strike_type)); + + // ...and parses the option from its parent spend + let parent_spend = sim.coin_spend(option.coin.parent_coin_info).expect("missing parent"); + let parent_puzzle = ctx.alloc(&parent_spend.puzzle_reveal)?; + let parent_puzzle = Puzzle::parse(&ctx, parent_puzzle); + let parent_solution = ctx.alloc(&parent_spend.solution)?; + let parsed = OptionContract::parse_child(&mut ctx, parent_spend.coin, parent_puzzle, parent_solution)?; + assert_eq!(parsed, Some(option)); + + // Alice can't reclaim the underlying before it expires + let reclaim = |ctx: &mut SpendContext| -> anyhow::Result<()> { + let creator_spend = alice_p2.spend_with_conditions( + ctx, + Conditions::new().create_coin(alice.puzzle_hash, 100, Memos::None), + )?; + underlying.clawback_coin_spend(ctx, underlying_coin, creator_spend)?; + Ok(()) + }; + + reclaim(&mut ctx)?; + assert!(sim.clone().spend_coins(ctx.take(), std::slice::from_ref(&alice.sk)).is_err()); + + // After it expires, she can + sim.set_next_timestamp(seconds)?; + reclaim(&mut ctx)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + assert_eq!(sim.unspent_coins(alice.puzzle_hash, false)[0].amount, 100); + + Ok(()) +} +``` + +## Transferring + +Options are transferred like any other singleton. With the action system, `Action::send(option_id, puzzle_hash, amount, hint)`. At a low level, `option.transfer(ctx, &p2, new_p2_puzzle_hash, extra_conditions)` spends it and returns the child, hinted to the new owner. Options can also be offered and requested in [offers](/sdk/offers): `OfferCoins::options` holds offered options, `RequestedPayments::options` holds requested ones, and `OptionAssetInfo` (the underlying coin id and delegated puzzle hash) is what's needed to build the requested option's puzzle hash. + +## Gotchas + +- **Verify before you trust.** The option singleton only commits to the underlying coin id and a hash of the exercise conditions. Rebuild the `OptionUnderlying` from the metadata, the launcher hint and the underlying coin, and check `is_backed_by`. Also check that the underlying coin is still unspent. +- **Melting is exercising.** `Action::melt_singleton` on an option only works as part of an exercise, since the melt has to be paired with the message to the underlying, and the underlying spend asserts the strike payment. +- **Timestamps are absolute.** `seconds` is a Unix timestamp, not a duration. +- **NFT underlyings are locked immediately.** When the underlying is an NFT minted or updated in the same transaction, no later action can touch it (`DriverError::NoSourceForOutput`). diff --git a/docs/sdk/primitives/other.md b/docs/sdk/primitives/other.md deleted file mode 100644 index 6702392..0000000 --- a/docs/sdk/primitives/other.md +++ /dev/null @@ -1,252 +0,0 @@ ---- -slug: /sdk/primitives/other -title: Other Primitives ---- - -# Other Primitives - -The SDK includes several additional primitives beyond the core CAT and NFT types. This page provides brief overviews of these primitives. - -## Vault - -Vaults provide multi-signature custody for Chia assets. They enable secure storage patterns where multiple parties or conditions must be satisfied to spend funds. - -```rust -use chia_wallet_sdk::prelude::*; - -// Vault structure -let vault = Vault { - coin, - launcher_id, - proof, - custody_hash, // Defines spending requirements -}; - -// Spend a vault with a MIPS (Multi-Input Puzzle Spend) -vault.spend(ctx, &mips_spend)?; - -// Compute the child vault after a spend -let child_vault = vault.child(new_custody_hash, new_amount); -``` - -For vault internals and specification, see [CHIP-0043 (MIPS)](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0043.md). - -**Use cases:** - -- Multi-signature wallets requiring M-of-N approval -- Time-locked custody arrangements -- Institutional custody solutions -- Escrow patterns - -For the complete API, see [Vault in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.Vault.html). - ---- - -## DID (Decentralized Identifiers) - -DIDs provide on-chain identity management following the singleton pattern. A DID maintains a persistent identity that can be associated with metadata and used for authentication. - -```rust -use chia_wallet_sdk::prelude::*; - -// DID structure -let did = Did { - coin, - info: DidInfo { - launcher_id, - recovery_list_hash, - num_verifications_required, - metadata, - inner_puzzle_hash, - }, - proof, -}; -``` - -**Use cases:** - -- Creator verification for NFTs -- On-chain identity for applications -- Recovery mechanisms with trusted parties - -For the complete API, see [Did in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.Did.html). - ---- - -## Option Contracts - -Options enable on-chain derivatives trading where one party has the right (but not obligation) to buy or sell an asset at a predetermined price. - -```rust -use chia_wallet_sdk::prelude::*; - -// Option contract structure -let option = OptionContract { - coin, - info: OptionInfo { - launcher_id, - underlying, - option_type, // Call or Put - strike_price, - expiry, - }, - proof, -}; -``` - -**Use cases:** - -- Hedging price risk -- Speculation on asset prices -- Structured financial products - -For the complete API, see [OptionContract in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.OptionContract.html). - ---- - -## ClawbackV2 - -ClawbackV2 enables recoverable payments where the sender can reclaim funds within a specified time window if needed. - -```rust -use chia_wallet_sdk::prelude::*; - -// Clawback allows sender to recover funds before timeout -let clawback = ClawbackV2 { - coin, - sender_puzzle_hash, - recipient_puzzle_hash, - clawback_timeout, // Blocks until recipient can claim -}; -``` - -**Use cases:** - -- Reversible payments for dispute resolution -- Escrow with sender recovery option -- Safe transfers to new/unverified addresses - -For the complete API, see [ClawbackV2 in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.ClawbackV2.html). - ---- - -## StreamedAsset - -StreamedAsset implements time-locked or vesting payments where funds are released gradually over time. - -```rust -use chia_wallet_sdk::prelude::*; - -// Streamed payments release funds over time -let streamed = StreamedAsset { - coin, - start_timestamp, - end_timestamp, - recipient_puzzle_hash, - // Funds unlock linearly between start and end -}; -``` - -**Use cases:** - -- Employee vesting schedules -- Subscription payments -- Gradual fund release for projects - -For the complete API, see [StreamedAsset in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.StreamedAsset.html). - ---- - -## Bulletin - -Bulletin provides on-chain data storage using the singleton pattern. It allows storing arbitrary data that can be updated over time. - -```rust -use chia_wallet_sdk::prelude::*; - -// Bulletin for on-chain data storage -// Uses BulletinLayer for puzzle construction -``` - -**Use cases:** - -- On-chain configuration storage -- Decentralized content publishing -- Data availability proofs - -For the complete API, see [BulletinLayer in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.BulletinLayer.html). - ---- - -## Singleton - -The Singleton primitive is the foundation for NFTs, DIDs, Vaults, and other unique assets. It ensures only one instance of an asset exists at any time. - -```rust -use chia_wallet_sdk::prelude::*; - -// Generic singleton structure -let singleton = Singleton { - coin, - info: SingletonInfo { - launcher_id, // Permanent unique identifier - inner_puzzle_hash, - }, - proof, -}; -``` - -**Key properties:** - -- Unique identity via launcher_id -- Lineage proofs ensure authenticity -- State persists across spends - -For the complete API, see [Singleton in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.Singleton.html). - ---- - -## Launcher - -Launchers are used to create new singletons (NFTs, DIDs, etc.). The launcher coin's ID becomes the permanent identifier for the singleton. - -```rust -use chia_wallet_sdk::prelude::*; - -// Create a launcher for minting singletons -let launcher = Launcher::new(parent_coin_id, amount); -let launcher_id = launcher.coin().coin_id(); - -// The launcher_id becomes the permanent identifier -// for the resulting NFT, DID, or other singleton -``` - -For the complete API, see [Launcher in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.Launcher.html). - ---- - -## Feature-Gated Primitives - -Some primitives require feature flags to enable: - -### DataStore (chip-0035) - -Data layer support for off-chain data with on-chain proofs: - -```toml -chia-wallet-sdk = { version = "0.32", features = ["chip-0035"] } -``` - -### Action Layer (action-layer) - -High-level transaction builder for complex multi-asset operations: - -```toml -chia-wallet-sdk = { version = "0.32", features = ["action-layer"] } -``` - ---- - -## API Reference - -For complete documentation on all primitives, see the [chia-sdk-driver rustdocs](https://docs.rs/chia-sdk-driver). diff --git a/docs/sdk/primitives/singletons.md b/docs/sdk/primitives/singletons.md new file mode 100644 index 0000000..999ac8d --- /dev/null +++ b/docs/sdk/primitives/singletons.md @@ -0,0 +1,302 @@ +--- +title: Singletons & Launchers +--- + +# Singletons & Launchers + +A singleton is a coin with a unique, provable identity that persists as it is spent and recreated. NFTs, +DIDs, vaults, option contracts, DataLayer stores and the action layer registries are all singletons. The +singleton top layer (`singleton_top_layer_v1_1`) enforces that each spend creates at most one odd-amount +child with the same identity, and that identity is the coin ID of the **launcher** coin that created it. + +This page covers the pieces every singleton shares: `Launcher` and `IntermediateLauncher` for creating +them, the generic `Singleton` type with the `SingletonInfo` trait, proofs, and melting. The specific +singletons have their own pages: [NFT](/sdk/primitives/nft), [DID](/sdk/primitives/did), +[Option Contracts](/sdk/primitives/options), [Vaults](/sdk/primitives/vaults), +[DataLayer](/sdk/primitives/datalayer) and [Action Layer](/sdk/primitives/action-layer). Background on +the puzzle is at [Singletons on chialisp.com](https://chialisp.com/singletons). + +:::tip +With the [Action System](/sdk/action-system), `Action::create_did`, `Action::mint_nft` and +`Action::mint_option` launch singletons for you, and `Action::melt_singleton` melts one. You only need the +types on this page when you build singleton spends by hand or create your own kind of singleton. +::: + +## How a singleton is launched + +1. A parent coin (for example an XCH coin) creates a **launcher coin** with the well-known + `singleton_launcher` puzzle. The launcher's coin ID becomes the singleton's **launcher ID**. +2. The launcher is spent in the same transaction. Its solution names the singleton puzzle hash and + amount, and it creates the first singleton coin, called the **eve** coin. +3. The launcher announces its solution, and the parent asserts that announcement, so nobody can spend the + launcher with a different solution (and hijack the launcher ID) once the parent is spent. + +`Launcher` handles steps 2 and 3. You decide on the singleton's inner puzzle hash first, because the +launcher commits to the full puzzle hash. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn launch_singleton() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + + // The parent coin will create the launcher coin with amount 1. + let launcher = Launcher::new(alice.coin.coin_id(), 1); + let launcher_id = launcher.coin().coin_id(); + + // Spend the launcher to create the eve singleton. Here the singleton's inner + // puzzle is just Alice's standard puzzle. The last argument is the launcher's + // key/value metadata list, which is usually empty. + let (parent_conditions, eve_coin) = + launcher.spend(ctx, alice.puzzle_hash, ())?; + + // `parent_conditions` creates the launcher and asserts its announcement. + alice_p2.spend(ctx, alice.coin, parent_conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + assert_eq!(eve_coin.parent_coin_info, launcher_id); + assert!(sim.coin_state(eve_coin.coin_id()).is_some()); + + Ok(()) +} +``` + +The primitive-specific launch methods are extension methods on `Launcher`: `Launcher::mint_nft` and +`mint_eve_nft` for NFTs, `create_did`, `create_simple_did` and `create_eve_did` for DIDs, and similar +`mint_vault` for [vaults](/sdk/primitives/vaults), and `mint_datastore` for [DataLayer](/sdk/primitives/datalayer) stores (`chip-0035`). They take the `Launcher` by value, so each launcher can only be +used once. + +### Launcher variants + +| Constructor | Use it when | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `Launcher::new(parent_coin_id, amount)` | the parent creates the launcher and asserts it in the same spend | +| `Launcher::with_memos(parent, amount, memos)` | as above, but the launcher coin is hinted (useful for finding it later) | +| `Launcher::create_early(parent, amount)` | the launcher is created by one spend and launched from another; returns the `CreateCoin` to output | +| `Launcher::create_early_with_memos(...)` | as above, hinted | +| `Launcher::from_coin(coin, conditions)` | you already have the launcher coin and the conditions the parent must output | +| `.with_singleton_amount(amount)` | the singleton amount differs from the launcher amount (e.g. a 0 mojo launcher creating a 1 mojo singleton) | + +Singletons must have an odd amount, which is why 1 mojo is used almost everywhere. + +### Creating many launchers from one parent: `IntermediateLauncher` + +A launcher's coin ID depends on its parent, puzzle hash and amount. Since every launcher has the same +puzzle hash, one parent can only create one launcher of a given amount. `IntermediateLauncher` works +around this by first creating a 0-mojo intermediate coin curried with `(mint_number, mint_total)`, which +then creates the launcher. Each index gives a unique intermediate coin and so a unique launcher ID. + +```rust +use chia_wallet_sdk::driver::IntermediateLauncher; +use chia_wallet_sdk::prelude::*; + +#[test] +fn many_launchers() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(3); + let alice_p2 = StandardLayer::new(alice.pk); + + let total = 3; + let mut conditions = Conditions::new(); + let mut launcher_ids = Vec::new(); + + for i in 0..total { + // `create` spends the intermediate coin and returns a normal `Launcher`. + let launcher = IntermediateLauncher::new(alice.coin.coin_id(), i, total).create(ctx)?; + launcher_ids.push(launcher.coin().coin_id()); + + let (launch, _eve_coin) = launcher.spend(ctx, alice.puzzle_hash, ())?; + conditions = conditions.extend(launch); + } + + alice_p2.spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Every launcher ID is different. + let unique: std::collections::HashSet<_> = launcher_ids.iter().collect(); + assert_eq!(unique.len(), 3); + + Ok(()) +} +``` + +The intermediate coins have amount 0 and the launchers have amount 1, so the parent must have at least +`total` mojos available. This is the pattern used for bulk NFT minting from a DID; see +[NFT](/sdk/primitives/nft#bulk-minting). + +## `Singleton` and `SingletonInfo` + +```rust ignore +pub struct Singleton { + pub coin: Coin, + pub proof: Proof, + pub info: I, +} + +pub trait SingletonInfo { + fn launcher_id(&self) -> Bytes32; + fn inner_puzzle_hash(&self) -> TreeHash; + fn puzzle_hash(&self) -> TreeHash; // provided: singleton layer around inner_puzzle_hash +} +``` + +`Singleton` is generic over an info type that describes everything inside the singleton layer except +the p2 puzzle. `Nft` is `Singleton`, `Did` is `Singleton`, and the vault, option and +action layer primitives follow the same shape. As with `Cat`, the `Singleton` plus the ability to spend +its p2 puzzle is everything you need to store. + +The methods on `Singleton` are generic: + +- `child_lineage_proof()` returns the `LineageProof` for any child of this coin. +- `child_with(info, amount)` returns the child `Singleton` for a new info value. Construct the child from + the new info, rather than copying the singleton and mutating `info`, so the coin's puzzle hash matches. + +### Proofs + +The singleton puzzle needs to know its parent was a legitimate singleton with the same launcher ID. +`Proof` is either: + +- `Proof::Eve(EveProof)` for the eve coin, whose parent is the launcher. It contains the launcher's parent + coin ID and amount. +- `Proof::Lineage(LineageProof)` for every later coin. It contains the parent's parent coin ID, the + parent's **inner** puzzle hash and the parent's amount. + +The eve coin is normally spent in the same transaction it's created in (the NFT and DID launch methods do +this for you), so the singletons you store and sync will have lineage proofs. Spending a singleton +returns its child with the next proof filled in, and the `parse_child` functions of each primitive +reconstruct the proof from the parent spend. + +## A custom singleton + +Implementing `SingletonInfo` is enough to use `Singleton` for your own singletons. This example +launches a singleton owned directly by a standard puzzle, spends it once and then melts it: + +```rust +use chia_wallet_sdk::chia::puzzle_types::{ + EveProof, Proof, + singleton::{SingletonArgs, SingletonSolution}, +}; +use chia_wallet_sdk::prelude::*; + +#[derive(Debug, Clone, Copy)] +struct MyInfo { + launcher_id: Bytes32, + p2_puzzle_hash: Bytes32, +} + +impl SingletonInfo for MyInfo { + fn launcher_id(&self) -> Bytes32 { + self.launcher_id + } + + fn inner_puzzle_hash(&self) -> TreeHash { + // No extra layers, so the inner puzzle is the p2 puzzle. + self.p2_puzzle_hash.into() + } +} + +fn spend_singleton( + ctx: &mut SpendContext, + singleton: &Singleton, + p2: &StandardLayer, + conditions: Conditions, +) -> anyhow::Result<()> { + let inner = p2.spend_with_conditions(ctx, conditions)?; + let puzzle = ctx.curry(SingletonArgs::new(singleton.info.launcher_id, inner.puzzle))?; + let solution = ctx.alloc(&SingletonSolution { + lineage_proof: singleton.proof, + amount: singleton.coin.amount, + inner_solution: inner.solution, + })?; + ctx.spend(singleton.coin, Spend::new(puzzle, solution))?; + Ok(()) +} + +#[test] +fn custom_singleton() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); + + let launcher = Launcher::new(alice.coin.coin_id(), 1); + let launcher_coin = launcher.coin(); + let info = MyInfo { + launcher_id: launcher_coin.coin_id(), + p2_puzzle_hash: alice.puzzle_hash, + }; + + let (launch, eve_coin) = launcher.spend(ctx, info.inner_puzzle_hash().into(), ())?; + alice_p2.spend(ctx, alice.coin, launch)?; + + let eve = Singleton::new( + eve_coin, + Proof::Eve(EveProof { + parent_parent_coin_info: launcher_coin.parent_coin_info, + parent_amount: launcher_coin.amount, + }), + info, + ); + assert_eq!(eve.coin.puzzle_hash, info.puzzle_hash().into()); + + // Recreate the singleton. The inner puzzle outputs its *inner* puzzle hash, + // and the singleton layer wraps the odd-amount output for us. + spend_singleton( + ctx, + &eve, + &alice_p2, + Conditions::new().create_coin(info.inner_puzzle_hash().into(), 1, Memos::None), + )?; + let child = eve.child_with(info, 1); + + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + assert!(sim.coin_state(child.coin.coin_id()).is_some()); + + // Melt the singleton: no odd child, plus the special melt condition. + spend_singleton(ctx, &child, &alice_p2, Conditions::new().melt_singleton())?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + assert!(sim.coin_state(child.coin.coin_id()).unwrap().spent_height.is_some()); + + Ok(()) +} +``` + +In a real singleton you would add your own layers between the singleton layer and the p2 puzzle, and +`inner_puzzle_hash` would return the hash of that whole stack. See +[Layers & Puzzles](/sdk/concepts/layers) for `SingletonLayer` and writing custom layers. + +## Melting + +A singleton is destroyed ("melted") when its inner puzzle outputs `Conditions::melt_singleton()` +(a `CREATE_COIN` with the magic amount `-113`) and doesn't create an odd-amount child. The singleton's +mojos are released into the transaction, where they can pay the fee or be claimed by another spend. +Melted singletons can't be revived, and the launcher ID can never be reused. + +For DIDs, use `did.spend_with(ctx, &p2, Conditions::new().melt_singleton())`. Not every singleton +supports melting: the NFT layers reject it (see [NFT](/sdk/primitives/nft#transferring)), so NFTs are +burned by sending them to an unspendable puzzle hash instead. + +## Gotchas + +- Singleton amounts must be odd. Even-amount outputs of a singleton are ordinary coins, not children, so + a singleton can create extra coins (for example a 0 mojo intermediate coin) without affecting its + lineage. +- Inside the singleton, `CREATE_COIN` for the child uses the inner puzzle hash. The singleton layer wraps + it. Passing the full singleton puzzle hash creates a different, invalid coin. +- The launcher must be spent in the same transaction as its parent; otherwise anyone can front-run the + launch. The conditions returned by `Launcher::spend` include the announcement assertion that prevents + this, so always add them to the parent's spend. +- Launcher IDs are only unique per parent coin and amount. Use `IntermediateLauncher` (or + `create_early` from different intermediate coins) to launch several singletons from one coin. + +See the [driver rustdoc](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/) for the full +`Launcher`, `IntermediateLauncher` and `Singleton` APIs. diff --git a/docs/sdk/primitives/standard.md b/docs/sdk/primitives/standard.md index f140f87..f5467fc 100644 --- a/docs/sdk/primitives/standard.md +++ b/docs/sdk/primitives/standard.md @@ -1,739 +1,266 @@ --- -slug: /sdk/primitives/standard title: Standard (XCH) --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - # Standard (XCH) -The `StandardLayer` is the foundation for XCH ownership in Chia. It implements the "standard transaction" puzzle (`p2_delegated_puzzle_or_hidden_puzzle`), which allows coins to be spent by providing a signature from the owner's public key. - -## Overview - -Standard coins are the basic unit of XCH ownership. When you hold XCH in a wallet, your coins use the standard puzzle locked to your public key. The puzzle allows spending by: - -1. Providing a valid BLS signature from the corresponding secret key -2. Outputting conditions that define what happens (create coins, fees, etc.) - -## Creating a StandardLayer - - - - -```rust -use chia_wallet_sdk::prelude::*; - -// Create a layer from a public key -let p2 = StandardLayer::new(public_key); +Almost every XCH coin on the Chia blockchain is locked by the standard transaction puzzle, +`p2_delegated_puzzle_or_hidden_puzzle`. The SDK drives it with `StandardLayer`. You use it directly +whenever you spend XCH, and indirectly whenever a CAT, NFT, DID or other asset is owned by a normal +wallet address, because the standard puzzle is then the asset's inner ("p2") puzzle. -// The puzzle hash can be computed from the public key -let puzzle_hash = StandardLayer::puzzle_hash(public_key); -``` - - - - -```typescript -import { standardPuzzleHash, PublicKey } from "chia-wallet-sdk"; - -// The puzzle hash can be computed from a synthetic public key -const puzzleHash = standardPuzzleHash(publicKey); - -// In Node.js/Python, spends are created directly via the Clvm class -// rather than through a separate layer object -``` +Unlike the other primitives on these pages, standard coins have no outer layer, lineage or on-chain +state beyond the coin itself, so there is no separate `Xch` type. A plain `Coin` plus the public key that +owns it is all you need. - - +:::tip +For ordinary payments with change and fees, the [Action System](/sdk/action-system) can select coins, +balance amounts and produce the spends for you (`Action::send`, `Action::fee`). This page covers the +underlying primitive, which is what you need for custom flows or when you want full control. +::: -```python -from chia_wallet_sdk import standard_puzzle_hash, PublicKey +## How the standard puzzle works -# The puzzle hash can be computed from a synthetic public key -puzzle_hash = standard_puzzle_hash(public_key) +The puzzle is curried with a single argument, the owner's **synthetic public key**. To spend the coin +you provide a delegated puzzle and its solution. The puzzle runs the delegated puzzle, returns its +conditions, and adds an `AGG_SIG_ME` condition requiring a signature from the synthetic key over the +delegated puzzle's tree hash. So whoever holds the key decides what the coin does, and the signature +commits to exactly those conditions. -# In Node.js/Python, spends are created directly via the Clvm class -# rather than through a separate layer object -``` +The synthetic key is derived from the wallet key and a hidden puzzle hash. Wallets use the default +hidden puzzle, which can't be spent, so in practice a standard coin can only be spent by signing. See +[Keys & Signing](/sdk/concepts/signing) for key derivation, synthetic keys and how signatures are +collected. - - +`StandardLayer` implements three traits: -## Spending Standard Coins +| Trait | What it gives you | +| --------------------- | --------------------------------------------------------------------------------- | +| `Layer` | `construct_puzzle`, `construct_spend`, `parse_puzzle` and `parse_solution` | +| `SpendWithConditions` | `spend_with_conditions`, which quotes a `Conditions` list as the delegated puzzle | +| `ToTreeHash` | the puzzle hash of the curried puzzle, which is the address of the key | -### Basic Spend +The `SpendWithConditions` impl is what lets a `StandardLayer` be passed as the `inner` argument of +methods like `Nft::transfer` or `Did::update` on the other primitive pages. -The simplest spend creates a new coin and optionally pays a fee: +## Sending XCH - - +`StandardLayer::spend` builds the coin spend for a list of conditions and adds it to the +[SpendContext](/sdk/concepts/spend-context). The outputs must add up to the input amount, minus +whatever is left over as the fee (`reserve_fee` asserts that the leftover is at least that amount). ```rust use chia_wallet_sdk::prelude::*; -let ctx = &mut SpendContext::new(); - -// Build the conditions -let conditions = Conditions::new() - .create_coin(recipient_puzzle_hash, 900, Memos::None) - .reserve_fee(100); - -// Create the spend -let p2 = StandardLayer::new(public_key); -p2.spend(ctx, coin, conditions)?; - -let coin_spends = ctx.take(); -``` - - - - -```typescript -import { Clvm, Coin } from "chia-wallet-sdk"; - -const clvm = new Clvm(); - -// Build the conditions -const conditions = [ - clvm.createCoin(recipientPuzzleHash, 900n, null), - clvm.reserveFee(100n), -]; - -// Create the spend -clvm.spendStandardCoin(coin, publicKey, clvm.delegatedSpend(conditions)); - -const coinSpends = clvm.coinSpends(); -``` - - - - -```python -from chia_wallet_sdk import Clvm, Coin - -clvm = Clvm() - -# Build the conditions -conditions = [ - clvm.create_coin(recipient_puzzle_hash, 900, None), - clvm.reserve_fee(100), -] - -# Create the spend -clvm.spend_standard_coin(coin, public_key, clvm.delegated_spend(conditions)) - -coin_spends = clvm.coin_spends() -``` - - - - -### Sending with Hints - -Hints help wallets discover coins. Add the recipient's puzzle hash as a memo: - - - - -```rust -let ctx = &mut SpendContext::new(); - -let memos = ctx.hint(recipient_puzzle_hash)?; -let conditions = Conditions::new() - .create_coin(recipient_puzzle_hash, amount, memos); - -StandardLayer::new(public_key).spend(ctx, coin, conditions)?; -``` - - - - -```typescript -const clvm = new Clvm(); - -// Include puzzle hash as memo for coin discovery -const conditions = [ - clvm.createCoin( - recipientPuzzleHash, - amount, - clvm.alloc([recipientPuzzleHash]), - ), -]; - -clvm.spendStandardCoin(coin, publicKey, clvm.delegatedSpend(conditions)); -``` - - - - -```python -clvm = Clvm() - -# Include puzzle hash as memo for coin discovery -conditions = [ - clvm.create_coin(recipient_puzzle_hash, amount, clvm.alloc([recipient_puzzle_hash])), -] - -clvm.spend_standard_coin(coin, public_key, clvm.delegated_spend(conditions)) -``` - - - - -### Multiple Outputs - -A single spend can create multiple output coins: - - - - -```rust -let conditions = Conditions::new() - .create_coin(recipient_a, 500, ctx.hint(recipient_a)?) - .create_coin(recipient_b, 400, ctx.hint(recipient_b)?) - .reserve_fee(100); - -StandardLayer::new(public_key).spend(ctx, coin, conditions)?; -``` - - - - -```typescript -const conditions = [ - clvm.createCoin(recipientA, 500n, clvm.alloc([recipientA])), - clvm.createCoin(recipientB, 400n, clvm.alloc([recipientB])), - clvm.reserveFee(100n), -]; - -clvm.spendStandardCoin(coin, publicKey, clvm.delegatedSpend(conditions)); -``` - - - - -```python -conditions = [ - clvm.create_coin(recipient_a, 500, clvm.alloc([recipient_a])), - clvm.create_coin(recipient_b, 400, clvm.alloc([recipient_b])), - clvm.reserve_fee(100), -] - -clvm.spend_standard_coin(coin, public_key, clvm.delegated_spend(conditions)) -``` - - - - -### Spending Multiple Coins - -When spending multiple coins in one transaction, you must link them using `assert_concurrent_spend` to ensure atomicity. This prevents malicious actors from separating your spends and executing them individually. - - - - -```rust -let ctx = &mut SpendContext::new(); - -// First coin - sends to recipient, asserts second coin is spent together -let conditions1 = Conditions::new() - .create_coin(recipient, 1000, ctx.hint(recipient)?) - .assert_concurrent_spend(coin2.coin_id()); -StandardLayer::new(pk1).spend(ctx, coin1, conditions1)?; - -// Second coin - pays fee, asserts first coin is spent together -let conditions2 = Conditions::new() - .reserve_fee(100) - .assert_concurrent_spend(coin1.coin_id()); -StandardLayer::new(pk2).spend(ctx, coin2, conditions2)?; - -let coin_spends = ctx.take(); -``` - - - - -```typescript -const clvm = new Clvm(); - -// First coin - sends to recipient, asserts second coin is spent together -const conditions1 = [ - clvm.createCoin(recipient, 1000n, clvm.alloc([recipient])), - clvm.assertConcurrentSpend(coin2.coinId()), -]; -clvm.spendStandardCoin(coin1, pk1, clvm.delegatedSpend(conditions1)); +#[test] +fn send_xch() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -// Second coin - pays fee, asserts first coin is spent together -const conditions2 = [ - clvm.reserveFee(100n), - clvm.assertConcurrentSpend(coin1.coinId()), -]; -clvm.spendStandardCoin(coin2, pk2, clvm.delegatedSpend(conditions2)); + let alice = sim.bls(1_000); + let bob = sim.bls(0); -const coinSpends = clvm.coinSpends(); -``` + let alice_p2 = StandardLayer::new(alice.pk); - - + // Hint the outputs with the recipient's puzzle hash so wallets can find them. + let bob_memos = ctx.hint(bob.puzzle_hash)?; + let change_memos = ctx.hint(alice.puzzle_hash)?; -```python -clvm = Clvm() + alice_p2.spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(bob.puzzle_hash, 600, bob_memos) + .create_coin(alice.puzzle_hash, 350, change_memos) + .reserve_fee(50), + )?; -# First coin - sends to recipient, asserts second coin is spent together -conditions1 = [ - clvm.create_coin(recipient, 1000, clvm.alloc([recipient])), - clvm.assert_concurrent_spend(coin2.coin_id()), -] -clvm.spend_standard_coin(coin1, pk1, clvm.delegated_spend(conditions1)) + // The simulator signs with the given keys and validates the spend bundle. + sim.spend_coins(ctx.take(), &[alice.sk])?; -# Second coin - pays fee, asserts first coin is spent together -conditions2 = [ - clvm.reserve_fee(100), - clvm.assert_concurrent_spend(coin1.coin_id()), -] -clvm.spend_standard_coin(coin2, pk2, clvm.delegated_spend(conditions2)) + // The child coin can be computed without looking anything up. + let bob_coin = Coin::new(alice.coin.coin_id(), bob.puzzle_hash, 600); + assert!(sim.coin_state(bob_coin.coin_id()).is_some()); -coin_spends = clvm.coin_spends() + Ok(()) +} ``` - - +A coin's ID is `sha256(parent_coin_id + puzzle_hash + amount)`, so the child is fully determined by the +`CREATE_COIN` condition and the parent. Two `CREATE_COIN` conditions with the same puzzle hash and +amount in the same spend would produce the same coin ID, which isn't allowed. -:::warning -Always use `assert_concurrent_spend` when spending multiple coins together. Without it, an attacker could take your signed spend bundle and submit only some of the spends, potentially stealing funds. -::: - -## Building Conditions - -The `Conditions` builder provides methods for common operations: +## Spending several coins together - - +When a transaction spends more than one coin, each spend's conditions are independent. Link them with +`assert_concurrent_spend` (or announcements) so each spend is only valid if the others are included in +the same block. This guarantees the transaction is all-or-nothing at the puzzle level. ```rust -Conditions::new() - // Create output coins - .create_coin(puzzle_hash, amount, memos) - - // Transaction fee (goes to farmers) - .reserve_fee(fee_amount) - - // Link multiple spends together (IMPORTANT for security) - .assert_concurrent_spend(other_coin_id) - - // Coin announcements for coordinating multi-spend transactions - .create_coin_announcement(message) - .assert_coin_announcement(announcement_id) - - // Puzzle announcements - .create_puzzle_announcement(message) - .assert_puzzle_announcement(announcement_id) - - // Time conditions - .assert_seconds_absolute(timestamp) - .assert_height_absolute(block_height) -``` - - - - -```typescript -// Conditions are built as an array of Program objects -const conditions = [ - // Create output coins - clvm.createCoin(puzzleHash, amount, memos), - - // Transaction fee (goes to farmers) - clvm.reserveFee(feeAmount), - - // Link multiple spends together (IMPORTANT for security) - clvm.assertConcurrentSpend(otherCoinId), - - // Coin announcements for coordinating multi-spend transactions - clvm.createCoinAnnouncement(message), - clvm.assertCoinAnnouncement(announcementId), - - // Puzzle announcements - clvm.createPuzzleAnnouncement(message), - clvm.assertPuzzleAnnouncement(announcementId), - - // Time conditions - clvm.assertSecondsAbsolute(timestamp), - clvm.assertHeightAbsolute(blockHeight), -]; -``` - - - - -```python -# Conditions are built as a list of Program objects -conditions = [ - # Create output coins - clvm.create_coin(puzzle_hash, amount, memos), - - # Transaction fee (goes to farmers) - clvm.reserve_fee(fee_amount), - - # Link multiple spends together (IMPORTANT for security) - clvm.assert_concurrent_spend(other_coin_id), - - # Coin announcements for coordinating multi-spend transactions - clvm.create_coin_announcement(message), - clvm.assert_coin_announcement(announcement_id), +use chia_wallet_sdk::prelude::*; - # Puzzle announcements - clvm.create_puzzle_announcement(message), - clvm.assert_puzzle_announcement(announcement_id), +#[test] +fn spend_two_coins() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); - # Time conditions - clvm.assert_seconds_absolute(timestamp), - clvm.assert_height_absolute(block_height), -] -``` + let alice = sim.bls(500); + let alice_p2 = StandardLayer::new(alice.pk); - - + // A second coin owned by the same key. + let second = sim.new_coin(alice.puzzle_hash, 300); -For the complete list of conditions, see the [Conditions API in docs.rs](https://docs.rs/chia-sdk-types/latest/chia_sdk_types/struct.Conditions.html). + let bob = sim.bls(0); + let memos = ctx.hint(bob.puzzle_hash)?; -## Using SpendWithConditions + // The first coin creates the whole output... + alice_p2.spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(bob.puzzle_hash, 800, memos) + .assert_concurrent_spend(second.coin_id()), + )?; -For simpler cases, you can use `spend_with_conditions` to get a `Spend` object without immediately adding it to the context: + // ...and the second coin contributes its value without creating any coins. + alice_p2.spend( + ctx, + second, + Conditions::new().assert_concurrent_spend(alice.coin.coin_id()), + )?; -```rust -let p2 = StandardLayer::new(public_key); + sim.spend_coins(ctx.take(), &[alice.sk])?; -// Get the inner spend (useful for wrapping in CAT, etc.) -let inner_spend = p2.spend_with_conditions( - ctx, - Conditions::new().create_coin(recipient, amount, memos), -)?; + Ok(()) +} ``` -This is particularly useful when the standard spend needs to be wrapped by another layer (like CAT or NFT). +Conservation is checked across the whole spend bundle, not per coin, which is why the second coin can +have no outputs at all. -## Calculating the New Coin +## Using the standard puzzle as an inner puzzle -After spending, you often need to reference the newly created coin: - - - +Other primitives take the p2 spend as a `Spend` (a puzzle and solution pair) rather than spending a +coin directly. `spend_with_conditions` returns that `Spend` without adding anything to the context: ```rust -// The parent coin -let parent_coin = coin; - -// Conditions create a new coin -let conditions = Conditions::new() - .create_coin(recipient_puzzle_hash, 900, Memos::None); - -StandardLayer::new(public_key).spend(ctx, parent_coin, conditions)?; - -// Calculate the new coin's ID -let new_coin = Coin::new(parent_coin.coin_id(), recipient_puzzle_hash, 900); -println!("New coin ID: {}", new_coin.coin_id()); -``` - - - - -```typescript -import { Coin } from "chia-wallet-sdk"; - -// The parent coin -const parentCoin = coin; - -// Conditions create a new coin -const conditions = [clvm.createCoin(recipientPuzzleHash, 900n, null)]; - -clvm.spendStandardCoin(parentCoin, publicKey, clvm.delegatedSpend(conditions)); - -// Calculate the new coin's ID -const newCoin = new Coin(parentCoin.coinId(), recipientPuzzleHash, 900n); -console.log("New coin ID:", newCoin.coinId()); -``` +use chia_wallet_sdk::prelude::*; - - +#[test] +fn inner_spend() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); -```python -from chia_wallet_sdk import Coin + let alice = sim.bls(1); + let alice_p2 = StandardLayer::new(alice.pk); -# The parent coin -parent_coin = coin + // A Spend that can be wrapped by the CAT, NFT or DID layers. + let inner: Spend = alice_p2.spend_with_conditions( + ctx, + Conditions::new().create_coin(alice.puzzle_hash, 1, Memos::None), + )?; -# Conditions create a new coin -conditions = [clvm.create_coin(recipient_puzzle_hash, 900, None)] + // The puzzle hash of the inner puzzle is the owner's address. + assert_eq!(ctx.tree_hash(inner.puzzle), alice_p2.tree_hash()); -clvm.spend_standard_coin(parent_coin, public_key, clvm.delegated_spend(conditions)) + // Spending it directly is equivalent to `alice_p2.spend(...)`. + ctx.spend(alice.coin, inner)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; -# Calculate the new coin's ID -new_coin = Coin(parent_coin.coin_id(), recipient_puzzle_hash, 900) -print("New coin ID:", new_coin.coin_id()) + Ok(()) +} ``` - - - -## Signing Transactions +If the delegated puzzle is something other than a quoted list of conditions, use +`delegated_inner_spend`, which takes an arbitrary delegated puzzle and solution as a `Spend`. +`SpendContext::delegated_spend` builds such a quoted-conditions `Spend` on its own, which is useful when +you need a delegated puzzle but not the standard puzzle around it. -After building spends, you need to sign them before broadcasting. The SDK calculates what signatures are required and you provide them. +## Puzzle hashes and addresses -### Basic Signing +The puzzle hash of a standard coin depends only on the synthetic key. You can compute it with +`StandardLayer::tree_hash` or `StandardArgs::curry_tree_hash`, and encode it as an `xch1...` address with +`Address` (see [Utilities](/sdk/utilities)). ```rust +use chia_wallet_sdk::chia::puzzle_types::{DeriveSynthetic, standard::StandardArgs}; use chia_wallet_sdk::prelude::*; -let ctx = &mut SpendContext::new(); - -// Build your spends -StandardLayer::new(public_key).spend(ctx, coin, conditions)?; -let coin_spends = ctx.take(); - -// Calculate required signatures -let mut allocator = Allocator::new(); -let required = RequiredSignature::from_coin_spends( - &mut allocator, - &coin_spends, - &AggSigConstants::new(agg_sig_me_additional_data), -)?; - -// Sign each requirement and aggregate -let mut aggregated_signature = Signature::default(); -for req in required { - let RequiredSignature::Bls(bls_req) = req else { continue }; - aggregated_signature += &sign(&secret_key, bls_req.message()); -} +#[test] +fn puzzle_hash_and_address() -> anyhow::Result<()> { + let sk = SecretKey::from_seed(&[1; 32]); -// Create the spend bundle -let spend_bundle = SpendBundle::new(coin_spends, aggregated_signature); -``` + // Wallets use the synthetic key with the default hidden puzzle. + let synthetic_pk = sk.public_key().derive_synthetic(); -### Signing with Multiple Keys + let puzzle_hash: Bytes32 = StandardLayer::new(synthetic_pk).tree_hash().into(); + assert_eq!(puzzle_hash, StandardArgs::curry_tree_hash(synthetic_pk).into()); -When multiple coins are spent with different keys: + let address = Address::new(puzzle_hash, "xch".to_string()).encode()?; + assert!(address.starts_with("xch1")); -```rust -use std::collections::HashMap; - -// Map public keys to secret keys for lookup -let key_pairs: HashMap = secret_keys - .iter() - .map(|sk| (sk.public_key(), sk)) - .collect(); - -// Sign each required signature with the correct key -let mut aggregated_signature = Signature::default(); -for req in required { - let RequiredSignature::Bls(bls_req) = req else { continue }; - let sk = key_pairs.get(&bls_req.public_key) - .expect("missing key for signature"); - aggregated_signature += &sign(sk, bls_req.message()); + Ok(()) } ``` -### Network Constants - -The `AggSigConstants` vary by network. Use the appropriate constants: - -```rust -use chia_sdk_types::{MAINNET_CONSTANTS, TESTNET11_CONSTANTS}; - -// For mainnet -let constants = AggSigConstants::new(MAINNET_CONSTANTS.agg_sig_me_additional_data); - -// For testnet11 -let constants = AggSigConstants::new(TESTNET11_CONSTANTS.agg_sig_me_additional_data); -``` - -:::info -The signature message includes data specific to the coin being spent (coin ID, puzzle hash, etc.) combined with network-specific constants. This prevents signatures from being replayed across networks. +:::note +The `Simulator`'s `bls` key pairs use the public key directly as the curried key, without synthetic +derivation. That's fine for tests, but a real wallet's addresses come from synthetic keys derived from +hardened or unhardened child keys of the master key. ::: -## Complete Example - -Here's a full example of building, signing, and creating a spend bundle for an XCH transfer: +## Parsing a standard spend - - +To find out what a standard coin spend did, parse the puzzle with `StandardLayer::parse_puzzle` and run +it to get the output conditions: ```rust -use std::collections::HashMap; use chia_wallet_sdk::prelude::*; -fn send_xch( - source_coin: Coin, - source_secret_key: &SecretKey, - recipient_puzzle_hash: Bytes32, - amount: u64, - fee: u64, - agg_sig_data: Bytes32, // Network-specific (e.g., MAINNET_CONSTANTS.agg_sig_me_additional_data) -) -> Result { +#[test] +fn parse_standard_spend() -> anyhow::Result<()> { + let mut sim = Simulator::new(); let ctx = &mut SpendContext::new(); - let source_public_key = source_secret_key.public_key(); - - // Calculate change (if any) - let change = source_coin.amount - amount - fee; - let source_puzzle_hash = StandardLayer::puzzle_hash(source_public_key); - - // Build conditions - let mut conditions = Conditions::new() - .create_coin(recipient_puzzle_hash, amount, ctx.hint(recipient_puzzle_hash)?) - .reserve_fee(fee); - - // Add change output if needed - if change > 0 { - conditions = conditions.create_coin( - source_puzzle_hash, - change, - ctx.hint(source_puzzle_hash)? - ); - } - - // Create the spend - StandardLayer::new(source_public_key).spend(ctx, source_coin, conditions)?; - let coin_spends = ctx.take(); - - // Calculate required signatures - let mut allocator = Allocator::new(); - let required = RequiredSignature::from_coin_spends( - &mut allocator, - &coin_spends, - &AggSigConstants::new(agg_sig_data), - )?; - - // Sign and aggregate - let mut aggregated_signature = Signature::default(); - for req in required { - let RequiredSignature::Bls(bls_req) = req else { continue }; - aggregated_signature += &sign(source_secret_key, bls_req.message()); - } - // Return complete spend bundle - Ok(SpendBundle::new(coin_spends, aggregated_signature)) -} -``` - - - - -```typescript -import { Clvm, Coin, Simulator, standardPuzzleHash } from "chia-wallet-sdk"; - -function sendXch( - sourceCoin: Coin, - sourcePublicKey: PublicKey, - recipientPuzzleHash: Uint8Array, - amount: bigint, - fee: bigint, -) { - const clvm = new Clvm(); - const sourcePuzzleHash = standardPuzzleHash(sourcePublicKey); - - // Calculate change (if any) - const change = sourceCoin.amount - amount - fee; - - // Build conditions - const conditions = [ - clvm.createCoin( - recipientPuzzleHash, - amount, - clvm.alloc([recipientPuzzleHash]), - ), - clvm.reserveFee(fee), - ]; - - // Add change output if needed - if (change > 0n) { - conditions.push( - clvm.createCoin(sourcePuzzleHash, change, clvm.alloc([sourcePuzzleHash])), - ); - } - - // Create the spend - clvm.spendStandardCoin( - sourceCoin, - sourcePublicKey, - clvm.delegatedSpend(conditions), - ); - - return clvm.coinSpends(); + let alice = sim.bls(1); + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(alice.puzzle_hash, 1, Memos::None), + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Look up the spend (from the simulator here, or a full node in practice). + let coin_spend = sim.coin_spend(alice.coin.coin_id()).unwrap(); + let puzzle = ctx.alloc(&coin_spend.puzzle_reveal)?; + let solution = ctx.alloc(&coin_spend.solution)?; + + let layer = StandardLayer::parse_puzzle(ctx, Puzzle::parse(ctx, puzzle))? + .expect("not a standard puzzle"); + assert_eq!(layer.synthetic_key, alice.pk); + + let output = ctx.run(puzzle, solution)?; + let conditions: Vec = ctx.extract(output)?; + let create_coins: Vec<_> = conditions + .into_iter() + .filter_map(Condition::into_create_coin) + .collect(); + assert_eq!(create_coins.len(), 1); + + Ok(()) } - -// Example usage with Simulator (handles signing automatically) -const sim = new Simulator(); -const alice = sim.bls(1000n); // Creates key pair with 1000 mojo coin - -const coinSpends = sendXch( - alice.coin, - alice.pk, - recipientPuzzleHash, - 900n, - 100n, -); - -// Simulator signs and validates the transaction -sim.spendCoins(coinSpends, [alice.sk]); -``` - - - - -```python -from chia_wallet_sdk import Clvm, Coin, Simulator, standard_puzzle_hash - -def send_xch( - source_coin: Coin, - source_public_key: PublicKey, - recipient_puzzle_hash: bytes, - amount: int, - fee: int -): - clvm = Clvm() - source_puzzle_hash = standard_puzzle_hash(source_public_key) - - # Calculate change (if any) - change = source_coin.amount - amount - fee - - # Build conditions - conditions = [ - clvm.create_coin(recipient_puzzle_hash, amount, clvm.alloc([recipient_puzzle_hash])), - clvm.reserve_fee(fee), - ] - - # Add change output if needed - if change > 0: - conditions.append( - clvm.create_coin(source_puzzle_hash, change, clvm.alloc([source_puzzle_hash])) - ) - - # Create the spend - clvm.spend_standard_coin(source_coin, source_public_key, clvm.delegated_spend(conditions)) - - return clvm.coin_spends() - -# Example usage with Simulator (handles signing automatically) -sim = Simulator() -alice = sim.bls(1000) # Creates key pair with 1000 mojo coin - -coin_spends = send_xch( - alice.coin, - alice.pk, - recipient_puzzle_hash, - 900, - 100 -) - -# Simulator signs and validates the transaction -sim.spend_coins(coin_spends, [alice.sk]) ``` - - +## Gotchas -## API Reference +- `StandardLayer::new` takes the **synthetic** key, not the wallet's master or child key. Passing the + wrong key gives you a different puzzle hash, so the coin won't match and the spend fails. +- Remember to `reserve_fee` (or simply leave value unclaimed) for the fee. Any value you don't create as + outputs goes to the farmer, so double check change outputs. +- Hints are memos, not part of the puzzle hash. An unhinted coin is still spendable, but wallets that + sync by hint won't see it. Use `ctx.hint(puzzle_hash)` for the recipient's puzzle hash. +- Everything in the delegated puzzle is covered by the signature, so once signed the conditions can't be + changed. Build the full set of conditions before signing. -For the complete `StandardLayer` API, see [docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.StandardLayer.html). +See the [`StandardLayer` rustdoc](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/) for +the full API. diff --git a/docs/sdk/primitives/streaming.md b/docs/sdk/primitives/streaming.md new file mode 100644 index 0000000..5dc04fb --- /dev/null +++ b/docs/sdk/primitives/streaming.md @@ -0,0 +1,190 @@ +--- +title: Streamed Assets +--- + +# Streamed Assets + +A **streamed asset** is an XCH or CAT coin that pays a recipient linearly over time. The coin is +locked by the streaming puzzle, which knows the recipient, an end time, and the time of the last +payment. Whenever the recipient claims, the coin pays out the share that has vested since the last +payment and recreates itself with the remainder. Optionally, a clawback puzzle hash can stop the stream +early and take back whatever hasn't vested yet. + +Use this for vesting schedules, salaries, or subscriptions, where the payer commits funds up front but +the recipient only gets access to them gradually. + +The relevant types are in `chia_wallet_sdk::driver` (no feature flag): + +| Type | Purpose | +| --------------------- | ------------------------------------------------------------------------------------------------ | +| `StreamingPuzzleInfo` | The stream's parameters: `recipient`, `clawback_ph`, `end_time`, `last_payment_time` | +| `StreamedAsset` | A streaming coin: the `Coin`, its CAT `asset_id` and lineage proof (if it's a CAT), and the info | +| `StreamLayer` | The [layer](/sdk/concepts/layers) for the streaming puzzle itself | + +## How payments are calculated + +Each spend chooses a `payment_time`. The amount paid is + +```text +to_pay = amount * (payment_time - last_payment_time) / (end_time - last_payment_time) +``` + +and the new coin has `last_payment_time = payment_time` and `amount - to_pay` mojos. Once +`payment_time` reaches `end_time` the whole remaining amount is paid and the stream isn't recreated. +`StreamingPuzzleInfo::amount_to_be_paid` does this calculation (in 128-bit arithmetic, like the puzzle), +and `clamp_payment_time` clamps a time past the end to `end_time`. + +Each spend requires a message from the recipient's puzzle (or, for a clawback, the clawback puzzle) +whose content is `payment_time`, so only the recipient can trigger payments and only the clawback +puzzle can stop the stream. A normal payment asserts that `payment_time` has already passed +(`ASSERT_SECONDS_ABSOLUTE`), so the recipient can't claim funds early. A clawback does the opposite: it +asserts that `payment_time` is still in the future (`ASSERT_BEFORE_SECONDS_ABSOLUTE`), so the payer +can't backdate it to avoid paying what has already vested. On a clawback, the recipient is paid up to +`payment_time` and the rest goes to `clawback_ph`. + +## Streaming XCH + +This example locks 1000 mojos in a stream that vests over 1000 seconds. The recipient claims half way +through, and then the payer claws back the rest. + +```rust +use chia_wallet_sdk::chia::consensus::make_aggsig_final_message::u64_to_bytes; +use chia_wallet_sdk::driver::{StreamedAsset, StreamingPuzzleInfo}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn stream_xch() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let payer = sim.bls(1000); + let recipient = sim.bls(0); + + // For simplicity the clawback puzzle is `1`, which anyone can spend. Use a real puzzle in practice. + let clawback_puzzle = ctx.alloc(&1)?; + let clawback_puzzle_hash: Bytes32 = ctx.tree_hash(clawback_puzzle).into(); + + let start = sim.next_timestamp(); + let info = StreamingPuzzleInfo::new( + recipient.puzzle_hash, + Some(clawback_puzzle_hash), + start + 1000, + start, + ); + + // The launch hints let the recipient find the stream and recover its parameters. + let hints = ctx.alloc(&info.get_launch_hints())?; + StandardLayer::new(payer.pk).spend( + ctx, + payer.coin, + Conditions::new().create_coin(info.inner_puzzle_hash().into(), 1000, Memos::Some(hints)), + )?; + sim.spend_coins(ctx.take(), &[payer.sk])?; + + // Parse the stream from the spend that created it. + let launch_spend = sim.coin_spend(payer.coin.coin_id()).unwrap(); + let (Some(stream), _, _) = StreamedAsset::from_parent_spend(ctx, &launch_spend)? else { + anyhow::bail!("no stream found"); + }; + assert_eq!(stream.info, info); + + // Half way through, the recipient claims what has vested. + let payment_time = start + 500; + sim.set_next_timestamp(payment_time)?; + assert_eq!(stream.info.amount_to_be_paid(stream.coin.amount, payment_time)?, 500); + + let message_coin = sim.new_coin(recipient.puzzle_hash, 0); + let stream_coin_id = ctx.alloc(&stream.coin.coin_id())?; + StandardLayer::new(recipient.pk).spend( + ctx, + message_coin, + Conditions::new().send_message( + 23, + u64_to_bytes(payment_time).into(), + vec![stream_coin_id], + ), + )?; + stream.spend(ctx, payment_time, false)?; + sim.spend_coins(ctx.take(), &[recipient.sk.clone()])?; + + // The stream continues with the remaining 500 mojos. + let claim_spend = sim.coin_spend(stream.coin.coin_id()).unwrap(); + let (Some(stream), clawed_back, _) = StreamedAsset::from_parent_spend(ctx, &claim_spend)? else { + anyhow::bail!("stream ended"); + }; + assert!(!clawed_back); + assert_eq!(stream.coin.amount, 500); + assert_eq!(stream.info.last_payment_time, payment_time); + + // Later, the clawback puzzle stops the stream. A clawback's payment time must still be in the + // future when the spend is included. + let clawback_time = start + 750; + sim.set_next_timestamp(clawback_time - 1)?; + + let clawback_coin = sim.new_coin(clawback_puzzle_hash, 0); + let stream_coin_id = ctx.alloc(&stream.coin.coin_id())?; + let clawback_solution = ctx.alloc(&Conditions::new().send_message( + 23, + u64_to_bytes(clawback_time).into(), + vec![stream_coin_id], + ))?; + ctx.spend(clawback_coin, Spend::new(clawback_puzzle, clawback_solution))?; + stream.spend(ctx, clawback_time, true)?; + sim.spend_coins(ctx.take(), &[])?; + + // The stream is over, and the recipient got paid for the time up to the clawback. + let clawback_spend = sim.coin_spend(stream.coin.coin_id()).unwrap(); + let (child, clawed_back, paid) = StreamedAsset::from_parent_spend(ctx, &clawback_spend)?; + assert!(child.is_none()); + assert!(clawed_back); + assert_eq!(paid, 250); + + let recipient_total: u64 = sim + .unspent_coins(recipient.puzzle_hash, false) + .iter() + .map(|coin| coin.amount) + .sum(); + assert_eq!(recipient_total, 750); + assert_eq!(sim.unspent_coins(clawback_puzzle_hash, false)[0].amount, 250); + + Ok(()) +} +``` + +The message mode `23` (`0b010_111`) means the sender is committed by puzzle hash and the receiver by +full coin identity; see [Conditions](/sdk/concepts/conditions) for how message modes work. + +## Parsing streams + +`StreamedAsset::from_parent_spend(ctx, &coin_spend)` takes the spend of a coin's parent and returns +`(Option, clawed_back, paid_amount)`: + +- If the parent is itself a streaming coin, the child stream is computed from its solution. If the + parent was clawed back, it returns `(None, true, to_pay)`. If it was the final payment, it returns + `(None, false, 0)`. +- Otherwise it looks for a `CREATE_COIN` whose memos are valid launch hints + (`StreamingPuzzleInfo::from_memos`) and whose puzzle hash matches the stream they describe. This is + how a recipient discovers a new stream: the first memo, the recipient puzzle hash, also works as a + hint. + +Both XCH and CAT streams are handled. For a CAT parent, the returned `StreamedAsset` includes the asset +id and lineage proof. + +## Streaming CATs + +To stream a CAT, create a CAT with `info.inner_puzzle_hash()` as its inner puzzle hash (with the launch +hints as memos), then parse it with `from_parent_spend` as above. `StreamedAsset::spend` adds the CAT +layer automatically. Construct one directly with `StreamedAsset::cat(coin, asset_id, lineage_proof, info)` +or `StreamedAsset::xch(coin, info)`. See [CAT](/sdk/primitives/cat) for issuing and spending CATs. + +## Gotchas + +- `payment_time` must be at least `last_payment_time`, and the stream must not have ended + (`DriverError::InvalidStreamPaymentTime`). Times past `end_time` are clamped, and the message must + contain the clamped time. +- A claim must be in a block whose timestamp is at least `payment_time`, and a clawback in a block + whose timestamp is before it. In the [Simulator](/sdk/testing), set the timestamp of the next block + with `set_next_timestamp`. +- Each payment recreates the coin, so every claim costs a transaction. Claiming less often means fewer, + larger payouts. +- Without a `clawback_ph`, the stream can't be stopped. diff --git a/docs/sdk/primitives/vaults.md b/docs/sdk/primitives/vaults.md new file mode 100644 index 0000000..3e95fe1 --- /dev/null +++ b/docs/sdk/primitives/vaults.md @@ -0,0 +1,567 @@ +--- +title: Vaults & MIPS +--- + +# Vaults & MIPS + +A **vault** is a singleton whose inner puzzle is a custody configuration built with **MIPS**, the +multi-issuer puzzle system (CHIP-0043, see the [CHIPs repository](https://github.com/Chia-Network/chips)). Instead of +holding funds directly, a vault controls other coins (XCH, CATs, NFTs) whose p2 puzzle is locked to +the vault's launcher id. Because the vault is a singleton, its custody rules can be changed (rekeyed) +without moving any of those coins. + +Use vaults when you need more than a single BLS key: multisig, secp256k1/secp256r1 or passkey +signers, timelocked recovery paths, nested quorums, or restrictions on what a signer is allowed to do. + +The pieces fit together like this: + +| Piece | What it is | +| -------------------------------- | ---------------------------------------------------------------------------------------------- | +| `Vault` / `VaultInfo` | `Singleton`: the vault coin, its launcher id and its current `custody_hash` | +| `Launcher::mint_vault` | Launches a new vault with a given custody hash | +| MIPS members | Leaf puzzles that authorize a spend (BLS, secp, passkey, another singleton, a fixed puzzle...) | +| `MofN` | Combines members (or nested `MofN`s) into an m-of-n quorum | +| `Restriction` | Extra puzzles attached to a member or quorum that limit its conditions or the delegated puzzle | +| `MipsSpend` / `InnerPuzzleSpend` | Describes how to spend a custody tree: the delegated spend plus the member spends you're using | +| `P2Singleton` | p2 puzzle for coins owned by the vault | +| `P2ConditionsOrSingleton` | p2 puzzle that can either output a fixed set of conditions or be spent by the vault | +| `MipsMemo` | On-chain memo that reveals a custody configuration so it can be recovered from the blockchain | + +All of these live in `chia_wallet_sdk::driver`; the member, restriction and wrapper puzzles live in +`chia_wallet_sdk::types::puzzles`. `Vault` and `VaultInfo` are in the prelude. For background on +singletons and launchers see [Singletons & Launchers](/sdk/primitives/singletons). + +## How a MIPS puzzle is built + +Every node in a custody tree is wrapped the same way: + +1. The inner puzzle: either a **member** puzzle or an **m-of-n** puzzle over child nodes. +2. Optionally, a **restrictions** layer with member condition validators and delegated puzzle validators. +3. At the top level only, a **delegated puzzle feeder**, which takes the delegated puzzle and solution + from the solution, runs it, and passes its tree hash down to the members to sign. +4. An **index wrapper** that curries in a `nonce`, so that the same member can appear with different + puzzle hashes. + +`mips_puzzle_hash(nonce, restrictions, inner_puzzle_hash, top_level)` computes the hash of one such +node. The `top_level` flag must be `true` only for the root of the tree (the vault's custody hash, or a +p2 puzzle), and `false` for every node nested inside an `MofN`. + +The delegated puzzle is the part that actually does something: a quoted list of conditions. Members +sign its tree hash, not the conditions themselves, so every member in a quorum signs the same message. + +### Members + +| Member | Signer | +| -------------------------------------------- | -------------------------------------------------------------------- | +| `BlsMember`, `BlsTaprootMember` | BLS key (signs with `AGG_SIG_ME`). Taproot uses a synthetic key | +| `K1Member` | secp256k1 key, signs `sha256(delegated_puzzle_hash + coin_id)` | +| `R1Member` | secp256r1 key, same message as `K1Member` | +| `PasskeyMember` | WebAuthn passkey (secp256r1) | +| `SingletonMember`, `SingletonMemberWithMode` | Another singleton (for example another vault) approves via a message | +| `FixedPuzzleMember` | Only allows one specific delegated puzzle hash | +| `Eip712Member` (`chip-0037`) | secp256k1 key signing an EIP-712 typed message | + +Each secp/BLS member also has a `...PuzzleAssert` variant (for example `K1MemberPuzzleAssert`) that +commits to the vault's puzzle hash instead of the coin id. These are used for "fast forward" spends, +where the same signature needs to stay valid even if the vault coin is replaced by a newer coin with the +same puzzle hash. + +### Restrictions and wrappers + +A `Restriction` has a `RestrictionKind` and a puzzle hash: + +- `MemberCondition`: a validator that inspects the conditions output by the member or quorum. For + example `Timelock` requires an `ASSERT_SECONDS_RELATIVE` of at least some number of seconds. +- `DelegatedPuzzleHash`: a validator that inspects the delegated puzzle hash. +- `DelegatedPuzzleWrapper`: a wrapper puzzle that is wrapped around the delegated puzzle and can filter + or add conditions. The SDK enforces these with the `EnforceDelegatedPuzzleWrappers` validator. + Built-in wrappers include `PreventConditionOpcode`, `PreventMultipleCreateCoinsMod`, + `ForceAssertCoinAnnouncementMod`, `ForceCoinMessageMod`, `ForceSingletonRecreationMod`, `Timelock`, + and `Force1of2RestrictedVariable`. + +Wrappers change what the members sign: each member signs the hash of the delegated puzzle after all of +the wrappers have been applied. `MipsSpend` handles this for you as long as you use the same wrapper +order on every path. + +## Minting a vault and spending its coins + +This example creates a vault with a single BLS member, sends XCH to the vault's `P2Singleton` address, +and then spends that XCH. The XCH coin's spend only runs if the vault sends it a message containing the +tree hash of the coin's delegated puzzle, so the vault's signers authorize every coin spend in one +signature. + +```rust +use chia_wallet_sdk::driver::{InnerPuzzleSpend, MipsSpend, P2Singleton, mips_puzzle_hash}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::{MessageFlags, MessageSide, puzzles::BlsMember}; + +#[test] +fn spend_vault_owned_xch() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + // A 1-of-1 custody configuration with a single BLS key. + let alice = sim.bls(1); + let member = BlsMember::new(alice.pk); + let custody_hash = mips_puzzle_hash(0, vec![], member.curry_tree_hash(), true); + + // Launch the vault from one of Alice's coins. + let (launch_conditions, vault) = + Launcher::new(alice.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, launch_conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // Coins sent to the P2Singleton puzzle hash are owned by the vault. + let p2 = P2Singleton::new(vault.info.launcher_id, 0); + let vault_coin = sim.new_coin(p2.tree_hash().into(), 1000); + + // The vault-owned coin outputs these conditions once the vault approves them. + let bob_puzzle_hash = Bytes32::new([1; 32]); + let delegated = ctx.delegated_spend(Conditions::new().create_coin( + bob_puzzle_hash, + 1000, + Memos::None, + ))?; + let delegated_puzzle_hash = ctx.tree_hash(delegated.puzzle); + let coin_spend = p2.spend(ctx, vault.info.custody_hash.into(), vault.coin.amount, delegated)?; + ctx.spend(vault_coin, coin_spend)?; + + // The vault sends a message to the coin, and recreates itself with the same custody. + let mode = MessageFlags::PUZZLE.encode(MessageSide::Sender) + | MessageFlags::COIN.encode(MessageSide::Receiver); + let coin_id = ctx.alloc(&vault_coin.coin_id())?; + let vault_conditions = Conditions::new() + .send_message(mode, delegated_puzzle_hash.to_vec().into(), vec![coin_id]) + .create_coin(vault.info.custody_hash.into(), vault.coin.amount, Memos::None); + + let mut spend = MipsSpend::new(ctx.delegated_spend(vault_conditions)?); + let member_puzzle = ctx.curry(member)?; + spend.members.insert( + custody_hash, + InnerPuzzleSpend::new(0, vec![], Spend::new(member_puzzle, NodePtr::NIL)), + ); + vault.spend(ctx, &spend)?; + + // The BLS member emits an AGG_SIG_ME, which the simulator signs with Alice's key. + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let child = vault.child(vault.info.custody_hash, vault.coin.amount); + assert!(sim.coin_state(child.coin.coin_id()).is_some()); + assert_eq!(sim.unspent_coins(bob_puzzle_hash, false).len(), 1); + + Ok(()) +} +``` + +A few things to notice: + +- `MipsSpend::members` is keyed by the puzzle hash of each node, as computed by `mips_puzzle_hash`. The + entry for the root (`custody_hash`) tells the SDK how to spend the top of the tree. +- `Vault::spend` wraps the MIPS spend in the singleton layer. The delegated conditions must recreate the + vault (an odd-amount `CREATE_COIN`), unless you mean to melt it. +- `Vault::child(custody_hash, amount)` gives you the next vault coin. Pass a different custody hash to + track a rekey. +- The XCH coin's `P2Singleton::spend` takes the vault's current inner puzzle hash and amount, because the + `SingletonMember` puzzle needs them to recompute the vault's puzzle hash and receive the message. +- `nonce` lets one vault have many p2 puzzle hashes (`P2Singleton::new(launcher_id, nonce)`), for example + one per address. + +In practice you would usually build the coin spends with the [action system](/sdk/action-system) and +plug in the vault's p2 spends as described in [Custom Spends](/sdk/action-system/custom-spends). + +## M-of-n custody + +`MofN::new(required, items)` combines node hashes into a quorum. Depending on the shape, it compiles to +a 1-of-n (merkle tree, reveal one path), n-of-n (all members), or general m-of-n puzzle. When spending, +insert an `InnerPuzzleSpend::m_of_n` for the quorum node and a member spend for exactly `required` of +its members. + +This 2-of-3 example uses secp256k1 keys, which sign `sha256(delegated_puzzle_hash + vault_coin_id)` +directly (there's no `AGG_SIG` condition to sign, the signature goes in the member's solution). + +```rust +use chia_wallet_sdk::chia::sha2::Sha256; +use chia_wallet_sdk::driver::{InnerPuzzleSpend, MipsSpend, MofN, mips_puzzle_hash}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::{K1Member, K1MemberSolution}; + +#[test] +fn two_of_three_vault() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let keys = K1Pair::range_vec(3); + let members: Vec = keys.iter().map(|k| K1Member::new(k.pk)).collect(); + + // Nested members are not top level. + let member_hashes: Vec = members + .iter() + .map(|m| mips_puzzle_hash(0, vec![], m.curry_tree_hash(), false)) + .collect(); + + let quorum = MofN::new(2, member_hashes.clone()); + let custody_hash = mips_puzzle_hash(0, vec![], quorum.inner_puzzle_hash(), true); + + let funder = sim.bls(1); + let (launch, vault) = + Launcher::new(funder.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(funder.pk).spend(ctx, funder.coin, launch)?; + sim.spend_coins(ctx.take(), &[funder.sk])?; + + let conditions = + Conditions::new().create_coin(vault.info.custody_hash.into(), 1, Memos::None); + let mut spend = MipsSpend::new(ctx.delegated_spend(conditions)?); + + spend.members.insert( + custody_hash, + InnerPuzzleSpend::m_of_n(0, vec![], quorum.required, quorum.items.clone()), + ); + + // Keys 0 and 2 sign. + let delegated_puzzle_hash = ctx.tree_hash(spend.delegated.puzzle); + for i in [0, 2] { + let mut hasher = Sha256::new(); + hasher.update(delegated_puzzle_hash); + hasher.update(vault.coin.coin_id()); + let signature = keys[i].sk.sign_prehashed(&hasher.finalize())?; + + let puzzle = ctx.curry(members[i])?; + let solution = ctx.alloc(&K1MemberSolution::new(vault.coin.coin_id(), signature))?; + spend.members.insert( + member_hashes[i], + InnerPuzzleSpend::new(0, vec![], Spend::new(puzzle, solution)), + ); + } + + vault.spend(ctx, &spend)?; + sim.spend_coins(ctx.take(), &[])?; + + Ok(()) +} +``` + +If you insert more member spends than `required`, the SDK uses the first `required` distinct members in +the order of `items`. `MofN::validate` (called during spending) rejects a `required` of zero or more +than the number of items, since such a puzzle is either spendable by anyone or not at all. + +## Restricting what a member can do + +Restrictions are attached to a node via the `restrictions` argument of `mips_puzzle_hash` and +`InnerPuzzleSpend`. Delegated puzzle wrappers also need a spend in `MipsSpend::restrictions`, keyed by +the wrapper's puzzle hash. + +Here the vault's only member is wrapped in `PreventConditionOpcode`, so it can't create coin +announcements. A normal spend still works, but one that tries to output `CREATE_COIN_ANNOUNCEMENT` +fails. + +```rust +use chia_wallet_sdk::chia::consensus::opcodes::CREATE_COIN_ANNOUNCEMENT; +use chia_wallet_sdk::driver::{ + InnerPuzzleSpend, MipsSpend, Restriction, RestrictionKind, mips_puzzle_hash, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::{BlsMember, PreventConditionOpcode}; + +fn spend_vault( + ctx: &mut SpendContext, + vault: &Vault, + member: BlsMember, + wrapper: PreventConditionOpcode, + restriction: Restriction, + conditions: Conditions, +) -> anyhow::Result<()> { + let mut spend = MipsSpend::new(ctx.delegated_spend(conditions)?); + + let wrapper_puzzle = ctx.curry(wrapper)?; + spend.restrictions.insert( + wrapper.curry_tree_hash(), + Spend::new(wrapper_puzzle, NodePtr::NIL), + ); + + let member_puzzle = ctx.curry(member)?; + spend.members.insert( + vault.info.custody_hash, + InnerPuzzleSpend::new(0, vec![restriction], Spend::new(member_puzzle, NodePtr::NIL)), + ); + + vault.spend(ctx, &spend)?; + Ok(()) +} + +#[test] +fn restricted_vault() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let member = BlsMember::new(alice.pk); + let wrapper = PreventConditionOpcode::new(CREATE_COIN_ANNOUNCEMENT); + let restriction = Restriction { + kind: RestrictionKind::DelegatedPuzzleWrapper, + puzzle_hash: wrapper.curry_tree_hash(), + }; + let custody_hash = mips_puzzle_hash(0, vec![restriction], member.curry_tree_hash(), true); + + let (launch, vault) = + Launcher::new(alice.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, launch)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // Creating a coin announcement is rejected by the wrapper. + let forbidden = Conditions::new() + .create_coin(custody_hash.into(), 1, Memos::None) + .create_coin_announcement(b"hello".to_vec().into()); + spend_vault(ctx, &vault, member, wrapper, restriction, forbidden)?; + assert!(sim.spend_coins(ctx.take(), &[alice.sk.clone()]).is_err()); + + // Anything else is allowed. + let allowed = Conditions::new().create_coin(custody_hash.into(), 1, Memos::None); + spend_vault(ctx, &vault, member, wrapper, restriction, allowed)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + Ok(()) +} +``` + +The typical vault configuration used by wallets is a top-level 1-of-2 between a **custody** quorum and +a **recovery** quorum. The recovery path is restricted with wrappers so that it can only start a +recovery: it can't create announcements or send messages, can only create one coin, and must go through +`Force1of2RestrictedVariable`, which forces the new custody to be a 1-of-2 between the old custody +(which can cancel) and a timelocked path that finishes the recovery. The helper +`vault_recovery_delegated_puzzle_hash` (and `calculate_vault_start_recovery_message`) in +`chia_wallet_sdk::driver` compute the wrapped delegated puzzle hash that recovery signers must sign. + +## Recovering a vault's configuration from memos + +A custody hash alone doesn't tell you who the signers are. To make a vault recoverable from the +blockchain (for example when restoring a wallet), write a `MipsMemo` into the memos of the +`CREATE_COIN` that creates the vault with a new custody hash. A `MipsMemo` mirrors the custody tree: +an `InnerPuzzleMemo` per node (nonce, `RestrictionMemo`s, and either a `MemberMemo` or a `MofNMemo`). + +Each `MemberMemo` constructor takes a `reveal` flag. If it's `true`, the memo includes the public key +(or launcher id), so anyone can parse it. If it's `false`, only the puzzle hash is stored, and you need +to supply candidate keys in a `MipsMemoContext` to recognize it. + +This example rekeys a vault to a 1-of-2 between two BLS keys, writes the memo on chain, recovers the +configuration from the vault's coin spend, and then spends with the new custody. + +```rust +use chia_wallet_sdk::driver::{ + InnerPuzzleMemo, InnerPuzzleSpend, MemberMemo, MemoKind, MipsMemo, MipsMemoContext, MipsSpend, + MofN, MofNMemo, ParsedMember, mips_puzzle_hash, +}; +use chia_wallet_sdk::chia::puzzle_types::singleton::SingletonArgs; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::BlsMember; + +#[test] +fn rekey_with_memo() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let bob = BlsPair::new(42); + + // Start with Alice as the only member. + let alice_member = BlsMember::new(alice.pk); + let custody_hash = mips_puzzle_hash(0, vec![], alice_member.curry_tree_hash(), true); + let (launch, vault) = + Launcher::new(alice.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, launch)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // Describe the new custody: 1-of-2 between Alice and Bob, with keys revealed. + let mut items = Vec::new(); + for pk in [alice.pk, bob.pk] { + let member = MemberMemo::bls(ctx, pk, false, false, true)?; + items.push(InnerPuzzleMemo::new(0, vec![], MemoKind::Member(member))); + } + let memo = MipsMemo::new(InnerPuzzleMemo::new( + 0, + vec![], + MemoKind::MofN(MofNMemo::new(1, items)), + )); + let new_custody_hash = memo.try_inner_puzzle_hash()?; + + // Rekey, writing the memo into the vault's CREATE_COIN. + let memos = ctx.memos(&vec![memo])?; + let mut spend = MipsSpend::new(ctx.delegated_spend( + Conditions::new().create_coin(new_custody_hash.into(), 1, memos), + )?); + let puzzle = ctx.curry(alice_member)?; + spend.members.insert( + custody_hash, + InnerPuzzleSpend::new(0, vec![], Spend::new(puzzle, NodePtr::NIL)), + ); + vault.spend(ctx, &spend)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // Later: recover the configuration from the vault's coin spend. + let coin_spend = sim.coin_spend(vault.coin.coin_id()).unwrap(); + let puzzle = ctx.alloc(&coin_spend.puzzle_reveal)?; + let solution = ctx.alloc(&coin_spend.solution)?; + let output = ctx.run(puzzle, solution)?; + let conditions: Vec = ctx.extract(output)?; + + let create_coin = conditions + .into_iter() + .filter_map(Condition::into_create_coin) + .find(|cc| cc.amount % 2 == 1) + .unwrap(); + let Memos::Some(memos) = create_coin.memos else { + anyhow::bail!("missing memos"); + }; + let memo_list: Vec = ctx.extract(memos)?; + + // Use the bounded parser for untrusted input, and check it matches the child's puzzle hash. + let recovered = MipsMemo::from_clvm_bounded(ctx, memo_list[0])?; + let recovered_custody_hash = recovered.try_inner_puzzle_hash()?; + assert_eq!( + create_coin.puzzle_hash, + SingletonArgs::curry_tree_hash(vault.info.launcher_id, recovered_custody_hash).into() + ); + + let MemoKind::MofN(m_of_n) = &recovered.inner_puzzle.kind else { + anyhow::bail!("expected an m-of-n"); + }; + let mut keys = Vec::new(); + for item in &m_of_n.items { + let MemoKind::Member(member) = &item.kind else { + anyhow::bail!("expected a member"); + }; + match member.parse(ctx, &MipsMemoContext::default()) { + Some(ParsedMember::Bls(member)) => keys.push(member.public_key), + other => anyhow::bail!("unexpected member {other:?}"), + } + } + assert_eq!(keys, vec![alice.pk, bob.pk]); + + // Spend the rekeyed vault with Bob's key, using the recovered configuration. + let vault = vault.child(recovered_custody_hash, 1); + let member_hashes: Vec = keys + .iter() + .map(|&pk| mips_puzzle_hash(0, vec![], BlsMember::new(pk).curry_tree_hash(), false)) + .collect(); + assert_eq!( + recovered_custody_hash, + mips_puzzle_hash(0, vec![], MofN::new(1, member_hashes.clone()).inner_puzzle_hash(), true) + ); + + let mut spend = MipsSpend::new(ctx.delegated_spend( + Conditions::new().create_coin(recovered_custody_hash.into(), 1, Memos::None), + )?); + spend.members.insert( + recovered_custody_hash, + InnerPuzzleSpend::m_of_n(0, vec![], 1, member_hashes.clone()), + ); + let puzzle = ctx.curry(BlsMember::new(bob.pk))?; + spend.members.insert( + member_hashes[1], + InnerPuzzleSpend::new(0, vec![], Spend::new(puzzle, NodePtr::NIL)), + ); + vault.spend(ctx, &spend)?; + sim.spend_coins(ctx.take(), &[bob.sk])?; + + Ok(()) +} +``` + +Things to keep in mind with memos: + +- Always parse memos from the chain with `MipsMemo::from_clvm_bounded`. It rejects deeply nested or + oversized memos and m-of-n shapes that can't be spent (such as duplicate members), which plain + `FromClvm` doesn't. +- Never trust a memo on its own; check `try_inner_puzzle_hash()` (or `verify`) against the puzzle hash + that was actually created. +- The order of `MofNMemo::items` matters, since it determines the merkle tree. Wallets typically sort + members by puzzle hash. +- `RestrictionMemo` and `WrapperMemo` have constructors for the built-in restrictions (`timelock`, + `enforce_delegated_puzzle_wrappers`, `force_1_of_2_restricted_variable`, `prevent_condition_opcode` + and so on), and `parse` methods that return `ParsedRestriction` / `ParsedWrapper`. +- A `MemberMemo` whose memo is the full puzzle reveal parses as `ParsedMember::Custom`, which is how a + fixed recovery-finish puzzle is revealed. + +## P2Singleton and P2ConditionsOrSingleton + +`P2Singleton` is a top-level MIPS puzzle with a single `SingletonMember`. Its puzzle hash depends only on +the launcher id and nonce, so it stays the same when the vault is rekeyed. Spend it with +`P2Singleton::spend`, passing the delegated spend, and have the vault send the matching message as +shown above. + +`P2ConditionsOrSingleton` is a 1-of-2 between a fixed set of conditions and the vault. Anyone can spend +the fixed path, which outputs exactly the committed conditions; the vault can spend it like a normal +`P2Singleton` coin. Vaults use this to pre-split coins for offers: the fixed path pays into the +settlement puzzle, and the vault can still cancel by spending the singleton path. The default version, +`P2ConditionsOrSingletonVersion::FixedPuzzleMember`, uses a `FixedPuzzleMember`; `Legacy` exists for +coins created with the original format. + +The struct only stores the hash of the fixed path, so a coin can be reconstructed from that hash alone. +`P2ConditionsOrSingleton::new` takes the fixed path hash directly, and `from_quoted_conditions_hash` +calculates it from the quoted conditions hash and version. When spending the fixed path, the version is +detected from the quoted conditions (`match_quoted_conditions_hash`). + +:::note +This changed in 0.37.0. In earlier versions, `new` took the quoted conditions hash and version and +stored them on the struct. +::: + +```rust since-0.37 +use chia_wallet_sdk::driver::{P2ConditionsOrSingleton, P2ConditionsOrSingletonVersion}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn p2_conditions_or_singleton_fixed_path() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let conditions = Conditions::new().create_coin(Bytes32::new([1; 32]), 1, Memos::None); + let quoted_conditions = ctx.delegated_spend(conditions)?; + let quoted_conditions_hash = ctx.tree_hash(quoted_conditions.puzzle).into(); + + let launcher_id = Bytes32::new([2; 32]); + let p2 = P2ConditionsOrSingleton::from_quoted_conditions_hash( + launcher_id, + 0, + quoted_conditions_hash, + P2ConditionsOrSingletonVersion::FixedPuzzleMember, + ); + + // No signature is needed to take the fixed path. + let coin = sim.new_coin(p2.tree_hash().into(), 1); + let spend = p2.fixed_spend(ctx, quoted_conditions)?; + ctx.spend(coin, spend)?; + sim.spend_coins(ctx.take(), &[])?; + + Ok(()) +} +``` + +The vault path is `P2ConditionsOrSingleton::p2_singleton_spend`, which takes the same arguments as +`P2Singleton::spend`. When the vault spends one of these coins, include +`ASSERT_MY_COIN_ID` in the coin's delegated conditions; [clear signing](/sdk/clear-signing) requires it. + +## CHIP-0037 puzzles + +With the `chip-0037` feature, the SDK adds puzzles for controlling Chia coins from Ethereum-style +signers: + +- `Eip712Member` (in `types::puzzles`), a MIPS member that verifies a secp256k1 signature over an + EIP-712 typed message. Use `eip712_prefix_and_domain_separator(genesis_challenge)`, + `eip712_type_hash()` and `eip712_hash_to_sign(...)` to build the message. +- `P2Eip712MessageLayer`, a standalone p2 puzzle that's spent by an EIP-712 signature. +- `P2ControllerPuzzleLayer`, which locks a coin to a controller puzzle hash: the coin is spent when a + coin with that puzzle hash sends it a message with the delegated puzzle hash. This lets one signature + unlock many coins. + +## Gotchas + +- `top_level` must be `true` only for the root node. Using the wrong flag gives a valid-looking but + unspendable puzzle hash. +- `MipsSpend` looks up nodes by puzzle hash, so if a hash you inserted doesn't match the tree you get + `DriverError::MissingSubpathSpend`. +- Every member signs the same delegated puzzle hash. For secp members you compute the message yourself; + for BLS members the `AGG_SIG_ME` condition is signed like any other (see + [Keys & Signing](/sdk/concepts/signing)). +- If a vault spend doesn't recreate the vault, the vault is melted and every coin locked to its + `P2Singleton` puzzle hashes becomes unspendable. +- To check what a vault transaction does before signing it, see [Clear Signing](/sdk/clear-signing). diff --git a/docs/sdk/quick-start.md b/docs/sdk/quick-start.md new file mode 100644 index 0000000..b92deb5 --- /dev/null +++ b/docs/sdk/quick-start.md @@ -0,0 +1,224 @@ +--- +title: Quick Start +--- + +# Quick Start + +This page builds a first transaction three ways: by hand with the standard puzzle, declaratively with +the action system, and finally against mainnet. The first two run entirely on your machine against the +[simulator](/sdk/testing), so you can paste them into a test file and run `cargo test`. + +## Set up a project + +```bash +cargo new --lib my-chia-app +cd my-chia-app +cargo add chia-wallet-sdk --features rustls +cargo add anyhow +cargo add tokio --features full +``` + +`anyhow` and `tokio` aren't required by the SDK, but the examples on these pages use them for error +handling and async networking. + +## Send XCH by hand + +Alice owns a coin worth 1,000 mojos. She sends 900 mojos to Bob and leaves 100 mojos as the fee. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn send_xch() -> anyhow::Result<()> { + // A fresh simulated blockchain, and a key pair with a 1,000 mojo coin. + let mut sim = Simulator::new(); + let alice = sim.bls(1_000); + let bob = BlsPair::new(1); + + // The context holds CLVM memory and collects the coin spends. + let ctx = &mut SpendContext::new(); + + // What Alice's coin should do when it's spent. The hint lets Bob's wallet find the coin. + let hint = ctx.hint(bob.puzzle_hash)?; + let conditions = Conditions::new() + .create_coin(bob.puzzle_hash, 900, hint) + .reserve_fee(100); + + // Alice's coin is locked by the standard puzzle with her public key. + StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; + + // Sign the collected spends with Alice's key and submit them to the simulator. + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let bob_coins = sim.unspent_coins(bob.puzzle_hash, false); + assert_eq!(bob_coins.len(), 1); + assert_eq!(bob_coins[0].amount, 900); + + Ok(()) +} +``` + +What happened: + +- [`SpendContext`](/sdk/concepts/spend-context) is where every puzzle and solution is built. It + collects the coin spends you add, and `take()` hands them back. +- [`Conditions`](/sdk/concepts/conditions) is a builder for the conditions a coin outputs when it's + spent. Here, one new coin and a fee. +- [`StandardLayer`](/sdk/primitives/standard) knows how to spend coins locked by the standard + transaction puzzle. `spend` builds the puzzle reveal and solution and adds the coin spend to the context. +- `Simulator::spend_coins` works out which signatures are required, signs them with the keys you pass, and + validates the resulting spend bundle exactly like a full node would. A mistake such as outputs that + exceed the input, or a missing signature, comes back as an error. + +The amounts must balance: the inputs (1,000) have to cover the outputs (900) plus the fee (100). Any +mojos you don't assign to a coin go to the farmer, so forgetting a change output silently overpays. + +## Send XCH with the action system + +For anything beyond a single coin, it's easier to say what should happen and let the +[action system](/sdk/action-system) work out the spends. It calculates change, adds the fee, and links +the spends so they can't be separated. + +```rust +use chia_wallet_sdk::chia::puzzle_types::Memos; +use chia_wallet_sdk::prelude::*; +use indexmap::indexmap; + +#[test] +fn send_xch_with_actions() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let alice = sim.bls(1_000); + let bob = BlsPair::new(1); + let ctx = &mut SpendContext::new(); + + // Coins to spend, and where change goes. + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + + // What the transaction should do. + let deltas = spends.apply( + ctx, + &[ + Action::send(Id::Xch, bob.puzzle_hash, 250, Memos::None), + Action::fee(50), + ], + )?; + + // Spend every coin with the standard puzzle, using the key that matches its puzzle hash. + let outputs = spends.finish_with_keys( + ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { alice.puzzle_hash => alice.pk }, + )?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Bob's payment, plus 700 mojos of change back to Alice. + assert_eq!(outputs.xch.len(), 2); + assert_eq!(sim.unspent_coins(alice.puzzle_hash, false)[0].amount, 700); + + Ok(()) +} +``` + +The same pattern issues CATs, mints NFTs and DIDs, and takes offers. See the +[action reference](/sdk/action-system/actions) for every action. + +## Going to mainnet + +On a real network, three things change: the keys come from the user's wallet, the coins come from a +full node, and the signature has to be made for the right network. This function sends XCH from the +first address of a wallet using [coinset.org](https://coinset.org): + +```rust +use std::collections::HashMap; + +use chia_wallet_sdk::chia::bls::{master_to_wallet_unhardened, sign}; +use chia_wallet_sdk::chia::puzzle_types::DeriveSynthetic; +use chia_wallet_sdk::prelude::*; + +pub async fn send_on_mainnet( + master_sk: &SecretKey, + recipient_address: &str, + amount: u64, + fee: u64, +) -> anyhow::Result { + let client = CoinsetClient::mainnet(); + + // Wallets lock coins to the standard puzzle with a synthetic key, derived per address index. + let sk = master_to_wallet_unhardened(master_sk, 0).derive_synthetic(); + let p2 = StandardLayer::new(sk.public_key()); + let puzzle_hash: Bytes32 = p2.tree_hash().into(); + + // Decode the recipient's address, making sure it's a mainnet address. + let recipient = Address::decode(recipient_address)?.expect_prefix("xch")?; + + // Find unspent coins and pick enough of them to cover the amount and fee. + let records = client + .get_coin_records_by_puzzle_hash(puzzle_hash, None, None, Some(false), None) + .await? + .coin_records + .unwrap_or_default(); + let coins: Vec = records.into_iter().map(|record| record.coin).collect(); + let selected = select_coins(coins, amount + fee)?; + let total: u64 = selected.iter().map(|coin| coin.amount).sum(); + + // The first coin creates the payment and change. Every coin asserts that the first one is spent + // in the same block, so the spends can't be separated. + let ctx = &mut SpendContext::new(); + let hint = ctx.hint(recipient)?; + + for (index, &coin) in selected.iter().enumerate() { + let conditions = if index == 0 { + let mut conditions = Conditions::new() + .create_coin(recipient, amount, hint) + .reserve_fee(fee); + if total > amount + fee { + conditions = conditions.create_coin(puzzle_hash, total - amount - fee, Memos::None); + } + conditions + } else { + Conditions::new().assert_concurrent_spend(selected[0].coin_id()) + }; + p2.spend(ctx, coin, conditions)?; + } + + // Sign every required message with the synthetic key, using mainnet's constants. + let coin_spends = ctx.take(); + let keys = HashMap::from([(sk.public_key(), &sk)]); + let mut signature = Signature::default(); + for required in RequiredSignature::from_coin_spends( + ctx, + &coin_spends, + &AggSigConstants::from(&*MAINNET_CONSTANTS), + )? { + let RequiredSignature::Bls(required) = required else { + anyhow::bail!("unexpected secp signature"); + }; + let sk = keys + .get(&required.public_key) + .ok_or_else(|| anyhow::anyhow!("missing key"))?; + signature += &sign(sk, required.message()); + } + + let spend_bundle = SpendBundle::new(coin_spends, signature); + let transaction_id = spend_bundle.name(); + + let response = client.push_tx(spend_bundle).await?; + if !response.success { + anyhow::bail!("push_tx failed: {}", response.error.unwrap_or_default()); + } + + Ok(transaction_id) +} +``` + +A production wallet would also scan more than one address index, check that coins aren't already being +spent in the mempool, and wait for confirmation. The pages linked below cover each of these pieces: + +- [Keys & Signing](/sdk/concepts/signing) for key derivation, synthetic keys and network constants. +- [RPC clients](/sdk/networking/rpc) and the [peer protocol](/sdk/networking/peer) for finding coins and + broadcasting. +- [Utilities](/sdk/utilities) for addresses and coin selection. +- [Testing](/sdk/testing) for testing code like this against the simulator before it touches mainnet. diff --git a/docs/sdk/spend-context.md b/docs/sdk/spend-context.md deleted file mode 100644 index 894f0c1..0000000 --- a/docs/sdk/spend-context.md +++ /dev/null @@ -1,373 +0,0 @@ ---- -slug: /sdk/spend-context -title: SpendContext ---- - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# SpendContext - -`SpendContext` is the central abstraction for building transactions in the Wallet SDK. It manages CLVM memory allocation, caches puzzle hashes for efficiency, and collects coin spends that will form your transaction. - -:::info Language Bindings -In Node.js and Python, the equivalent functionality is provided by the `Clvm` class. While the API differs slightly, the core concepts remain the same: allocate CLVM values, build spends, and collect coin spends. -::: - -## What SpendContext Does - -SpendContext serves three primary purposes: - -1. **CLVM Memory Management** - Wraps the CLVM `Allocator` to handle memory for puzzle and solution construction -2. **Puzzle Caching** - Caches compiled puzzles by their tree hash to avoid redundant serialization -3. **Spend Collection** - Accumulates `CoinSpend` objects that will be combined into a spend bundle - -## Creating a SpendContext - - - - -```rust -use chia_wallet_sdk::prelude::*; - -let ctx = &mut SpendContext::new(); -``` - -The `&mut` reference pattern is used throughout the SDK because most operations need to allocate CLVM nodes or add spends to the context. - - - - -```typescript -import { Clvm } from "chia-wallet-sdk"; - -const clvm = new Clvm(); -``` - -The `Clvm` class combines the functionality of `SpendContext` (memory management and spend collection) with direct methods for creating conditions and spending coins. - - - - -```python -from chia_wallet_sdk import Clvm - -clvm = Clvm() -``` - -The `Clvm` class combines the functionality of `SpendContext` (memory management and spend collection) with direct methods for creating conditions and spending coins. - - - - -## Core Operations - -### Adding Spends - -The primary way to add spends is through the primitive APIs, which handle puzzle construction internally: - - - - -```rust -// StandardLayer handles puzzle construction and calls ctx.spend() internally -StandardLayer::new(public_key).spend(ctx, coin, conditions)?; -``` - -For lower-level control, you can add spends directly: - -```rust -// Add a pre-constructed spend -ctx.spend(coin, Spend::new(puzzle_ptr, solution_ptr))?; - -// Or insert a fully serialized CoinSpend -ctx.insert(coin_spend); -``` - - - - -```typescript -// spendStandardCoin handles puzzle construction and collects spends internally -clvm.spendStandardCoin(coin, publicKey, clvm.delegatedSpend(conditions)); - -// For CATs and NFTs, use the dedicated methods -clvm.spendCats([catSpend]); -clvm.spendNft(nft, innerSpend); -``` - - - - -```python -# spend_standard_coin handles puzzle construction and collects spends internally -clvm.spend_standard_coin(coin, public_key, clvm.delegated_spend(conditions)) - -# For CATs and NFTs, use the dedicated methods -clvm.spend_cats([cat_spend]) -clvm.spend_nft(nft, inner_spend) -``` - - - - -### Extracting Spends - -When you're ready to sign and broadcast, extract the collected spends: - - - - -```rust -let coin_spends: Vec = ctx.take(); -``` - - - - -```typescript -const coinSpends = clvm.coinSpends(); -``` - - - - -```python -coin_spends = clvm.coin_spends() -``` - - - - -The `take()` / `coinSpends()` / `coin_spends()` method removes all spends from the context, allowing you to reuse it for building another transaction. - -### Allocating CLVM Values - -When working with custom puzzles or conditions, you may need to allocate values directly: - - - - -```rust -// Allocate a value to CLVM -let node_ptr = ctx.alloc(&my_value)?; - -// Extract a value from CLVM -let value: MyType = ctx.extract(node_ptr)?; - -// Compute tree hash of a node -let hash = ctx.tree_hash(node_ptr); -``` - - - - -```typescript -// Allocate values to CLVM (returns a Program) -const program = clvm.alloc([puzzleHash, amount]); - -// Get tree hash of a program -const hash = program.treeHash(); - -// Allocate nil (empty list) -const nil = clvm.nil(); -``` - - - - -```python -# Allocate values to CLVM (returns a Program) -program = clvm.alloc([puzzle_hash, amount]) - -# Get tree hash of a program -hash = program.tree_hash() - -# Allocate nil (empty list) -nil = clvm.nil() -``` - - - - -### Serialization - -Convert CLVM values to `Program` for use in coin spends: - -```rust -// Standard serialization -let program = ctx.serialize(&my_value)?; - -// With back-references (smaller output for repeated structures) -let program = ctx.serialize_with_backrefs(&my_value)?; -``` - -### Memos and Hints - -The SDK provides helpers for creating memos (used for coin hints/discovery): - -```rust -// Create a hint memo from a puzzle hash -let memos = ctx.hint(puzzle_hash)?; - -// Create custom memos from any serializable value -let memos = ctx.memos(&[puzzle_hash, other_data])?; -``` - -### Currying Puzzles - -For constructing curried puzzles from known modules: - -```rust -// Curry arguments into a module -let curried_ptr = ctx.curry(MyModArgs { arg1, arg2 })?; -``` - -## Lifecycle Pattern - -The typical lifecycle of a SpendContext: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 1. Create SpendContext │ -│ let ctx = &mut SpendContext::new(); │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 2. Build Spends │ -│ - Use primitive APIs (StandardLayer, Cat, Nft, etc.) │ -│ - Each call adds CoinSpends to the context │ -│ - Puzzles are cached automatically │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 3. Extract Spends │ -│ let coin_spends = ctx.take(); │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 4. Sign │ -│ - Calculate required signatures │ -│ - Sign with appropriate keys │ -│ - Create SpendBundle │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ 5. Broadcast │ -│ - Submit SpendBundle to network │ -│ - Or validate with Simulator for testing │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Mental Model - -Think of SpendContext as a **transaction builder** that: - -- Acts as a scratchpad for constructing CLVM data structures -- Remembers puzzles you've used so they don't need to be rebuilt -- Collects all the individual coin spends that will form your transaction - -The mutable reference pattern (`&mut ctx`) reflects that building a transaction is an inherently stateful operation - each spend you add modifies the context. - -## Complete Example - - - - -```rust -use chia_wallet_sdk::prelude::*; - -fn build_transaction( - coin: Coin, - public_key: PublicKey, - recipient: Bytes32, - amount: u64, - fee: u64, -) -> Result, DriverError> { - // 1. Create context - let ctx = &mut SpendContext::new(); - - // 2. Build conditions - let memos = ctx.hint(recipient)?; - let conditions = Conditions::new() - .create_coin(recipient, amount, memos) - .reserve_fee(fee); - - // 3. Spend the coin - StandardLayer::new(public_key).spend(ctx, coin, conditions)?; - - // 4. Extract and return spends - Ok(ctx.take()) -} -``` - - - - -```typescript -import { Clvm, Coin, PublicKey, CoinSpend } from "chia-wallet-sdk"; - -function buildTransaction( - coin: Coin, - publicKey: PublicKey, - recipient: Uint8Array, - amount: bigint, - fee: bigint, -): CoinSpend[] { - // 1. Create context - const clvm = new Clvm(); - - // 2. Build conditions with hints - const conditions = [ - clvm.createCoin(recipient, amount, clvm.alloc([recipient])), - clvm.reserveFee(fee), - ]; - - // 3. Spend the coin - clvm.spendStandardCoin(coin, publicKey, clvm.delegatedSpend(conditions)); - - // 4. Extract and return spends - return clvm.coinSpends(); -} -``` - - - - -```python -from chia_wallet_sdk import Clvm, Coin, PublicKey, CoinSpend -from typing import List - -def build_transaction( - coin: Coin, - public_key: PublicKey, - recipient: bytes, - amount: int, - fee: int -) -> List[CoinSpend]: - # 1. Create context - clvm = Clvm() - - # 2. Build conditions with hints - conditions = [ - clvm.create_coin(recipient, amount, clvm.alloc([recipient])), - clvm.reserve_fee(fee), - ] - - # 3. Spend the coin - clvm.spend_standard_coin(coin, public_key, clvm.delegated_spend(conditions)) - - # 4. Extract and return spends - return clvm.coin_spends() -``` - - - - -## API Reference - -For the complete API, see [SpendContext in docs.rs](https://docs.rs/chia-sdk-driver/latest/chia_sdk_driver/struct.SpendContext.html). diff --git a/docs/sdk/testing.md b/docs/sdk/testing.md deleted file mode 100644 index b12267c..0000000 --- a/docs/sdk/testing.md +++ /dev/null @@ -1,651 +0,0 @@ ---- -slug: /sdk/testing -title: Testing ---- - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Testing - -The SDK includes a built-in simulator for testing transactions without connecting to a real network. This enables fast, deterministic testing of your Chia applications. - -## Overview - -The `chia-sdk-test` crate provides: - -- **Simulator** - An in-memory blockchain for validation -- **Test utilities** - Helpers for creating keys and coins -- **Spend validation** - Verify transactions before broadcast - -## Setting Up the Simulator - - - - -```rust -use chia_wallet_sdk::prelude::*; - -// Create a new simulator instance -let mut sim = Simulator::new(); -``` - - - - -```typescript -import { Simulator } from "chia-wallet-sdk"; - -// Create a new simulator instance -const sim = new Simulator(); -``` - - - - -```python -from chia_wallet_sdk import Simulator - -# Create a new simulator instance -sim = Simulator() -``` - - - - -The simulator starts with an empty state. You'll need to create coins before you can spend them. - -## Creating Test Keys and Coins - -The simulator provides helpers for creating BLS key pairs with funded coins: - - - - -```rust -// Create a key pair with a coin worth 1000 mojos -let alice = sim.bls(1_000); - -// alice contains: -// - alice.pk: PublicKey -// - alice.sk: SecretKey -// - alice.puzzle_hash: Bytes32 -// - alice.coin: Coin - -// Create multiple test identities -let alice = sim.bls(1_000_000); -let bob = sim.bls(500_000); -let charlie = sim.bls(0); // No initial funds -``` - - - - -```typescript -// Create a key pair with a coin worth 1000 mojos -const alice = sim.bls(1000n); - -// alice contains: -// - alice.pk: PublicKey -// - alice.sk: SecretKey -// - alice.puzzleHash: Uint8Array -// - alice.coin: Coin - -// Create multiple test identities -const alice = sim.bls(1_000_000n); -const bob = sim.bls(500_000n); -const charlie = sim.bls(0n); // No initial funds -``` - - - - -```python -# Create a key pair with a coin worth 1000 mojos -alice = sim.bls(1000) - -# alice contains: -# - alice.pk: PublicKey -# - alice.sk: SecretKey -# - alice.puzzle_hash: bytes -# - alice.coin: Coin - -# Create multiple test identities -alice = sim.bls(1_000_000) -bob = sim.bls(500_000) -charlie = sim.bls(0) # No initial funds -``` - - - - -## Validating Transactions - -After building a transaction, validate it with the simulator: - - - - -```rust -let ctx = &mut SpendContext::new(); - -// Build your transaction -let conditions = Conditions::new() - .create_coin(bob.puzzle_hash, 900, Memos::None) - .reserve_fee(100); - -StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; - -// Extract spends and validate -let coin_spends = ctx.take(); -sim.spend_coins(coin_spends, &[alice.sk])?; -``` - - - - -```typescript -const clvm = new Clvm(); - -// Build your transaction -const conditions = [ - clvm.createCoin(bob.puzzleHash, 900n, null), - clvm.reserveFee(100n), -]; - -clvm.spendStandardCoin(alice.coin, alice.pk, clvm.delegatedSpend(conditions)); - -// Extract spends and validate -const coinSpends = clvm.coinSpends(); -sim.spendCoins(coinSpends, [alice.sk]); -``` - - - - -```python -clvm = Clvm() - -# Build your transaction -conditions = [ - clvm.create_coin(bob.puzzle_hash, 900, None), - clvm.reserve_fee(100), -] - -clvm.spend_standard_coin(alice.coin, alice.pk, clvm.delegated_spend(conditions)) - -# Extract spends and validate -coin_spends = clvm.coin_spends() -sim.spend_coins(coin_spends, [alice.sk]) -``` - - - - -If the transaction is invalid, `spend_coins` returns an error describing the failure. - -## Simulator State - -The simulator maintains blockchain state: - -```rust -// Check if a coin exists and is unspent -let coin_state = sim.coin_state(coin_id); - -// The simulator tracks: -// - Created coins -// - Spent coins -// - Current block height -``` - -## Testing Patterns - -### Basic Transaction Test - - - - -```rust -#[test] -fn test_simple_transfer() -> Result<()> { - let mut sim = Simulator::new(); - let ctx = &mut SpendContext::new(); - - let alice = sim.bls(1_000); - let bob_puzzle_hash = sim.bls(0).puzzle_hash; - - let conditions = Conditions::new() - .create_coin(bob_puzzle_hash, 900, Memos::None) - .reserve_fee(100); - - StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; - sim.spend_coins(ctx.take(), &[alice.sk])?; - - Ok(()) -} -``` - - - - -```typescript -import test from "ava"; -import { Clvm, Simulator } from "chia-wallet-sdk"; - -test("simple transfer", (t) => { - const sim = new Simulator(); - const clvm = new Clvm(); - - const alice = sim.bls(1000n); - const bobPuzzleHash = sim.bls(0n).puzzleHash; - - const conditions = [ - clvm.createCoin(bobPuzzleHash, 900n, null), - clvm.reserveFee(100n), - ]; - - clvm.spendStandardCoin(alice.coin, alice.pk, clvm.delegatedSpend(conditions)); - sim.spendCoins(clvm.coinSpends(), [alice.sk]); - - t.pass(); -}); -``` - - - - -```python -import pytest -from chia_wallet_sdk import Clvm, Simulator - -def test_simple_transfer(): - sim = Simulator() - clvm = Clvm() - - alice = sim.bls(1000) - bob_puzzle_hash = sim.bls(0).puzzle_hash - - conditions = [ - clvm.create_coin(bob_puzzle_hash, 900, None), - clvm.reserve_fee(100), - ] - - clvm.spend_standard_coin(alice.coin, alice.pk, clvm.delegated_spend(conditions)) - sim.spend_coins(clvm.coin_spends(), [alice.sk]) -``` - - - - -### Testing CAT Operations - - - - -```rust -#[test] -fn test_cat_issuance() -> Result<()> { - let mut sim = Simulator::new(); - let ctx = &mut SpendContext::new(); - - let alice = sim.bls(1_000); - let p2 = StandardLayer::new(alice.pk); - let memos = ctx.hint(alice.puzzle_hash)?; - - // Issue CAT - let conditions = Conditions::new() - .create_coin(alice.puzzle_hash, 1_000, memos); - - let (issue_cat, cats) = Cat::issue_with_coin( - ctx, - alice.coin.coin_id(), - 1_000, - conditions, - )?; - - p2.spend(ctx, alice.coin, issue_cat)?; - sim.spend_coins(ctx.take(), &[alice.sk])?; - - // Verify CAT was created - assert_eq!(cats.len(), 1); - assert_eq!(cats[0].coin.amount, 1_000); - - Ok(()) -} -``` - - - - -```typescript -import test from "ava"; -import { Cat, CatInfo, CatSpend, Clvm, Coin, Simulator } from "chia-wallet-sdk"; - -test("issues and spends a cat", (t) => { - const sim = new Simulator(); - const clvm = new Clvm(); - - const alice = sim.bls(1n); - - const tail = clvm.nil(); - const assetId = tail.treeHash(); - const catInfo = new CatInfo(assetId, null, alice.puzzleHash); - - // Issue a CAT - clvm.spendStandardCoin( - alice.coin, - alice.pk, - clvm.delegatedSpend([clvm.createCoin(catInfo.puzzleHash(), 1n)]), - ); - - const eve = new Cat( - new Coin(alice.coin.coinId(), catInfo.puzzleHash(), 1n), - null, - catInfo, - ); - - clvm.spendCats([ - new CatSpend( - eve, - clvm.standardSpend( - alice.pk, - clvm.delegatedSpend([ - clvm.createCoin(alice.puzzleHash, 1n, clvm.alloc([alice.puzzleHash])), - clvm.runCatTail(tail, clvm.nil()), - ]), - ), - ), - ]); - - sim.spendCoins(clvm.coinSpends(), [alice.sk]); - - t.pass(); -}); -``` - - - - -```python -from chia_wallet_sdk import Cat, CatInfo, CatSpend, Clvm, Coin, Simulator - -def test_issues_and_spends_a_cat(): - sim = Simulator() - clvm = Clvm() - - alice = sim.bls(1) - - tail = clvm.nil() - asset_id = tail.tree_hash() - cat_info = CatInfo(asset_id, None, alice.puzzle_hash) - - # Issue a CAT - clvm.spend_standard_coin( - alice.coin, - alice.pk, - clvm.delegated_spend([clvm.create_coin(cat_info.puzzle_hash(), 1)]) - ) - - eve = Cat( - Coin(alice.coin.coin_id(), cat_info.puzzle_hash(), 1), - None, - cat_info - ) - - clvm.spend_cats([ - CatSpend( - eve, - clvm.standard_spend( - alice.pk, - clvm.delegated_spend([ - clvm.create_coin(alice.puzzle_hash, 1, clvm.alloc([alice.puzzle_hash])), - clvm.run_cat_tail(tail, clvm.nil()), - ]) - ) - ), - ]) - - sim.spend_coins(clvm.coin_spends(), [alice.sk]) -``` - - - - -### Testing Invalid Transactions - - - - -```rust -#[test] -fn test_insufficient_funds_fails() { - let mut sim = Simulator::new(); - let ctx = &mut SpendContext::new(); - - let alice = sim.bls(1_000); - - // Try to create more than we have - let conditions = Conditions::new() - .create_coin(alice.puzzle_hash, 2_000, Memos::None); - - StandardLayer::new(alice.pk) - .spend(ctx, alice.coin, conditions) - .unwrap(); - - // This should fail - let result = sim.spend_coins(ctx.take(), &[alice.sk]); - assert!(result.is_err()); -} -``` - - - - -```typescript -test("insufficient funds fails", (t) => { - const sim = new Simulator(); - const clvm = new Clvm(); - - const alice = sim.bls(1000n); - - // Try to create more than we have - const conditions = [clvm.createCoin(alice.puzzleHash, 2000n, null)]; - - clvm.spendStandardCoin(alice.coin, alice.pk, clvm.delegatedSpend(conditions)); - - // This should throw an error - t.throws(() => { - sim.spendCoins(clvm.coinSpends(), [alice.sk]); - }); -}); -``` - - - - -```python -def test_insufficient_funds_fails(): - sim = Simulator() - clvm = Clvm() - - alice = sim.bls(1000) - - # Try to create more than we have - conditions = [clvm.create_coin(alice.puzzle_hash, 2000, None)] - - clvm.spend_standard_coin(alice.coin, alice.pk, clvm.delegated_spend(conditions)) - - # This should raise an exception - with pytest.raises(Exception): - sim.spend_coins(clvm.coin_spends(), [alice.sk]) -``` - - - - -### Testing Multi-Spend Transactions - - - - -```rust -#[test] -fn test_multi_spend() -> Result<()> { - let mut sim = Simulator::new(); - let ctx = &mut SpendContext::new(); - - let alice = sim.bls(1_000); - let bob = sim.bls(500); - let charlie_ph = sim.bls(0).puzzle_hash; - - // Alice sends 900 - StandardLayer::new(alice.pk).spend( - ctx, - alice.coin, - Conditions::new().create_coin(charlie_ph, 900, Memos::None), - )?; - - // Bob pays the fee - StandardLayer::new(bob.pk).spend( - ctx, - bob.coin, - Conditions::new() - .create_coin(bob.puzzle_hash, 400, Memos::None) - .reserve_fee(100), - )?; - - sim.spend_coins(ctx.take(), &[alice.sk, bob.sk])?; - - Ok(()) -} -``` - - - - -```typescript -test("multi spend", (t) => { - const sim = new Simulator(); - const clvm = new Clvm(); - - const alice = sim.bls(1000n); - const bob = sim.bls(500n); - const charliePh = sim.bls(0n).puzzleHash; - - // Alice sends 900 - clvm.spendStandardCoin( - alice.coin, - alice.pk, - clvm.delegatedSpend([clvm.createCoin(charliePh, 900n, null)]), - ); - - // Bob pays the fee - clvm.spendStandardCoin( - bob.coin, - bob.pk, - clvm.delegatedSpend([ - clvm.createCoin(bob.puzzleHash, 400n, null), - clvm.reserveFee(100n), - ]), - ); - - sim.spendCoins(clvm.coinSpends(), [alice.sk, bob.sk]); - - t.pass(); -}); -``` - - - - -```python -def test_multi_spend(): - sim = Simulator() - clvm = Clvm() - - alice = sim.bls(1000) - bob = sim.bls(500) - charlie_ph = sim.bls(0).puzzle_hash - - # Alice sends 900 - clvm.spend_standard_coin( - alice.coin, - alice.pk, - clvm.delegated_spend([clvm.create_coin(charlie_ph, 900, None)]) - ) - - # Bob pays the fee - clvm.spend_standard_coin( - bob.coin, - bob.pk, - clvm.delegated_spend([ - clvm.create_coin(bob.puzzle_hash, 400, None), - clvm.reserve_fee(100), - ]) - ) - - sim.spend_coins(clvm.coin_spends(), [alice.sk, bob.sk]) -``` - - - - -## Organizing Test Code - -### Test Module Structure - -```rust -#[cfg(test)] -mod tests { - use super::*; - use chia_wallet_sdk::prelude::*; - - #[test] - fn test_feature_a() -> Result<()> { - // ... - } - - #[test] - fn test_feature_b() -> Result<()> { - // ... - } -} -``` - -### Reusable Test Helpers - -```rust -#[cfg(test)] -mod tests { - use super::*; - - fn setup_test() -> (Simulator, SpendContext, BlsPairWithCoin) { - let sim = Simulator::new(); - let ctx = SpendContext::new(); - let alice = sim.bls(1_000_000); - (sim, ctx, alice) - } - - #[test] - fn test_with_helper() -> Result<()> { - let (mut sim, ctx, alice) = setup_test(); - let ctx = &mut ctx; - // ... - } -} -``` - -## Limitations - -The simulator is designed for transaction validation, not full blockchain simulation: - -- No mempool simulation -- No block timing -- No network latency -- Simplified fee handling - -For integration testing against real network behavior, use testnet. - -## API Reference - -For the complete testing API, see [chia-sdk-test on docs.rs](https://docs.rs/chia-sdk-test). diff --git a/docs/sdk/testing/full-node-simulator.md b/docs/sdk/testing/full-node-simulator.md new file mode 100644 index 0000000..a701a6c --- /dev/null +++ b/docs/sdk/testing/full-node-simulator.md @@ -0,0 +1,393 @@ +--- +title: Full Node Simulator +--- + +# Full Node Simulator + +`FullNodeSimulator` is an in-memory model of a full node, as opposed to the [`Simulator`](/sdk/testing), +which models only consensus validation. Transactions go into a mempool and are only confirmed when you +farm a block. The mempool handles conflicts, replace-by-fee, identical spend deduplication and singleton +fast-forward. You can revert blocks and reorganize the chain, and query everything through methods that +return the same response types as the [RPC clients](/sdk/networking/rpc). + +With the `full-node-simulator-http` feature, `FullNodeSimulatorServer` serves the simulator over HTTP +with the full node RPC API, so `CoinsetClient` (or any other language's RPC client) can drive it as if it +were a real node. + +Use it to test the lifecycle of a transaction rather than just its validity: code that pushes +transactions and waits for confirmation, fee bumping, handling of reorgs, or an application that reads +chain state over RPC. + +| | `Simulator` | `FullNodeSimulator` | +| ---------------- | -------------------------------------------------------------- | -------------------------------------------------------- | +| Confirmation | Immediately, one block per transaction | When you call `farm_block` | +| Mempool | None | Conflicts, replace-by-fee, dedup, singleton fast-forward | +| Reorgs | No | `revert_blocks`, `reorg_blocks` | +| Funding | `new_coin`, `bls` | Prefarm coins, farming rewards, or `new_coin` | +| Signatures | Testnet11 constants, `spend_coins` signs for you | Simulator constants, you sign | +| Queries | `coin_state`, `children` and similar | Full node RPC methods and response types | +| Over the network | [`PeerSimulator`](/sdk/testing/peer-simulator) (peer protocol) | `FullNodeSimulatorServer` (HTTP RPC) | + +`FullNodeSimulator` and `FullNodeSimulatorEvent` are in `chia_wallet_sdk::test` and need no feature flag. + +## The chain + +`FullNodeSimulator::new()` starts at height 1 with a genesis block, seeded with 1337. Use `with_seed` for a +different deterministic chain, or `with_secret_key` to choose the master key. The genesis block contains +two prefarm coins worth 21 million XCH in total, paid to the standard puzzle of the master key's +hardened wallet key at index 1 (`get_prefarm_puzzle_hash`). Timestamps start at the wall-clock time the +simulator was created and advance by one second per block. + +Every block pays a 2 XCH reward to the farming puzzle hash, which starts as the prefarm puzzle hash. +Change it with `set_farming_ph` to fund a test wallet by farming. You can also add coins directly with +`new_coin` or `insert_coin`, as with the plain simulator. + +## Signing + +The simulator has its own network constants. Its genesis challenge, which is also its `AGG_SIG_ME` +additional data, is returned by `get_aggsig_additional_data()`. Sign with that, not with mainnet or +testnet11 constants, or the simulator rejects the signature. + +```rust +use chia_wallet_sdk::chia::bls::{SecretKey, master_to_wallet_hardened, sign}; +use chia_wallet_sdk::chia::puzzle_types::DeriveSynthetic; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::FullNodeSimulator; + +/// Signs coin spends for the full node simulator with BLS keys. +fn sign_for_simulator( + sim: &FullNodeSimulator, + coin_spends: &[CoinSpend], + secret_keys: &[SecretKey], +) -> anyhow::Result { + let mut allocator = Allocator::new(); + let required = RequiredSignature::from_coin_spends( + &mut allocator, + coin_spends, + &AggSigConstants::new(sim.get_aggsig_additional_data()), + )?; + + let mut signature = Signature::default(); + for required in required { + let RequiredSignature::Bls(required) = required else { + continue; + }; + let sk = secret_keys + .iter() + .find(|sk| sk.public_key() == required.public_key) + .ok_or_else(|| anyhow::anyhow!("missing key"))?; + signature += &sign(sk, required.message()); + } + Ok(signature) +} + +#[test] +fn spend_prefarm() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + + // The prefarm belongs to the synthetic key of the master key's wallet index 1. + let sk = master_to_wallet_hardened(&sim.get_master_secret_key(), 1).derive_synthetic(); + let prefarm_puzzle_hash = sim.get_prefarm_puzzle_hash(); + + let records = sim + .get_coin_records_by_puzzle_hash(prefarm_puzzle_hash, None, None, None) + .coin_records + .unwrap(); + let prefarm = records[0].coin; + + // Send 1 XCH to Alice, with the rest as change. + let alice = BlsPair::new(0); + let ctx = &mut SpendContext::new(); + StandardLayer::new(sk.public_key()).spend( + ctx, + prefarm, + Conditions::new() + .create_coin(alice.puzzle_hash, 1_000_000_000_000, Memos::None) + .create_coin( + prefarm_puzzle_hash, + prefarm.amount - 1_000_000_000_000, + Memos::None, + ), + )?; + let coin_spends = ctx.take(); + let signature = sign_for_simulator(&sim, &coin_spends, &[sk])?; + + // Pushing only adds the transaction to the mempool. + let response = sim.push_tx(SpendBundle::new(coin_spends, signature)); + assert!(response.success, "{:?}", response.error); + assert!( + sim.get_coin_records_by_puzzle_hash(alice.puzzle_hash, None, None, None) + .coin_records + .unwrap() + .is_empty() + ); + + // Farming a block confirms it. + sim.farm_block(1); + let alice_coins = sim + .get_coin_records_by_puzzle_hash(alice.puzzle_hash, None, None, None) + .coin_records + .unwrap(); + assert_eq!(alice_coins.len(), 1); + assert_eq!(alice_coins[0].confirmed_block_index, sim.height()); + + Ok(()) +} +``` + +## Pushing transactions + +`push_tx(spend_bundle)` validates a spend bundle against the current peak and adds it to the mempool. It +returns a `PushTxResponse` with `success`, a `status` of `"SUCCESS"` or `"FAILED"`, and an `error` +message. `push_tx_detailed` returns the same response plus the `SimulatorError`, so tests can match on +the `ErrorCode`. + +The mempool follows the full node's rules: + +- A bundle that spends a coin already spent by a mempool item conflicts with it. It replaces the + existing items only if it spends every coin they spend, and pays a higher fee than they do combined. + Otherwise it's rejected with `ErrorCode::MempoolConflict`. +- Spends that are eligible for deduplication (identical spends of the same coin) don't conflict, and are + included once. +- If a bundle spends a singleton coin that has since been spent, by a confirmed block or a mempool item, + and the spend can be fast-forwarded, the simulator rewrites it to spend the singleton's latest coin. + +`farm_block(n)` farms `n` blocks (at least one). Each block includes every mempool item that's still +valid and doesn't conflict with one already included, and returns its `BlockRecord`. + +```rust +use chia_wallet_sdk::chia::consensus::validation_error::ErrorCode; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::{FullNodeSimulator, to_program, to_puzzle}; + +/// Spends an anyone-can-spend coin, creating one child. The difference is the fee. +fn spend_with_fee(coin: Coin, puzzle_reveal: &Program, fee: u64) -> anyhow::Result { + let solution = to_program([CreateCoin::new( + coin.puzzle_hash, + coin.amount - fee, + Memos::::None, + )])?; + Ok(SpendBundle::new( + vec![CoinSpend::new(coin, puzzle_reveal.clone(), solution)], + Signature::default(), + )) +} + +#[test] +fn replace_by_fee() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 1_000); + + let first = spend_with_fee(coin, &puzzle_reveal, 10)?; + let same_fee = spend_with_fee(coin, &puzzle_reveal, 10)?; + let higher_fee = spend_with_fee(coin, &puzzle_reveal, 50)?; + + assert!(sim.push_tx(first.clone()).success); + + // An identical bundle is already in the mempool, so it's accepted without changes. + assert!(sim.push_tx(same_fee).success); + + // A conflicting bundle with a lower or equal fee is rejected. + let lower_fee = spend_with_fee(coin, &puzzle_reveal, 5)?; + let rejected = sim.push_tx_detailed(lower_fee); + assert!(!rejected.response.success); + assert!(matches!( + rejected.error, + Some(SimulatorError::Validation(ErrorCode::MempoolConflict)) + )); + + // A higher fee replaces the original. + assert!(sim.push_tx(higher_fee.clone()).success); + assert!(sim.get_mempool_item_by_tx_id(first.name()).mempool_item.is_none()); + assert!(sim.get_mempool_item_by_tx_id(higher_fee.name()).mempool_item.is_some()); + + sim.farm_block(1); + let children = sim + .get_coin_records_by_parent_ids(vec![coin.coin_id()], None, None, None) + .coin_records + .unwrap(); + assert_eq!(children[0].coin.amount, 950); + + Ok(()) +} +``` + +## Queries + +The query methods mirror the full node RPC API and return `chia-sdk-coinset` response types, so the +assertions in your tests look like the code that reads a real node: + +- Chain: `height`, `header_hash`, `header_hash_of`, `get_blockchain_state`, `get_network_info` + (network name `simulator0`, prefix `txch`), `get_block_record`, `get_block_record_by_height`, + `get_block_records`, `get_additions_and_removals`, `get_block_spends` +- Coins: `get_coin_record_by_name`, `get_coin_records_by_names`, `get_coin_records_by_puzzle_hash(es)`, + `get_coin_records_by_hint(s)`, `get_coin_records_by_parent_ids`, `get_puzzle_and_solution` +- Mempool: `get_mempool_item_by_tx_id`, `get_mempool_items_by_coin_name` + +Unlike the trait methods, the coin record queries have no `cursor` argument, since nothing is truncated. +Full blocks (`get_block`, `get_blocks`) aren't modeled. + +## Reorgs and events + +`revert_blocks(n)` removes the last `n` blocks and puts their transactions back into the mempool, as a +full node does when its peak rolls back. It returns the header hashes of the removed blocks. + +`reorg_blocks(n, m)` simulates switching to a competing chain: it reverts `n` blocks and farms `n + m` +new ones from the current mempool, so the new chain is longer. Transactions from the reverted blocks are +not put back into the mempool, as if the competing chain never saw them, so their spent coins become +unspent again. + +Every farmed block and every reorg is recorded as a `FullNodeSimulatorEvent` (`Block` or `Reorg`). +`drain_events` returns and clears them. Use them to drive the code under test the way a node's +notifications would. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::{FullNodeSimulator, FullNodeSimulatorEvent, to_program, to_puzzle}; + +#[test] +fn rollbacks_and_reorgs() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + + let solution = to_program([CreateCoin::new(puzzle_hash, 100, Memos::::None)])?; + let bundle = SpendBundle::new( + vec![CoinSpend::new(coin, puzzle_reveal, solution)], + Signature::default(), + ); + assert!(sim.push_tx(bundle).success); + sim.farm_block(1); + + let is_spent = |sim: &FullNodeSimulator| { + sim.get_coin_record_by_name(coin.coin_id()) + .coin_record + .is_some_and(|record| record.spent) + }; + assert!(is_spent(&sim)); + + // Rolling back the block returns the transaction to the mempool... + sim.revert_blocks(1); + assert!(!is_spent(&sim)); + assert_eq!( + sim.get_mempool_items_by_coin_name(coin.coin_id()) + .mempool_items + .unwrap() + .len(), + 1 + ); + + // ...so the next block confirms it again. + sim.farm_block(1); + assert!(is_spent(&sim)); + sim.drain_events(); + + // A reorg replaces the block with one that doesn't include the transaction. + sim.reorg_blocks(1, 1); + assert!(!is_spent(&sim)); + + let events = sim.drain_events(); + assert!(matches!(events.last(), Some(FullNodeSimulatorEvent::Reorg { .. }))); + + Ok(()) +} +``` + +When `serde` is enabled on `chia-sdk-test` (the `full-node-simulator-http` feature turns it on), +`dump_state` and `restore_state` save and load the whole chain and mempool as a JSON string, for example +to share a prepared fixture between tests. + +## Serving over HTTP + +`FullNodeSimulatorServer` runs the simulator behind an HTTP server on a random port on `127.0.0.1`. It +answers the full node RPC endpoints that `ChiaRpcClient` uses (except `get_block` and `get_blocks`, which +return `success: false`), plus `get_aggsig_additional_data` and a few control endpoints under `/sim/`: + +| Endpoint | Body | Effect | +| --------------------------------------- | ------------------------------------------------------- | ---------------------------------------- | +| `/sim/farm_block` | `{ "blocks": 1 }` | `farm_block`, returns `block_records` | +| `/sim/revert_blocks` | `{ "blocks": 1 }` | `revert_blocks`, returns `header_hashes` | +| `/sim/reorg_blocks` | `{ "num_of_blocks_to_rev": 1, "num_of_new_blocks": 1 }` | `reorg_blocks` | +| `/sim/new_coin` | `{ "puzzle_hash": "0x…", "amount": 100 }` | `new_coin`, returns the `coin` | +| `/sim/insert_coin` | `{ "coin": { … } }` | `insert_coin` | +| `/sim/set_farming_ph` | `{ "puzzle_hash": "0x…" }` | `set_farming_ph` | +| `/sim/dump_state`, `/sim/restore_state` | `{}`, `{ "state": "…" }` | `dump_state`, `restore_state` | +| `/sim/drain_events` | `{}` | `drain_events`, returns `events` | + +:::info Feature flag +The server needs the `full-node-simulator-http` feature of `chia-sdk-test`. `chia-wallet-sdk` doesn't +have a feature that enables it, so add `chia-sdk-test` as a direct dependency (at the same version) with +the feature turned on. The example below is not compiled as part of these docs for that reason. + +```toml +[dev-dependencies] +chia-sdk-test = { version = "0.36", features = ["full-node-simulator-http"] } +``` + +::: + +`FullNodeSimulatorServer::new()` starts a server with a default simulator. To control the simulator from +the test as well, create it yourself as an `Arc>` and pass it to +`with_simulator`. The server stops when it's dropped. + +```rust ignore +use std::sync::{Arc, Mutex}; + +use chia_sdk_test::{FullNodeSimulator, FullNodeSimulatorServer}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::{to_program, to_puzzle}; + +#[tokio::test] +async fn rpc_against_simulator() -> anyhow::Result<()> { + let sim = Arc::new(Mutex::new(FullNodeSimulator::new())); + let server = FullNodeSimulatorServer::with_simulator(sim.clone()).await?; + + // The code under test only sees an RPC client. + let client = CoinsetClient::new(server.url()); + + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.lock().unwrap().new_coin(puzzle_hash, 100); + + let solution = to_program([CreateCoin::new(puzzle_hash, 100, Memos::::None)])?; + let response = client + .push_tx(SpendBundle::new( + vec![CoinSpend::new(coin, puzzle_reveal, solution)], + Signature::default(), + )) + .await?; + assert!(response.success); + + // Farm from the test, then observe the result over RPC. + sim.lock().unwrap().farm_block(1); + + let record = client + .get_coin_record_by_name(coin.coin_id()) + .await? + .coin_record + .unwrap(); + assert!(record.spent); + + Ok(()) +} +``` + +The simulator is behind a `std::sync::Mutex`, so don't hold its guard across an `.await`. Lock it, call +the method, and let the guard drop, as above. + +The Node.js and Python [bindings](/sdk/bindings) also expose `FullNodeSimulator` with a `startServer` +(`start_server`) method, so tests in those languages can run the same HTTP simulator. + +## Gotchas + +- Sign with `get_aggsig_additional_data()`. Signatures made for testnet11, including by + `chia_wallet_sdk::test::sign_transaction`, fail with `BadAggregateSignature`. +- `push_tx` succeeding means the transaction is in the mempool, not confirmed. Farm a block before + checking coin records. +- Heights start at 1 (genesis), not 0 as in `Simulator`. +- There's no way to set the clock. Timestamps follow the wall clock at creation plus one second per block, + so absolute timelocks in the future can't be reached by farming quickly. Use the `Simulator` to test + time-based conditions. + +See the [chia-sdk-test rustdocs](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/test/index.html) +for the full API. diff --git a/docs/sdk/testing/index.md b/docs/sdk/testing/index.md new file mode 100644 index 0000000..41dc224 --- /dev/null +++ b/docs/sdk/testing/index.md @@ -0,0 +1,497 @@ +--- +title: Simulator +slug: /sdk/testing +--- + +# Simulator + +The `chia-sdk-test` crate (re-exported as `chia_wallet_sdk::test`) contains `Simulator`, an in-memory +blockchain for testing spends. You create coins out of thin air, build spends with the rest of the SDK, +and hand them to the simulator, which validates them with the same consensus code a full node uses: +puzzle execution and cost, conditions, timelocks, announcements and messages, and aggregate signatures. +If the simulator accepts a spend bundle, the network would accept it too, so tests can catch real bugs +without a node or a testnet wallet. + +Almost every example in these docs is a `#[test]` against the simulator. The key types (`Simulator`, +`SimulatorConfig`, `SimulatorError`, `BlsPair`, `BlsPairWithCoin`, `K1Pair`, `R1Pair`) are in the +prelude. + +| Tool | Use it for | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `Simulator` | Fast, synchronous unit tests of spends | +| [`PeerSimulator`](/sdk/testing/peer-simulator) | Testing code that talks to a full node over the peer protocol (`Peer`) | +| [`FullNodeSimulator`](/sdk/testing/full-node-simulator) | Testing mempool behavior, block farming and reorgs, or code that uses the full node RPC API | + +## A first test + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn send_and_find_coin() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + // A key pair with a standard (XCH) coin of 1,000 mojos. + let alice = sim.bls(1_000); + let bob = BlsPair::new(42); + + let memos = ctx.hint(bob.puzzle_hash)?; + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(bob.puzzle_hash, 900, memos) + .reserve_fee(100), + )?; + + // Signs with the given keys, validates and applies the spends as a new block. + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Alice's coin is spent and Bob has a new coin. + assert!(sim.coin_state(alice.coin.coin_id()).unwrap().spent_height.is_some()); + + let bob_coins = sim.unspent_coins(bob.puzzle_hash, false); + assert_eq!(bob_coins.len(), 1); + assert_eq!(bob_coins[0].amount, 900); + + Ok(()) +} +``` + +## Coins and keys + +The simulator doesn't have a genesis block or farming rewards. You create coins directly: + +- `new_coin(puzzle_hash, amount)` creates a coin with a random parent ID and returns it. +- `insert_coin(coin)` adds a coin you constructed yourself, which is useful when the parent ID matters + (for example, an eve coin whose parent must be a particular launcher). +- `bls(amount)` generates a fresh BLS key pair, creates a standard coin for it, and returns a + `BlsPairWithCoin` with `sk`, `pk`, `puzzle_hash` and `coin`. + +The key pair types are deterministic, so the same seed always produces the same key: + +| Type | Contents | Use it for | +| ----------------- | ---------------------------------------------- | ------------------------------------------------------------ | +| `BlsPair` | BLS `sk`, `pk`, and the standard `puzzle_hash` | Standard wallets, CAT/NFT/DID owners | +| `BlsPairWithCoin` | A `BlsPair` plus a funded `coin` | Returned by `Simulator::bls` | +| `K1Pair` | secp256k1 `sk` and `pk` | [Vault](/sdk/primitives/vaults) members using secp256k1 keys | +| `R1Pair` | secp256r1 `sk` and `pk` | Vault members using passkeys (secp256r1) | + +Each has `new(seed)`, `range::()` for an array of `N` pairs seeded `0..N`, `range_with_seed`, and +`range_vec` / `range_vec_with_seed` for a `Vec`. `Simulator::bls` draws its keys from the simulator's own +seeded RNG, so they're deterministic too, but differ from `BlsPair::new(0)` and friends. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn many_signers() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let [alice, bob, carol] = BlsPair::range::<3>(); + let alice_coin = sim.new_coin(alice.puzzle_hash, 500); + let bob_coin = sim.new_coin(bob.puzzle_hash, 500); + + // Both coins pay Carol, and the transaction needs both signatures. + StandardLayer::new(alice.pk).spend( + ctx, + alice_coin, + Conditions::new().create_coin(carol.puzzle_hash, 500, Memos::None), + )?; + StandardLayer::new(bob.pk).spend( + ctx, + bob_coin, + Conditions::new().create_coin(carol.puzzle_hash, 500, Memos::None), + )?; + + sim.spend_coins(ctx.take(), &[alice.sk, bob.sk])?; + + assert_eq!(sim.unspent_coins(carol.puzzle_hash, false).len(), 2); + + Ok(()) +} +``` + +## Submitting spends + +There are two ways to submit a transaction: + +- `spend_coins(coin_spends, &secret_keys)` works out which BLS signatures the spends require, signs + them with the matching keys from `secret_keys`, and submits the result. It fails with + `SimulatorError::MissingKey` if a required key isn't in the list. Extra keys are ignored. +- `new_transaction(spend_bundle)` submits a `SpendBundle` you've already signed, for example to test your + own signing code, an offer, or an invalid signature. + +`spend_coins` signs with testnet11's `AGG_SIG_ME` additional data, and the simulator validates with +testnet11's consensus constants, so sign with `TESTNET11_CONSTANTS` when you sign manually. The signing +step is also available on its own as `chia_wallet_sdk::test::sign_transaction(&coin_spends, &keys)`. +secp256k1 and secp256r1 signatures are part of the puzzle solution rather than the aggregate signature, +so you put them into the spend yourself, and `spend_coins` only needs the BLS keys. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn sign_manually() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1_000); + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(alice.puzzle_hash, 1_000, Memos::None), + )?; + let coin_spends = ctx.take(); + + // Work out what needs signing, using testnet11's constants to match the simulator. + let mut allocator = Allocator::new(); + let required = RequiredSignature::from_coin_spends( + &mut allocator, + &coin_spends, + &AggSigConstants::new(TESTNET11_CONSTANTS.agg_sig_me_additional_data), + )?; + + let mut signature = Signature::default(); + for required in required { + let RequiredSignature::Bls(required) = required else { + continue; + }; + assert_eq!(required.public_key, alice.pk); + signature += &chia_wallet_sdk::chia::bls::sign(&alice.sk, required.message()); + } + + let updates = sim.new_transaction(SpendBundle::new(coin_spends, signature))?; + + // The result maps each affected coin ID to its new state. + assert_eq!(updates.len(), 2); + + Ok(()) +} +``` + +Each successful transaction is its own block. `spend_coins` and `new_transaction` return an +`IndexMap` of every coin the transaction created or spent, which is handy for finding +outputs without recomputing their IDs. A rejected transaction leaves the simulator unchanged. + +See [Keys & Signing](/sdk/concepts/signing) for how `RequiredSignature` works. + +## Validation errors + +When a transaction is invalid, you get a `SimulatorError`: + +| Variant | Meaning | +| ------------------------------- | --------------------------------------------------------------------------------------------- | +| `Validation(ErrorCode)` | Consensus rejected the bundle. The `ErrorCode` is the same one a full node would report | +| `Signer(SignerError)` | `spend_coins` couldn't work out the required signatures (for example, a puzzle failed to run) | +| `MissingKey` | `spend_coins` wasn't given a key for a required signature | +| `Io`, `ChainState`, `StateDump` | Used by the full node simulator and serialization | + +`ErrorCode` lives in `chia_wallet_sdk::chia::consensus::validation_error`. Matching on it makes tests of +failure cases precise. The `expect_spend(result, should_pass)` helper asserts that a result passed or +failed, for tests that run the same spend with different inputs. + +```rust +use chia_wallet_sdk::chia::consensus::validation_error::ErrorCode; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::expect_spend; + +#[test] +fn rejects_invalid_spends() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let alice = sim.bls(1_000); + let alice_p2 = StandardLayer::new(alice.pk); + + // Creating more value than the coin has. + alice_p2.spend( + ctx, + alice.coin, + Conditions::new().create_coin(alice.puzzle_hash, 2_000, Memos::None), + )?; + let result = sim.spend_coins(ctx.take(), &[alice.sk.clone()]); + assert!(matches!( + result, + Err(SimulatorError::Validation(ErrorCode::MintingCoin)) + )); + + // Signing with the wrong key. + let mallory = BlsPair::new(1); + alice_p2.spend( + ctx, + alice.coin, + Conditions::new().create_coin(alice.puzzle_hash, 1_000, Memos::None), + )?; + let result = sim.spend_coins(ctx.take(), &[mallory.sk]); + assert!(matches!(result, Err(SimulatorError::MissingKey))); + + // The valid spend still works, because failed attempts don't change the state. + alice_p2.spend( + ctx, + alice.coin, + Conditions::new().create_coin(alice.puzzle_hash, 1_000, Memos::None), + )?; + expect_spend(sim.spend_coins(ctx.take(), &[alice.sk]), true); + + Ok(()) +} +``` + +## Height and time + +The simulator starts at height 0 with a next timestamp of 0. Every transaction creates a block at the +current height and timestamp, then increments the height by one and the timestamp by one second. You can +move the clock yourself: + +| Method | Effect | +| ------------------------------------------ | -------------------------------------------------------------------------- | +| `height()` | The height the next block will have | +| `next_timestamp()` | The timestamp the next block will have | +| `pass_time(seconds)` | Moves the next timestamp forward | +| `set_next_timestamp(t)` | Sets the next timestamp. Fails if `t` is before the last block's timestamp | +| `create_block()` | Creates an empty block, advancing height by one | +| `header_hash()` / `header_hash_of(height)` | Random but deterministic header hashes, per block | + +Timelocks are checked against these values. For example, a coin can be locked until a timestamp with +`assert_seconds_absolute`, and the test advances time to unlock it: + +```rust +use chia_wallet_sdk::chia::consensus::validation_error::ErrorCode; +use chia_wallet_sdk::prelude::*; + +#[test] +fn timelocked_spend() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let alice = sim.bls(1_000); + + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(alice.puzzle_hash, 1_000, Memos::None) + .assert_seconds_absolute(3_600), + )?; + let coin_spends = ctx.take(); + + let result = sim.spend_coins(coin_spends.clone(), &[alice.sk.clone()]); + assert!(matches!( + result, + Err(SimulatorError::Validation(ErrorCode::AssertSecondsAbsoluteFailed)) + )); + + sim.pass_time(3_600); + sim.spend_coins(coin_spends, &[alice.sk])?; + + Ok(()) +} +``` + +Relative timelocks (`assert_height_relative`, `assert_seconds_relative`) are measured from the block the +coin was created in. A coin made with `new_coin` or `bls` belongs to the next block, which doesn't +exist until a transaction or `create_block` creates it. Call `sim.create_block()` after creating a coin +whose relative timelock you want to test, or the spend fails with +`ErrorCode::EphemeralRelativeCondition`. + +## Looking up state + +The simulator records coin states, spends and hints, so tests can exercise the same lookups a wallet +does against a node: + +| Method | Returns | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `coin_state(coin_id)` | The `CoinState` (coin, `created_height`, `spent_height`), if known | +| `lookup_coin_ids(&ids)` | Coin states for a set of IDs | +| `lookup_puzzle_hashes(puzzle_hashes, include_hints)` | Coin states locked by, or optionally hinted with, the given puzzle hashes | +| `unspent_coins(puzzle_hash, include_hints)` | Unspent coins for one puzzle hash | +| `children(coin_id)` | Coins created by a coin | +| `hinted_coins(hint)` | IDs of coins hinted with `hint` | +| `coin_spend(coin_id)`, `puzzle_reveal`, `solution`, `puzzle_and_solution` | How a spent coin was spent | + +Hints are recorded from the memos of `CREATE_COIN` conditions, the same way a full node indexes them. +`hint_coin(coin_id, hint)` adds a hint by hand, for coins you created with `new_coin`. + +A useful pattern is to test parsing the way it happens in a real wallet: spend a coin, then reconstruct +the child from the parent spend the simulator recorded rather than from values you already have. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn find_and_parse_cat_by_hint() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let alice = sim.bls(1_000); + + // Issue a CAT hinted to Alice. + let memos = ctx.hint(alice.puzzle_hash)?; + let (issue_cat, _cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 1_000, + Conditions::new().create_coin(alice.puzzle_hash, 1_000, memos), + )?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // A wallet finds its CATs by hint, since their puzzle hash isn't the wallet's. + let hinted = sim.unspent_coins(alice.puzzle_hash, true); + let cat_coin = hinted + .into_iter() + .find(|coin| coin.puzzle_hash != alice.puzzle_hash) + .expect("missing CAT coin"); + + // Rebuild the CAT from its parent's spend. + let parent_spend = sim + .coin_spend(cat_coin.parent_coin_info) + .expect("missing parent spend"); + let puzzle = ctx.alloc(&parent_spend.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + let solution = ctx.alloc(&parent_spend.solution)?; + + let cats = Cat::parse_children(ctx, parent_spend.coin, puzzle, solution)? + .expect("parent isn't a CAT"); + assert!(cats.iter().any(|cat| cat.coin == cat_coin)); + + Ok(()) +} +``` + +## Configuration + +`Simulator::new()` uses `SimulatorConfig::default()`. Use `Simulator::with_config` to change it: + +| Field | Default | Effect | +| ---------------- | --------------------------------- | ------------------------------------------------------------------------------------------ | +| `seed` | `1337` | Seed for the RNG behind coin parent IDs, header hashes and `bls` keys | +| `save_spends` | `true` | Record coin spends, needed for `coin_spend`, `puzzle_reveal` and `solution` | +| `save_hints` | `true` | Record hints from `CREATE_COIN` memos, needed for hint lookups | +| `timestamp_mode` | `Increment { start: 0, step: 1 }` | Currently not used by `Simulator`; timestamps always start at 0 and advance by 1 per block | + +Turning off `save_spends` and `save_hints` reduces memory use in long-running tests that create +many transactions. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn custom_config() { + let config = SimulatorConfig { + seed: 7, + save_spends: false, + ..SimulatorConfig::default() + }; + + let mut a = Simulator::with_config(config); + let mut b = Simulator::with_config(config); + + // The same seed gives the same keys and coins. + assert_eq!(a.bls(100).coin, b.bls(100).coin); +} +``` + +`Simulator` implements `Clone` and `PartialEq`, so you can snapshot the state before a branch of a test +and compare or restore it afterwards. With the `serde` feature of `chia-sdk-test` (not exposed by +`chia-wallet-sdk`, so add `chia-sdk-test` as a direct dependency to use it), `serialize` and +`deserialize` save and load the simulator state as bytes. + +## Helpers + +`chia_wallet_sdk::test` has a few more helpers for tests: + +- `to_program(value)` converts any `ToClvm` value to a `Program`, and `to_puzzle(value)` also returns its + tree hash. `to_puzzle(1)` gives the puzzle `1`, which returns its solution as its conditions, so anyone + can spend it. It's the quickest way to make a coin for a test that isn't about signatures. +- `debug_announcements(&coin_spends)` prints every announcement the spends create and assert, and + panics if an asserted announcement isn't created. Use it to debug `AssertCoinAnnouncementFailed` and + similar errors. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::{to_program, to_puzzle}; + +#[test] +fn anyone_can_spend() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + + // The solution is the list of conditions. + let solution = to_program([CreateCoin::new(puzzle_hash, 100, Memos::::None)])?; + + sim.new_transaction(SpendBundle::new( + vec![CoinSpend::new(coin, puzzle_reveal, solution)], + Signature::default(), + ))?; + + assert_eq!(sim.children(coin.coin_id()).len(), 1); + + Ok(()) +} +``` + +## Benchmarking cost + +`Benchmark` collects the CLVM cost of spends under labels and prints a summary table with the average, +count, minimum, maximum and median for each label. `add_spends` measures the cost and then submits the +spends to the simulator (signing them like `spend_coins`), so it slots into a normal test. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::Benchmark; + +#[test] +fn benchmark_standard_spend() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let mut ctx = SpendContext::new(); + let mut benchmark = Benchmark::new("Standard spend".to_string()); + + for outputs in [1, 2, 5] { + let alice = sim.bls(1_000); + let mut conditions = Conditions::new(); + for amount in 1..=outputs { + // Outputs must be unique, so vary the amount. The rest is left as the fee. + conditions = conditions.create_coin(alice.puzzle_hash, amount, Memos::None); + } + + StandardLayer::new(alice.pk).spend(&mut ctx, alice.coin, conditions)?; + let coin_spends = ctx.take(); + + benchmark.add_spends( + &mut ctx, + &mut sim, + coin_spends, + &format!("{outputs} outputs"), + &[alice.sk], + )?; + } + + // Pass a file name to also write the table to disk. + benchmark.print_summary(None); + + Ok(()) +} +``` + +The cost includes the per-byte cost of the spend bundle and the conditions, as a full node computes it. +For the cost of a single spend bundle outside a benchmark, use `spend_bundle_cost` (see +[Utilities](/sdk/utilities)). + +## Limitations + +The simulator models consensus validation, not a network: + +- There's no mempool. A valid transaction is confirmed immediately in its own block, so there's no fee + competition, replacement by fee or deduplication. Use the + [full node simulator](/sdk/testing/full-node-simulator) to test those. +- There are no reorgs, block rewards or farming. +- Header hashes are random, not real block hashes, and there are no block records. +- The maximum cost per transaction is half the block limit (5,500,000,000), matching the mempool's limit + for a single transaction. + +See the [chia-sdk-test rustdocs](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/test/index.html) +for the full API. diff --git a/docs/sdk/testing/peer-simulator.md b/docs/sdk/testing/peer-simulator.md new file mode 100644 index 0000000..60ae3a3 --- /dev/null +++ b/docs/sdk/testing/peer-simulator.md @@ -0,0 +1,272 @@ +--- +title: Peer Simulator +--- + +# Peer Simulator + +`PeerSimulator` runs a [`Simulator`](/sdk/testing) behind a local WebSocket server that speaks the Chia +wallet protocol. You connect a real [`Peer`](/sdk/networking/peer) to it, so code that syncs coin state, +handles subscriptions or submits transactions over the peer protocol can be tested end to end without a +full node. + +Use it to test the networking side of a wallet: puzzle and coin state requests, pagination, +subscription updates and transaction submission. For testing spends alone, the plain `Simulator` is +simpler and faster. + +:::info Feature flag +`PeerSimulator` requires the `peer-simulator` feature of `chia-wallet-sdk`, and a Tokio runtime. It's +usually only needed in tests, so enable it in `dev-dependencies`: + +```toml +[dev-dependencies] +chia-wallet-sdk = { version = "0.36", features = ["peer-simulator"] } +tokio = { version = "1", features = ["full"] } +``` + +::: + +## Starting a simulator and connecting + +`PeerSimulator::new()` binds a random port on `127.0.0.1` and starts serving in a background task. The +server shuts down when the `PeerSimulator` is dropped. There are three ways to connect: + +| Method | Returns | Behavior | +| ----------------- | --------------------------------- | ------------------------------------------------------------------------------------ | +| `connect()` | `Peer` | Discards the initial `NewPeakWallet` and spawns a task that drains incoming messages | +| `connect_split()` | `(Peer, mpsc::Receiver)` | Discards the initial `NewPeakWallet` and gives you the receiver for later events | +| `connect_raw()` | `(Peer, mpsc::Receiver)` | Leaves the initial `NewPeakWallet` in the receiver | + +The connection is plain `ws://` with no TLS and no handshake, so you don't need a certificate or the +`rustls`/`native-tls` features. The `Peer` you get is the same type you'd use against a full node. + +`PeerSimulator` derefs to `tokio::sync::Mutex`. Lock it to create coins and inspect state, +using the same methods as the [Simulator](/sdk/testing). + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::{PeerSimulator, sign_transaction}; + +#[tokio::test] +async fn send_transaction_to_simulator() -> anyhow::Result<()> { + let sim = PeerSimulator::new().await?; + let peer = sim.connect().await?; + + // Fund a key pair directly in the simulator. + let alice = sim.lock().await.bls(1_000); + let bob = BlsPair::new(1); + + // Build and sign the transaction as you would for mainnet. + let ctx = &mut SpendContext::new(); + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(bob.puzzle_hash, 1_000, Memos::None), + )?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[alice.sk])?; + + // Submit it over the peer protocol. + let ack = peer + .send_transaction(SpendBundle::new(coin_spends, signature)) + .await?; + assert_eq!(ack.status, 1, "{:?}", ack.error); + + // Query the result over the peer protocol too. + let response = peer + .request_coin_state( + vec![alice.coin.coin_id()], + None, + sim.config().genesis_challenge, + false, + ) + .await? + .expect("request was rejected"); + assert!(response.coin_states[0].spent_height.is_some()); + + Ok(()) +} +``` + +`sign_transaction` signs with testnet11's `AGG_SIG_ME` data, which matches the peer simulator's +validation rules. Like the plain simulator, each accepted transaction is confirmed immediately in its +own block. + +## Subscriptions and events + +When a transaction is accepted, the simulator sends every connected peer a `NewPeakWallet`, and sends a +`CoinStateUpdate` to each peer subscribed to an affected coin ID, puzzle hash or hint. Subscribe with +`request_puzzle_state` / `request_coin_state` (with the subscribe flag set) or with +`register_for_ph_updates` / `register_for_coin_updates`, then read the events from the receiver returned +by `connect_split`. + +```rust +use std::time::Duration; + +use chia_wallet_sdk::chia::protocol::{ + CoinStateFilters, CoinStateUpdate, Message, ProtocolMessageTypes, +}; +use chia_wallet_sdk::chia::traits::Streamable; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::{PeerSimulator, sign_transaction}; +use tokio::sync::mpsc; + +async fn next_coin_update( + receiver: &mut mpsc::Receiver, +) -> anyhow::Result { + loop { + let message = tokio::time::timeout(Duration::from_secs(5), receiver.recv()) + .await? + .expect("connection closed"); + + if message.msg_type == ProtocolMessageTypes::CoinStateUpdate { + return Ok(CoinStateUpdate::from_bytes(&message.data)?); + } + } +} + +#[tokio::test] +async fn receives_payment_notification() -> anyhow::Result<()> { + let sim = PeerSimulator::new().await?; + + // Bob's wallet connects and subscribes to his puzzle hash. + let (bob_peer, mut bob_events) = sim.connect_split().await?; + let bob = BlsPair::new(1); + + let existing = bob_peer + .request_puzzle_state( + vec![bob.puzzle_hash], + None, + sim.config().genesis_challenge, + CoinStateFilters::new(true, true, true, 0), + true, + ) + .await? + .expect("request was rejected"); + assert!(existing.coin_states.is_empty()); + + // Alice pays Bob from a separate connection. + let alice_peer = sim.connect().await?; + let alice = sim.lock().await.bls(1_000); + + let ctx = &mut SpendContext::new(); + let memos = ctx.hint(bob.puzzle_hash)?; + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(bob.puzzle_hash, 1_000, memos), + )?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[alice.sk])?; + + let ack = alice_peer + .send_transaction(SpendBundle::new(coin_spends, signature)) + .await?; + assert_eq!(ack.status, 1); + + // Bob is told about his new coin. + let update = next_coin_update(&mut bob_events).await?; + assert_eq!(update.items.len(), 1); + assert_eq!(update.items[0].coin.puzzle_hash, bob.puzzle_hash); + assert_eq!(update.items[0].coin.amount, 1_000); + assert_eq!(update.height, sim.lock().await.height()); + + Ok(()) +} +``` + +Updates are only sent for transactions submitted through a peer. Changes you make directly through the +`Simulator` (such as `new_coin` or `spend_coins` on the locked simulator) don't notify anyone, so set up +those coins before the code under test subscribes, or submit spends with `send_transaction`. + +## Rejected transactions + +An invalid transaction isn't a Rust error. The simulator answers with a `TransactionAck` whose `status` +is `3` (failed) and whose `error` describes the consensus error, just as a full node would. This lets you +test your wallet's handling of rejections: + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::PeerSimulator; + +#[tokio::test] +async fn rejects_unsigned_spend() -> anyhow::Result<()> { + let sim = PeerSimulator::new().await?; + let peer = sim.connect().await?; + let alice = sim.lock().await.bls(1_000); + + let ctx = &mut SpendContext::new(); + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(alice.puzzle_hash, 1_000, Memos::None), + )?; + + // No signature. + let ack = peer + .send_transaction(SpendBundle::new(ctx.take(), Signature::default())) + .await?; + + assert_eq!(ack.status, 3); + assert!(ack.error.unwrap().contains("BadAggregateSignature")); + + // The coin is still unspent. + let coin_state = sim.lock().await.coin_state(alice.coin.coin_id()).unwrap(); + assert_eq!(coin_state.spent_height, None); + + Ok(()) +} +``` + +## Supported requests + +The simulator implements the requests a light wallet needs: + +- `send_transaction` +- `request_puzzle_state` and `request_coin_state`, including filters, subscriptions and resuming from a + `previous_height` and `header_hash` (see [Known issues](#known-issues)) +- `register_for_ph_updates` and `register_for_coin_updates` +- `remove_puzzle_subscriptions` and `remove_coin_subscriptions` +- `request_puzzle_and_solution` and `request_children` + +Any other message (for example `request_peers`, `request_transaction` or block requests) is unsupported. +The simulator logs an error and closes that connection, so the request never gets a response. + +## Configuration + +`PeerSimulator::with_config` takes a `PeerSimulatorConfig`: + +| Field | Default | Effect | +| ------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | +| `genesis_challenge` | testnet11's genesis challenge | The header hash to pass with `previous_height: None` in state requests | +| `max_subscriptions` | 200,000 | Subscriptions allowed per peer before requests are rejected with `ExceededSubscriptionLimit` | +| `max_response_coins` | 100,000 | Maximum coin states returned by `request_coin_state` | +| `puzzle_state_batch_size` | 30,000 | Above this many coin states, `request_puzzle_state` splits the response into pages | + +`reset()` replaces the simulator state and all subscriptions with fresh ones, without restarting the +server or dropping connections. The underlying `Simulator` always uses the default `SimulatorConfig`. + +## Known issues + +Two parts of the state requests don't currently behave like a full node, so don't rely on the simulator +to test them: + +- Rejections are sent with the response's message type. When the simulator rejects a + `request_puzzle_state` or `request_coin_state` (reorg or subscription limit) or a + `request_puzzle_and_solution` (coin not spent at that height), the `Peer` tries to decode the rejection as a response and returns + `ClientError::Streamable` instead of the inner `Err(rejection)`. +- When a `request_puzzle_state` response is split into pages, the `height` it returns is the first + height it left out, rather than the last height it included. A client that resumes from that height, + as it should against a full node, skips the coins created or spent at that height. + +## Gotchas + +- Don't hold the simulator lock across a peer request. The server locks the same mutex to answer, so + `let guard = sim.lock().await;` followed by `peer.request_coin_state(...).await` deadlocks. Scope the + guard in a block, or lock it inline (`sim.lock().await.bls(1_000)`). +- If you use `connect_split` or `connect_raw`, keep reading the receiver or drop it. A full receiver + blocks the connection, including responses to your requests. +- The simulator validates with testnet11's constants regardless of `genesis_challenge`, so always sign + for testnet11. + +See the [chia-sdk-test rustdocs](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/test/index.html) +for the full API. diff --git a/docs/sdk/troubleshooting.md b/docs/sdk/troubleshooting.md index 74cf850..adc23fd 100644 --- a/docs/sdk/troubleshooting.md +++ b/docs/sdk/troubleshooting.md @@ -1,377 +1,200 @@ --- -slug: /sdk/troubleshooting title: Troubleshooting --- # Troubleshooting -This page covers common issues when working with the Wallet SDK and how to resolve them. - -## My spend failed to validate - -### Symptoms - -- Simulator returns an error -- Transaction rejected by full node -- CLVM execution fails - -### Common Causes - -**Incorrect puzzle hash** - -The puzzle hash used to create a coin must match the puzzle used to spend it: - -```rust -// When creating a coin -let puzzle_hash = StandardLayer::puzzle_hash(public_key); -conditions.create_coin(puzzle_hash, amount, memos); - -// When spending, use the same key -StandardLayer::new(public_key).spend(ctx, coin, conditions)?; -``` - -**Mismatched amounts** - -Output amounts must not exceed input amounts: - -```rust -// Input: 1000 mojos -let coin_amount = 1000; - -// Outputs must sum to <= 1000 -let send_amount = 900; -let fee = 100; -// Total: 900 + 100 = 1000 ✓ -``` - -**Missing lineage proof** - -CATs and singletons require valid lineage proofs: - -```rust -// Ensure CAT has lineage proof set -let cat = Cat { - coin, - info, - lineage_proof: Some(lineage_proof), // Required for spending -}; -``` - ---- - -## Signature invalid - -### Symptoms - -- `AggSig validation failed` -- Transaction rejected with signature error -- Spend bundle won't aggregate - -### Common Causes - -**Wrong key used for signing** - -Ensure you sign with the key that matches the puzzle: - -```rust -// The public key in the puzzle -let p2 = StandardLayer::new(alice.pk); -p2.spend(ctx, coin, conditions)?; - -// Must sign with the corresponding secret key -sim.spend_coins(spends, &[alice.sk])?; // Not bob.sk! -``` - -**Missing signatures** - -Multi-input transactions may require multiple signatures: - -```rust -// If spending coins from different keys -StandardLayer::new(alice.pk).spend(ctx, coin1, conditions1)?; -StandardLayer::new(bob.pk).spend(ctx, coin2, conditions2)?; - -// Both keys must sign -sim.spend_coins(spends, &[alice.sk, bob.sk])?; -``` - -**Incorrect AGG_SIG_ME data** - -When signing manually, ensure you use the correct AGG_SIG_ME additional data: - -```rust -// AGG_SIG_ME includes coin_id + genesis_challenge -// Make sure you're using the right network's genesis challenge -``` - ---- - -## Coin not found - -### Symptoms - -- `Coin not in database` -- `Unknown coin` -- Spend references non-existent coin - -### Common Causes - -**Coin already spent** - -A coin can only be spent once. Check if it's already been used: - -```rust -// Each coin has a unique ID -let coin_id = coin.coin_id(); - -// If this coin was spent in a previous transaction, -// you cannot spend it again -``` - -**Incorrect coin construction** - -When computing child coins, ensure the values match: - -```rust -// The child coin is determined by: -let child = Coin::new( - parent_coin.coin_id(), // Parent's coin ID - puzzle_hash, // Must match create_coin puzzle hash - amount, // Must match create_coin amount -); -``` - -**Transaction not confirmed** - -If depending on a recent transaction, ensure it's confirmed: - -```rust -// In simulation, spends are instant -// On mainnet, wait for block confirmation before using outputs -``` - ---- - -## Announcement assertion failed - -### Symptoms - -- `ASSERT_COIN_ANNOUNCEMENT_FAIL` -- `ASSERT_PUZZLE_ANNOUNCEMENT_FAIL` -- Announcements don't match - -### Common Causes - -**Incorrect announcement ID calculation** - -Coin announcements include the coin ID: - -```rust -// Coin announcement ID = sha256(coin_id + message) -// Puzzle announcement ID = sha256(puzzle_hash + message) - -// When asserting, the ID must match exactly -conditions - .create_coin_announcement(message) - .assert_coin_announcement(expected_announcement_id); -``` - -**Missing announcement creation** - -Every assertion needs a corresponding creation: - -```rust -// Spend 1: Create the announcement -let conditions1 = Conditions::new() - .create_coin_announcement(b"hello"); - -// Spend 2: Assert the announcement -let announcement_id = /* calculate from coin_id + message */; -let conditions2 = Conditions::new() - .assert_coin_announcement(announcement_id); -``` - -**Different message bytes** - -Ensure message bytes match exactly: - -```rust -// These are different! -b"hello" // [104, 101, 108, 108, 111] -"hello" // String, needs .as_bytes() -``` - ---- - -## Insufficient fee - -### Symptoms - -- Transaction sits in mempool -- `Fee too low` -- Transaction eventually dropped - -### Common Causes - -**No fee specified** - -Always include a fee for mainnet transactions: - -```rust -let conditions = Conditions::new() - .create_coin(recipient, amount, memos) - .reserve_fee(fee); // Don't forget this -``` - -**Fee calculation** - -Fees are in mojos. During high demand, fees may need to be higher: - -```rust -// Minimum fee depends on network conditions -// Check current fee estimates from a full node -let fee = 100_000_000; // 0.0001 XCH = 100M mojos -``` - -**Fee in wrong spend** - -If batching multiple spends, fee can be in any one of them: - -```rust -// This is fine - fee in second spend -StandardLayer::new(pk1).spend(ctx, coin1, Conditions::new() - .create_coin(dest, amount1, memos))?; - -StandardLayer::new(pk2).spend(ctx, coin2, Conditions::new() - .create_coin(dest, amount2, memos) - .reserve_fee(fee))?; // Fee here covers both -``` - ---- - -## CAT amount mismatch - -### Symptoms - -- CAT spend fails validation -- `CAT amount mismatch` -- Lineage verification fails - -### Common Causes - -**Input/output imbalance** - -CAT amounts must balance (no creation or destruction): - -```rust -// If spending 1000 CAT, must output 1000 CAT -let cat_spends = [CatSpend::new( - cat, // 1000 CAT input - inner_spend_with_conditions, // Must create exactly 1000 CAT output -)]; -``` - -:::info -CATs cannot pay transaction fees directly. Fees must be paid with XCH in a separate spend within the same transaction. -::: - ---- - -## Spends can be separated / Partial bundle attack - -### Symptoms - -- Multi-coin transaction behaves unexpectedly -- Funds lost when only some spends execute -- Attacker extracts individual spends from your bundle - -### Common Causes - -**Missing `assert_concurrent_spend`** - -When spending multiple coins together, you must link them: - -```rust -// WRONG: Spends can be separated -let conditions1 = Conditions::new() - .create_coin(recipient, 1000, memos); -StandardLayer::new(pk1).spend(ctx, coin1, conditions1)?; - -let conditions2 = Conditions::new() - .reserve_fee(100); -StandardLayer::new(pk2).spend(ctx, coin2, conditions2)?; - -// RIGHT: Spends are linked and atomic -let conditions1 = Conditions::new() - .create_coin(recipient, 1000, memos) - .assert_concurrent_spend(coin2.coin_id()); // Link to coin2 -StandardLayer::new(pk1).spend(ctx, coin1, conditions1)?; - -let conditions2 = Conditions::new() - .reserve_fee(100) - .assert_concurrent_spend(coin1.coin_id()); // Link to coin1 -StandardLayer::new(pk2).spend(ctx, coin2, conditions2)?; -``` - -:::warning -Without `assert_concurrent_spend`, an attacker can take your signed spend bundle, remove some spends, and submit only the ones beneficial to them. Always link all spends in a multi-coin transaction. -::: - ---- - -## General Debugging Tips - -### Use the Simulator - -Always test with the simulator first: - -```rust -let mut sim = Simulator::new(); -// Setup state... -let result = sim.spend_coins(spends, &keys); - -if let Err(e) = result { - println!("Spend failed: {:?}", e); +Problems with transactions show up in one of three places: + +- **While building**, as a `DriverError` from the driver crate (for example `InsufficientFunds` from the + action system, or `FromClvm` when a puzzle doesn't parse). +- **While signing**, as a `SignerError` from `RequiredSignature::from_coin_spends`. +- **While validating**, as an `ErrorCode` from consensus. The [simulator](/sdk/testing) returns these as + `SimulatorError::Validation(code)`, and a full node returns the same codes in the `error` of a rejected + transaction. + +This page goes through the common validation errors first, then build-time errors. + +## Reproduce it in the simulator + +Before anything else, rebuild the failing transaction in a test against the `Simulator`. It validates +bundles the same way a full node does, gives you the exact `ErrorCode`, and lets you change one thing at +a time. Matching on the error code makes the failure precise: + +```rust +use chia_wallet_sdk::chia::consensus::validation_error::ErrorCode; +use chia_wallet_sdk::prelude::*; + +#[test] +fn common_validation_errors() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let alice = sim.bls(1_000); + let p2 = StandardLayer::new(alice.pk); + + // Outputs exceed the input. + p2.spend(ctx, alice.coin, Conditions::new().create_coin(alice.puzzle_hash, 1_001, Memos::None))?; + let error = sim.spend_coins(ctx.take(), &[alice.sk.clone()]).unwrap_err(); + assert!(matches!(error, SimulatorError::Validation(ErrorCode::MintingCoin))); + + // The leftover is smaller than the reserved fee. + p2.spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(alice.puzzle_hash, 950, Memos::None) + .reserve_fee(100), + )?; + let error = sim.spend_coins(ctx.take(), &[alice.sk.clone()]).unwrap_err(); + assert!(matches!(error, SimulatorError::Validation(ErrorCode::ReserveFeeConditionFailed))); + + // Asserting a spend that isn't in the bundle. + p2.spend( + ctx, + alice.coin, + Conditions::new().assert_concurrent_spend(Bytes32::new([1; 32])), + )?; + let error = sim.spend_coins(ctx.take(), &[alice.sk.clone()]).unwrap_err(); + assert!(matches!(error, SimulatorError::Validation(ErrorCode::AssertConcurrentSpendFailed))); + + // Signed for the wrong network: the simulator uses testnet11. + p2.spend(ctx, alice.coin, Conditions::new().reserve_fee(1_000))?; + let coin_spends = ctx.take(); + let mut signature = Signature::default(); + for required in RequiredSignature::from_coin_spends( + ctx, + &coin_spends, + &AggSigConstants::from(&*MAINNET_CONSTANTS), + )? { + if let RequiredSignature::Bls(required) = required { + signature += &chia_wallet_sdk::chia::bls::sign(&alice.sk, required.message()); + } + } + let error = sim.new_transaction(SpendBundle::new(coin_spends, signature)).unwrap_err(); + assert!(matches!(error, SimulatorError::Validation(ErrorCode::BadAggregateSignature))); + + // Spending the same coin twice. + p2.spend(ctx, alice.coin, Conditions::new().reserve_fee(1_000))?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + p2.spend(ctx, alice.coin, Conditions::new().reserve_fee(1_000))?; + let error = sim.spend_coins(ctx.take(), &[alice.sk]).unwrap_err(); + assert!(matches!(error, SimulatorError::Validation(ErrorCode::DoubleSpend))); + + Ok(()) } ``` -### Check Puzzle Hashes - -Verify puzzle hashes match: - -```rust -println!("Expected: {}", StandardLayer::puzzle_hash(pk)); -println!("Actual: {}", coin.puzzle_hash); -``` - -### Verify Amounts - -Ensure amounts balance: - -```rust -let total_input: u64 = coins.iter().map(|c| c.amount).sum(); -let total_output: u64 = /* sum of create_coin amounts + fee */; - -assert_eq!(total_input, total_output, "Amount mismatch"); -``` - -### Inspect Conditions - -Print conditions before spending: - -```rust -let conditions = Conditions::new() - .create_coin(ph, amount, memos) - .reserve_fee(fee); - -println!("Conditions: {:?}", conditions); -``` - -## Getting Help - -If you're stuck: - -1. Check the [SDK rustdocs](https://docs.rs/chia-wallet-sdk) for API details -2. Review the [examples](https://github.com/Chia-Network/chia-wallet-sdk/tree/main/examples) -3. Search existing issues on [GitHub](https://github.com/Chia-Network/chia-wallet-sdk/issues) -4. Ask in the Chia developer community +To see what a spend actually outputs, run its puzzle with its solution and parse the conditions, as +shown in [Conditions](/sdk/concepts/conditions#parsing-conditions). Most failures become obvious once +you compare the conditions you expected with the ones the puzzle produced. + +## Validation errors + +| Error code | What it usually means | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MintingCoin` | The outputs of the bundle add up to more than its inputs. Check your amounts and change. | +| `ReserveFeeConditionFailed` | The bundle's leftover value (inputs minus outputs) is less than the sum of `RESERVE_FEE` conditions. | +| `BadAggregateSignature` | A signature is missing, made with the wrong key, or made for the wrong network. See [Signing](#signature-problems). | +| `DoubleSpend` | The coin was already spent, or the same coin appears twice in the bundle. | +| `UnknownUnspent` | The coin doesn't exist yet (it isn't confirmed, or its parent isn't in the bundle), or the coin's id is wrong because the puzzle hash or amount you built it with is wrong. | +| `WrongPuzzleHash` | The puzzle reveal doesn't hash to the coin's puzzle hash. You're spending the coin with the wrong key, inner puzzle, or layer arguments. | +| `GeneratorRuntimeError` | The puzzle raised an error (`x`) or failed to run with the solution. This includes failed CAT, singleton and NFT checks, which are enforced inside the puzzles. | +| `AssertConcurrentSpendFailed`, `AssertConcurrentPuzzleFailed` | A spend asserted another spend that isn't in the bundle. | +| `AssertCoinAnnouncementFailed`, `AssertPuzzleAnnouncementFailed` | An announcement was asserted but not created. The announcement id is `sha256(coin_id or puzzle_hash + message)`, so check which side you hashed. | +| `MessageNotSentOrReceived` | A `SEND_MESSAGE` and `RECEIVE_MESSAGE` don't pair up. Both sides must agree on the mode, message and the coin they commit to. | +| `AssertSecondsRelativeFailed`, `AssertHeightRelativeFailed`, and the absolute variants | A timelock hasn't passed yet. In the simulator, use `pass_time` and `create_block`. | +| `EphemeralRelativeCondition` | A relative timelock on a coin that was created in the same block. It has no confirmed height or timestamp yet. | +| `AssertBeforeSecondsAbsoluteFailed` and friends | The transaction expired. | +| `CostExceeded` | The bundle is too expensive. Split it into several transactions. | + +### Signature problems + +A `BadAggregateSignature` means the aggregate signature doesn't match the messages the spends require. +The usual causes: + +- **Wrong network.** `AGG_SIG_ME` and its variants commit to the network's genesis challenge, so a + signature made with `MAINNET_CONSTANTS` is invalid on testnet11 and vice versa. The simulator uses + testnet11. See [Keys & Signing](/sdk/concepts/signing). +- **Wrong key.** Wallet coins are locked with a **synthetic** key. Signing with the unsynthesized wallet + key gives a valid signature for the wrong public key. Derive the synthetic secret key with + `DeriveSynthetic` and sign with that. +- **Missing signer.** Every spend that requires a signature needs one. In a multi-key transaction, + collect the required signatures once and make sure every one of them is signed. +- **Changed spends.** If you modify the coin spends after signing, the messages change and the signature + no longer matches. Sign last. + +`Simulator::spend_coins` returns `SimulatorError::MissingKey` instead, if it isn't given a secret key that +matches a required public key. + +### Coin ids that don't match + +Coin ids are `sha256(parent_coin_id + puzzle_hash + amount)`. If you compute a child coin yourself and +the network says it doesn't exist (`UnknownUnspent`), one of the three inputs is wrong. Common mistakes: + +- Using the **p2** puzzle hash for a CAT, NFT or DID child. Their coins' puzzle hashes are the full, + wrapped puzzle hash. Use the primitive's `child` methods (for example `Cat::child`) or + `info.puzzle_hash()` instead of building the `Coin` yourself. +- Building a CAT's `Coin` from the parent's id and amount, but with the inner puzzle hash from a + `CREATE_COIN` condition, rather than the outer puzzle hash. + +### Spends that can be separated + +If someone else's transaction confirms with only part of your bundle, the spends weren't linked. Anyone +can take a valid subset of a bundle from the mempool and submit it on its own. Link every spend in a +transaction with `assert_concurrent_spend`, announcements, or a `Relation` in the action system. See +[Application Patterns](/sdk/patterns#link-spends-that-belong-together). + +## Errors while building + +| `DriverError` | Cause and fix | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `InsufficientFunds` | The coins added to `Spends` don't cover the actions. Select more coins using `Deltas`, as shown in [coin selection with deltas](/sdk/action-system#coin-selection-with-deltas). | +| `MissingKey` | `finish_with_keys` found a coin whose p2 puzzle hash isn't in the key map. | +| `MissingSpend` | `Spends::spend` wasn't given a p2 spend for every item in `unspent()`. See [Custom Spends](/sdk/action-system/custom-spends). | +| `CannotEmitConditions` | The action system needed a spend to emit transaction-wide conditions, and no coin could. Add an XCH coin. | +| `DuplicateSettlementPayment` | Two settlement coins would make the same notarized payment. Use distinct nonces. | +| `SingletonAmountMismatch` | A `send` or `melt_singleton` action's amount doesn't match the singleton's amount. | +| `AmountOverflow`, `FeeOverflow` | An amount doesn't fit in a `u64`. | +| `MissingChild` | The spend you parsed or performed didn't recreate the singleton (for example it was melted), so there's no child to return. | +| `NotRevocable` | `add_for_revocation` was called with a CAT that has no hidden puzzle. | +| `FromClvm`, `ToClvm` | A value didn't have the shape its type expects. Usually a puzzle or solution of a different type, or a different version of a puzzle. | +| `Eval` | Running a puzzle failed. The same spend would fail on chain. | +| `OldSettlementPayment` | The offer uses the old version of the settlement payments puzzle, which isn't supported. | + +The SDK's parsers return `Ok(None)` when a puzzle simply isn't the type you asked for (for example +`Cat::parse_children` on an NFT spend), and an error only when it looks like that type but is malformed. +So an `Ok(None)` while you expected a value usually means you're parsing the wrong spend, often the coin's +own spend instead of its parent's. See the [primitives overview](/sdk/primitives#parsing-from-the-parent-coin-spend). + +## Errors while signing + +`RequiredSignature::from_coin_spends` runs every puzzle to find its `AGG_SIG_*` conditions, so a puzzle +that fails to run fails here, as `SignerError::Eval`, before anything is submitted. Other variants: + +| `SignerError` | Cause | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `Eval` | A puzzle failed to run with its solution. The spend is invalid. | +| `FromClvm` | A puzzle's output isn't a valid list of conditions. | +| `InfinityPublicKey` | A condition asks for a signature from the infinity public key, which can't sign. | +| `InvalidSecpKey` | A secp condition contains an invalid public key. | +| `PuzzleHashMismatch` | A coin spend's puzzle reveal doesn't hash to the coin's puzzle hash. | +| `UnsafeMessageHasAdditionalData` | An `AGG_SIG_UNSAFE` message ends with the network's additional data, which would let it be replayed as an `AGG_SIG_ME`. | +| `AggSigMessageTooLong` | A message is longer than consensus allows. | + +## Networking problems + +- **Requests that never return.** If a peer disconnects, outstanding requests can wait forever. Wrap + peer requests in `tokio::time::timeout`, as described in [Peer Protocol](/sdk/networking/peer). +- **`WrongNetwork` on connect.** The network id you passed doesn't match the node's (`"mainnet"` or + `"testnet11"`). +- **TLS errors.** Networking requires the `rustls` or `native-tls` feature, and full nodes require a + client certificate. See [certificates and connectors](/sdk/networking/peer#certificates-and-connectors). +- **A transaction is accepted but never confirms.** It might have been outbid, or dropped by the node you + sent it to. Check whether it's still in the mempool, rebroadcast it to other peers, or replace it with a + higher fee. + +## Getting help + +If you've narrowed a problem down to a small simulator test and it still looks wrong, open an issue on +[chia-wallet-sdk](https://github.com/xch-dev/chia-wallet-sdk/issues) with the test, or ask in the +[Sage Discord](https://discord.gg/sagewallet). diff --git a/docs/sdk/utilities.md b/docs/sdk/utilities.md new file mode 100644 index 0000000..258156d --- /dev/null +++ b/docs/sdk/utilities.md @@ -0,0 +1,262 @@ +--- +title: Utilities +--- + +# Utilities + +This page covers the smaller helpers that most applications need at some point: address encoding, coin selection, hex parsing, network constants, merkle trees, cost estimation, and the `chia-sdk-cli` command line tool. Addresses, coin selection and hex parsing live in `chia-sdk-utils` (`chia_wallet_sdk::utils`), and the rest are in `chia_wallet_sdk::types` and `chia_wallet_sdk::driver`. The most common items are in the prelude. + +## Addresses + +Chia addresses are [bech32m](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki) encodings of a puzzle hash with a human-readable prefix: `xch` on mainnet and `txch` on testnet11. The same encoding is used with other prefixes for other 32-byte identifiers, such as `nft` for NFT launcher ids and `did:chia:` for DID launcher ids. + +- `Address { puzzle_hash, prefix }` is for 32-byte payloads. `Address::decode` fails if the data isn't exactly 32 bytes. +- `Bech32 { data, prefix }` is for payloads of any length. +- `expect_prefix(prefix)` on either type returns the payload, or an error if the prefix is different. Use it to reject testnet addresses in a mainnet app, or an NFT id where an address was expected. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn addresses() -> anyhow::Result<()> { + let puzzle_hash = Bytes32::new([7; 32]); + + let address = Address::new(puzzle_hash, "xch".to_string()).encode()?; + assert!(address.starts_with("xch1")); + + // Decode and check the network in one step. + let decoded = Address::decode(&address)?.expect_prefix("xch")?; + assert_eq!(decoded, puzzle_hash); + assert!(Address::decode(&address)?.expect_prefix("txch").is_err()); + + // NFT ids use the same encoding with the launcher id. + let launcher_id = Bytes32::new([9; 32]); + let nft_id = Address::new(launcher_id, "nft".to_string()).encode()?; + assert!(nft_id.starts_with("nft1")); + + // Bech32 handles arbitrary-length data. + let encoded = Bech32::new(Bytes::new(vec![1, 2, 3]), "test".to_string()).encode()?; + assert_eq!(Bech32::decode(&encoded)?.data.as_ref(), &[1, 2, 3]); + + Ok(()) +} +``` + +Decoding rejects bech32 strings that aren't bech32m (such as Bitcoin segwit v0 addresses) with `Bech32Error::InvalidFormat`. + +## Coin selection + +`select_coins(spendable_coins, amount)` picks a set of coins whose total is at least `amount`. It follows the same approach as the Chia reference wallet: + +1. A single coin that exactly matches the amount, if there is one. +2. All of the coins smaller than the amount, if they add up to exactly the amount. +3. The smallest single coin larger than the amount, if the smaller coins don't add up to enough. +4. Otherwise, a knapsack search over the coins, and finally the largest coins until the amount is reached. + +Duplicate coins are ignored, and the result never contains more than 500 coins. It returns `CoinSelectionError::NoSpendableCoins`, `InsufficientBalance(total)` or `ExceededMaxCoins` when no selection is possible. The knapsack search uses a fixed seed, so the result is deterministic for a given input. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::utils::CoinSelectionError; + +#[test] +fn coin_selection() -> anyhow::Result<()> { + let puzzle_hash = Bytes32::new([1; 32]); + let coins: Vec = [100, 250, 400, 1_000] + .into_iter() + .enumerate() + .map(|(i, amount)| Coin::new(Bytes32::new([i as u8; 32]), puzzle_hash, amount)) + .collect(); + + // An exact match is preferred. + let selected = select_coins(coins.clone(), 400)?; + assert_eq!(selected.iter().map(|coin| coin.amount).collect::>(), [400]); + + // The selection always covers the amount; the excess becomes change. + let selected = select_coins(coins.clone(), 600)?; + let total: u64 = selected.iter().map(|coin| coin.amount).sum(); + assert!(total >= 600); + + assert_eq!( + select_coins(coins, 10_000), + Err(CoinSelectionError::InsufficientBalance(1_750)) + ); + + Ok(()) +} +``` + +Coin selection only looks at amounts. Filter out coins that are already being spent in pending transactions before calling it. The [action system](/sdk/action-system/custom-spends) has its own selection based on the deltas of a transaction, which also handles CATs and fees. + +## Hex + +`parse_hex` decodes a hex string into `Bytes`, accepting an optional `0x` prefix. For fixed-size values, convert the result with `try_into`, or use the `hex-literal` crate's `hex!` macro for constants. + +```rust +use chia_wallet_sdk::prelude::*; + +#[test] +fn hex_values() -> anyhow::Result<()> { + let bytes = parse_hex("0xcafe")?; + assert_eq!(bytes.as_ref(), &[0xca, 0xfe]); + + let coin_id: Bytes32 = parse_hex(&"ab".repeat(32))?.try_into()?; + assert_eq!(coin_id, Bytes32::new([0xab; 32])); + + // Bytes and Bytes32 print as lowercase hex without a prefix. + assert_eq!(coin_id.to_string(), "ab".repeat(32)); + + Ok(()) +} +``` + +## Network constants + +`MAINNET_CONSTANTS` and `TESTNET11_CONSTANTS` (in `chia_wallet_sdk::types` and the prelude) are the `ConsensusConstants` of each network. The fields you'll use most are: + +| Field | Used for | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `genesis_challenge` | Identifying the network, for example when connecting to [peers](/sdk/networking/peer) | +| `agg_sig_me_additional_data` and the other `agg_sig_*_additional_data` fields | Signature domain separation, through `AggSigConstants` (see [Keys & Signing](/sdk/concepts/signing#required-signatures)) | +| `max_block_cost_clvm` | The maximum cost of a block (11 billion) | +| `cost_per_byte` | The cost charged per byte of a spend bundle's serialized generator | + +For a custom network or local test chain, `chia_wallet_sdk::types::default_constants(genesis_challenge, agg_sig_me)` builds a set of mainnet-like constants with your own genesis challenge and signature domain. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::default_constants; + +#[test] +fn constants() { + assert_ne!(MAINNET_CONSTANTS.genesis_challenge, TESTNET11_CONSTANTS.genesis_challenge); + + // On mainnet and testnet11, the AGG_SIG_ME data is the genesis challenge. + assert_eq!( + MAINNET_CONSTANTS.agg_sig_me_additional_data, + MAINNET_CONSTANTS.genesis_challenge + ); + + let genesis = Bytes32::new([1; 32]); + let custom = default_constants(genesis, genesis); + let constants = AggSigConstants::from(&custom); + assert_eq!(constants.me(), genesis); +} +``` + +## Merkle trees + +`MerkleTree` builds a merkle tree over a list of `Bytes32` leaves, using the same hashing and proof format as Chia's on-chain puzzles (for example `p2_1_of_n`). Puzzles commit to the `root()`, and a spend reveals one leaf with its `proof(leaf)`. `MerkleProof` implements `ToClvm` and `FromClvm`, so you can put it directly into a solution. + +The [P2OneOfManyLayer](/sdk/concepts/layers#built-in-layers), [clawbacks](/sdk/primitives/clawback), [vaults](/sdk/primitives/vaults) and [action layer](/sdk/primitives/action-layer) singletons all use merkle trees to commit to a set of puzzles while only revealing the one being used. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::chia::sha2::Sha256; + +/// Recomputes the root from a leaf and its proof, as an on-chain puzzle would. +fn verify(root: Bytes32, leaf: Bytes32, proof: &MerkleProof) -> bool { + let sha256 = |parts: &[&[u8]]| { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part); + } + Bytes32::new(hasher.finalize()) + }; + + let mut hash = sha256(&[&[1], &leaf]); + for (i, sibling) in proof.proof.iter().enumerate() { + hash = if proof.path & (1 << i) == 0 { + sha256(&[&[2], &hash, sibling]) + } else { + sha256(&[&[2], sibling, &hash]) + }; + } + hash == root +} + +#[test] +fn merkle_tree() -> anyhow::Result<()> { + let leaves: Vec = (1..=5).map(|i| Bytes32::new([i; 32])).collect(); + let tree = MerkleTree::new(&leaves); + + let proof = tree.proof(leaves[3]).expect("leaf is in the tree"); + assert!(verify(tree.root(), leaves[3], &proof)); + assert!(tree.proof(Bytes32::new([99; 32])).is_none()); + + // Proofs are CLVM values, encoded as (path . proof). + let ctx = &mut SpendContext::new(); + let ptr = ctx.alloc(&proof)?; + assert_eq!(ctx.extract::(ptr)?, proof); + + Ok(()) +} +``` + +`MerkleTree::new` builds every proof up front, so creating the tree costs time proportional to the number of leaves but each `proof` lookup is a hash map access. An empty tree has a root of all zeros. Duplicate leaves share a single proof entry, so avoid them. + +## Estimating cost + +Every spend bundle has a CLVM cost, and fees are priced per unit of cost. `spend_bundle_cost(&coin_spends)` (in `chia_wallet_sdk::driver`) estimates the cost the mempool will assign: the execution cost of each puzzle, plus fixed costs for each `AGG_SIG_*` and `CREATE_COIN` condition, plus the cost of the serialized bytes. + +```rust +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::driver::spend_bundle_cost; + +#[test] +fn estimate_fee() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + let alice = sim.bls(1_000_000_000_000); + + let build = |ctx: &mut SpendContext, fee: u64| -> anyhow::Result> { + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(alice.puzzle_hash, 1_000_000_000_000 - fee, Memos::None) + .reserve_fee(fee), + )?; + Ok(ctx.take()) + }; + + // Estimate the cost with a placeholder fee, then price it at 5 mojos per cost. + let cost = spend_bundle_cost(&build(ctx, 0)?)?; + let fee = cost * 5; + assert!(cost > 0); + + sim.spend_coins(build(ctx, fee)?, &[alice.sk])?; + + Ok(()) +} +``` + +The cost depends slightly on the fee amount itself, since it changes the serialized size by a few bytes. Round the fee up, or estimate with a fee of similar magnitude. + +## Other helpers + +| Item | Location | Purpose | +| ------------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `announcement_id(coin_info, message)` | `chia_wallet_sdk::types` | Computes the id to assert an announcement; see [Conditions](/sdk/concepts/conditions#announcements) | +| `payment_assertion(puzzle_hash, notarized_payment_hash)` | `chia_wallet_sdk::types` | Builds the `AssertPuzzleAnnouncement` for a settlement payment; see [Offers](/sdk/offers) | +| `tree_hash_notarized_payment(&allocator, ¬arized_payment)` | `chia_wallet_sdk::types` | The hash of a notarized payment, with memos hashed in place | +| `run_puzzle_with_cost(...)` | `chia_wallet_sdk::types` | Runs a puzzle with a cost limit and returns the cost; see [CLVM Values](/sdk/concepts/clvm#running-puzzles) | + +## Command line tool + +The `chia-sdk-cli` crate is a small command line tool for converting between hex and bech32m. Install it from the SDK repository: + +```sh +cargo install --git https://github.com/xch-dev/chia-wallet-sdk chia-sdk-cli +``` + +```sh +# Encode a puzzle hash as an address (the prefix defaults to xch). +chia-sdk-cli encode 0x0707070707070707070707070707070707070707070707070707070707070707 +chia-sdk-cli encode --prefix txch 0707070707070707070707070707070707070707070707070707070707070707 + +# Decode any bech32m string back to hex. +chia-sdk-cli decode xch1... +``` + +`encode` accepts hex with or without a `0x` prefix, and `decode` prints the payload as hex without a prefix, whatever its length. diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 3e6684c..233a3b6 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -55,7 +55,14 @@ const config: Config = { [ "@docusaurus/plugin-client-redirects", { - redirects: [{ from: "/sage", to: "/sage/getting-started" }], + redirects: [ + { from: "/sage", to: "/sage/getting-started" }, + // Wallet SDK pages that moved when the section was restructured. + { from: "/sdk/spend-context", to: "/sdk/concepts/spend-context" }, + { from: "/sdk/actions", to: "/sdk/action-system" }, + { from: "/sdk/connectivity", to: "/sdk/networking/peer" }, + { from: "/sdk/primitives/other", to: "/sdk/primitives" }, + ], // Sage pages used to be served without the /sage prefix. createRedirects: (existingPath: string) => existingPath.startsWith("/sage/") diff --git a/sidebars.ts b/sidebars.ts index fb8a0fc..56d03a6 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -17,32 +17,109 @@ const sidebars: SidebarsConfig = { }, ], sdk: [ - { type: "doc", id: "sdk/index", label: "Quick Start" }, - { type: "doc", id: "sdk/spend-context", label: "SpendContext" }, - { type: "doc", id: "sdk/actions", label: "Action System" }, - { type: "doc", id: "sdk/connectivity", label: "Connectivity" }, + { type: "doc", id: "sdk/index", label: "Introduction" }, + { type: "doc", id: "sdk/quick-start", label: "Quick Start" }, { type: "category", - label: "Primitives", + label: "Core Concepts", + items: [ + { type: "doc", id: "sdk/concepts/clvm", label: "CLVM Values" }, + { + type: "doc", + id: "sdk/concepts/spend-context", + label: "SpendContext", + }, + { type: "doc", id: "sdk/concepts/conditions", label: "Conditions" }, + { type: "doc", id: "sdk/concepts/layers", label: "Layers & Puzzles" }, + { type: "doc", id: "sdk/concepts/signing", label: "Keys & Signing" }, + ], + }, + { + type: "category", + label: "Action System", + link: { type: "doc", id: "sdk/action-system/index" }, items: [ { type: "doc", - id: "sdk/primitives/standard", - label: "Standard (XCH)", + id: "sdk/action-system/actions", + label: "Action Reference", + }, + { + type: "doc", + id: "sdk/action-system/custom-spends", + label: "Custom Spends", }, + ], + }, + { + type: "category", + label: "Primitives", + link: { type: "doc", id: "sdk/primitives/index" }, + items: [ + { type: "doc", id: "sdk/primitives/standard", label: "Standard (XCH)" }, { type: "doc", id: "sdk/primitives/cat", label: "CAT" }, + { type: "doc", id: "sdk/primitives/singletons", label: "Singletons" }, { type: "doc", id: "sdk/primitives/nft", label: "NFT" }, + { type: "doc", id: "sdk/primitives/did", label: "DID" }, { type: "doc", - id: "sdk/primitives/other", - label: "Other Primitives", + id: "sdk/primitives/options", + label: "Option Contracts", + }, + { type: "doc", id: "sdk/primitives/vaults", label: "Vaults & MIPS" }, + { type: "doc", id: "sdk/primitives/clawback", label: "Clawback" }, + { + type: "doc", + id: "sdk/primitives/streaming", + label: "Streamed Assets", + }, + { type: "doc", id: "sdk/primitives/bulletin", label: "Bulletins" }, + { type: "doc", id: "sdk/primitives/datalayer", label: "DataLayer" }, + { + type: "doc", + id: "sdk/primitives/action-layer", + label: "Action Layer", }, ], }, { type: "doc", id: "sdk/offers", label: "Offers" }, - { type: "doc", id: "sdk/testing", label: "Testing" }, - { type: "doc", id: "sdk/patterns", label: "Application Patterns" }, - { type: "doc", id: "sdk/troubleshooting", label: "Troubleshooting" }, + { type: "doc", id: "sdk/clear-signing", label: "Clear Signing" }, + { + type: "category", + label: "Networking", + items: [ + { type: "doc", id: "sdk/networking/peer", label: "Peer Protocol" }, + { type: "doc", id: "sdk/networking/rpc", label: "RPC Clients" }, + { type: "doc", id: "sdk/networking/daemon", label: "Daemon" }, + ], + }, + { + type: "category", + label: "Testing", + link: { type: "doc", id: "sdk/testing/index" }, + items: [ + { + type: "doc", + id: "sdk/testing/peer-simulator", + label: "Peer Simulator", + }, + { + type: "doc", + id: "sdk/testing/full-node-simulator", + label: "Full Node Simulator", + }, + ], + }, + { type: "doc", id: "sdk/utilities", label: "Utilities" }, + { type: "doc", id: "sdk/bindings", label: "Bindings" }, + { + type: "category", + label: "Guides", + items: [ + { type: "doc", id: "sdk/patterns", label: "Application Patterns" }, + { type: "doc", id: "sdk/troubleshooting", label: "Troubleshooting" }, + ], + }, ], }; From 25a63caeea4012ed4930d4abcb252c7ae9c1be5f Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Nov 2026 23:24:48 -0500 Subject: [PATCH 2/7] Correct Wallet SDK docs claims against the SDK source --- docs/sdk/action-system/actions.md | 2 +- docs/sdk/bindings.md | 20 ++++++++++---------- docs/sdk/concepts/clvm.md | 4 ++-- docs/sdk/concepts/conditions.md | 2 +- docs/sdk/concepts/layers.md | 4 ++-- docs/sdk/concepts/signing.md | 2 +- docs/sdk/concepts/spend-context.md | 2 +- docs/sdk/index.md | 2 +- docs/sdk/networking/peer.md | 4 ++-- docs/sdk/offers.md | 2 +- docs/sdk/patterns.md | 2 +- docs/sdk/primitives/action-layer.md | 20 ++++++++++---------- docs/sdk/primitives/cat.md | 9 +++++---- docs/sdk/primitives/clawback.md | 9 +++++---- docs/sdk/primitives/datalayer.md | 15 ++++++++------- docs/sdk/primitives/standard.md | 2 +- docs/sdk/primitives/vaults.md | 10 +++++----- docs/sdk/testing/full-node-simulator.md | 4 ++-- docs/sdk/testing/index.md | 10 +++++----- docs/sdk/testing/peer-simulator.md | 4 ++-- 20 files changed, 66 insertions(+), 63 deletions(-) diff --git a/docs/sdk/action-system/actions.md b/docs/sdk/action-system/actions.md index 2f612f4..d2473c3 100644 --- a/docs/sdk/action-system/actions.md +++ b/docs/sdk/action-system/actions.md @@ -432,7 +432,7 @@ fn did_lifecycle() -> anyhow::Result<()> { `Action::mint_option(creator_puzzle_hash, seconds, underlying_id, underlying_amount, strike_type, amount)` mints an [option contract](/sdk/primitives/options) singleton from an XCH coin, and locks `underlying_amount` of the underlying asset (XCH, a CAT, or an NFT, by `underlying_id`) in a coin that the option controls. Until the `seconds` timestamp, the owner of the option can exercise it by paying the `strike_type` (an `OptionType`) to `creator_puzzle_hash`. After that, the creator can reclaim the underlying asset. -The option is `Id::New(index)`, and its owner is the p2 puzzle hash of the XCH coin it was minted from. Send it elsewhere with `Action::send`. When the underlying is an NFT, the NFT is locked in the same transaction, so no other action can use it afterwards (they fail with `DriverError::NoSourceForOutput`). +The option is `Id::New(index)`. Unless it's sent elsewhere with `Action::send`, the option goes to the change puzzle hash. When the underlying is an NFT, the NFT is locked in the same transaction, so no other action can use it afterwards (they fail with `DriverError::NoSourceForOutput`). ### Exercising with `Action::melt_singleton` diff --git a/docs/sdk/bindings.md b/docs/sdk/bindings.md index e3456a2..24be3e4 100644 --- a/docs/sdk/bindings.md +++ b/docs/sdk/bindings.md @@ -45,16 +45,16 @@ translation: Types are converted at the boundary: -| Rust | Node.js | WASM | Python | -| ------------------------- | ------------------------------ | ------------------- | -------------------------------- | -| `Bytes32`, `Bytes` | `Uint8Array` in, `Buffer` out | `Uint8Array` | `bytes` | -| `u64`, `u128`, `BigInt` | `bigint` (`1000n`) | `bigint` | `int` | -| `u8`, `u16`, `u32` | `number` | `number` | `int` | -| `Option` | `T`, `null` or `undefined` | `T` or `undefined` | `T` or `None` | -| `Vec` | Array | Array | `list` | -| `Result` | Returns `T`, throws an `Error` | Returns `T`, throws | Returns `T`, raises `ValueError` | -| `async fn` | Returns a `Promise` | Returns a `Promise` | Awaitable (use with `asyncio`) | -| `snake_case` method names | `camelCase` | `camelCase` | `snake_case` | +| Rust | Node.js | WASM | Python | +| ------------------------- | ---------------------------------------------- | ------------------- | -------------------------------- | +| `Bytes32`, `Bytes` | `Uint8Array` in, `Buffer` out | `Uint8Array` | `bytes` | +| `u64`, `u128`, `BigInt` | `bigint` (`1000n`) | `bigint` | `int` | +| `u8`, `u16`, `u32` | `number` | `number` | `int` | +| `Option` | `T` or `null` (inputs also accept `undefined`) | `T` or `undefined` | `T` or `None` | +| `Vec` | Array | Array | `list` | +| `Result` | Returns `T`, throws an `Error` | Returns `T`, throws | Returns `T`, raises `ValueError` | +| `async fn` | Returns a `Promise` | Returns a `Promise` | Awaitable (use with `asyncio`) | +| `snake_case` method names | `camelCase` | `camelCase` | `snake_case` | Structs such as `Coin`, `CoinSpend`, `SpendBundle` and `CatInfo` become classes with a constructor and properties for their fields. Passing an object to a method doesn't consume it, so you can keep using it diff --git a/docs/sdk/concepts/clvm.md b/docs/sdk/concepts/clvm.md index 72e6512..13b6c39 100644 --- a/docs/sdk/concepts/clvm.md +++ b/docs/sdk/concepts/clvm.md @@ -149,7 +149,7 @@ fn derived_types() -> anyhow::Result<()> { } ``` -The derive macros generate code that refers to the `clvm_traits` crate by name. `use chia_wallet_sdk::prelude::*;` brings that name into scope, so derives work without adding `clvm-traits` as a direct dependency. If you don't import the prelude, add `use chia_wallet_sdk::clvm_traits;` or pass `#[clvm(crate_name = chia_wallet_sdk::clvm_traits)]`. +The derive macros generate code that refers to the `clvm_traits` crate by name. `use chia_wallet_sdk::prelude::*;` brings that name into scope, so derives work without adding `clvm-traits` as a direct dependency. If you don't import the prelude, add `use chia_wallet_sdk::clvm_traits;`. (`#[clvm(crate_name = ...)]` takes a single identifier, not a path, so it can't point at `chia_wallet_sdk::clvm_traits` directly.) Use `NodePtr` as a field type (or a generic parameter that you later set to `NodePtr`) to leave part of a value unparsed. Most SDK solution types, like `StandardSolution` and `CatSolution`, are generic for exactly this reason. @@ -454,5 +454,5 @@ In debug builds and tests, puzzles run under Rue's debug dialect, which records - **Pointers belong to one allocator.** Using a `NodePtr` with a different `Allocator` (or after `SpendContext::reset`) doesn't fail loudly; it points at whatever is at that index. Keep a single `SpendContext` per transaction you build. - **Zero is nil.** `0u64`, `false`, `None`, `()` and an empty string all encode to the same nil atom, so `Option` can't distinguish `Some(0)` from `None`. - **`list` is lenient, `proper_list` is strict.** `#[clvm(list)]` ignores extra trailing items when parsing. That's usually what you want for solutions and conditions, but it means a round trip doesn't preserve them. -- **Integer parsing only checks the length.** `FromClvm` for integers fails if the atom is too long for the type, but doesn't check for a minimal encoding or, for unsigned types, the sign bit. The atom `0x80` is -128 in CLVM but parses as `128u8`. When the exact value matters for security, compare against the encoding the puzzle itself would see, or parse as `Bytes`. +- **Integer parsing doesn't require a minimal encoding.** `FromClvm` for integers fails if the value doesn't fit the type, and unsigned types reject atoms with the sign bit set (the atom `0x80` is -128, so it fails as `u8`). Redundant leading padding is accepted, though: `0x0001` parses as `1u8` even though a puzzle would see a different atom than `0x01`. When the exact encoding matters for security, compare against the encoding the puzzle itself would see, or parse as `Bytes`. - **Tree hashes aren't SHA-256 of the serialized bytes.** Always use `tree_hash`, `ToTreeHash` or `tree_hash_from_bytes` to get a puzzle hash. diff --git a/docs/sdk/concepts/conditions.md b/docs/sdk/concepts/conditions.md index 9fa1cab..ce7212c 100644 --- a/docs/sdk/concepts/conditions.md +++ b/docs/sdk/concepts/conditions.md @@ -321,7 +321,7 @@ The type parameter `T` controls how the open-ended parts of a condition are pars ## Gotchas - **Conditions don't sign anything.** An `AGG_SIG_*` condition only states that a signature is required. You still have to produce the signature; see [Keys & Signing](/sdk/concepts/signing). -- **Fees are implicit.** The fee is whatever input value isn't spent on outputs. `reserve_fee` only asserts that the fee is at least the given amount, and protects against a bundle being stripped of its fee-paying spend. +- **Fees are implicit.** The fee is whatever input value isn't spent on outputs. `reserve_fee` only asserts that the bundle's total fee is at least the given amount; it doesn't move any value by itself. - **Memos must be allocated in the same allocator as the spend.** `Memos` points into a specific `SpendContext`. Build it with that context's `hint` or `memos`. - **Only the first memo is a hint, and only if it's exactly 32 bytes.** - **Condition order isn't significant** to consensus, but duplicate `CREATE_COIN` conditions with the same puzzle hash and amount create the same coin id and fail. Vary the amount, or use separate parents. diff --git a/docs/sdk/concepts/layers.md b/docs/sdk/concepts/layers.md index 26113c9..9f63a33 100644 --- a/docs/sdk/concepts/layers.md +++ b/docs/sdk/concepts/layers.md @@ -4,7 +4,7 @@ title: Layers & Puzzles # Layers & Puzzles -Most Chia puzzles are built by wrapping one puzzle inside another. A CAT is the CAT puzzle wrapped around an inner puzzle that decides who owns it, usually the standard puzzle. An NFT is five puzzles deep: singleton, NFT state, NFT ownership, royalty transfer, and an owner puzzle. The SDK models each of these wrappers as a **layer**: a Rust type that knows how to build its puzzle and solution, and how to recognize itself in an existing puzzle. +Most Chia puzzles are built by wrapping one puzzle inside another. A CAT is the CAT puzzle wrapped around an inner puzzle that decides who owns it, usually the standard puzzle. An NFT is four puzzles deep: singleton, NFT state, NFT ownership (with a royalty transfer program curried in), and an owner puzzle. The SDK models each of these wrappers as a **layer**: a Rust type that knows how to build its puzzle and solution, and how to recognize itself in an existing puzzle. Layers are the low-level building blocks underneath the [primitives](/sdk/primitives). Use them directly when you need a puzzle combination that no primitive covers, when you write your own puzzle, or when you want to inspect an arbitrary coin spend. For ordinary XCH, CAT and NFT transactions, the primitives and the [action system](/sdk/action-system) are simpler. @@ -384,7 +384,7 @@ If you only need to curry a puzzle and don't need parsing, `ctx.curry(args)` and | `AugmentedConditionLayer` | Adds a fixed condition to the inner puzzle's output | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options) | | `StreamLayer` | Streamed payments | [Streamed Assets](/sdk/primitives/streaming) | | `BulletinLayer` | Nonce wrapper that distinguishes bulletin coins | [Bulletins](/sdk/primitives/bulletin) | -| `DelegatedPuzzleFeederLayer`, `IndexWrapperLayer`, `SingletonMemberLayer` | Multi-issuer puzzle system (MIPS) building blocks | [Vaults & MIPS](/sdk/primitives/vaults) | +| `DelegatedPuzzleFeederLayer`, `IndexWrapperLayer`, `SingletonMemberLayer` | Meta Inner Puzzle Spec (MIPS) building blocks | [Vaults & MIPS](/sdk/primitives/vaults) | | `DelegationLayer`, `WriterLayer`, `OracleLayer` | DataLayer delegation (feature `chip-0035`) | [DataLayer](/sdk/primitives/datalayer) | | `P2ControllerPuzzleLayer`, `P2Eip712MessageLayer` | Coins controlled by another puzzle or by an EIP-712 signature (feature `chip-0037`) | Ethereum-style (secp256k1) wallets | | `ActionLayer`, `PrecommitLayer`, `StateSchedulerLayer`, `MOfNLayer`, `P2DelegatedBySingletonLayer`, `ConditionsLayer`, `P2ParentLayer` | Action layer singletons (feature `action-layer`) | [Action Layer](/sdk/primitives/action-layer) | diff --git a/docs/sdk/concepts/signing.md b/docs/sdk/concepts/signing.md index 8b86a25..add946c 100644 --- a/docs/sdk/concepts/signing.md +++ b/docs/sdk/concepts/signing.md @@ -86,7 +86,7 @@ fn derive_wallet_keys() -> anyhow::Result<()> { You don't need to work out signature messages yourself. `RequiredSignature::from_coin_spends` runs each coin spend, collects its `AGG_SIG_*` conditions, and returns a `RequiredSignature` for each one: -- `RequiredSignature::Bls(RequiredBlsSignature)` has the `public_key` and the full `message()` to sign. The message is the condition's raw message, followed by the coin information the condition commits to (for `AGG_SIG_ME`, the coin id), followed by a network-specific domain string. +- `RequiredSignature::Bls(RequiredBlsSignature)` has the `public_key` and the full `message()` to sign. The message is the condition's raw message, followed by the coin information the condition commits to (for `AGG_SIG_ME`, the coin id), followed by a network-specific domain string. `AGG_SIG_UNSAFE` is the exception: its message is signed as is, with no coin information or domain string. - `RequiredSignature::Secp(RequiredSecpSignature)` is a secp256k1 or secp256r1 signature that a puzzle verifies itself. See [Secp keys](#secp-keys). The domain strings come from `AggSigConstants`, which you build from the network's consensus constants. Mainnet and testnet11 have different genesis challenges, so the same spend needs a different signature on each network: diff --git a/docs/sdk/concepts/spend-context.md b/docs/sdk/concepts/spend-context.md index c50dc7e..07d9252 100644 --- a/docs/sdk/concepts/spend-context.md +++ b/docs/sdk/concepts/spend-context.md @@ -62,7 +62,7 @@ When you're done, get the coin spends back with: - `ctx.take()`, which removes and returns them, leaving the context ready for another transaction. - `ctx.iter()`, to inspect them without removing them. -- `for coin_spend in ctx` (`IntoIterator`), to consume the context. +- `for coin_spend in ctx` (`IntoIterator`), to consume an owned `SpendContext`. ```rust use chia_wallet_sdk::prelude::*; diff --git a/docs/sdk/index.md b/docs/sdk/index.md index 660650a..5e73dc5 100644 --- a/docs/sdk/index.md +++ b/docs/sdk/index.md @@ -99,7 +99,7 @@ what assets are involved: 2. **Describe what should happen.** Either spend coins directly with a [primitive](/sdk/primitives) such as `StandardLayer`, `Cat` or `Nft`, passing the [conditions](/sdk/concepts/conditions) each coin should output, or describe the whole transaction declaratively with the [action system](/sdk/action-system) - and let it select change, link spends and build the conditions for you. + and let it create change, link spends and build the conditions for you. 3. **Sign.** Work out which signatures the spends require with `RequiredSignature` and sign them with your keys. See [Keys & Signing](/sdk/concepts/signing). 4. **Submit.** Send the `SpendBundle` to the network through a [peer](/sdk/networking/peer) or an diff --git a/docs/sdk/networking/peer.md b/docs/sdk/networking/peer.md index 62ce08d..1023ecd 100644 --- a/docs/sdk/networking/peer.md +++ b/docs/sdk/networking/peer.md @@ -23,7 +23,7 @@ not compiled. `Peer`, `PeerOptions`, `Network` and the rate limiter types are al ```toml [dependencies] -chia-wallet-sdk = { version = "0.36", features = ["rustls"] } +chia-wallet-sdk = { version = "0.37", features = ["rustls"] } tokio = { version = "1", features = ["full"] } ``` @@ -467,7 +467,7 @@ Every fallible function returns `ClientError`. The variants you'll handle most o | `WebSocket`, `Io` | The connection couldn't be opened or was lost | | `WrongNetwork`, `WrongNodeType` | The handshake succeeded but the peer is on another network or isn't a full node | | `MissingHandshake` | The connection closed before the peer sent its handshake | -| `InvalidResponse`, `UnexpectedMessage` | The peer replied with a message of the wrong type | +| `InvalidResponse` | The peer replied with a message of the wrong type | | `Recv` | The response channel for a request was dropped | | `Streamable` | A message couldn't be decoded | | `Ssl`, `Rustls`, `NativeTls`, `Pem`, `MissingPkcs8Key`, `MissingCa` | Certificate or TLS setup failed | diff --git a/docs/sdk/offers.md b/docs/sdk/offers.md index d9cd13b..4610410 100644 --- a/docs/sdk/offers.md +++ b/docs/sdk/offers.md @@ -479,7 +479,7 @@ Decompression supports every version of the format the reference wallet has used - **Treat offers from others as untrusted.** Parse them with `Offer::from_spend_bundle`, which rejects puzzle reveals that don't match their coin's puzzle hash (`DriverError::WrongPuzzleHash`). `from_input_spend_bundle` skips this check, so only use it for spends you built yourself. - **Check what you're getting.** Compare `offered_coins()` and `requested_payments()` (or `arbitrage()`) with what the user agreed to, and check that the coins the maker's spends consume are still unspent on chain. An offer can reference coins that have already been spent, in which case it can't be taken. - **Old offers are rejected.** Offers using the retired, insecure settlement payments puzzle (`OLD_SETTLEMENT_PAYMENT_HASH`) fail to parse with `DriverError::OldSettlementPayment`. -- **Revocable CATs are a different asset.** A revocable CAT can be clawed back by its issuer, and has a different settlement puzzle hash than a CAT with the same asset id but no revocation layer. Check `asset_info().cat(asset_id)?.hidden_puzzle_hash` before treating an offered CAT as the one you expect. Mixing both kinds under one asset id fails with `DriverError::IncompatibleAssetInfo`. +- **Revocable CATs are a different asset.** A revocable CAT can be clawed back by its issuer, and has a different settlement puzzle hash than a CAT with the same asset id but no revocation layer. Check the offered CAT's `hidden_puzzle_hash` in `asset_info().cat(asset_id)` before treating an offered CAT as the one you expect. Mixing both kinds under one asset id fails with `DriverError::IncompatibleAssetInfo`. - **Settlement spends are asserted.** When taking an offer, every payment a settlement coin makes is asserted by one of your conditions spends, so a third party can't strip the settlement spends out of the bundle and claim the coins. Don't set `disable_settlement_assertions` unless something else ties them to your spend. - **Amounts can overflow.** Offered and requested totals are `u128`. A malicious offer can have totals that don't fit in a coin, which fail with `DriverError::AmountOverflow` (or `InsufficientFunds`) rather than wrapping around. Use `coin_amount` to convert totals. - **Royalties are enforced on chain.** An NFT's royalty must be paid for an offer involving it to succeed. Use `requested_royalty_amounts` and `offered_royalty_amounts` to show users the real cost. `offered_royalties` fails with `DriverError::MissingAssetInfo` if a requested NFT has no asset info, rather than silently skipping its royalty. diff --git a/docs/sdk/patterns.md b/docs/sdk/patterns.md index 20bcd87..aa61a3f 100644 --- a/docs/sdk/patterns.md +++ b/docs/sdk/patterns.md @@ -166,7 +166,7 @@ example, they could keep the spend that pays a fee and drop the one that pays yo of one transaction together: - With the action system, choose a [`Relation`](/sdk/action-system#relations). - `Relation::AssertConcurrent` makes every spend assert every other spend, and is the right default. + `Relation::AssertConcurrent` makes each spend assert the previous one in a ring, so none can be dropped, and is the right default. - By hand, add `assert_concurrent_spend(other_coin_id)` (or announcements, as described in [Conditions](/sdk/concepts/conditions)) to each spend, as in the example above. diff --git a/docs/sdk/primitives/action-layer.md b/docs/sdk/primitives/action-layer.md index acd8995..d2171b3 100644 --- a/docs/sdk/primitives/action-layer.md +++ b/docs/sdk/primitives/action-layer.md @@ -67,13 +67,13 @@ To follow a singleton on chain, use `from_parent_spend` / `from_spend` on its co ## Applications -| Application | Types | What it does | -| ------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| CATalog | `CatalogRegistry`, `CatalogRegistryInfo`, `CatalogRegistryState`, `CatalogRegistryConstants` | A registry of CATs. Registering a CAT (by TAIL hash) mints an NFT that holds its metadata (`CatNftMetadata`). Each TAIL can only be registered once, enforced by slots | -| XCHandles | `XchandlesRegistry`, `XchandlesRegistryInfo`, `XchandlesRegistryState`, `XchandlesConstants` | A registry of handles (names) with expiration, priced by a pricing puzzle. Handles can be registered, extended, expired and re-registered, and updated | -| Reward distributor | `RewardDistributor`, `RewardDistributorInfo`, `RewardDistributorState`, `RewardDistributorConstants` | Distributes rewards (a CAT held in a `Reserve`) to entries over epochs. Entries are managed by a manager singleton, NFT stakers or CAT stakers (`RewardDistributorType`) | -| Medieval vault | `MedievalVault`, `MedievalVaultInfo` | A plain m-of-n BLS multisig singleton, used to administer other singletons | -| State scheduler | `StateScheduler`, `StateSchedulerInfo` | A singleton that sends a pre-committed sequence of state updates to another singleton at given times (for example to schedule price changes) | +| Application | Types | What it does | +| ------------------ | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CATalog | `CatalogRegistry`, `CatalogRegistryInfo`, `CatalogRegistryState`, `CatalogRegistryConstants` | A registry of CATs. Registering a CAT (by TAIL hash) mints an NFT that holds its metadata (`CatNftMetadata`). Each TAIL can only be registered once, enforced by slots | +| XCHandles | `XchandlesRegistry`, `XchandlesRegistryInfo`, `XchandlesRegistryState`, `XchandlesConstants` | A registry of handles (names) with expiration, priced by a pricing puzzle. Handles can be registered, extended, expired and re-registered, and updated | +| Reward distributor | `RewardDistributor`, `RewardDistributorInfo`, `RewardDistributorState`, `RewardDistributorConstants` | Distributes rewards (a CAT held in a `Reserve`) to entries over epochs. Entries are managed by a manager singleton, or are NFTs from a collection, NFTs from a curated DataLayer store, or staked CATs (`RewardDistributorType`) | +| Medieval vault | `MedievalVault`, `MedievalVaultInfo` | A plain m-of-n BLS multisig singleton, used to administer other singletons | +| State scheduler | `StateScheduler`, `StateSchedulerInfo` | A singleton that sends a pre-committed sequence of state updates to another singleton at given times (for example to schedule price changes) | Each registry's actions are separate types: @@ -326,9 +326,9 @@ fn medieval_vault_rekey() -> anyhow::Result<()> { } ``` -`MedievalVault::spend` makes the delegated puzzle non-replayable by asserting the coin id and the -network's genesis challenge (`delegated_conditions`), so pass the genesis challenge of the network you -are on. `spend_sunsafe` skips that, and `delegated_puzzle_for_rekey` / +`MedievalVault::spend` makes the delegated puzzle non-replayable by asserting the coin id (unless the +conditions already assert the coin or parent id) and adding the network's genesis challenge as a +`REMARK` (`delegated_conditions`), so pass the genesis challenge of the network you are on. `spend_sunsafe` skips that, and `delegated_puzzle_for_rekey` / `delegated_puzzle_for_flexible_send_message` build delegated puzzles for signers that sign offline. ## State scheduler diff --git a/docs/sdk/primitives/cat.md b/docs/sdk/primitives/cat.md index c3cb858..e5c6a1c 100644 --- a/docs/sdk/primitives/cat.md +++ b/docs/sdk/primitives/cat.md @@ -54,8 +54,9 @@ the eve coin is spent immediately with a `RUN_CAT_TAIL` condition that reveals t helpers do both. They add the eve spend to the context, and return the conditions the parent must output plus the resulting CAT coins. -The eve coin's outputs are the `extra_conditions` you pass in. They must create exactly `amount` worth of -coins (the TAIL is checked against the delta), and those coins become the issued CATs. +The eve coin's outputs are the `extra_conditions` you pass in. They should create exactly `amount` worth of +coins, and those coins become the issued CATs. Any difference is passed to the TAIL as a delta, which +the single issuance TAIL rejects and other TAILs may treat as an extra mint or melt. ### Single issuance @@ -572,8 +573,8 @@ need the hash the CAT layer sees, for example in lineage proofs. The `Cat` metho - Spend different asset IDs in separate `spend_all` calls; they can't share a ring. - Every output inside a CAT spend becomes a CAT of the same asset. To pay a fee or send XCH, spend an XCH coin in the same bundle and link it (for example with `assert_concurrent_spend`). -- The issuance outputs must sum to exactly the issued amount. Too much or too little makes the TAIL fail - with a `clvm raise` error. +- With `single_issuance`, the outputs must sum to exactly the issued amount. Too much or too little + makes the TAIL fail with a `clvm raise` error. Other TAILs see the difference as a mint or melt. - The eve coin used during issuance has no hint and is spent immediately, so you don't need to track it. See the [`Cat` rustdoc](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/) for the complete diff --git a/docs/sdk/primitives/clawback.md b/docs/sdk/primitives/clawback.md index 1646815..094aa31 100644 --- a/docs/sdk/primitives/clawback.md +++ b/docs/sdk/primitives/clawback.md @@ -43,8 +43,8 @@ variants when you need the `Spend` to wrap in another layer, for example with `C To let the receiver's wallet find and decode the clawback, create the coin with memos of the form `(receiver_puzzle_hash, (clawback.memo(), ...))`. The first memo is a hint, so the coin shows up when the receiver looks up coins hinted to their puzzle hash. The second is `(sender_puzzle_hash, (seconds, ()))`, -which together with the coin's amount and the hint lets anyone reconstruct the `ClawbackV2` with -`ClawbackV2::from_memo`. `from_memo` checks the reconstructed puzzle hash against the coin, so a bad +which together with the coin's amount, the hint and the `hinted` flag lets anyone reconstruct the +`ClawbackV2` with `ClawbackV2::from_memo`. `from_memo` checks the reconstructed puzzle hash against the coin, so a bad memo just returns `None`. This example sends a clawback from Alice to Bob, has Bob discover it from the chain, and then claims it @@ -293,6 +293,7 @@ with a hint. For custom spends, use `receiver_spend` and `sender_spend`. to control block timestamps (see [Simulator](/sdk/testing)). - `amount` and `hinted` are part of a `ClawbackV2`'s puzzle hash. The push-through path pays exactly `amount`, so the coin's amount must match it. -- If the receiver is a vault, the paths are spent with the vault's p2 spend as the inner spend. Clear - signing requires vault transactions that use the sender path to expire before the clawback does; see +- If the sender or receiver is a vault, its path is spent with the vault's p2 spend as the inner spend. + Clear signing requires vault transactions that use the sender path to expire no later than the + clawback's `seconds`; see [Clear Signing](/sdk/clear-signing#clawbacks-and-expiration). diff --git a/docs/sdk/primitives/datalayer.md b/docs/sdk/primitives/datalayer.md index 082a462..b08c42c 100644 --- a/docs/sdk/primitives/datalayer.md +++ b/docs/sdk/primitives/datalayer.md @@ -44,12 +44,12 @@ The metadata type is generic: `DatastoreMetadata` is the standard format, but an When a store has delegated puzzles, its inner puzzle is a `DelegationLayer` that can be spent either by the owner or by any delegated puzzle in a merkle tree: -| Role | Can do | -| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Owner | Anything: update metadata, change the delegated puzzles, transfer ownership, melt | -| Admin | Update metadata and replace the set of delegated puzzles (`UpdateDatastoreMerkleRoot`) | -| Writer | Only update metadata. The `WriterLayer` filters out every other condition | -| Oracle | Anyone can spend it, as long as they pay `fee` mojos to the oracle puzzle hash. The spend makes an announcement that the payer can assert, so it can be used to prove the store's current state on chain | +| Role | Can do | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Owner | Anything: update metadata, change the delegated puzzles, transfer ownership, melt | +| Admin | Update metadata and replace the set of delegated puzzles (`UpdateDatastoreMerkleRoot`) | +| Writer | Update metadata, but not the delegated puzzles. The `WriterLayer` raises if the inner puzzle outputs `UpdateDatastoreMerkleRoot` | +| Oracle | Anyone can spend it, as long as they pay `fee` mojos to the oracle puzzle hash (the fee must be even; `OracleLayer::new` returns `None` otherwise). The spend makes an announcement that the payer can assert, so it can be used to prove the store's current state on chain | ## Launching and updating a store @@ -274,7 +274,8 @@ which destroys the singleton. Admins and writers can't melt it. - `Datastore::spend` consumes the store and returns a `CoinSpend`. It doesn't insert it into the `SpendContext`; call `ctx.insert(coin_spend)` yourself. -- A writer spend can only update metadata, and an admin spend can't change the owner. Conditions they +- A writer spend can't change the delegated puzzles, and neither admins nor writers can change the owner, + recreate the coin themselves or melt it. Conditions they aren't allowed to output make the spend fail. - `Datastore::from_spend` verifies that the memos are consistent with the created puzzle hash, so you can't be tricked into tracking a different owner than the one actually on chain. diff --git a/docs/sdk/primitives/standard.md b/docs/sdk/primitives/standard.md index f5467fc..31bf3a2 100644 --- a/docs/sdk/primitives/standard.md +++ b/docs/sdk/primitives/standard.md @@ -14,7 +14,7 @@ state beyond the coin itself, so there is no separate `Xch` type. A plain `Coin` owns it is all you need. :::tip -For ordinary payments with change and fees, the [Action System](/sdk/action-system) can select coins, +For ordinary payments with change and fees, the [Action System](/sdk/action-system) can create change, balance amounts and produce the spends for you (`Action::send`, `Action::fee`). This page covers the underlying primitive, which is what you need for custom flows or when you want full control. ::: diff --git a/docs/sdk/primitives/vaults.md b/docs/sdk/primitives/vaults.md index 3e95fe1..5f1a1cf 100644 --- a/docs/sdk/primitives/vaults.md +++ b/docs/sdk/primitives/vaults.md @@ -5,7 +5,7 @@ title: Vaults & MIPS # Vaults & MIPS A **vault** is a singleton whose inner puzzle is a custody configuration built with **MIPS**, the -multi-issuer puzzle system (CHIP-0043, see the [CHIPs repository](https://github.com/Chia-Network/chips)). Instead of +Meta Inner Puzzle Spec ([CHIP-0043](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0043.md)). Instead of holding funds directly, a vault controls other coins (XCH, CATs, NFTs) whose p2 puzzle is locked to the vault's launcher id. Because the vault is a singleton, its custody rules can be changed (rekeyed) without moving any of those coins. @@ -61,7 +61,7 @@ sign its tree hash, not the conditions themselves, so every member in a quorum s | `FixedPuzzleMember` | Only allows one specific delegated puzzle hash | | `Eip712Member` (`chip-0037`) | secp256k1 key signing an EIP-712 typed message | -Each secp/BLS member also has a `...PuzzleAssert` variant (for example `K1MemberPuzzleAssert`) that +Each secp/BLS member except `Eip712Member` also has a `...PuzzleAssert` variant (for example `K1MemberPuzzleAssert`) that commits to the vault's puzzle hash instead of the coin id. These are used for "fast forward" spends, where the same signature needs to stay valid even if the vault coin is replaced by a newer coin with the same puzzle hash. @@ -71,7 +71,7 @@ same puzzle hash. A `Restriction` has a `RestrictionKind` and a puzzle hash: - `MemberCondition`: a validator that inspects the conditions output by the member or quorum. For - example `Timelock` requires an `ASSERT_SECONDS_RELATIVE` of at least some number of seconds. + example `Timelock` requires an `ASSERT_SECONDS_RELATIVE` with its curried number of seconds. - `DelegatedPuzzleHash`: a validator that inspects the delegated puzzle hash. - `DelegatedPuzzleWrapper`: a wrapper puzzle that is wrapped around the delegated puzzle and can filter or add conditions. The SDK enforces these with the `EnforceDelegatedPuzzleWrappers` validator. @@ -247,8 +247,8 @@ than the number of items, since such a puzzle is either spendable by anyone or n ## Restricting what a member can do Restrictions are attached to a node via the `restrictions` argument of `mips_puzzle_hash` and -`InnerPuzzleSpend`. Delegated puzzle wrappers also need a spend in `MipsSpend::restrictions`, keyed by -the wrapper's puzzle hash. +`InnerPuzzleSpend`. Each restriction (validator or wrapper) also needs a spend in +`MipsSpend::restrictions`, keyed by its puzzle hash. Here the vault's only member is wrapped in `PreventConditionOpcode`, so it can't create coin announcements. A normal spend still works, but one that tries to output `CREATE_COIN_ANNOUNCEMENT` diff --git a/docs/sdk/testing/full-node-simulator.md b/docs/sdk/testing/full-node-simulator.md index a701a6c..68dc6c9 100644 --- a/docs/sdk/testing/full-node-simulator.md +++ b/docs/sdk/testing/full-node-simulator.md @@ -153,7 +153,7 @@ The mempool follows the full node's rules: and the spend can be fast-forwarded, the simulator rewrites it to spend the singleton's latest coin. `farm_block(n)` farms `n` blocks (at least one). Each block includes every mempool item that's still -valid and doesn't conflict with one already included, and returns its `BlockRecord`. +valid and doesn't conflict with one already included, and it returns the new blocks' `BlockRecord`s. ```rust use chia_wallet_sdk::chia::consensus::validation_error::ErrorCode; @@ -322,7 +322,7 @@ the feature turned on. The example below is not compiled as part of these docs f ```toml [dev-dependencies] -chia-sdk-test = { version = "0.36", features = ["full-node-simulator-http"] } +chia-sdk-test = { version = "0.37", features = ["full-node-simulator-http"] } ``` ::: diff --git a/docs/sdk/testing/index.md b/docs/sdk/testing/index.md index 41dc224..2313fe6 100644 --- a/docs/sdk/testing/index.md +++ b/docs/sdk/testing/index.md @@ -291,9 +291,9 @@ fn timelocked_spend() -> anyhow::Result<()> { Relative timelocks (`assert_height_relative`, `assert_seconds_relative`) are measured from the block the coin was created in. A coin made with `new_coin` or `bls` belongs to the next block, which doesn't -exist until a transaction or `create_block` creates it. Call `sim.create_block()` after creating a coin -whose relative timelock you want to test, or the spend fails with -`ErrorCode::EphemeralRelativeCondition`. +exist until a transaction or `create_block` creates it, so it has no creation timestamp yet. Call +`sim.create_block()` after creating a coin whose relative seconds timelock you want to test, or the +spend fails with `ErrorCode::EphemeralRelativeCondition`. ## Looking up state @@ -406,7 +406,7 @@ and compare or restore it afterwards. With the `serde` feature of `chia-sdk-test tree hash. `to_puzzle(1)` gives the puzzle `1`, which returns its solution as its conditions, so anyone can spend it. It's the quickest way to make a coin for a test that isn't about signatures. - `debug_announcements(&coin_spends)` prints every announcement the spends create and assert, and - panics if an asserted announcement isn't created. Use it to debug `AssertCoinAnnouncementFailed` and + panics if an asserted announcement isn't created or a created one isn't asserted. Use it to debug `AssertCoinAnnouncementFailed` and similar errors. ```rust @@ -489,7 +489,7 @@ The simulator models consensus validation, not a network: competition, replacement by fee or deduplication. Use the [full node simulator](/sdk/testing/full-node-simulator) to test those. - There are no reorgs, block rewards or farming. -- Header hashes are random, not real block hashes, and there are no block records. +- Header hashes are generated by the simulator, not real block hashes, and there are no block records. - The maximum cost per transaction is half the block limit (5,500,000,000), matching the mempool's limit for a single transaction. diff --git a/docs/sdk/testing/peer-simulator.md b/docs/sdk/testing/peer-simulator.md index 60ae3a3..fd41834 100644 --- a/docs/sdk/testing/peer-simulator.md +++ b/docs/sdk/testing/peer-simulator.md @@ -19,7 +19,7 @@ usually only needed in tests, so enable it in `dev-dependencies`: ```toml [dev-dependencies] -chia-wallet-sdk = { version = "0.36", features = ["peer-simulator"] } +chia-wallet-sdk = { version = "0.37", features = ["peer-simulator"] } tokio = { version = "1", features = ["full"] } ``` @@ -239,7 +239,7 @@ The simulator logs an error and closes that connection, so the request never get | ------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | | `genesis_challenge` | testnet11's genesis challenge | The header hash to pass with `previous_height: None` in state requests | | `max_subscriptions` | 200,000 | Subscriptions allowed per peer before requests are rejected with `ExceededSubscriptionLimit` | -| `max_response_coins` | 100,000 | Maximum coin states returned by `request_coin_state` | +| `max_response_coins` | 100,000 | Maximum coin states `request_puzzle_state` considers for one response | | `puzzle_state_batch_size` | 30,000 | Above this many coin states, `request_puzzle_state` splits the response into pages | `reset()` replaces the simulator state and all subscriptions with fresh ones, without restarting the From 34cc1224784a81ee23a56208301ac54929fa8903 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Nov 2026 23:41:09 -0500 Subject: [PATCH 3/7] Document unreleased Wallet SDK features from main --- docs/sdk/bindings.md | 5 + docs/sdk/clear-signing.md | 339 +++++++++++++++++- docs/sdk/concepts/layers.md | 49 +-- docs/sdk/index.md | 18 + docs/sdk/offers.md | 148 +++++++- docs/sdk/primitives/action-layer.md | 27 +- docs/sdk/primitives/auctions.md | 488 ++++++++++++++++++++++++++ docs/sdk/primitives/fusion.md | 459 ++++++++++++++++++++++++ docs/sdk/primitives/index.md | 12 + docs/sdk/primitives/partial-offers.md | 407 +++++++++++++++++++++ docs/sdk/primitives/secure-the-bag.md | 349 ++++++++++++++++++ docs/sdk/primitives/splitter.md | 232 ++++++++++++ docs/sdk/primitives/vaults.md | 242 +++++++++++++ sidebars.ts | 19 + 14 files changed, 2759 insertions(+), 35 deletions(-) create mode 100644 docs/sdk/primitives/auctions.md create mode 100644 docs/sdk/primitives/fusion.md create mode 100644 docs/sdk/primitives/partial-offers.md create mode 100644 docs/sdk/primitives/secure-the-bag.md create mode 100644 docs/sdk/primitives/splitter.md diff --git a/docs/sdk/bindings.md b/docs/sdk/bindings.md index 24be3e4..bc3c30a 100644 --- a/docs/sdk/bindings.md +++ b/docs/sdk/bindings.md @@ -277,6 +277,11 @@ asyncio.run(main()) The Python package has the same networking classes as Node.js: `RpcClient`, `Peer`, `Connector`, `Certificate` and `FullNodeSimulator.start_server`. +On the SDK's `main` branch (unreleased), the bindings also include partial offers, auctions, the +splitter, NFT fusion, Secure the Bag and vault signed message verification, with the same names as the +Rust types (for example `PartialOffer`, `Auction`, `Fusion` and `SecureTheBag`). The binding packages +enable every feature, so there's nothing to turn on. + ## What isn't in the bindings The bindings cover building, signing, parsing and validating spends for every primitive, offers, the diff --git a/docs/sdk/clear-signing.md b/docs/sdk/clear-signing.md index 78a7cd0..b413673 100644 --- a/docs/sdk/clear-signing.md +++ b/docs/sdk/clear-signing.md @@ -20,6 +20,13 @@ asserting that it's spent), and only if the coin's conditions can't be changed w that commitment. Anything that could be swapped out by whoever assembles the final spend bundle is either left out or causes an error. +:::note Unreleased +The SDK's `main` branch (not in a release yet; the latest release is 0.37.1) adds several clear signing +features: signed messages, vaults controlled by other vaults, Secure the Bag payments and expired +clawback push-throughs. It's also stricter in a few places. These are marked "On `main`" below. +Everything else describes 0.37.1, which `main` still does the same way. +::: + ## How it works 1. The vault's delegated spend must be a quoted list of conditions (the format produced by @@ -38,6 +45,20 @@ either left out or causes an error. 5. Settlement payments that the transaction asserts are matched against revealed requested payments, which is how offers are summarized. +On `main` (unreleased), there are a few differences: + +- The vault must be recreated with an amount of 1 (`VAULT_AMOUNT`), or parsing fails with + `InvalidVaultAmount`. Clear signing doesn't know the vault coin's actual amount, so if the vault could + have more, recreating it with 1 would hide that the rest goes to the fee. +- A revealed spend that can't be parsed is ignored if nothing links it to the vault. On 0.37.1 any + unparsable revealed spend fails the whole transaction. On `main` the error is still returned if the spend + receives a vault message or the transaction asserts it. +- If the delegated spend authorizes a revealed controlled vault, or is a message envelope, it's parsed + as described in [Vaults controlled by other vaults](#vaults-controlled-by-other-vaults) and + [Signed messages](#signed-messages). +- Coins that are visited before anything asserts their spend are visited again once a later spend + asserts them. The result no longer depends on the order the spends are traversed in. + ## Example This example builds a vault with a single BLS member, has the vault spend one of its XCH coins to pay @@ -226,11 +247,21 @@ p2 spend (see [Custom Spends](/sdk/action-system/custom-spends)), then pass the | `reveal_clawback` | A `ClawbackV2` p2 puzzle, so spends of coins locked in it can be recognized | | `reveal_p2_conditions_or_singleton` | A `P2ConditionsOrSingleton`, so children locked to it are recognized as offer pre-splits | | `reveal_vault_nonce` | An extra `P2Singleton` nonce the vault uses (nonce 0 is always included) | +| `reveal_bag` (`main`) | A `SecureTheBag`, so a coin created with its root is shown as the bag's payments | +| `reveal_signing_vault` (`main`) | The current custody hash (and optionally the coin) of the vault being signed for | +| `reveal_controlled_vault` (`main`) | The spend of a vault controlled by the vault being signed for (`RevealedControlledVault`) | Coins created by the transaction are only recognized as returning to the vault if their puzzle hash is one of the vault's p2 puzzle hashes. These are calculated from the revealed nonces and reported in `VaultTransaction::p2_puzzle_hashes`. +On `main`, `reveal_signing_vault(custody_hash, coin)` tells the parser the signing vault's current +custody hash. Clear signing can't look it up, so it's only checked by the signature. Calculate the +message that the members sign (`calculate_vault_puzzle_message` or `calculate_vault_coin_message`) from +the returned `VaultTransaction::signing_vault`, not from anything else in the request. Then a wrong +custody hash or coin gives a signature that can't spend the vault. The coin, if given, must have the +vault's puzzle hash (`WrongSigningVaultPuzzleHash`) and an amount of 1. + ## Output: `VaultTransaction` | Field | Meaning | @@ -247,11 +278,23 @@ one of the vault's p2 puzzle hashes. These are calculated from the revealed nonc | `reserved_fee` | Total `RESERVE_FEE` of the vault and its verified spends | | `delegated_puzzle_hash` | The hash the signers are signing | +On `main`, `VaultTransaction` also has: + +| Field | Meaning | +| -------------------- | ----------------------------------------------------------------------------------------------------- | +| `launcher_id` | The vault whose transaction it is: the signing vault, or the last vault in `authorization_path` | +| `custody_hash` | That vault's current custody hash, if known. Compare it with `vault_child` to detect a custody change | +| `signed_messages` | The messages signed by a message envelope, in order. `signs_messages()` is true if there are any | +| `authorization_path` | The controlled vaults that the signing vault authorizes, down to the vault whose transaction it is | +| `signing_vault` | The `SigningVault` (custody hash, puzzle hash, coin) revealed with `reveal_signing_vault` | + Each child (`ParsedChild`) has a `transfer_type`: `Sent`, `Burned` (sent to the burn puzzle hash), `Offered` (sent to the settlement puzzle), or `OfferPreSplit` (locked into a revealed `P2ConditionsOrSingleton`). Its `memos` field (`ParsedMemos`) has the effective recipient p2 puzzle hash, a decoded `ClawbackV2` if present, any human-readable memos, and the fixed conditions of an offer -pre-split. +pre-split. On `main` there's also `SecureTheBag` (see [below](#secure-the-bag-payments)). A child with a +clawback is always `Sent`. Its p2 puzzle hash is the receiver's, but the sender can still claw it back, so +it isn't shown as burned, offered, an offer pre-split or a bag. To validate a custody change, compare `vault_child.custody_hash` against a [`MipsMemo`](/sdk/primitives/vaults#recovering-a-vaults-configuration-from-memos) for the new configuration. @@ -265,6 +308,277 @@ the clawback expires. The parser requires the vault to commit to an expiration `DriverError::UnguaranteedClawBack`, since a transaction that could be held and replayed after the timeout would do something different. +On `main` (unreleased), clawback spends are handled more carefully. A vault message is received by a +coin, not by a puzzle, so another path of the same clawback could consume the message without outputting +the conditions it commits to. A clawback spend's conditions (its fees, announcement and concurrent spend +assertions) are therefore only trusted once the transaction's time bounds rule out the other paths: + +- **Sender path**: trusted once the transaction expires no later than the clawback's timeout. If a spend + found later in the traversal lowers the expiration, the sender spend is trusted after the fact. + Without that, it still fails with `UnguaranteedClawBack`. +- **Receiver path** with a vault message: the transaction must also be unable to be valid before the + timeout. That means the vault (or a message-bound spend that isn't a clawback) asserts + `ASSERT_SECONDS_ABSOLUTE` of at least the clawback's `seconds`. Otherwise parsing fails with + `UnguaranteedClawbackExpiry`. A receiver spend without a vault message is only shown if the clawback + has expired in the same sense. +- **Push-through**: once a clawback has expired, anyone can push it through to the receiver without a + signature. If the vault asserts the clawback coin's spend (`ASSERT_CONCURRENT_SPEND`) and that the + clawback has expired (`ASSERT_SECONDS_ABSOLUTE`), the push-through is shown as a verified spend with + `ClawbackPath::PushThrough`. Its conditions come from the clawback, not the revealed solution. Its + children are followed like any other, and the coin isn't necessarily the vault's. + +The time bounds only come from the vault's own conditions and from message-bound spends that aren't +clawbacks, never from clawback spends themselves. + +```rust unreleased +use chia_wallet_sdk::driver::{ + ClawbackInfo, ClawbackPath, ClawbackV2, InnerPuzzleSpend, MipsSpend, P2Singleton, Reveals, + mips_puzzle_hash, parse_vault_transaction, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::BlsMember; + +/// The vault's delegated spend asserts the clawback's spend, and optionally that it has expired. +fn vault_delegated_spend( + ctx: &mut SpendContext, + vault: &Vault, + clawback_coin: Coin, + earliest_time: Option, +) -> anyhow::Result { + let mut conditions = Conditions::new() + .assert_concurrent_spend(clawback_coin.coin_id()) + .create_coin(vault.info.custody_hash.into(), vault.coin.amount, Memos::None); + + if let Some(time) = earliest_time { + conditions = conditions.assert_seconds_absolute(time); + } + + Ok(ctx.delegated_spend(conditions)?) +} + +#[test] +fn clear_sign_push_through() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let member = BlsMember::new(alice.pk); + let custody_hash = mips_puzzle_hash(0, vec![], member.curry_tree_hash(), true); + let (conditions, vault) = + Launcher::new(alice.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // The vault sent Bob a clawback that expires at 1000 seconds. + let p2 = P2Singleton::new(vault.info.launcher_id, 0); + let bob = Bytes32::new([1; 32]); + let clawback = ClawbackV2::new(p2.tree_hash().into(), bob, 1000, 100, false); + let clawback_coin = sim.new_coin(clawback.tree_hash().into(), 100); + sim.set_next_timestamp(2000)?; + + // Anyone can push it through to Bob now, and the vault asserts that. + clawback.push_through_coin_spend(ctx, clawback_coin)?; + let push_through = ctx.take(); + + let mut reveals = Reveals::from_coin_spends(ctx, &push_through)?; + reveals.reveal_clawback(clawback); + + // Without asserting that the clawback expired, the sender could claw it back instead. + let delegated_spend = vault_delegated_spend(ctx, &vault, clawback_coin, None)?; + let tx = parse_vault_transaction(reveals.clone(), ctx, vault.info.launcher_id, delegated_spend)?; + assert!(tx.spends.is_empty()); + + // Asserting the expiration rules out the sender path, so the push-through is shown. + let delegated_spend = vault_delegated_spend(ctx, &vault, clawback_coin, Some(1000))?; + let tx = parse_vault_transaction(reveals, ctx, vault.info.launcher_id, delegated_spend)?; + assert_eq!(tx.spends.len(), 1); + assert_eq!( + tx.spends[0].clawback, + Some(ClawbackInfo::new(clawback, ClawbackPath::PushThrough)) + ); + assert_eq!(tx.spends[0].children[0].memos.p2_puzzle_hash, bob); + + // Sign and submit it. + let mut spend = MipsSpend::new(delegated_spend); + let member_puzzle = ctx.curry(member)?; + spend.members.insert( + custody_hash, + InnerPuzzleSpend::new(0, vec![], Spend::new(member_puzzle, NodePtr::NIL)), + ); + vault.spend(ctx, &spend)?; + let coin_spends = [push_through, ctx.take()].concat(); + sim.spend_coins(coin_spends, &[alice.sk])?; + + assert_eq!(sim.unspent_coins(bob, false).len(), 1); + + Ok(()) +} +``` + +## Secure the Bag payments + +On `main` (unreleased), a vault can fund a [Secure the Bag](/sdk/primitives/secure-the-bag). Without +help, the root coin looks like a payment to an unknown puzzle hash. Reveal the bag with +`Reveals::reveal_bag(bag)` and a child created with the bag's root hash and the same asset (including the +CAT asset id and a revocable CAT's hidden puzzle hash) gets `TransferType::SecureTheBag(SecureTheBagInfo)`. +The child must also have exactly the bag's total amount. `SecureTheBagInfo` has the bag's format and its +payments as `ParsedBagPayment`s. Each one is `Burned` (to the burn puzzle hash) or `Sent`. A payment to +the settlement puzzle is `Sent` too, since no offer in the transaction can settle a coin that only exists +once the bag is unrolled. + +The root counts as one output that leaves the vault in `asset_flows`, even if some payments go back +to the vault, since they're only paid when someone unrolls the bag. Compare the payments' puzzle hashes +with `p2_puzzle_hashes` to show which ones return to the vault. A Legacy bag with a single payment and no +clawback has no nodes, so `reveal_bag` ignores it and the payment is shown as a normal child. + +```rust unreleased +use chia_wallet_sdk::driver::{ + BURN_PUZZLE_HASH, BagAsset, BagFormat, BagPayment, InnerPuzzleSpend, MipsSpend, P2Singleton, + Reveals, SecureTheBag, TransferType, mips_puzzle_hash, parse_vault_transaction, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::{MessageFlags, MessageSide, puzzles::BlsMember}; + +#[test] +fn clear_sign_secure_the_bag() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1); + let member = BlsMember::new(alice.pk); + let custody_hash = mips_puzzle_hash(0, vec![], member.curry_tree_hash(), true); + let (conditions, vault) = + Launcher::new(alice.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + let p2 = P2Singleton::new(vault.info.launcher_id, 0); + let p2_puzzle_hash: Bytes32 = p2.tree_hash().into(); + let coin = sim.new_coin(p2_puzzle_hash, 1000); + + // Pay Bob and Carol, burn some, and send the rest back to the vault. + let bag = SecureTheBag::build( + BagFormat::Legacy { leaf_width: Some(2), sorted: true }, + BagAsset::Xch, + [ + BagPayment::new(Bytes32::new([1; 32]), 300), + BagPayment::new(Bytes32::new([2; 32]), 200), + BagPayment::new(BURN_PUZZLE_HASH, 250), + BagPayment::new(p2_puzzle_hash, 250), + ], + )?; + + // The vault authorizes its coin to fund the bag's root. + let hint = ctx.hint(bag.root_hash())?; + let delegated = ctx.delegated_spend(Conditions::new().create_coin( + bag.root_hash(), + bag.total_amount(), + hint, + ))?; + let delegated_puzzle_hash = ctx.tree_hash(delegated.puzzle); + let spend = p2.spend(ctx, vault.info.custody_hash.into(), vault.coin.amount, delegated)?; + ctx.spend(coin, spend)?; + + let mode = MessageFlags::PUZZLE.encode(MessageSide::Sender) + | MessageFlags::COIN.encode(MessageSide::Receiver); + let coin_id = ctx.alloc(&coin.coin_id())?; + let vault_delegated = ctx.delegated_spend( + Conditions::new() + .send_message(mode, delegated_puzzle_hash.to_vec().into(), vec![coin_id]) + .create_coin(vault.info.custody_hash.into(), vault.coin.amount, Memos::None), + )?; + + let mut mips = MipsSpend::new(vault_delegated); + let member_puzzle = ctx.curry(member)?; + mips.members.insert( + custody_hash, + InnerPuzzleSpend::new(0, vec![], Spend::new(member_puzzle, NodePtr::NIL)), + ); + vault.spend(ctx, &mips)?; + let coin_spends = ctx.take(); + + let mut reveals = Reveals::from_coin_spends(ctx, &coin_spends)?; + reveals.reveal_bag(bag.clone()); + let tx = parse_vault_transaction(reveals, ctx, vault.info.launcher_id, vault_delegated)?; + + let child = &tx.spends[0].children[0]; + let TransferType::SecureTheBag(info) = &child.transfer_type else { + panic!("expected a bag"); + }; + assert_eq!(info.payments.len(), 4); + + let burned = info + .payments + .iter() + .filter(|payment| payment.transfer_type == TransferType::Burned) + .count(); + assert_eq!(burned, 1); + + // One payment goes back to the vault once the bag is unrolled. + let returned = info + .payments + .iter() + .filter(|payment| tx.p2_puzzle_hashes.contains(&payment.payment.puzzle_hash)) + .count(); + assert_eq!(returned, 1); + + sim.spend_coins(coin_spends, &[alice.sk])?; + + Ok(()) +} +``` + +## CAT issuance + +A verified CAT spend that runs its TAIL is reported in `issuances` as an `Issuance`, with the coin id, +asset id, `extra_delta` and `IssuanceKind`. The `extra_delta` comes from the spend's solution, which the +vault's message doesn't cover. On `main` (unreleased), `Issuance::verified` is true only if the vault also +sends a message that authorizes the extra delta (for a TAIL of kind `EverythingWithSingleton`). Only +verified extra deltas count toward the issued and melted amounts in `asset_flows`. An unverified one could +be different when the transaction is included in a block. + +## Signed messages + +On `main` (unreleased), a vault can [sign off-chain messages](/sdk/primitives/vaults#signed-messages) +with a `vault_message_envelope`. When the delegated spend is an envelope, `parse_vault_transaction` +returns the messages in `signed_messages` (and `signs_messages()` is true), and nothing else. The only +other condition is `vault_child`, which recreates the vault. If the signing vault's custody hash is +revealed, the vault must be recreated with exactly that custody hash and an amount of 1 +(`VaultMessageEnvelopeChangesVault`). The verifier requires this too. Messages can be at most 1,024 bytes +(`VaultMessageTooLong`). A delegated spend that sends messages to the message coin and does anything else +fails with `MixedVaultMessageEnvelope`, so a transaction can't pass as a message signature. + +## Vaults controlled by other vaults + +On `main` (unreleased), a vault's custody can include another vault as a singleton member, so one vault +(the controller) approves the spends of another. When the vault being signed for sends a message to a +vault that's revealed with `reveal_controlled_vault`, clear signing follows it down to that vault. That +vault can control another vault in turn, up to `MAX_CONTROLLED_VAULT_DEPTH` (8) vaults. The returned +transaction is the one of the vault at the end, and the vaults that authorize it are listed in +`authorization_path` as `VaultAuthorization`s. + +- Reveal each controlled vault with a `RevealedControlledVault`: its coin, launcher id, current custody + hash, its delegated spend, and a `ControlledVaultRecovery` if the spend goes through its recovery path. + The coin must have the singleton puzzle hash for the launcher id and custody hash + (`WrongControlledVaultPuzzleHash`) and an amount of 1. Also reveal its coin spend + (`reveal_coin_spend`), so its puzzle can be checked to be a vault. That stops an NFT the vault owns + from being passed off as a controlled vault (`InvalidControlledVaultPuzzle`). +- A controller can only recreate itself and send the controlled vault one message, its delegated puzzle + hash (`InvalidVaultControllerSpend`, `WrongControlledVaultMessage`). A controlled vault in the path + must be recreated unchanged (`VaultControllerChangesVault`). The signing vault must be too, if its + custody hash is revealed. Otherwise show its `controller_child` like a `vault_child`. +- If the controlled vault's member is fast-forwardable, the message is sent to its puzzle hash rather than + its coin id. That's only accepted for a revealed controlled vault, since any coin with that puzzle hash + could receive it. +- For message signing, each controller must use a `vault_controller_envelope` + (`InvalidVaultControllerEnvelope`), which also messages a coin that can never exist, so the controller's + spend can't be valid on chain either. +- Clear signing checks that a controlled vault is a vault, not that its custody really includes the + controller. Like the rest of clear signing, it assumes the vaults in the transaction are the user's. + +Use `reveal_vault_nonce` for the nonces of the vault at the end of the path, since that's the vault +whose coins are parsed. + ## Linked offers A vault can't sign an offer the way a normal wallet does, because its coin spends need a message from @@ -277,6 +591,14 @@ When the `P2ConditionsOrSingleton` puzzles are revealed, `VaultTransaction::link the future offer: its reserved fee and the payments it requests (split into ones paid back to the vault and external ones). For making and taking offers in general, see [Offers](/sdk/offers). +On `main` (unreleased), the action system builds offer takes that clear signing can verify when a vault +is the taker. Settlement coins return only their own surplus, and the rest of the change comes from the +taker's own coins. Set `ConditionConfig::return_unplaced_conditions` on the `Spends`. Then, if no coin +spend can emit the transaction-wide conditions (such as the assertions of the settlement payments, when +the vault spends none of its coins), they're returned in `Spends::unplaced_conditions` and no +intermediate coin is created. Add them to the vault's delegated spend. `finish_with_keys` rejects unplaced +conditions, since it has nowhere to put them. + ## Signing helpers `chia_wallet_sdk::driver` also exposes the messages that vault members sign, which is useful for @@ -304,4 +626,19 @@ Parsing is conservative, so many unusual but valid transactions are rejected. Co | `MissingP2ConditionsOrSingletonAssertion` | A vault spend of a `P2ConditionsOrSingleton` coin doesn't include `ASSERT_MY_COIN_ID` | | `UnguaranteedClawBack` | A clawback sender path is used without a matching expiration | +On `main` (unreleased), these are added: + +| Error | Cause | +| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `InvalidVaultAmount` | A vault is recreated, or revealed, with an amount other than 1 | +| `UnguaranteedClawbackExpiry` | A clawback receiver path is used without asserting that the clawback has expired | +| `MixedVaultMessageEnvelope` | A delegated spend signs messages and also does something else | +| `VaultMessageEnvelopeChangesVault` | A vault that signs messages, or authorizes another vault to, isn't recreated unchanged | +| `WrongSigningVaultPuzzleHash` | The revealed signing vault coin doesn't match its launcher id and custody hash | +| `WrongControlledVaultPuzzleHash` / `InvalidControlledVaultPuzzle` | A revealed controlled vault's coin or puzzle isn't that vault | +| `InvalidVaultControllerSpend` / `InvalidVaultControllerEnvelope` | A controller does more than authorize one controlled vault | +| `WrongControlledVaultMessage` | The controller's message doesn't match the controlled vault's delegated puzzle hash | +| `VaultControllerChangesVault` | A vault in the authorization path doesn't recreate itself unchanged | +| `DuplicateControlledVault` / `ControlledVaultTooDeep` | A controlled vault appears twice, or the path is longer than 8 vaults | + All of these are variants of `DriverError`. diff --git a/docs/sdk/concepts/layers.md b/docs/sdk/concepts/layers.md index 9f63a33..62798be 100644 --- a/docs/sdk/concepts/layers.md +++ b/docs/sdk/concepts/layers.md @@ -365,29 +365,32 @@ If you only need to curry a puzzle and don't need parsing, `ctx.curry(args)` and ## Built-in layers -| Layer | Puzzle | Typically wraps / used by | -| -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `StandardLayer` (alias `P2DelegatedOrHiddenLayer`) | `p2_delegated_puzzle_or_hidden_puzzle`, the standard XCH puzzle | Ownership of most coins; [Standard](/sdk/primitives/standard) | -| `P2DelegatedConditionsLayer` | `p2_delegated_conditions`: a key signs a list of conditions | Simple key ownership | -| `CatLayer` | CAT v2 | [CAT](/sdk/primitives/cat) | -| `RevocationLayer` | Revocation layer for revocable CATs | [CAT](/sdk/primitives/cat) | -| `SingletonLayer` | Singleton top layer v1.1 | [Singletons](/sdk/primitives/singletons), NFTs, DIDs, vaults | -| `NftStateLayer` | NFT state layer (metadata and updater) | [NFT](/sdk/primitives/nft) | -| `NftOwnershipLayer` | NFT ownership layer (current DID owner and transfer program) | [NFT](/sdk/primitives/nft) | -| `RoyaltyTransferLayer` | NFT royalty transfer program | [NFT](/sdk/primitives/nft) | -| `DidLayer` | DID inner puzzle (metadata and recovery) | [DID](/sdk/primitives/did) | -| `SettlementLayer` | `settlement_payments`, used by offers | [Offers](/sdk/offers) | -| `OptionContractLayer` | Option contract | [Option Contracts](/sdk/primitives/options) | -| `P2SingletonLayer` | Coin spendable only alongside a singleton | [Singletons](/sdk/primitives/singletons), [Clear Signing](/sdk/clear-signing) | -| `P2OneOfManyLayer` | Pick one of many delegated puzzles from a merkle root | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options), [MerkleTree](/sdk/utilities#merkle-trees) | -| `P2CurriedLayer` | Spend by revealing a puzzle that hashes to a curried puzzle hash | [Clawback](/sdk/primitives/clawback) | -| `AugmentedConditionLayer` | Adds a fixed condition to the inner puzzle's output | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options) | -| `StreamLayer` | Streamed payments | [Streamed Assets](/sdk/primitives/streaming) | -| `BulletinLayer` | Nonce wrapper that distinguishes bulletin coins | [Bulletins](/sdk/primitives/bulletin) | -| `DelegatedPuzzleFeederLayer`, `IndexWrapperLayer`, `SingletonMemberLayer` | Meta Inner Puzzle Spec (MIPS) building blocks | [Vaults & MIPS](/sdk/primitives/vaults) | -| `DelegationLayer`, `WriterLayer`, `OracleLayer` | DataLayer delegation (feature `chip-0035`) | [DataLayer](/sdk/primitives/datalayer) | -| `P2ControllerPuzzleLayer`, `P2Eip712MessageLayer` | Coins controlled by another puzzle or by an EIP-712 signature (feature `chip-0037`) | Ethereum-style (secp256k1) wallets | -| `ActionLayer`, `PrecommitLayer`, `StateSchedulerLayer`, `MOfNLayer`, `P2DelegatedBySingletonLayer`, `ConditionsLayer`, `P2ParentLayer` | Action layer singletons (feature `action-layer`) | [Action Layer](/sdk/primitives/action-layer) | +| Layer | Puzzle | Typically wraps / used by | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `StandardLayer` (alias `P2DelegatedOrHiddenLayer`) | `p2_delegated_puzzle_or_hidden_puzzle`, the standard XCH puzzle | Ownership of most coins; [Standard](/sdk/primitives/standard) | +| `P2DelegatedConditionsLayer` | `p2_delegated_conditions`: a key signs a list of conditions | Simple key ownership | +| `CatLayer` | CAT v2 | [CAT](/sdk/primitives/cat) | +| `RevocationLayer` | Revocation layer for revocable CATs | [CAT](/sdk/primitives/cat) | +| `SingletonLayer` | Singleton top layer v1.1 | [Singletons](/sdk/primitives/singletons), NFTs, DIDs, vaults | +| `NftStateLayer` | NFT state layer (metadata and updater) | [NFT](/sdk/primitives/nft) | +| `NftOwnershipLayer` | NFT ownership layer (current DID owner and transfer program) | [NFT](/sdk/primitives/nft) | +| `RoyaltyTransferLayer` | NFT royalty transfer program | [NFT](/sdk/primitives/nft) | +| `DidLayer` | DID inner puzzle (metadata and recovery) | [DID](/sdk/primitives/did) | +| `SettlementLayer` | `settlement_payments`, used by offers | [Offers](/sdk/offers) | +| `OptionContractLayer` | Option contract | [Option Contracts](/sdk/primitives/options) | +| `P2SingletonLayer` | Coin spendable only alongside a singleton | [Singletons](/sdk/primitives/singletons), [Clear Signing](/sdk/clear-signing) | +| `P2OneOfManyLayer` | Pick one of many delegated puzzles from a merkle root | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options), [MerkleTree](/sdk/utilities#merkle-trees) | +| `P2CurriedLayer` | Spend by revealing a puzzle that hashes to a curried puzzle hash | [Clawback](/sdk/primitives/clawback) | +| `AugmentedConditionLayer` | Adds a fixed condition to the inner puzzle's output | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options) | +| `StreamLayer` | Streamed payments | [Streamed Assets](/sdk/primitives/streaming) | +| `BulletinLayer` | Nonce wrapper that distinguishes bulletin coins | [Bulletins](/sdk/primitives/bulletin) | +| `DelegatedPuzzleFeederLayer`, `IndexWrapperLayer`, `SingletonMemberLayer` | Meta Inner Puzzle Spec (MIPS) building blocks | [Vaults & MIPS](/sdk/primitives/vaults) | +| `DelegationLayer`, `WriterLayer`, `OracleLayer` | DataLayer delegation (feature `chip-0035`) | [DataLayer](/sdk/primitives/datalayer) | +| `P2ControllerPuzzleLayer`, `P2Eip712MessageLayer` | Coins controlled by another puzzle or by an EIP-712 signature (feature `chip-0037`) | Ethereum-style (secp256k1) wallets | +| `ActionLayer`, `PrecommitLayer`, `StateSchedulerLayer`, `MOfNLayer`, `P2DelegatedBySingletonLayer`, `ConditionsLayer`, `P2ParentLayer` | Action layer singletons (feature `action-layer`) | [Action Layer](/sdk/primitives/action-layer) | +| `SplitterLayer` | CHIP-0008 splitter: divides a coin between fixed recipients (feature `chip-0008`, unreleased) | [Splitter](/sdk/primitives/splitter) | +| `FusionLayer` | CHIP-0021 fusion singleton inner puzzle (feature `chip-0021`, unreleased) | [NFT Fusion](/sdk/primitives/fusion) | +| `P2FusionLayer` | p2 puzzle of NFTs locked in a fusion (feature `chip-0021`, unreleased) | [NFT Fusion](/sdk/primitives/fusion) | Besides these, any type that implements both `ToClvm` and `FromClvm` is a layer, whose puzzle is the value itself and whose solution is a `NodePtr`. `Puzzle` is a layer too, as shown above. Both are useful as placeholder inner layers in tests and when parsing. diff --git a/docs/sdk/index.md b/docs/sdk/index.md index 5e73dc5..56210ef 100644 --- a/docs/sdk/index.md +++ b/docs/sdk/index.md @@ -49,6 +49,24 @@ chia-wallet-sdk = { version = "0.37", features = ["rustls"] } | `offer-compression` | Compressing and decompressing [offer](/sdk/offers) strings | | `peer-simulator` | The [peer simulator](/sdk/testing/peer-simulator), an in-process full node for networking tests | +The SDK's `main` branch adds four more feature flags that aren't in a release yet: + +| Feature | Enables | +| ----------- | ------------------------------------------------------------- | +| `chip-0008` | The [splitter](/sdk/primitives/splitter) puzzle (CHIP-0008) | +| `chip-0021` | [NFT fusion](/sdk/primitives/fusion) (CHIP-0021) | +| `chip-0052` | [Partial offers](/sdk/primitives/partial-offers) (CHIP-0052) | +| `auction` | [Auctions](/sdk/primitives/auctions) (implies `action-layer`) | + +To use them before they're released, depend on the git repository: + +```toml +[dependencies] +chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["rustls", "chip-0052"] } +``` + +Pages and sections that cover unreleased features say so at the top. + Everything else, including the [simulator](/sdk/testing), is always available. ## How the crate is organized diff --git a/docs/sdk/offers.md b/docs/sdk/offers.md index 4610410..0447dfe 100644 --- a/docs/sdk/offers.md +++ b/docs/sdk/offers.md @@ -9,6 +9,8 @@ An offer is a partially complete transaction that trades assets without trusting The SDK represents an offer as [`Offer`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/struct.Offer.html), and both sides are built with the [action system](/sdk/action-system), which is how the SDK's own tests make and take offers. The offer types are in the prelude; the royalty helpers and compression functions are in `chia_wallet_sdk::driver`. +For selling one fungible asset for another in any amount at a fixed price, see [Partial Offers](/sdk/primitives/partial-offers) (CHIP-0052, unreleased), which can be filled many times and are taken as ordinary offers. + ## How offers work - **Settlement payments puzzle.** `SETTLEMENT_PAYMENT_HASH` (in `chia_wallet_sdk::puzzles`) is a puzzle that anyone can spend, but that can only make notarized payments. For each notarized payment it makes, it creates a puzzle announcement of the payment's hash. Offered assets are sent to this puzzle (inside their CAT, NFT or option layers), and requested payments are made by it. @@ -46,6 +48,12 @@ To take an offer: 4. Apply `offer.requested_payments().actions()`, which is an `Action::settle` for each requested notarized payment, plus anything else you want (a fee, royalties). The offered assets go to your change puzzle hash, unless you send them elsewhere. 5. Finish and sign, then combine your spend bundle with the maker's with `offer.take(spend_bundle)`. +:::note Unreleased +On the SDK's `main` branch, change is split by who has value left over when you take an offer. Settlement coins return whatever they have left after their payments, and the rest of the change is created by one of your own coins' conditions spends, rather than by the first settlement coin. Usually that means a single change coin, from your own coin, while the offered amount is still paid and asserted by the settlement coins. + +`main` also adds `ConditionConfig::return_unplaced_conditions` (`spends.conditions.return_unplaced_conditions = true`). When none of the spends can emit the transaction-wide conditions (for example, a vault taking a one-sided offer without spending any of its own coins), they're returned in `Spends::unplaced_conditions` instead of being emitted by an intermediate coin, so the caller can emit them from its own spend, such as a vault's delegated spend. `finish_with_keys` fails with `DriverError::CannotEmitConditions` if any are left. Together these make a vault's offer take something [clear signing](/sdk/clear-signing) can verify. +::: + Here's a complete trade of a CAT for XCH, including encoding the offer as a string and parsing it on the taker's side: ```rust @@ -163,6 +171,7 @@ Before taking an offer (or showing it to a user), look at what it does: - `offer.arbitrage()`: an `Arbitrage` with `offered` and `requested` sides, each an `ArbitrageSide` with `xch`, `cats`, `nfts` and `options`. `requested` is what the offer gives you beyond what it asks for of the same asset, and `offered` is what you need to supply. - `offer.fee()`: the fee the maker's spends leave for the farmer, as an `i128` (negative if the spends create more than they spend). `offered_coins().fee` is the fee they asserted with `RESERVE_FEE`. - `offer.offered_royalties()` and `offer.requested_royalties()`: the `RoyaltyInfo` of requested and offered NFTs (see below). +- On `main` (unreleased), `offered_coins().nft_trade_prices` has the trade prices each offered NFT revealed when it was spent into settlement, and `offered_coins().prepaid_royalties` has the royalty payments the maker's settlement spends made up front for requested NFTs. To cancel an offer you made, spend any of the coins it spends. `offer.cancellable_coin_spends()` returns the maker's spends of coins that weren't created within the offer, which are the coins to spend. Several offers can be combined into one with `offer.extend(other)`, as long as they don't spend the same coins (`DriverError::ConflictingOfferInputs`). @@ -170,7 +179,7 @@ To cancel an offer you made, spend any of the coins it spends. `offer.cancellabl NFTs with a royalty require a royalty payment whenever they're traded through an offer. The NFT's transfer program asserts that, for each trade price it's told about, the royalty was paid by a settlement coin, with the NFT's launcher id as the nonce. A trade price is an amount and the settlement puzzle hash of the asset it's paid in (`TradePrice`). -When several royalty NFTs are traded together, the price is split evenly between them: +The maker of an offer that sells royalty NFTs splits the requested price evenly between them when it commits to trade prices, and a maker bidding for royalty NFTs splits the offered price the same way when it pays their royalties up front. These helpers do the math: | Function | What it does | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -183,6 +192,16 @@ When several royalty NFTs are traded together, the price is split evenly between There are two cases, depending on which side the NFT is on. +In 0.37.1, `offer.requested_royalty_amounts()` and `offer.offered_royalty_amounts()` are computed the same way, by splitting the offer's requested (or offered) totals evenly between its royalty NFTs. That's correct for a single maker's offer, but not for aggregated offers whose makers committed to different trade prices. + +:::note Unreleased +On the SDK's `main` branch, royalties come from what's actually in the offer rather than an even split: + +- **Offered NFTs.** `OfferCoins::nft_trade_prices` records the trade prices each offered NFT revealed when its maker spent it into settlement. `offer.requested_royalty_payments(&mut ctx)?` returns the royalty payment each NFT asserts for each of its trade prices, as `RequestedPayments` whose `.actions()` pay them, and `requested_royalty_amounts()` is their total. Identical royalties are only paid once, since one payment satisfies every identical assertion, and royalties the offer already prepaid are left out. Both fail with `DriverError::ZeroRoyaltyPayment(launcher_id)` if a trade price's royalty rounds down to zero (settlement can never pay it, so the offer can't be taken), `DriverError::UnknownSettlementAsset` if a trade price is in an asset the offer doesn't know, or `DriverError::AmountOverflow`. +- **Requested NFTs.** `OfferCoins::prepaid_royalties` holds the royalty payments the maker made up front, in the form an NFT's royalty assertion accepts (a single payment whose memos are exactly the royalty puzzle hash, notarized with the launcher id). `offered_royalty_amounts()` totals them, and `offer.requested_nft_trade_prices(launcher_id)` returns the trade prices the taker should reveal on the NFT they supply so that it asserts exactly those payments. +- **New helpers.** `payable_trade_prices(&trade_prices, royalty_basis_points)` drops trade prices whose royalty would round down to zero, so a maker never commits to an unpayable trade price (a small sale with only zero royalties can be taken without any royalty payment). `AssetInfo::settlement_puzzle_hash(asset_id)` and `settlement_asset_id(puzzle_hash)` convert between a CAT asset id (`None` for XCH) and its settlement puzzle hash, `OfferAmounts::add_amount(asset_id, amount)` adds to a total, and `RequestedPayments::fungible()`/`fungible_mut(asset_id)` iterate over or extend the XCH and CAT payments together. + ::: + ### Offering an NFT The maker offering an NFT tells it the trade prices with `Action::update_nft(nft_id, vec![], Some(TransferNftById::new(None, trade_prices)))`, computed from the requested amounts. The taker pays the royalty from what they're paying anyway: `offer.requested_royalty_amounts()?` is the total, and `Action::settle_royalty` pays each NFT's royalty. If the taker doesn't pay it, the NFT's spend fails and the offer can't be taken. @@ -297,11 +316,130 @@ fn nft_for_xch_with_royalty() -> anyhow::Result<()> { } ``` -With several royalty NFTs, compute the royalty for each one separately (`calculate_nft_royalty(trade_price, royalty.basis_points)` with the per-NFT trade price), and pay one `settle_royalty` per NFT and asset. +With several royalty NFTs on 0.37.1, compute the royalty for each one separately (`calculate_nft_royalty(trade_price, royalty.basis_points)` with the per-NFT trade price), and pay one `settle_royalty` per NFT and asset. + +On `main` (unreleased), the maker should filter each NFT's trade prices with `payable_trade_prices`, and the taker should pay `offer.requested_royalty_payments(&mut ctx)?.actions()` instead of computing royalties itself. That's what makes aggregated offers work: here two makers sell NFTs with different royalties at different prices, and the taker of the combined offer pays each royalty on the price its NFT committed to, rather than on half of the total: + +```rust unreleased +use chia_wallet_sdk::chia::puzzle_types::{ + Memos, + offer::{NotarizedPayment, Payment}, +}; +use chia_wallet_sdk::driver::{ + TransferNftById, calculate_trade_price_amounts, calculate_trade_prices, payable_trade_prices, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::test::sign_transaction; +use indexmap::indexmap; + +/// Mints an NFT with a royalty, and returns the offer spend bundle that sells it for `price` mojos. +fn sell_nft( + sim: &mut Simulator, + ctx: &mut SpendContext, + royalty_puzzle_hash: Bytes32, + basis_points: u16, + price: u64, +) -> anyhow::Result { + let maker = sim.bls(1); + let keys = indexmap! { maker.puzzle_hash => maker.pk }; + let hint = ctx.hint(maker.puzzle_hash)?; + + let mut spends = Spends::new(maker.puzzle_hash); + spends.add(maker.coin); + let deltas = + spends.apply(ctx, &[Action::mint_empty_royalty_nft(royalty_puzzle_hash, basis_points)])?; + let outputs = spends.finish_with_keys(ctx, &deltas, Relation::None, &keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&maker.sk))?; + let nft = outputs.nfts[&Id::New(0)]; + let nft_id = Id::Existing(nft.info.launcher_id); + + let mut requested_payments = RequestedPayments::new(); + requested_payments.xch.push(NotarizedPayment::new( + Offer::nonce(vec![nft.coin.coin_id()]), + vec![Payment::new(maker.puzzle_hash, price, hint)], + )); + let asset_info = AssetInfo::new(); + + // Commit to the trade price, leaving out any whose royalty would round down to zero + let trade_prices = calculate_trade_prices( + &calculate_trade_price_amounts(&requested_payments.amounts(), 1), + &asset_info, + )?; + let trade_prices = payable_trade_prices(&trade_prices, basis_points); + + let mut spends = Spends::new(maker.puzzle_hash); + spends.add(nft); + spends.conditions.required = spends + .conditions + .required + .extend(requested_payments.assertions(ctx, &asset_info)?); + let deltas = spends.apply( + ctx, + &[ + Action::update_nft(nft_id, vec![], Some(TransferNftById::new(None, trade_prices))), + Action::send(nft_id, SETTLEMENT_PAYMENT_HASH.into(), 1, Memos::None), + ], + )?; + spends.finish_with_keys(ctx, &deltas, Relation::AssertConcurrent, &keys)?; + + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[maker.sk])?; + let offer = Offer::from_input_spend_bundle( + ctx, + SpendBundle::new(coin_spends, signature), + requested_payments, + asset_info, + )?; + Ok(offer.to_spend_bundle(ctx)?) +} + +#[test] +fn aggregated_royalties() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let royalty_a = Bytes32::new([1; 32]); + let royalty_b = Bytes32::new([2; 32]); + + // A 5% royalty NFT for 10,000 mojos, and a 10% royalty NFT for 30,000 mojos + let first = sell_nft(&mut sim, ctx, royalty_a, 500, 10_000)?; + let second = sell_nft(&mut sim, ctx, royalty_b, 1_000, 30_000)?; + let offer = Offer::from_spend_bundle(ctx, &SpendBundle::aggregate(&[first, second]))?; + + // 5% of 10,000 plus 10% of 30,000, rather than royalties on 20,000 each + assert_eq!(offer.requested_royalty_amounts()?.xch, 500 + 3_000); + + let taker = sim.bls(43_500); + let mut spends = Spends::new(taker.puzzle_hash); + spends.add(offer.offered_coins().clone()); + spends.add(taker.coin); + + let mut actions = offer.requested_payments().actions(); + actions.extend(offer.requested_royalty_payments(ctx)?.actions()); + + let deltas = spends.apply(ctx, &actions)?; + spends.finish_with_keys( + ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { taker.puzzle_hash => taker.pk }, + )?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[taker.sk])?; + sim.new_transaction(offer.take(SpendBundle::new(coin_spends, signature)))?; + + assert_eq!(sim.unspent_coins(royalty_a, false)[0].amount, 500); + assert_eq!(sim.unspent_coins(royalty_b, false)[0].amount, 3_000); + assert!(sim.unspent_coins(taker.puzzle_hash, false).is_empty()); + + Ok(()) +} +``` ### Requesting an NFT -When the maker requests an NFT, they pay its royalty up front, since the taker can't be trusted to. The maker adds `NftAssetInfo` for the NFT (which includes its royalty), and adds the royalty payments with `calculate_royalty_payments(...)?.actions()` alongside the offered XCH. The taker reveals the trade prices to the NFT when they spend it, with `update_nft`, computed from the offered amounts: +When the maker requests an NFT, they pay its royalty up front, since the taker can't be trusted to. The maker adds `NftAssetInfo` for the NFT (which includes its royalty), and adds the royalty payments with `calculate_royalty_payments(...)?.actions()` alongside the offered XCH. The taker reveals the trade prices to the NFT when they spend it, with `update_nft`. On 0.37.1, compute them from the offered amounts as below. On `main` (unreleased), use `offer.requested_nft_trade_prices(launcher_id)` instead, which derives them from the royalties the maker actually prepaid, so they match even when bids are aggregated or a bid requests several NFTs: ```rust use chia_wallet_sdk::chia::puzzle_types::{ @@ -426,6 +564,8 @@ fn xch_for_royalty_nft() -> anyhow::Result<()> { } ``` +On `main`, `offer.offered_royalty_amounts()` in this example is the 30 mojos the maker prepaid, read from its settlement spend, rather than a recomputed even split, and the taker could replace its `calculate_trade_prices(...)` call with `offer.requested_nft_trade_prices(launcher_id)` (which returns the same 1000 mojo trade price here). + The royalty math on its own: ```rust @@ -472,6 +612,8 @@ Offers are shared as bech32m strings with the `offer` prefix, containing a zlib- | `decompress_offer(&bytes)` | Compressed bytes to `SpendBundle` | | `compress_offer_bytes` / `decompress_offer_bytes` | Raw serialized bytes, without parsing | +On `main` (unreleased), the `chip-0052` feature adds `encode_partial_offer` and `decode_partial_offer`, which use the same compression with the `partial` prefix (see [Partial Offers](/sdk/primitives/partial-offers)). + Decompression supports every version of the format the reference wallet has used, and output is capped at 6 MB (`DriverError::DecompressionTooLarge`), matching chia-blockchain. Encode the result of `offer.to_spend_bundle(&mut ctx)?`, not the maker's input spend bundle, since the offer file needs the requested payments. ## Security considerations diff --git a/docs/sdk/primitives/action-layer.md b/docs/sdk/primitives/action-layer.md index d2171b3..cd9bcbd 100644 --- a/docs/sdk/primitives/action-layer.md +++ b/docs/sdk/primitives/action-layer.md @@ -19,6 +19,9 @@ coins and reserves. All of it is behind the `action-layer` feature, which also e chia-wallet-sdk = { version = "...", features = ["action-layer"] } ``` +On `main` (unreleased), the SDK also has an NFT [auction](/sdk/primitives/auctions) built on the action +layer, behind the `auction` feature (which implies `action-layer`). + :::note This is unrelated to the SDK's [action system](/sdk/action-system) (`Action`, `Spends`), which is a transaction builder. The action layer is an on-chain puzzle pattern. @@ -67,13 +70,14 @@ To follow a singleton on chain, use `from_parent_spend` / `from_spend` on its co ## Applications -| Application | Types | What it does | -| ------------------ | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| CATalog | `CatalogRegistry`, `CatalogRegistryInfo`, `CatalogRegistryState`, `CatalogRegistryConstants` | A registry of CATs. Registering a CAT (by TAIL hash) mints an NFT that holds its metadata (`CatNftMetadata`). Each TAIL can only be registered once, enforced by slots | -| XCHandles | `XchandlesRegistry`, `XchandlesRegistryInfo`, `XchandlesRegistryState`, `XchandlesConstants` | A registry of handles (names) with expiration, priced by a pricing puzzle. Handles can be registered, extended, expired and re-registered, and updated | -| Reward distributor | `RewardDistributor`, `RewardDistributorInfo`, `RewardDistributorState`, `RewardDistributorConstants` | Distributes rewards (a CAT held in a `Reserve`) to entries over epochs. Entries are managed by a manager singleton, or are NFTs from a collection, NFTs from a curated DataLayer store, or staked CATs (`RewardDistributorType`) | -| Medieval vault | `MedievalVault`, `MedievalVaultInfo` | A plain m-of-n BLS multisig singleton, used to administer other singletons | -| State scheduler | `StateScheduler`, `StateSchedulerInfo` | A singleton that sends a pre-committed sequence of state updates to another singleton at given times (for example to schedule price changes) | +| Application | Types | What it does | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CATalog | `CatalogRegistry`, `CatalogRegistryInfo`, `CatalogRegistryState`, `CatalogRegistryConstants` | A registry of CATs. Registering a CAT (by TAIL hash) mints an NFT that holds its metadata (`CatNftMetadata`). Each TAIL can only be registered once, enforced by slots | +| XCHandles | `XchandlesRegistry`, `XchandlesRegistryInfo`, `XchandlesRegistryState`, `XchandlesConstants` | A registry of handles (names) with expiration, priced by a pricing puzzle. Handles can be registered, extended, expired and re-registered, and updated | +| Reward distributor | `RewardDistributor`, `RewardDistributorInfo`, `RewardDistributorState`, `RewardDistributorConstants` | Distributes rewards (a CAT held in a `Reserve`) to entries over epochs. Entries are managed by a manager singleton, or are NFTs from a collection, NFTs from a curated DataLayer store, or staked CATs (`RewardDistributorType`) | +| Medieval vault | `MedievalVault`, `MedievalVaultInfo` | A plain m-of-n BLS multisig singleton, used to administer other singletons | +| State scheduler | `StateScheduler`, `StateSchedulerInfo` | A singleton that sends a pre-committed sequence of state updates to another singleton at given times (for example to schedule price changes) | +| Auction (unreleased, `auction` feature) | `Auction`, `AuctionInfo`, `AuctionSettings`, `AuctionState`, `AuctionReserve` | An English auction for an NFT, with bids in XCH or a CAT held in a reserve coin. It has its own driver (`Auction::spend`) rather than the `pending_spend` flow below. See [Auctions](/sdk/primitives/auctions) | Each registry's actions are separate types: @@ -354,7 +358,14 @@ computes the next generation. singleton launcher. Since there's only one possible prelauncher coin per parent and value, it guarantees that only one launcher (and so one NFT) exists per registration. - **`CatMaker`** and `DefaultCatMakerArgs`: small puzzles that compute a CAT puzzle hash from an inner - puzzle hash, so actions can verify payments in a CAT chosen by the registry's state. + puzzle hash, so actions can verify payments in a CAT chosen by the registry's state. `CatMaker` has + `Default`, `Revocable` and `Xch` variants (with `DefaultCatMakerArgs`, `RevocableCatMakerArgs` and + `XchCatMaker` in `chia_wallet_sdk::types::puzzles`). In 0.37.1 they're part of the action layer and + need the `action-layer` feature. On `main` (unreleased), they moved out of the action layer (to the + driver's `cat_maker` module and the types crate's `cat_makers` module) and are no longer + feature-gated, so other primitives such as [partial offers](/sdk/primitives/partial-offers) can use + them. The public paths (`chia_wallet_sdk::driver::CatMaker`, + `chia_wallet_sdk::types::puzzles::DefaultCatMakerArgs`, ...) are the same in both. - **`P2ParentCoin`**: a coin (XCH or CAT) that can be spent by whoever controls its parent's inner puzzle. Its solution reveals the parent's inner puzzle with a new solution, the puzzle checks it against the parent coin id, and the conditions it outputs become the coin's conditions. diff --git a/docs/sdk/primitives/auctions.md b/docs/sdk/primitives/auctions.md new file mode 100644 index 0000000..6551a77 --- /dev/null +++ b/docs/sdk/primitives/auctions.md @@ -0,0 +1,488 @@ +--- +title: Auctions +--- + +# Auctions + +An **auction** is a singleton that sells an NFT in an English (ascending price) auction. The seller +locks the NFT with the auction and picks the rules up front. After that, anyone can place a bid in XCH +or a CAT without the seller's involvement. Each bid locks up the bidder's funds and refunds the +previous bidder in full. When the auction ends, the NFT goes to the winning bidder and the winning bid +is paid out to the seller, the fee recipients and the NFT's royalty address. The auction is built on +the [action layer](/sdk/primitives/action-layer), so no one controls it once it's launched, the seller +included. + +:::info Unreleased +This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). +To try it, depend on the git repository with the `auction` feature: +`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["auction"] }` +::: + +The `auction` feature implies `action-layer`. The drivers are in `chia_wallet_sdk::driver` (`Auction`, +`AuctionInfo`, `AuctionSettings`, ...) and the puzzle types are in `chia_wallet_sdk::types::puzzles` +(`AuctionBid`, `AuctionState`, `AuctionTimings`, `AuctionPayments`, ...). + +## How an auction works + +An auction is made of three kinds of coins: + +```text +Auction singleton (amount 1) + SingletonLayer + ActionLayer { merkle root of [bid, end, cancel], AuctionState, reserve finalizer } + +Reserve coin (XCH or CAT) locked by the auction, holds the current winning bid +Locked NFT locked by the auction until it ends or is cancelled +``` + +- **The singleton** is an [action layer](/sdk/primitives/action-layer) singleton with three actions: + bid, end and cancel. Its state (`AuctionState`) is the amount in the reserve, whether the auction + has ended, and the current winning bid. Anyone can spend it by running actions. The puzzles enforce + the rules, not a signature. +- **The reserve** holds the winning bidder's funds. The singleton uses the action layer's reserve + finalizer: every spend of the singleton also spends the reserve and recreates it with the new + `state.reserve_amount`. Actions output reserve conditions (refunds and payouts), which the finalizer + forwards to the reserve coin. The reserve can be XCH (`AuctionReserve::Xch`) or a CAT + (`AuctionReserve::Cat`, revocable CATs included). It starts as an empty (0 mojo) coin. +- **The lock** is the p2 puzzle hash that the NFT and the reserve are locked with. + `auction_lock_p2_puzzle_hash(launcher_id)` is a [MIPS](/sdk/primitives/vaults) puzzle whose only member + is the auction singleton (a `SingletonMember`), so locked coins can only be spent when the auction + sends them a message. `spend_auction_lock` builds that spend. Because the lock is derived from the + launcher id, you lock the NFT before the auction exists, in the same transaction that launches it. + +### Actions + +| Action | Driver method | When it's allowed | What it does | +| ------ | -------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bid | `Auction::bid_action_spend(ctx, bid, grace)` | Before the end time, or within the grace period of the previous spend | Checks the bid is strictly higher than the winning bid and accepted by the bid verifier, refunds the previous bidder, and sets the reserve amount to the new bid's reserve amount | +| End | `Auction::end_action_spend(ctx, &nft)` | After the end time, and at least the grace period after the last spend | Pays out the winning bid from the reserve, recreates the reserve empty, marks the auction ended and authorizes the NFT to be unlocked | +| Cancel | `Auction::cancel_action_spend(ctx, &nft)` | Before the first bid, at any time | Authorized by a message from the payout puzzle. Marks the auction ended and sends the NFT back to the payout puzzle hash | + +You pass action spends to `Auction::spend(ctx, action_spends, other_cat_spends)`, which spends the +singleton, the reserve, and the settlement coin that pays the NFT royalty (if there is one), and +returns the child auction. Several actions can run in one spend, for example several bids, each +outbidding the last. + +An ended (or cancelled) auction can't be bid on, ended or cancelled again. Each of those fails on chain. + +### Bids + +An `AuctionBid` is an amount and the bidder's puzzle hash. That puzzle hash gets the NFT if the bid +wins, or a full refund if someone outbids it. + +A bidder must lock up more than the bid. `AuctionInfo::reserve_amount_for_bid(amount)` is the bid plus +the **buyer's premium** plus the **NFT royalty** (both in basis points of the bid, rounded down). For +example, with a 3% premium and a 5% royalty, a bid of 200 locks up 200 + 6 + 10 = 216 mojos. If the bid +is outbid, all 216 are refunded. + +To fund a bid, the bidder spends coins worth the reserve amount in the same spend bundle, and asserts +`Auction::bid_announcement_id(bid)`. The auction coin creates that coin announcement for each bid it +accepts, so the funds can only be used for that exact bid. Coins that don't create change leave +their value for the reserve. For CAT reserves, pass the bidder's CAT spends as `other_cat_spends`, +and `Auction::spend` spends them in the same CAT ring as the reserve. For XCH reserves, +`other_cat_spends` must be empty (otherwise `DriverError::UnexpectedAuctionCatSpends`). + +### Bid verifiers + +Besides being strictly higher than the current winning bid, each bid has to pass the auction's +`AuctionBidVerifier`: + +| Variant | Rule | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `Flat { minimum_bid, bid_increment }` | The first bid is at least `minimum_bid`, and each later bid is at least `bid_increment` higher | +| `Percent { minimum_bid, bid_increment_bps }` | The first bid is at least `minimum_bid`, and each later bid is at least `bid_increment_bps` higher (rounded down) | + +Both values are `NonZeroU64`. The verifier is a small curried puzzle (`AuctionFlatBidVerifierArgs` or +`AuctionPercentBidVerifierArgs`) that is curried into the bid action. + +### Timing + +`AuctionTimings { end_time, grace_period }` sets when bidding closes: + +- Before `end_time` (a Unix timestamp in seconds), bids are placed with `grace: false`. +- If `grace_period` is nonzero, a bid can still be placed after `end_time` with `grace: true`, as long + as it's within `grace_period` seconds of the previous spend of the auction. Every bid restarts the + window, which stops someone from sniping at the last second. +- The auction can be ended once `end_time` has passed and (with a grace period) `grace_period` seconds + have passed since the last spend. + +### Ending and payouts + +When the auction ends with a winning bid of `amount`, the reserve pays: + +| Recipient | Amount | +| --------------------------------- | ------------------------------------- | +| `buyers_premium.puzzle_hash` | `amount * buyers_premium.bps / 10000` | +| `commission.puzzle_hash` | `amount * commission.bps / 10000` | +| `payout_puzzle_hash` (the seller) | `amount` minus the commission | +| The NFT's royalty puzzle hash | `amount * royalty_bps / 10000` | + +Zero payments are skipped. The royalty goes through a settlement payments coin, with the NFT's +launcher id as the nonce of the notarized payment, the same way an [offer](/sdk/offers) pays NFT +royalties. `Auction::spend` creates and spends that settlement coin for you. `calculate_auction_payment` +does the same rounded-down math as the puzzles. + +The end and cancel actions also authorize the NFT to be unlocked. In the same spend bundle, call +`Auction::unlock_nft(ctx, &nft)` on the auction _before_ it's ended. It runs the **NFT unlocker** +puzzle (`AuctionNftUnlockerArgs`) to get the NFT's output conditions and spends the locked NFT +through the lock. The NFT goes to the winning bidder's puzzle hash, or back to the payout puzzle hash if +there were no bids. Its DID owner is cleared and, if there's a royalty, the transfer declares the +winning bid (paid through the settlement payments puzzle) as the trade price. `unlock_nft` returns the new NFT. + +### Cancelling + +Before the first bid, the seller can cancel the auction. Cancelling must be authorized by a coin with +the auction's payout puzzle hash, which outputs `Auction::cancel_authorization(ctx)` in the same +spend bundle. That is a `SEND_MESSAGE` of `AUCTION_CANCEL_MESSAGE` with mode +`AUCTION_CANCEL_MESSAGE_MODE`, which commits to the sender's puzzle hash and to this exact auction coin, +so an authorization can't be replayed later. Cancelling has no timing restrictions. It leaves the +auction in the same state as ending it with no bids. + +## Settings + +`AuctionSettings` is fixed when the auction is launched: + +| Field | Type | Description | +| -------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bid_verifier` | `AuctionBidVerifier` | Minimum bid and increment | +| `timings` | `AuctionTimings` | End time and grace period | +| `payments` | `AuctionPayments` | `buyers_premium` and `commission` (each an `AuctionBpsPayment { bps, puzzle_hash }`) and `payout_puzzle_hash`, which gets the proceeds (or the NFT if there are no bids) and authorizes cancels | + +The NFT's royalty (`RoyaltyInfo`: launcher id, royalty puzzle hash and basis points) is taken from the +NFT itself and curried into the auction too. + +The SDK rejects settings that the puzzles can't protect against, both when launching and when parsing: + +- Any of the premium, commission or royalty above `AUCTION_MAX_BPS` (10,000, or 100%): + `DriverError::AuctionBpsTooHigh`. +- Two paid recipients with the same puzzle hash (they could create identical coins, which would make + the auction impossible to end): `DriverError::DuplicateAuctionPaymentPuzzleHash`. A fee with 0 bps + isn't paid, so its puzzle hash doesn't count. +- A paid recipient that's the settlement payments puzzle hash or the auction's lock: + `DriverError::ReservedAuctionPaymentPuzzleHash`. + +## Types + +| Type | Crate path | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `Auction` | `driver` | `Singleton`: the auction coin, its lineage proof and info | +| `AuctionInfo` | `driver` | Launcher id, settings, locked NFT coin id, NFT royalty, state and reserve. Computes action hashes and the inner puzzle hash | +| `AuctionSettings`, `AuctionBidVerifier` | `driver` | The seller's settings | +| `AuctionReserve` | `driver` | `Xch(Coin)` or `Cat(Cat)` | +| `AuctionChildState` | `driver` | Result of `Auction::child_state`: the next state, the reserve's conditions and the royalty amount | +| `AuctionMemo`, `AuctionReserveMemo` | `driver` | Stored in the launcher solution so wallets can find the auction | +| `AuctionLaunch` | `driver` | A parsed launcher spend, before it's checked against the NFT | +| `auction_lock_p2_puzzle_hash`, `spend_auction_lock` | `driver` | The lock that holds the NFT and reserve | +| `AUCTION_SINGLETON_AMOUNT`, `AUCTION_MAX_BPS`, `calculate_auction_payment` | `driver` | Constants and fee math | +| `AuctionBid`, `AuctionState`, `AuctionTimings`, `AuctionPayments`, `AuctionBpsPayment` | `types::puzzles` | CLVM types used by the puzzles | +| `AuctionBidActionArgs`, `AuctionEndActionArgs`, `AuctionCancelActionArgs`, `AuctionNftUnlockerArgs`, `AuctionFlatBidVerifierArgs`, `AuctionPercentBidVerifierArgs` | `types::puzzles` | Curried arguments of the puzzles. You rarely need them directly, since `AuctionInfo` builds them | + +## Example: launch, bid and settle + +This example (adapted from the SDK's auction tests) mints an NFT, locks it and launches an XCH auction +for it in a single transaction. Then two bidders bid, and the auction is ended and settled. + +To launch: + +1. Pick the auction's launcher, so you know its id and the lock puzzle hash. +2. Send the NFT to `auction_lock_p2_puzzle_hash(launcher_id)`. +3. Create an empty reserve coin with the lock puzzle hash (for a CAT reserve, an empty CAT coin whose + inner puzzle hash is the lock). +4. Call `Launcher::launch_auction(ctx, settings, reserve, &locked_nft)`. The launcher must have a + singleton amount of `AUCTION_SINGLETON_AMOUNT` (1), since the reserve finalizer always recreates the + singleton with an amount of 1. + +```rust unreleased +use std::num::NonZeroU64; + +use chia_wallet_sdk::driver::{ + AuctionBidVerifier, AuctionLaunch, AuctionReserve, AuctionSettings, RoyaltyInfo, + auction_lock_p2_puzzle_hash, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::types::puzzles::{AuctionBid, AuctionBpsPayment, AuctionPayments, AuctionTimings}; + +const PREMIUM_PUZZLE_HASH: Bytes32 = Bytes32::new([1; 32]); +const COMMISSION_PUZZLE_HASH: Bytes32 = Bytes32::new([2; 32]); +const ROYALTY_PUZZLE_HASH: Bytes32 = Bytes32::new([3; 32]); +const END_TIME: u64 = 1_000; + +#[test] +fn auction_launch_bid_and_settle() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let seller = sim.bls(2); + let seller_p2 = StandardLayer::new(seller.pk); + + // Mint an NFT with a 5% royalty. Its launcher has an amount of 0, so it doesn't collide with + // the auction's launcher (amount 1) created by the same coin. + let mut mint = NftMint::new(HashedPtr::NIL, seller.puzzle_hash, 500, None); + mint.royalty_puzzle_hash = ROYALTY_PUZZLE_HASH; + let (mint_nft, nft) = Launcher::new(seller.coin.coin_id(), 0) + .with_singleton_amount(1) + .mint_nft(ctx, &mint)?; + + // Lock the NFT with the auction's launcher id. + let launcher = Launcher::new(seller.coin.coin_id(), 1); + let lock_puzzle_hash = auction_lock_p2_puzzle_hash(launcher.coin().coin_id()); + let locked_nft = nft.spend_with( + ctx, + &seller_p2, + Conditions::new().create_coin(lock_puzzle_hash, nft.coin.amount, Memos::None), + )?; + + // The reserve starts as an empty coin locked by the auction. + let reserve = AuctionReserve::Xch(Coin::new(seller.coin.coin_id(), lock_puzzle_hash, 0)); + + // A 100 mojo minimum bid and increment, a 3% buyer's premium and a 1% commission. + let settings = AuctionSettings::new( + AuctionBidVerifier::Flat { + minimum_bid: NonZeroU64::new(100).unwrap(), + bid_increment: NonZeroU64::new(100).unwrap(), + }, + AuctionTimings::new(END_TIME, 0), + AuctionPayments::new( + AuctionBpsPayment::new(300, PREMIUM_PUZZLE_HASH), + AuctionBpsPayment::new(100, COMMISSION_PUZZLE_HASH), + seller.puzzle_hash, + ), + ); + + let (launch_auction, mut auction) = + launcher.launch_auction(ctx, settings, reserve, &locked_nft)?; + + seller_p2.spend( + ctx, + seller.coin, + mint_nft + .create_coin(lock_puzzle_hash, 0, Memos::None) + .extend(launch_auction), + )?; + sim.spend_coins(ctx.take(), &[seller.sk])?; + + // Anyone can find the auction from its launcher spend. The royalty isn't in the memo, so it + // comes from the locked NFT (which a wallet would look up by `launch.nft_coin_id()`). + let launcher_spend = sim.coin_spend(auction.info.launcher_id).unwrap(); + let solution = ctx.alloc(&launcher_spend.solution)?; + let launch = AuctionLaunch::parse(ctx, launcher_spend.coin, solution).unwrap(); + assert_eq!(launch.nft_coin_id(), locked_nft.coin.coin_id()); + let royalty = RoyaltyInfo::new( + locked_nft.info.launcher_id, + locked_nft.info.royalty_puzzle_hash, + locked_nft.info.royalty_basis_points, + ); + assert_eq!(launch.into_auction(royalty, None)?, auction); + + // Each bidder funds the reserve with a coin worth exactly the reserve amount for their bid, + // and asserts the bid's announcement so the funds can't be used for anything else. + let mut bidders = Vec::new(); + + for amount in [100, 200] { + let reserve_amount = auction.info.reserve_amount_for_bid(amount).unwrap(); + let bidder = sim.bls(reserve_amount); + let bid = AuctionBid::new(amount, bidder.puzzle_hash); + + let bid_action = auction.bid_action_spend(ctx, bid, false)?; + StandardLayer::new(bidder.pk).spend( + ctx, + bidder.coin, + Conditions::new().assert_coin_announcement(auction.bid_announcement_id(bid)), + )?; + + let previous = auction; + auction = auction.spend(ctx, vec![bid_action], vec![])?; + sim.spend_coins(ctx.take(), &[bidder.sk.clone()])?; + + // Wallets follow the auction by parsing each spend of it. + let auction_spend = sim.coin_spend(previous.coin.coin_id()).unwrap(); + let solution = ctx.alloc(&auction_spend.solution)?; + assert_eq!(previous.parse_child(ctx, solution)?, auction); + + bidders.push((bidder, previous.info.reserve, reserve_amount)); + } + + // The bid of 200 locks up 200 + 6 (premium) + 10 (royalty). + assert_eq!(auction.info.state.reserve_amount, 216); + + // The first bidder was refunded in full by the reserve they were outbid from. + let (first, _, first_reserve_amount) = &bidders[0]; + let (_, outbid_reserve, _) = bidders[1]; + let refund = Coin::new( + outbid_reserve.coin().coin_id(), + first.puzzle_hash, + *first_reserve_amount, + ); + assert!(sim.coin_state(refund.coin_id()).is_some()); + + // After the end time, anyone can end the auction. No signatures are needed. + sim.set_next_timestamp(END_TIME)?; + let final_reserve = auction.info.reserve.coin(); + + let end_action = auction.end_action_spend(ctx, &locked_nft)?; + let won_nft = auction.unlock_nft(ctx, &locked_nft)?; + let auction = auction.spend(ctx, vec![end_action], vec![])?; + sim.spend_coins(ctx.take(), &[])?; + + assert!(auction.info.state.ended); + assert_eq!(won_nft.info.p2_puzzle_hash, bidders[1].0.puzzle_hash); + assert!(sim.coin_state(won_nft.coin.coin_id()).is_some()); + + // The reserve paid the premium, the commission and the seller. + for (puzzle_hash, amount) in [ + (PREMIUM_PUZZLE_HASH, 6), + (COMMISSION_PUZZLE_HASH, 2), + (seller.puzzle_hash, 198), + ] { + let coin = Coin::new(final_reserve.coin_id(), puzzle_hash, amount); + assert!(sim.coin_state(coin.coin_id()).is_some()); + } + + // The royalty was paid through a settlement coin. + let settlement = Coin::new(final_reserve.coin_id(), SETTLEMENT_PAYMENT_HASH.into(), 10); + let royalty_coin = Coin::new(settlement.coin_id(), ROYALTY_PUZZLE_HASH, 10); + assert!(sim.coin_state(royalty_coin.coin_id()).is_some()); + + // The reserve is recreated empty. + assert_eq!(auction.info.reserve.coin().amount, 0); + + Ok(()) +} +``` + +## Example: cancelling before the first bid + +The seller authorizes a cancel by spending a coin with the payout puzzle hash (any coin, even an empty +one) that outputs `cancel_authorization`. The NFT goes back to the payout puzzle hash. + +```rust unreleased +use std::num::NonZeroU64; + +use chia_wallet_sdk::driver::{ + AuctionBidVerifier, AuctionReserve, AuctionSettings, auction_lock_p2_puzzle_hash, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::{AuctionBpsPayment, AuctionPayments, AuctionTimings}; + +#[test] +fn auction_cancel() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let seller = sim.bls(2); + let seller_p2 = StandardLayer::new(seller.pk); + + // Mint, lock and launch, as in the previous example (with no fees and no royalty). + let mint = NftMint::new(HashedPtr::NIL, seller.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(seller.coin.coin_id(), 0) + .with_singleton_amount(1) + .mint_nft(ctx, &mint)?; + + let launcher = Launcher::new(seller.coin.coin_id(), 1); + let lock_puzzle_hash = auction_lock_p2_puzzle_hash(launcher.coin().coin_id()); + let locked_nft = nft.spend_with( + ctx, + &seller_p2, + Conditions::new().create_coin(lock_puzzle_hash, 1, Memos::None), + )?; + + let settings = AuctionSettings::new( + AuctionBidVerifier::Flat { + minimum_bid: NonZeroU64::new(1_000).unwrap(), + bid_increment: NonZeroU64::new(100).unwrap(), + }, + AuctionTimings::new(1_000_000, 300), + AuctionPayments::new( + AuctionBpsPayment::new(0, Bytes32::default()), + AuctionBpsPayment::new(0, Bytes32::default()), + seller.puzzle_hash, + ), + ); + let reserve = AuctionReserve::Xch(Coin::new(seller.coin.coin_id(), lock_puzzle_hash, 0)); + let (launch_auction, auction) = launcher.launch_auction(ctx, settings, reserve, &locked_nft)?; + + seller_p2.spend( + ctx, + seller.coin, + mint_nft + .create_coin(lock_puzzle_hash, 0, Memos::None) + .extend(launch_auction), + )?; + sim.spend_coins(ctx.take(), &[seller.sk.clone()])?; + + // A coin with the payout puzzle hash authorizes the cancel of this auction coin. + let authorizer = sim.new_coin(seller.puzzle_hash, 0); + let authorization = auction.cancel_authorization(ctx)?; + seller_p2.spend(ctx, authorizer, authorization)?; + + let cancel_action = auction.cancel_action_spend(ctx, &locked_nft)?; + let returned_nft = auction.unlock_nft(ctx, &locked_nft)?; + let auction = auction.spend(ctx, vec![cancel_action], vec![])?; + sim.spend_coins(ctx.take(), &[seller.sk])?; + + assert!(auction.info.state.ended); + assert_eq!(returned_nft.info.p2_puzzle_hash, seller.puzzle_hash); + assert!(sim.coin_state(returned_nft.coin.coin_id()).is_some()); + + Ok(()) +} +``` + +## Finding and following auctions + +The launcher solution's key value list is an `AuctionMemo`: a version (0), the settings, the coin id +of the locked NFT and an `AuctionReserveMemo` (the reserve's parent coin id, plus the asset id and +hidden puzzle hash for CAT reserves). To reconstruct an auction from chain data: + +1. `AuctionLaunch::parse(allocator, launcher_coin, launcher_solution)` returns `None` if the coin isn't a + launcher or the solution doesn't hold an auction memo. +2. Look up the NFT at `launch.nft_coin_id()` and build its `RoyaltyInfo`. The royalty is curried into + the auction but isn't in the memo. +3. For a CAT reserve, parse the spend of the reserve's parent (`launch.reserve_parent_coin_info()`) + with `Cat::parse_children` to get the reserve's lineage proof. The memo doesn't store it, since it + couldn't be trusted. +4. `launch.into_auction(royalty, reserve_lineage_proof)` validates the settings and checks that they + (and the royalty) produce the singleton puzzle hash that was launched. It fails with + `DriverError::AuctionPuzzleHashMismatch` if not, or `InvalidAuctionSingletonAmount` if the singleton + amount isn't 1. + +After that, call `auction.parse_child(ctx, solution)` with the solution of each confirmed spend of the +auction coin to get the next auction. It runs the revealed action puzzles to compute the new state, +so only use it on solutions from confirmed spends. + +`Auction::child_state(ctx, &action_spends)` runs actions without spending anything, the same way the +action layer and reserve finalizer will on chain. You can use it to check a bid before submitting it, +or to see the reserve's conditions and royalty amount. + +## Relation to the action layer + +The auction is an [action layer](/sdk/primitives/action-layer) singleton, but it has its own small +driver instead of the registry-style `new_action` / `pending_spend` / `finish_spend` flow. You build +action spends with `Auction`'s methods and pass them all to `Auction::spend`, which builds the +`ActionLayer` spend (with merkle proofs for each action), the `Finalizer::Reserve` solution and the +reserve spend. The pieces it uses are the action layer's `ActionLayer`, `Finalizer::Reserve` with the +default reserve-amount-from-state program, and reserve conditions. Unlike the reward distributor's +`Reserve`, the auction's reserve can be XCH, and it's locked with a MIPS singleton member instead of +`P2DelegatedBySingletonLayer`. + +## Gotchas + +- **Lock the NFT before launching.** `launch_auction` fails with `DriverError::AuctionNftNotLocked` + unless the NFT's p2 puzzle hash is the auction's lock, and with `InvalidAuctionReserve` unless the + reserve is an empty coin locked by the auction. The auction can't check this on chain, so before + bidding, wallets must check that the locked NFT is unspent and locked by the auction. +- **Any matching coin can be the reserve.** The reserve finalizer takes the reserve's parent from the + singleton's solution, so any coin with the reserve's puzzle hash and amount works. While the reserve + amount is 0, anyone can create an empty coin with the lock puzzle hash and spend it as the reserve + (useful if the original reserve was never created). This is why `parse_child` follows the reserve + that was actually spent rather than the one you were tracking. +- **Unlock in the same spend bundle.** The end and cancel actions only authorize the NFT's unlock. + Call `unlock_nft` on the auction _before_ calling `spend` with the end or cancel action, and include + both in the same transaction. +- **Fund bids exactly.** The bidder's coins must provide `reserve_amount_for_bid(amount)`, and should + assert `bid_announcement_id(bid)` so the funds can't be redirected to a different bid. +- The singleton amount is always 1 (`AUCTION_SINGLETON_AMOUNT`). Launchers with other singleton + amounts are rejected. +- A bid's puzzle hash gets both the refund and the NFT. A bid refund sent to the settlement payments + puzzle hash is treated as a normal refund, not as the royalty. diff --git a/docs/sdk/primitives/fusion.md b/docs/sdk/primitives/fusion.md new file mode 100644 index 0000000..2ea9a00 --- /dev/null +++ b/docs/sdk/primitives/fusion.md @@ -0,0 +1,459 @@ +--- +title: NFT Fusion +--- + +# NFT Fusion + +A fusion is a singleton that swaps one set of NFTs for another, in either direction, without a trusted +party. It implements [CHIP-0021 (NFT Fusion Puzzle)](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0021.md). +A project deploys a fusion listing the NFTs on each side and locks one side in it. Anyone who holds every +NFT on the other side can then make an [offer](/sdk/offers) of them for the locked ones, and anyone can +take that offer against the fusion. The swap can be reversed the same way, any number of times. Use it to +combine several NFTs into one, divide one into several, or upgrade an NFT to a new version while keeping +the original recoverable. + +:::info Unreleased +This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). +To try it, depend on the git repository with the `chip-0021` feature: +`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["chip-0021"] }` +::: + +## Concepts + +- **Sides.** A fusion has two fixed lists of NFT launcher IDs, side A and side B, curried into its puzzle. + By convention side A is the fused or upgraded NFTs and side B is the components or originals. Each side + needs at least one NFT, and no NFT can be listed twice. An upgrade is one NFT on each side. +- **Locking.** The NFTs on one side are held by the fusion's p2 puzzle (`P2FusionLayer`), which only + releases an NFT when the fusion singleton is spent in the same block and announces that release. + Normally the project locks side A after launching the fusion. +- **Fuse and defuse.** `FusionDirection::Fuse` locks side B and unlocks side A. + `FusionDirection::Defuse` locks side A and unlocks side B. The whole of both sides always moves + together. +- **Swaps via offers.** The NFTs being locked come from the settlement puzzle, so a regular NFT-for-NFT + offer made by any wallet works. The fusion singleton pays the requested NFTs to the maker. No signature + is needed to spend the singleton or the locked NFTs. +- **No minting.** The fusion doesn't create NFTs. Every NFT on both sides must already exist, so a + project mints the fused NFTs first, typically with its DID, following + [CHIP-0015](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0015.md) metadata to describe the + relationship. + +Only NFTs with an amount of 1 and the default metadata updater can be used. The driver rejects others with +`DriverError::UnsupportedFusionNft`, since the puzzle couldn't release them. + +## Types + +All of these are behind the `chip-0021` feature. + +| Item | Path | Purpose | +| ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `Fusion` (`Singleton`) | `chia_wallet_sdk::driver` | The fusion singleton: lock NFTs, take offers, swap, parse | +| `FusionLayer` | `chia_wallet_sdk::driver` | The singleton's inner puzzle: launcher ID and both sides. `p2_puzzle_hash()` is where locked NFTs live | +| `P2FusionLayer` | `chia_wallet_sdk::driver` | The p2 puzzle of locked NFTs, curried with the fusion's launcher ID | +| `FusionDirection`, `FusionUnlock`, `FusionSwap` | `chia_wallet_sdk::driver` | A swap's direction, a locked NFT and where to send it, and the result of a swap | +| `Launcher::for_fusion`, `Launcher::launch_fusion` | `chia_wallet_sdk::driver` | Deploying a fusion | +| `FUSION_SINGLETON_AMOUNT` | `chia_wallet_sdk::driver` | The singleton's amount (1 mojo) | +| `FusionSingletonArgs`, `FusionSingletonSolution`, `P2FusionArgs`, `P2FusionSolution`, `FusionNftToLock`, `FusionNftToUnlock` | `chia_wallet_sdk::types::puzzles` | Curried arguments and solutions of the two puzzles | +| `FUSION_HINT` | `chia_wallet_sdk::types::puzzles` | `sha256("CHIP-0021")`, the hint on a fusion's launcher and on each singleton coin after the eve coin | +| `FUSION_SINGLETON_PUZZLE(_HASH)`, `P2_FUSION_PUZZLE(_HASH)` | `chia_wallet_sdk::types::puzzles` | The compiled puzzles, as deployed on mainnet | + +## Launching and locking + +`Launcher::for_fusion(ctx, parent_coin_id)` creates a 1 mojo launcher hinted with `FUSION_HINT`, so the +fusion can be found on chain. `launch_fusion(ctx, side_a, side_b)` returns the conditions for the parent +coin and the eve `Fusion`. It stores the sides in the launcher solution's key value list, as the mainnet +deployments do, so `Fusion::parse_launcher` can recover the fusion later. + +`fusion.lock_nft(ctx, nft, &p2, extra_conditions)` spends an NFT you own (with any inner layer that +implements `SpendWithConditions`) to the fusion's p2 puzzle and returns the locked NFT. It also removes +any assigned owner (DID), because an offer can't request an NFT that has one. Use +`lock_nft_conditions` if you need the conditions for a custom spend. + +## Fusing and defusing + +`fusion.take_offer(ctx, &offer, &locked_nfts)` checks that the offer offers exactly one side and requests +exactly the other, works out the direction, and adds the spends of the fusion singleton, the offered +NFTs (into the fusion's p2 puzzle) and the locked NFTs (to the maker) to the `SpendContext`. Pass those +spends to `Offer::take` to get the final spend bundle. The returned `FusionSwap` has the next `fusion` +coin, the NFTs now `locked`, and the NFTs `unlocked` for the maker, ready for the next swap. + +This example has a project lock one NFT on side A, then a user fuses two NFTs into it and defuses it back: + +```rust unreleased +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::chia::puzzle_types::offer::{NotarizedPayment, Payment}; +use chia_wallet_sdk::driver::{Fusion, FusionDirection}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::sign_transaction; + +fn mint_nfts( + sim: &mut Simulator, + ctx: &mut SpendContext, + owner: &BlsPairWithCoin, + count: u64, +) -> anyhow::Result> { + let p2 = StandardLayer::new(owner.pk); + let mut nfts = Vec::new(); + + for edition_number in 1..=count { + let coin = sim.new_coin(owner.puzzle_hash, 1); + let metadata = ctx.alloc_hashed(&NftMetadata { + edition_number, + ..Default::default() + })?; + let mint = NftMint::new(metadata, owner.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(coin.coin_id(), 1).mint_nft(ctx, &mint)?; + p2.spend(ctx, coin, mint_nft)?; + nfts.push(nft); + } + + sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?; + Ok(nfts) +} + +/// An ordinary offer of some NFTs for others, as any wallet would make it. +fn make_offer( + ctx: &mut SpendContext, + maker: &BlsPairWithCoin, + offered: &[Nft], + requested: &[Nft], +) -> anyhow::Result { + let nonce = Offer::nonce(offered.iter().map(|nft| nft.coin.coin_id()).collect()); + + let mut requested_payments = RequestedPayments::new(); + let mut asset_info = AssetInfo::new(); + + for nft in requested { + let memos = ctx.hint(maker.puzzle_hash)?; + requested_payments.nfts.insert( + nft.info.launcher_id, + vec![NotarizedPayment::new( + nonce, + vec![Payment::new(maker.puzzle_hash, 1, memos)], + )], + ); + asset_info.insert_nft( + nft.info.launcher_id, + NftAssetInfo::new( + nft.info.metadata, + nft.info.metadata_updater_puzzle_hash, + nft.info.royalty_puzzle_hash, + nft.info.royalty_basis_points, + ), + )?; + } + + let assertions = requested_payments.assertions(ctx, &asset_info)?; + let p2 = StandardLayer::new(maker.pk); + + for nft in offered { + let _ = nft.lock_settlement(ctx, &p2, Vec::new(), Conditions::new().extend(assertions.clone()))?; + } + + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, std::slice::from_ref(&maker.sk))?; + + Ok(Offer::from_input_spend_bundle( + ctx, + SpendBundle::new(coin_spends, signature), + requested_payments, + asset_info, + )?) +} + +#[test] +fn fuse_and_defuse() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let project = sim.bls(0); + let user = sim.bls(0); + + // Side A is one fused NFT held by the project. Side B is two components held by the user. + let side_a = mint_nfts(&mut sim, ctx, &project, 1)?; + let side_b = mint_nfts(&mut sim, ctx, &user, 2)?; + let launcher_ids = |nfts: &[Nft]| nfts.iter().map(|nft| nft.info.launcher_id).collect(); + + // Launch the fusion and lock side A in it. + let project_p2 = StandardLayer::new(project.pk); + let parent = sim.new_coin(project.puzzle_hash, 1); + let (launch, fusion) = Launcher::for_fusion(ctx, parent.coin_id())?.launch_fusion( + ctx, + launcher_ids(&side_a), + launcher_ids(&side_b), + )?; + project_p2.spend(ctx, parent, launch)?; + + let locked: Vec = side_a + .into_iter() + .map(|nft| fusion.lock_nft(ctx, nft, &project_p2, Conditions::new())) + .collect::>()?; + + sim.spend_coins(ctx.take(), std::slice::from_ref(&project.sk))?; + + let fusion_p2_puzzle_hash = fusion.info.p2_puzzle_hash(); + assert_eq!(locked[0].info.p2_puzzle_hash, fusion_p2_puzzle_hash); + + // Fuse: the user offers side B and requests side A. Anyone can take the offer. + let offer = make_offer(ctx, &user, &side_b, &locked)?; + let fused = fusion.take_offer(ctx, &offer, &locked)?; + let spend_bundle = offer.take(SpendBundle::new(ctx.take(), Signature::default())); + sim.new_transaction(spend_bundle)?; + + assert_eq!(fused.unlocked[0].info.p2_puzzle_hash, user.puzzle_hash); + assert_eq!(fused.locked.len(), 2); + for nft in fused.unlocked.iter().chain(&fused.locked) { + assert!(sim.coin_state(nft.coin.coin_id()).is_some()); + } + + // Defuse: the user offers side A back and requests side B, using the next fusion coin. + let offer = make_offer(ctx, &user, &fused.unlocked, &fused.locked)?; + let defused = fused.fusion.take_offer(ctx, &offer, &fused.locked)?; + let spend_bundle = offer.take(SpendBundle::new(ctx.take(), Signature::default())); + sim.new_transaction(spend_bundle)?; + + assert_eq!(defused.locked[0].info.p2_puzzle_hash, fusion_p2_puzzle_hash); + for nft in &defused.unlocked { + assert_eq!(nft.info.p2_puzzle_hash, user.puzzle_hash); + assert!(sim.coin_state(nft.coin.coin_id()).is_some()); + } + + Ok(()) +} +``` + +Swaps don't pay NFT royalties, since no fungible asset changes hands. If the taker wants to pay a fee, +add a spend of another coin with `reserve_fee` to the bundle passed to `Offer::take`. + +Offers from other people should be parsed with `Offer::from_spend_bundle`, as on the +[Offers](/sdk/offers) page. `take_offer` rejects (`DriverError::FusionOfferMismatch`) an offer that offers or +requests anything besides the two sides, offers NFTs with an assigned owner, or requests payments the +fusion can't make (more than one payment per NFT, an amount other than 1, or a hint other than the +recipient's puzzle hash). + +## Swapping without an offer + +`fusion.swap(ctx, direction, &offered, &unlocks, nonce)` is the lower-level operation `take_offer` is +built on. `offered` are the NFTs being locked, which must already be in the settlement puzzle, and +`unlocks` are `FusionUnlock::new(locked_nft, recipient_puzzle_hash)` pairs. Both can be in any order. The +driver sorts them into the curried order, which the puzzle requires. + +You need this for a locked NFT that has an assigned owner (locked with `nft.transfer` rather than +`lock_nft`), since the puzzle commits to that owner and an offer can't request it. The spend that moves an +offered NFT into the settlement puzzle must assert the payment it expects in return, as offers do. +Otherwise anyone who sees that spend can pay the NFT to themselves, because settlement coins can be spent +by anyone: + +```rust unreleased +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::chia::puzzle_types::offer::{NotarizedPayment, Payment}; +use chia_wallet_sdk::driver::{FusionDirection, FusionUnlock}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::types::{payment_assertion, tree_hash_notarized_payment}; + +fn mint_nft(sim: &mut Simulator, ctx: &mut SpendContext, owner: &BlsPairWithCoin) -> anyhow::Result { + let coin = sim.new_coin(owner.puzzle_hash, 1); + let metadata = ctx.alloc_hashed(&NftMetadata::default())?; + let mint = NftMint::new(metadata, owner.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(coin.coin_id(), 1).mint_nft(ctx, &mint)?; + StandardLayer::new(owner.pk).spend(ctx, coin, mint_nft)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?; + Ok(nft) +} + +#[test] +fn direct_swap() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let project = sim.bls(0); + let user = sim.bls(0); + let upgraded = mint_nft(&mut sim, ctx, &project)?; + let original = mint_nft(&mut sim, ctx, &user)?; + + // An upgrade: one NFT on each side. The project locks the upgraded NFT (side A). + let project_p2 = StandardLayer::new(project.pk); + let parent = sim.new_coin(project.puzzle_hash, 1); + let (launch, fusion) = Launcher::for_fusion(ctx, parent.coin_id())?.launch_fusion( + ctx, + vec![upgraded.info.launcher_id], + vec![original.info.launcher_id], + )?; + project_p2.spend(ctx, parent, launch)?; + let locked = fusion.lock_nft(ctx, upgraded, &project_p2, Conditions::new())?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&project.sk))?; + + // The payment the user expects: the locked NFT, in the settlement puzzle, paid to them. + let nonce = Bytes32::new([42; 32]); + let memos = ctx.hint(user.puzzle_hash)?; + let payment = NotarizedPayment::new(nonce, vec![Payment::new(user.puzzle_hash, 1, memos)]); + let settlement_nft = NftInfo { + p2_puzzle_hash: SETTLEMENT_PAYMENT_HASH.into(), + ..locked.info + }; + let assertion = payment_assertion( + settlement_nft.puzzle_hash().into(), + tree_hash_notarized_payment(ctx, &payment), + ); + + // Move side B into the settlement puzzle, asserting the payment, then swap. + let settled = original.lock_settlement( + ctx, + &StandardLayer::new(user.pk), + Vec::new(), + Conditions::new().with(assertion), + )?; + let swap = fusion.swap( + ctx, + FusionDirection::Fuse, + &[settled], + &[FusionUnlock::new(locked, user.puzzle_hash)], + nonce, + )?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&user.sk))?; + + assert_eq!(swap.unlocked[0].info.launcher_id, upgraded.info.launcher_id); + assert_eq!(swap.unlocked[0].info.p2_puzzle_hash, user.puzzle_hash); + assert_eq!(swap.locked[0].info.p2_puzzle_hash, fusion.info.p2_puzzle_hash()); + + Ok(()) +} +``` + +`swap` validates everything before adding spends to the context: the NFTs must match the sides +(`FusionNftMismatch`), offered NFTs must be in the settlement puzzle, unlocked NFTs must be locked by +this fusion, and a recipient can't be the fusion's p2 puzzle or the settlement puzzle +(`InvalidFusionTarget`), since the NFT would stay locked or be left for anyone to take. + +## Finding and tracking a fusion + +The launcher coin and every fusion coin after the eve coin are hinted with `FUSION_HINT`, so a wallet or +indexer can find fusions by that hint. + +- `Fusion::parse_launcher(allocator, launcher_coin, launcher_solution)` recovers the eve fusion and its + sides from the launcher's spend. It returns `Ok(None)` if the coin isn't a launcher or the sides in its + solution don't match the singleton it created, and an error if a fusion was launched but is invalid + (an even amount, or sides `FusionLayer::new` rejects). It also accepts the newer reference + implementation's layout, which puts the sides in a fourth solution item. +- `Fusion::parse_child(allocator, parent_coin, parent_puzzle, parent_solution)` parses the next fusion coin + from a confirmed fusion spend, along with the `FusionDirection` of that swap, which tells you which side + is locked now: after `Fuse` side B is locked, after `Defuse` side A is. + +```rust unreleased +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::chia::puzzle_types::offer::{NotarizedPayment, Payment}; +use chia_wallet_sdk::driver::{Fusion, FusionDirection, FusionUnlock}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; +use chia_wallet_sdk::types::puzzles::FUSION_HINT; +use chia_wallet_sdk::types::{payment_assertion, tree_hash_notarized_payment}; + +fn mint_nft(sim: &mut Simulator, ctx: &mut SpendContext, owner: &BlsPairWithCoin) -> anyhow::Result { + let coin = sim.new_coin(owner.puzzle_hash, 1); + let metadata = ctx.alloc_hashed(&NftMetadata::default())?; + let mint = NftMint::new(metadata, owner.puzzle_hash, 0, None); + let (mint_nft, nft) = Launcher::new(coin.coin_id(), 1).mint_nft(ctx, &mint)?; + StandardLayer::new(owner.pk).spend(ctx, coin, mint_nft)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&owner.sk))?; + Ok(nft) +} + +#[test] +fn find_and_track_fusion() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let project = sim.bls(0); + let user = sim.bls(0); + let fused = mint_nft(&mut sim, ctx, &project)?; + let component = mint_nft(&mut sim, ctx, &user)?; + + let project_p2 = StandardLayer::new(project.pk); + let parent = sim.new_coin(project.puzzle_hash, 1); + let (launch, fusion) = Launcher::for_fusion(ctx, parent.coin_id())?.launch_fusion( + ctx, + vec![fused.info.launcher_id], + vec![component.info.launcher_id], + )?; + project_p2.spend(ctx, parent, launch)?; + let locked = fusion.lock_nft(ctx, fused, &project_p2, Conditions::new())?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&project.sk))?; + + // Find the launcher by its hint, and recover the fusion from the launcher spend. + let launcher_id = fusion.info.launcher_id; + assert_eq!(sim.hinted_coins(FUSION_HINT), [launcher_id]); + + let launcher_spend = sim.coin_spend(launcher_id).expect("launcher spend"); + let solution = ctx.alloc(&launcher_spend.solution)?; + let parsed = Fusion::parse_launcher(ctx, launcher_spend.coin, solution)?.expect("a fusion"); + assert_eq!(parsed, fusion); + assert_eq!(parsed.info.side_a(), [fused.info.launcher_id]); + + // Fuse, with the component moved into the settlement puzzle by the user. + let nonce = Bytes32::new([7; 32]); + let memos = ctx.hint(user.puzzle_hash)?; + let payment = NotarizedPayment::new(nonce, vec![Payment::new(user.puzzle_hash, 1, memos)]); + let settlement_nft = NftInfo { + p2_puzzle_hash: SETTLEMENT_PAYMENT_HASH.into(), + ..locked.info + }; + let assertion = payment_assertion( + settlement_nft.puzzle_hash().into(), + tree_hash_notarized_payment(ctx, &payment), + ); + let settled = component.lock_settlement( + ctx, + &StandardLayer::new(user.pk), + Vec::new(), + Conditions::new().with(assertion), + )?; + let swap = parsed.swap( + ctx, + FusionDirection::Fuse, + &[settled], + &[FusionUnlock::new(locked, user.puzzle_hash)], + nonce, + )?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&user.sk))?; + + // The next fusion coin is hinted too, and its parent spend says which side is locked now. + assert!(sim.hinted_coins(FUSION_HINT).contains(&swap.fusion.coin.coin_id())); + + let spend = sim.coin_spend(fusion.coin.coin_id()).expect("fusion spend"); + let puzzle = ctx.alloc(&spend.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + let solution = ctx.alloc(&spend.solution)?; + let (child, direction) = + Fusion::parse_child(ctx, spend.coin, puzzle, solution)?.expect("a fusion"); + + assert_eq!(child, swap.fusion); + assert_eq!(direction, FusionDirection::Fuse); + + Ok(()) +} +``` + +To find the locked NFTs themselves, look up coins hinted with `fusion.info.p2_puzzle_hash()`. `lock_nft` +and swaps hint the NFTs they lock with it. + +## Gotchas + +- **Locking is one-way.** A locked NFT can only leave through a swap. Lock only the NFTs of one side: + `lock_nft` doesn't check which side is currently locked, and if NFTs from both sides end up locked, + neither side can be swapped. An NFT that isn't on either side but is sent to the fusion's p2 puzzle is + stuck forever, so `lock_nft` refuses it, but a plain `nft.transfer` doesn't. +- **There's no admin key.** Once launched, the sides can't change and nothing can be withdrawn except by + swapping. Double-check the launcher IDs before launching. +- **The fusion must be confirmed before a swap.** The singleton asserts a relative height, so launching + (or locking) and swapping can't happen in the same block (`EphemeralRelativeCondition`). +- **Swaps use the current coin.** Each swap spends the latest fusion coin, so a swap built from a stale + `Fusion` fails as a double spend. Track the fusion with `parse_child`, or use the `fusion` returned in + `FusionSwap`. +- **Cost grows with the number of NFTs.** Both sides are swapped in a single spend, at roughly 120 million + cost per NFT. Beyond about 40 NFTs in total, a swap exceeds the mempool's cost limit. +- **Owners are removed on lock.** `lock_nft` clears an NFT's assigned DID so it can be requested in + offers. To keep the owner, transfer the NFT to `p2_puzzle_hash()` yourself and use + [`swap`](#swapping-without-an-offer) instead of offers. +- **The solution isn't signed.** Anyone can alter the parts of a fusion spend's solution that the puzzle + ignores, so `parse_child` only reads the direction, the same way the puzzle does. Don't parse a + confirmed fusion solution as `FusionSingletonSolution` and trust the rest. diff --git a/docs/sdk/primitives/index.md b/docs/sdk/primitives/index.md index 40df26b..b777fa3 100644 --- a/docs/sdk/primitives/index.md +++ b/docs/sdk/primitives/index.md @@ -235,6 +235,18 @@ In practice you rarely build proofs by hand: | [DataLayer](/sdk/primitives/datalayer) | `Datastore`, `DatastoreInfo` | `chip-0035` | | [Action Layer](/sdk/primitives/action-layer) | `CatalogRegistry`, `XchandlesRegistry`, `RewardDistributor`, `MedievalVault`, `StateScheduler`, `Slot` | `action-layer` | +These primitives are on the SDK's `main` branch and aren't in a release yet: + +| Page | Types | Feature | +| ------------------------------------------------ | -------------------------------------------------------- | ----------- | +| [Partial Offers](/sdk/primitives/partial-offers) | `PartialOffer`, `PartialOfferInfo`, `PartialOfferFill` | `chip-0052` | +| [Auctions](/sdk/primitives/auctions) | `Auction`, `AuctionInfo`, `AuctionSettings` | `auction` | +| [Splitter](/sdk/primitives/splitter) | `SplitterLayer`, `SplitterArgs`, `SplitterShare` | `chip-0008` | +| [NFT Fusion](/sdk/primitives/fusion) | `Fusion`, `FusionSwap`, `P2FusionLayer` | `chip-0021` | +| [Secure the Bag](/sdk/primitives/secure-the-bag) | `SecureTheBag`, `BagFormat`, `BagPayment`, `SecuredMint` | | + +Vault [signed messages](/sdk/primitives/vaults#signed-messages) are also unreleased. + All primitive types live in `chia_wallet_sdk::driver`, and the most common ones are re-exported from `chia_wallet_sdk::prelude`. See the [driver rustdoc](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/) for the full API. diff --git a/docs/sdk/primitives/partial-offers.md b/docs/sdk/primitives/partial-offers.md new file mode 100644 index 0000000..f858455 --- /dev/null +++ b/docs/sdk/primitives/partial-offers.md @@ -0,0 +1,407 @@ +--- +title: Partial Offers +--- + +# Partial Offers + +A partial offer ([CHIP-0052](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0052.md)) sells one fungible asset for another at a fixed price, and lets takers fill as much or as little of it as they want. The maker locks the offered asset (XCH, a CAT or a revocable CAT) in a coin that anyone can spend, as long as they pay the maker the requested asset at the agreed price. Each fill releases part of the offered asset to the taker and recreates the coin with what's left, until it's fully filled or the maker claws it back. + +Use a partial offer when you want order-book style trading of a single pair, for example "1000 CATs at 0.4 XCH each, in any amount". For trading several assets at once, NFTs or options, or anything that has to be taken all at once, use a normal [offer](/sdk/offers). + +:::info Unreleased +This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). +To try it, depend on the git repository with the `chip-0052` feature: +`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["chip-0052"] }` +::: + +The driver types are in [`chia_wallet_sdk::driver`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/index.html) and the puzzle types in `chia_wallet_sdk::types::puzzles`. None of them are in the prelude. + +## Partial offers and normal offers + +| | [Offer](/sdk/offers) | Partial offer | +| ------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| Assets | Any number of XCH, CATs, NFTs and options on each side | Exactly one asset on each side: XCH, a CAT or a revocable CAT | +| Filling | All or nothing | Any amount at or above a minimum, any number of times | +| Where the funds are | In the maker's coins, which are only spent when the offer is taken | In a partial offer coin, which is created by the maker's spends (on the first fill, or up front) | +| Cancelling | Spend any of the offered coins | Claw back the latest partial offer coin | +| Expiration | Optional `ASSERT_BEFORE_SECONDS_ABSOLUTE` in the maker's spend | Optional `ASSERT_BEFORE_SECONDS_ABSOLUTE` output on every fill | +| Shared as | `offer1...` | `partial1...` | + +The two fit together: filling a partial offer produces an ordinary `Offer`, which the taker takes with the same action system code as any other offer. + +## How it works + +The partial offer coin's p2 puzzle is a 1-of-2 puzzle (`P2OneOfManyLayer`) with two leaves: + +- **The partial offer puzzle**, which anyone can spend. Its solution says how much of the requested asset the taker pays (`other_asset_amount`). The puzzle then: + - asserts a notarized payment of that amount to the maker from the requested asset's settlement payments puzzle, with the coin's parent coin id as the nonce, + - releases `other_asset_amount * price_precision / precision` (rounded down) of the offered asset, + - recreates itself with the remaining amount, and + - outputs a fixed list of **persistent conditions**, which the SDK uses for an expiration (`ASSERT_BEFORE_SECONDS_ABSOLUTE`) and a minimum fee per fill (`RESERVE_FEE`). +- **The maker's puzzle hash**, so the maker can claw back whatever is left at any time, including after the offer expires. + +For a CAT, the 1-of-2 puzzle is wrapped in the CAT layer (and the revocation layer for a revocable CAT). The partial offer puzzle uses a CAT maker (`CatMaker`) to compute the full puzzle hash of the offered asset, so the same puzzle works for all three kinds of asset. + +The payout to the taker isn't signed by anyone, so the SDK always sends it to the settlement payments puzzle, as a settlement coin that the taker spends and asserts from their own signed spend. That's what makes a fill an ordinary offer. + +## Types + +| Type | What it is | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `PartialOfferAsset` | One side of the trade: `Xch`, or `Cat { asset_id, hidden_puzzle_hash }` (revocable if `hidden_puzzle_hash` is set). | +| `PartialOfferInfo` | The terms: offered and requested asset, `maker_puzzle_hash`, `price`, `min_requested_amount`, and optional `expiration` and `required_fee`. | +| `PartialOffer` | A partial offer coin on (or about to be on) chain: `coin`, `lineage_proof` (for CATs) and `info`. Fills, claws back and parses. | +| `PartialOfferFill` | The result of a fill: the `Offer` to take, `requested_amount`, `offered_amount`, `required_fee`, and the recreated `child` coin (if anything is left). | +| `PartialOfferBundle` | A partial offer as it's shared: the `PartialOffer` plus the maker's spends that create it (none if it's already on chain). | +| `PartialOfferHint` | The placeholder spend's puzzle reveal in an encoded partial offer, which describes the coin's terms and lineage proof. | +| `PartialOfferPrice` (types) | `price_precision` and `precision` (both `NonZeroU64`): each unit of the requested asset releases `price_precision / precision` of the offered asset. | +| `PartialOfferArgs`, `PartialOfferSolution` (types) | The curried arguments and solution of the partial offer puzzle (`PARTIAL_OFFER_PUZZLE`, `PARTIAL_OFFER_PUZZLE_HASH`). | + +`PartialOfferInfo::new` takes a `PartialOfferPrice` directly, and `PartialOfferInfo::from_amounts(offered, requested, maker_puzzle_hash, offered_amount, requested_amount)` reduces the two amounts to a price (1000 for 400 becomes 5 for every 2). The other terms are set with `with_min_requested_amount`, `with_expiration` (a timestamp in seconds) and `with_required_fee`. Given the terms, `p2_puzzle_hash()` is what the maker sends the offered asset to, and `puzzle_hash()` is the full puzzle hash of the coin. + +## Making, sharing and taking a partial offer + +To make a partial offer: + +1. Build the `PartialOfferInfo`. +2. Send the offered amount to `info.p2_puzzle_hash()`, with `info.creation_memos(ctx)?` as the memos. The memos hint the coin to the maker and record the requested asset, so the coin can be found on chain. Sign the spend bundle, but don't broadcast it unless you want the coin to exist up front. +3. `PartialOfferBundle::from_maker_spend_bundle(ctx, spend_bundle, info)?` finds the partial offer coin in your spends and checks that the bundle is safe to share (see [Security considerations](#security-considerations)). +4. `bundle.to_spend_bundle(ctx)?` adds the placeholder spend that describes the coin, and `encode_partial_offer` turns it into a `partial1...` string (this needs the `offer-compression` feature as well). + +To take it, decode it with `decode_partial_offer` and parse it with `PartialOfferBundle::from_spend_bundle(ctx, &spend_bundle)?`. Check the terms in `bundle.partial_offer().info`, then call `bundle.fill(ctx, requested_amount)?`. The fill's `offer` contains the maker's spends (so the first fill also creates the coin) and the spend of the partial offer coin, and requests the payment to the maker. Take it like any other offer, adding `fill.actions()`, which are the settle actions for the payment plus the required fee. + +This example sells 1000 CAT mojos for 400 XCH mojos, with a minimum fill, an expiration and a required fee. Bob fills part of it, then takes the rest from the recreated coin: + +```rust unreleased +use std::num::NonZeroU64; + +use chia_wallet_sdk::driver::{ + PartialOfferAsset, PartialOfferBundle, PartialOfferInfo, decode_partial_offer, + encode_partial_offer, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::sign_transaction; +use chia_wallet_sdk::types::puzzles::PartialOfferPrice; +use indexmap::indexmap; + +fn nz(value: u64) -> NonZeroU64 { + NonZeroU64::new(value).unwrap() +} + +fn balance(sim: &Simulator, puzzle_hash: Bytes32) -> u64 { + sim.unspent_coins(puzzle_hash, false).iter().map(|coin| coin.amount).sum() +} + +#[test] +fn partial_cat_for_xch() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1_000); + let bob = sim.bls(500); + let alice_keys = indexmap! { alice.puzzle_hash => alice.pk }; + let bob_keys = indexmap! { bob.puzzle_hash => bob.pk }; + + // Alice issues 1000 mojos of a CAT + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + let deltas = spends.apply(ctx, &[Action::single_issue_cat(None, 1_000)])?; + let outputs = spends.finish_with_keys(ctx, &deltas, Relation::None, &alice_keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + let cat = outputs.cats[&Id::New(0)][0]; + let asset_id = cat.info.asset_id; + let offered_asset = PartialOfferAsset::Cat { asset_id, hidden_puzzle_hash: None }; + + // Maker: 1000 of the CAT for 400 mojos, in fills of at least 50 mojos, for an hour, + // with a fee of at least 10 mojos on every fill + let info = PartialOfferInfo::from_amounts( + offered_asset, + PartialOfferAsset::Xch, + alice.puzzle_hash, + nz(1_000), + nz(400), + ) + .with_min_requested_amount(nz(50)) + .with_expiration(sim.next_timestamp() + 3_600) + .with_required_fee(10); + assert_eq!(info.price, PartialOfferPrice::new(nz(5), nz(2))); + + let memos = info.creation_memos(ctx)?; + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(cat); + let deltas = spends.apply( + ctx, + &[Action::send(Id::Existing(asset_id), info.p2_puzzle_hash(), 1_000, memos)], + )?; + spends.finish_with_keys(ctx, &deltas, Relation::None, &alice_keys)?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[alice.sk.clone()])?; + + let bundle = PartialOfferBundle::from_maker_spend_bundle( + ctx, + SpendBundle::new(coin_spends, signature), + info, + )?; + let text = encode_partial_offer(&bundle.to_spend_bundle(ctx)?)?; + assert!(text.starts_with("partial1")); + + // Taker: decode the partial offer and check its terms + let bundle = PartialOfferBundle::from_spend_bundle(ctx, &decode_partial_offer(&text)?)?; + let partial_offer = *bundle.partial_offer(); + assert_eq!(partial_offer.info, info); + assert_eq!(partial_offer.coin.amount, 1_000); + + // Pay 100 mojos, which releases 250 of the CAT + let fill = bundle.fill(ctx, 100)?; + assert_eq!(fill.offered_amount, 250); + assert_eq!(fill.required_fee, 10); + + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(fill.offer.offered_coins().clone()); + spends.add(bob.coin); + let deltas = spends.apply(ctx, &fill.actions())?; + spends.finish_with_keys(ctx, &deltas, Relation::AssertConcurrent, &bob_keys)?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[bob.sk.clone()])?; + sim.new_transaction(fill.offer.take(SpendBundle::new(coin_spends, signature)))?; + + // The partial offer coin was recreated with the remaining 750 + let child = fill.child.expect("not fully filled"); + assert_eq!(child.coin.amount, 750); + assert!(sim.coin_state(child.coin.coin_id()).is_some()); + + // Bob takes the rest. The maker's spends are on chain now, so he fills the coin directly. + let requested_amount = child.max_requested_amount().expect("fillable"); + assert_eq!(requested_amount, 300); + let fill = PartialOfferBundle::from_coin(child).fill(ctx, requested_amount)?; + assert!(fill.child.is_none()); + + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(fill.offer.offered_coins().clone()); + for coin in sim.unspent_coins(bob.puzzle_hash, false) { + spends.add(coin); + } + let deltas = spends.apply(ctx, &fill.actions())?; + spends.finish_with_keys(ctx, &deltas, Relation::AssertConcurrent, &bob_keys)?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[bob.sk.clone()])?; + sim.new_transaction(fill.offer.take(SpendBundle::new(coin_spends, signature)))?; + + // Alice received 400 mojos, and Bob has all of the CAT and paid 20 mojos in fees + assert_eq!(balance(&sim, alice.puzzle_hash), 400); + assert_eq!(balance(&sim, offered_asset.puzzle_hash(bob.puzzle_hash)), 1_000); + assert_eq!(balance(&sim, bob.puzzle_hash), 500 - 400 - 20); + + Ok(()) +} +``` + +A few things to note: + +- `fill.actions()` doesn't reserve the required fee again, since the partial offer coin already outputs `RESERVE_FEE`. Add `Action::fee` for any fee on top of it. If the taker leaves the fee out, the spend bundle fails with `ReserveFeeConditionFailed`. +- The payout goes to the taker's change puzzle hash, like any offered asset. Send it elsewhere with an `Action::send` of the offered asset. +- A taker who requests a CAT has to add their CAT coins to the spends, since the payment to the maker is made in that CAT. +- After an expired offer's deadline, fills fail with `AssertBeforeSecondsAbsoluteFailed`, and the remaining funds stay in the coin until the maker claws them back. + +## Fill amounts and rounding + +Each fill pays `requested_amount` and releases `requested_amount * price_precision / precision`, rounded down. The SDK checks fill amounts before building anything: + +- `PartialOfferInfo::offered_amount(requested)` and `requested_amount(offered)` convert between the two sides (the latter is the smallest payment that releases at least that much), returning `None` if the result doesn't fit in a `u64`. +- `PartialOffer::offered_amount(requested)` also checks the coin: it fails with `DriverError::InvalidPartialOfferFill` if the payment is below the minimum, releases nothing, or would release more than the coin has left. `fill` and `fill_spend` do the same check. +- `PartialOffer::max_requested_amount()` is the smallest payment that releases as much of the coin as possible. Because of rounding, a tiny remainder can be impossible to release, in which case only the maker can reclaim it. +- `PartialOffer::child(requested)` is the coin that a fill recreates, or `None` if it takes everything. + +```rust unreleased +use std::num::NonZeroU64; + +use chia_wallet_sdk::driver::{PartialOffer, PartialOfferAsset, PartialOfferInfo}; +use chia_wallet_sdk::prelude::*; + +fn nz(value: u64) -> NonZeroU64 { + NonZeroU64::new(value).unwrap() +} + +#[test] +fn partial_offer_amounts() { + // 1 XCH mojo for every 3 CAT mojos, in fills of at least 2 CAT mojos + let info = PartialOfferInfo::from_amounts( + PartialOfferAsset::Xch, + PartialOfferAsset::Cat { asset_id: Bytes32::new([1; 32]), hidden_puzzle_hash: None }, + Bytes32::new([2; 32]), + nz(1), + nz(3), + ) + .with_min_requested_amount(nz(2)); + + let offer = PartialOffer::new( + Coin::new(Bytes32::new([3; 32]), info.puzzle_hash(), 10), + None, + info, + ); + + // Below the minimum, and a fill that rounds down to nothing + assert!(matches!(offer.offered_amount(1), Err(DriverError::InvalidPartialOfferFill))); + assert!(matches!(offer.offered_amount(2), Err(DriverError::InvalidPartialOfferFill))); + + // Rounding down means paying 5 releases no more than paying 3 + assert_eq!(offer.offered_amount(3).ok(), Some(1)); + assert_eq!(offer.offered_amount(5).ok(), Some(1)); + assert_eq!(info.requested_amount(1), Some(3)); + + // More than the coin has left + assert!(matches!(offer.offered_amount(33), Err(DriverError::InvalidPartialOfferFill))); + + // Paying 30 takes everything, and smaller fills recreate the coin + assert_eq!(offer.max_requested_amount(), Some(30)); + assert_eq!(offer.child(30), None); + assert_eq!(offer.child(3).map(|child| child.coin.amount), Some(9)); +} +``` + +## Tracking, parsing and cancelling + +The partial offer coin changes on every fill, so to show a maker their open partial offers, or to fill one that has already been partially filled, you need its latest coin: + +- The first coin is hinted to the maker, since the creation memos start with the maker's puzzle hash. `PartialOfferInfo::parse_creation_memos(allocator, memos)` reads the maker puzzle hash and requested asset back from the `CREATE_COIN` that created it. The price and other terms aren't in the memos, so the maker should keep the `PartialOfferInfo` they made. +- `PartialOffer::parse(allocator, coin, puzzle, solution, requested_asset)` parses the spend of a partial offer coin that filled it, returning the coin (with its full terms) and the amount paid. It returns `None` for a spend that isn't a fill, such as a claw back. +- `PartialOffer::parse_child(...)` returns the coin that the fill recreated, or `None` if nothing is left. Follow it from the first coin until you reach an unspent one. + +The puzzle only commits to the hash of the requested asset's settlement puzzle, so for a CAT you need to pass the requested asset in (for example, from the creation memos). Without it, parsing a fill of an offer that requests a CAT fails with `DriverError::MissingAssetInfo`. For XCH, `None` is fine. + +To cancel a partial offer, the maker claws back its latest coin with `partial_offer.claw_back(ctx, inner_spend)?`, where `inner_spend` is a spend of the maker's puzzle (for a standard wallet, `StandardLayer::spend_with_conditions`) that creates a coin with the full remaining amount. `claw_back_spend` returns the spend without adding it to the context. Clawing back works at any time, with or without an expiration. If the coin was never created on chain, the maker can instead spend the coins their partial offer spends, just like a normal offer. + +This example creates the partial offer coin on chain right away, has Bob fill part of it, then has Alice find the latest coin from Bob's spend and claw it back: + +```rust unreleased +use std::num::NonZeroU64; + +use chia_wallet_sdk::driver::{ + PartialOffer, PartialOfferAsset, PartialOfferBundle, PartialOfferInfo, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::sign_transaction; +use indexmap::indexmap; + +fn nz(value: u64) -> NonZeroU64 { + NonZeroU64::new(value).unwrap() +} + +fn balance(sim: &Simulator, puzzle_hash: Bytes32) -> u64 { + sim.unspent_coins(puzzle_hash, false).iter().map(|coin| coin.amount).sum() +} + +#[test] +fn claw_back_partial_offer() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1_000); + let bob = sim.bls(200); + let alice_keys = indexmap! { alice.puzzle_hash => alice.pk }; + + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(alice.coin); + let deltas = spends.apply(ctx, &[Action::single_issue_cat(None, 1_000)])?; + let outputs = spends.finish_with_keys(ctx, &deltas, Relation::None, &alice_keys)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + let cat = outputs.cats[&Id::New(0)][0]; + let asset_id = cat.info.asset_id; + let offered_asset = PartialOfferAsset::Cat { asset_id, hidden_puzzle_hash: None }; + + let info = PartialOfferInfo::from_amounts( + offered_asset, + PartialOfferAsset::Xch, + alice.puzzle_hash, + nz(1_000), + nz(400), + ); + + // Alice creates the partial offer coin on chain up front + let memos = info.creation_memos(ctx)?; + let mut spends = Spends::new(alice.puzzle_hash); + spends.add(cat); + let deltas = spends.apply( + ctx, + &[Action::send(Id::Existing(asset_id), info.p2_puzzle_hash(), 1_000, memos)], + )?; + spends.finish_with_keys(ctx, &deltas, Relation::None, &alice_keys)?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[alice.sk.clone()])?; + let bundle = PartialOfferBundle::from_maker_spend_bundle( + ctx, + SpendBundle::new(coin_spends, signature), + info, + )?; + sim.new_transaction(bundle.spend_bundle().clone())?; + let first = *bundle.partial_offer(); + assert!(sim.hinted_coins(alice.puzzle_hash).contains(&first.coin.coin_id())); + + // Bob pays 200 mojos for 500 of the CAT + let fill = PartialOfferBundle::from_coin(first).fill(ctx, 200)?; + let mut spends = Spends::new(bob.puzzle_hash); + spends.add(fill.offer.offered_coins().clone()); + spends.add(bob.coin); + let deltas = spends.apply(ctx, &fill.actions())?; + spends.finish_with_keys( + ctx, + &deltas, + Relation::AssertConcurrent, + &indexmap! { bob.puzzle_hash => bob.pk }, + )?; + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[bob.sk.clone()])?; + sim.new_transaction(fill.offer.take(SpendBundle::new(coin_spends, signature)))?; + + // Alice follows the fill from the coin she created to the latest coin + let coin_spend = sim.coin_spend(first.coin.coin_id()).expect("spent"); + let puzzle = ctx.alloc(&coin_spend.puzzle_reveal)?; + let solution = ctx.alloc(&coin_spend.solution)?; + let puzzle = Puzzle::parse(ctx, puzzle); + + let (parsed, paid) = PartialOffer::parse(ctx, first.coin, puzzle, solution, None)? + .expect("is a fill"); + assert_eq!(parsed.info, info); + assert_eq!(paid, 200); + + let latest = PartialOffer::parse_child(ctx, first.coin, puzzle, solution, None)? + .expect("not fully filled"); + assert_eq!(latest.coin.amount, 500); + + // She claws back the remaining 500 + let hint = ctx.hint(alice.puzzle_hash)?; + let inner_spend = StandardLayer::new(alice.pk).spend_with_conditions( + ctx, + Conditions::new().create_coin(alice.puzzle_hash, 500, hint), + )?; + latest.claw_back(ctx, inner_spend)?; + sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; + + assert_eq!(balance(&sim, offered_asset.puzzle_hash(alice.puzzle_hash)), 500); + assert_eq!(balance(&sim, offered_asset.puzzle_hash(bob.puzzle_hash)), 500); + assert_eq!(balance(&sim, alice.puzzle_hash), 200); + + Ok(()) +} +``` + +`PartialOffer::cat()` returns the coin as a `Cat` whose p2 puzzle hash is the 1-of-2 puzzle, if the offered asset is a CAT, which is useful for displaying it alongside the maker's other CAT coins. + +## The encoded format + +An encoded partial offer is the maker's spend bundle plus one placeholder coin spend, compressed like an offer and encoded as bech32m with the `partial` prefix. The placeholder coin has the puzzle hash `PARTIAL_OFFER_HINT_PUZZLE_HASH` (32 bytes of `0x01`), the partial offer coin's parent coin id and amount, and a puzzle reveal of `PartialOfferHint`: the lineage proof, both assets, the price, the maker puzzle hash, the quoted persistent conditions and the minimum amount minus one. `PartialOfferInfo::alloc_hint` and `parse_hint` convert between the two. + +If the bundle contains the maker's spends, `from_spend_bundle` checks that they create the described coin and derives the lineage proof from them. If it doesn't, the coin is supposed to already be on chain, and the lineage proof comes from the hint. `PartialOfferBundle::from_coin` makes a bundle for a coin that's on chain, without any spends. + +## Lower-level spends + +`PartialOffer::fill_spend(ctx, requested_amount)` returns just the `Spend` of the partial offer coin, without the payment to the maker, so it's only valid alongside a matching notarized payment, which `notarized_payment(ctx, requested_amount)` builds. `PartialOfferInfo::construct_partial_puzzle`, `partial_puzzle_hash` and `merkle_tree` expose the pieces of the puzzle. Prefer `fill`, which builds both sides as an `Offer` and makes sure the payout is asserted. + +## Security considerations + +- **Only share bundles from `from_maker_spend_bundle`.** It rejects a bundle that creates the partial offer coin from a settlement payments coin (`DriverError::UnsignedPartialOfferCreation`), since anyone could change that spend's solution and take the funds. It also requires the creation memos (`DriverError::InvalidMemo`) and exactly one partial offer coin (`DriverError::PartialOfferNotCreated`, `DriverError::PartialOfferSiblings`). +- **One partial offer per parent coin.** The payment's nonce is the coin's parent id, so two partial offer coins with the same parent, maker and requested asset could both be filled by one payment, even if their prices differ. `from_maker_spend_bundle` rejects this with `DriverError::PartialOfferSiblings`; if you create partial offers some other way, give each its own parent. +- **Look up the latest coin before filling.** A shared partial offer describes the coin at the time it was shared. Once it's been filled or clawed back, the maker's spends in it can no longer be included, so check the coin on chain and follow `parse_child` to its latest descendant. +- **The payout must be asserted.** The partial offer solution isn't signed, so a payout that isn't asserted by the taker could be redirected by anyone who sees the transaction. `fill` sends it to a settlement coin that the taker's spends assert, and the CHIP-0052 puzzle's optional taker `CREATE_COIN` should not be used without an assertion. +- **Only known persistent conditions are accepted.** Parsing fails with `DriverError::UnsupportedPartialOfferConditions` if the persistent conditions contain anything other than one expiration and one fee, so that a wallet never hides a term it can't display. +- **Revocable CATs are a different asset.** As with normal offers, a CAT with a `hidden_puzzle_hash` can be clawed back by its issuer, and isn't interchangeable with a non-revocable CAT of the same asset id. Check `PartialOfferAsset` on both sides before filling. diff --git a/docs/sdk/primitives/secure-the-bag.md b/docs/sdk/primitives/secure-the-bag.md new file mode 100644 index 0000000..973f893 --- /dev/null +++ b/docs/sdk/primitives/secure-the-bag.md @@ -0,0 +1,349 @@ +--- +title: Secure the Bag +--- + +# Secure the Bag + +**Secure the Bag** lets you commit to a large list of payments with a single coin. The payments are +arranged into a tree of nodes, and each node's puzzle is a fixed list of `CREATE_COIN` conditions for its +children. The root of the tree is one puzzle hash. Sending the total amount to that puzzle hash secures +every payment in a single transaction, and you don't need to create thousands of coins up front. Later, +anyone can **unroll** the bag by spending nodes, either all at once or only along the path to one +payment. Node puzzles need no signature, and they can only output the committed children, so the +payments can't be changed or redirected once the root coin exists. + +Use it for airdrops and bulk payouts that are too large for one block, or when you want the payments +to be guaranteed now and let the recipients (or anyone else) pay the fees to claim them later. It +works for XCH, CATs and revocable CATs. It can also secure NFT bulk mints, through the secure the mint +pre-launcher puzzle (`SecuredMint`). + +:::info Unreleased +This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). +To try it, depend on the git repository: +`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk" }` +::: + +There's no feature flag. Everything on this page is in `chia_wallet_sdk::driver`. + +## The types + +| Type | What it is | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `SecureTheBag` | A built bag: its root hash, total amount, depth, payments and nodes. Spends nodes and unrolls the bag | +| `BagFormat` | How payments are ordered and grouped into nodes, and what each node outputs. Only `Legacy` exists for now | +| `BagAsset` | The asset in the bag: `Xch`, `Cat { asset_id }` or `RevocableCat { asset_id, hidden_puzzle_hash }` | +| `BagPayment` | One payment: recipient puzzle hash, amount and an optional `ClawbackV2` | +| `BagNode` / `BagChild` | A node and its children. A child is either another node or a payment | +| `BagCoin` | A coin in the bag on chain, `Xch(Coin)` or `Cat(Cat)`. Used as the starting point for spends | +| `SecuredMint` / `SecuredMintOutput` | An NFT mint secured by a one mojo pre-launcher coin, and the coins that minting from it creates | + +The root and node puzzle hashes are **p2 puzzle hashes**. For XCH they're the coins' puzzle hashes. For +CATs they're the puzzle hashes inside the CAT layer (and the revocation layer), so the same tree works +for every asset. `SecureTheBag::root_hash` is the p2 puzzle hash you send the funds to, and +`root_puzzle_hash` is the full puzzle hash of the root coin, including the asset's outer puzzle. + +## The Legacy format + +`BagFormat::Legacy` matches the bags that Cloud Wallet has already created on chain, so the SDK can +rebuild and unroll them. `BagFormat::legacy()` is the default: sorted payments and the default width. + +- Payments are sorted by the puzzle hash of the coin each one creates (when `sorted` is true), then split + into consecutive chunks of `leaf_width`. The nodes of each level are chunked the same way, level by + level, until one node remains. That node is the root. +- The default width depends on the number of payments: 100 for up to 1,000 payments, 250 up to 10,000, + 500 up to 100,000, and 1,000 beyond that. Set `leaf_width: Some(n)` to override it. +- Each node's puzzle is `(q . conditions)`. The first condition is a `CREATE_COIN_ANNOUNCEMENT` of `"$"`, + which a fee spend can assert. After it comes one `CREATE_COIN` per child. A payment is hinted with its + puzzle hash, a node with its own puzzle hash, and a clawback payment with + `(receiver_puzzle_hash clawback_memo)`, so wallets can find the clawback from the parent spend. +- A single payment without a clawback isn't bagged at all. The root hash is then the payment's own puzzle + hash, the depth is 0 and there are no nodes. +- Airdrops sort their payments, but Cloud Wallet's NFT bulk mints keep the order of the mints. For those, + use `BagFormat::Legacy { leaf_width, sorted: false }`. The root then depends on the order of the input. + +The root hash depends on the format, the width, the order (if unsorted) and every payment. To rebuild a +bag that's already on chain, you need the exact same inputs. + +## Building a bag + +`SecureTheBag::build(format, asset, payments)` builds the whole tree in memory. It fails with: + +| Error | Cause | +| --------------------- | ------------------------------------------------------------------------------------------- | +| `EmptyBag` | No payments | +| `DuplicateBagPayment` | Two payments would create a coin with the same puzzle hash | +| `BagClawbackMismatch` | A payment's clawback has a different receiver or amount than the payment | +| `AmountOverflow` | The total doesn't fit in a `u64` | +| `InvalidBagWidth` | A width of 0, or a width of 1 with more than one payment, which would never reach a root | +| `BagNodeCollision` | A payment's puzzle hash is the same as one of the nodes, which would make the bag ambiguous | + +Two payments to the same recipient and amount would create identical coins, so `build` rejects them. If +you need to pay someone twice, merge the payments. A payment and a clawback payment to the same receiver +are fine, since the clawback coin has a different puzzle hash. + +Once the bag is built, fund it by creating a coin of the asset with `root_hash()` (inside the asset's +outer puzzle) and exactly `total_amount()`. A root coin with less can't be spent, and anything more would +be lost to fees. + +The bag also lets you navigate the tree: + +- `payments()` returns the payments in the order of the bag (sorted, for a sorted format). +- `nodes()` returns every node keyed by its p2 puzzle hash, in the order they were built. Each level comes + before the level above it, so the root is last. +- `path(puzzle_hash)` returns the nodes from the root down to the coin with that p2 puzzle hash. For a + payment, use `BagPayment::coin_puzzle_hash()`, which is the clawback's puzzle hash for a clawback payment. +- `parent`, `node`, `amount` and `depth` look up individual parts of the tree. + +## Unrolling a bag + +There are four ways to spend nodes, depending on how much of the bag you want to unroll: + +| Method | What it spends | +| ------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `spend_node(ctx, coin)` | One node coin. Returns the coins it creates | +| `spend_path(ctx, start, puzzle_hash)` | Every node from `start` down to one coin, usually a payment. Returns that coin | +| `unroll(ctx, start)` | Every node under `start`. Returns the payment coins | +| `node_spend(ctx, puzzle_hash)` | Nothing. Returns the p2 `Spend` of a node, which you can wrap in the asset's outer puzzle yourself | + +`coin_path(start, puzzle_hash)` calculates the coins along the same path as `spend_path`, without spending +anything. The starting coin is checked to be part of the bag, with the right asset and amount. + +The start doesn't have to be the root. Once part of the bag has been unrolled, the root is spent, so a +second claim starts from the closest unspent node on its path. This example builds a bag of ten XCH +payments with a width of three. It funds the root, claims one payment, then claims a second payment +from a node that the first claim created: + +```rust unreleased +use chia_wallet_sdk::driver::{BagAsset, BagCoin, BagFormat, BagPayment, SecureTheBag}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn claim_payments_from_a_bag() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + // Ten payments of 1 to 10 mojos, in leaves of [0, 1, 2], [3, 4, 5], [6, 7, 8] and [9]. + let payments = (0..10u8).map(|i| BagPayment::new(Bytes32::new([i; 32]), u64::from(i) + 1)); + let format = BagFormat::Legacy { leaf_width: Some(3), sorted: true }; + let bag = SecureTheBag::build(format, BagAsset::Xch, payments)?; + + assert_eq!(bag.total_amount(), 55); + assert_eq!(bag.depth(), 3); + + // Securing every payment takes one coin with the root hash and the total amount. + let alice = sim.bls(bag.total_amount()); + let hint = ctx.hint(bag.root_hash())?; + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(bag.root_hash(), bag.total_amount(), hint), + )?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + let root = BagCoin::Xch(Coin::new(alice.coin.coin_id(), bag.root_hash(), bag.total_amount())); + + // Anyone can claim a payment by spending the nodes on its path. No signature is needed. + let first = Bytes32::new([4; 32]); + let payment = bag.spend_path(ctx, root, first)?; + sim.spend_coins(ctx.take(), &[])?; + + assert_eq!(payment.coin().amount, 5); + assert_eq!(sim.hinted_coins(first), vec![payment.coin().coin_id()]); + + // The root is spent now, so the second claim starts from the unspent leaf on its path. + let second = Bytes32::new([1; 32]); + let leaf = *bag.path(second).unwrap().last().unwrap(); + let leaf_coin = *bag.coin_path(root, leaf)?.last().unwrap(); + + let payment = bag.spend_path(ctx, leaf_coin, second)?; + sim.spend_coins(ctx.take(), &[])?; + + assert_eq!(sim.hinted_coins(second), vec![payment.coin().coin_id()]); + + Ok(()) +} +``` + +### CAT bags and clawbacks + +For a CAT bag, the root and nodes are CAT coins. `BagCoin::Cat` wraps a `Cat`, and the node spends go +through `Cat::spend_all`, so lineage proofs are handled for you. A payment with a `ClawbackV2` creates a +clawback coin instead of paying the receiver directly. Build it with `BagPayment::from_clawback`. The +sender can still claw it back before the timeout, as with any [clawback](/sdk/primitives/clawback). + +This example issues a CAT straight into a bag of five payments and one clawback payment, then unrolls +all of it in one transaction: + +```rust unreleased +use chia_wallet_sdk::chia::puzzle_types::cat::GenesisByCoinIdTailArgs; +use chia_wallet_sdk::driver::{BagAsset, BagFormat, BagPayment, BagCoin, ClawbackV2, SecureTheBag}; +use chia_wallet_sdk::prelude::*; + +#[test] +fn unroll_a_cat_bag() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let alice = sim.bls(1000); + let asset_id = GenesisByCoinIdTailArgs::curry_tree_hash(alice.coin.coin_id()).into(); + let asset = BagAsset::Cat { asset_id }; + + let mut payments: Vec = (0..5u8) + .map(|i| BagPayment::new(Bytes32::new([i; 32]), 100)) + .collect(); + + // Bob's payment can be clawed back by Alice for the first 100 seconds. + let bob = Bytes32::new([9; 32]); + let clawback = ClawbackV2::new(alice.puzzle_hash, bob, 100, 500, true); + payments.push(BagPayment::from_clawback(clawback)); + + let format = BagFormat::Legacy { leaf_width: Some(2), sorted: true }; + let bag = SecureTheBag::build(format, asset, payments)?; + + // Issue the CAT directly to the root hash, which is the p2 puzzle hash inside the CAT layer. + let hint = ctx.hint(bag.root_hash())?; + let (issue, cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + bag.total_amount(), + Conditions::new().create_coin(bag.root_hash(), bag.total_amount(), hint), + )?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, issue)?; + + let root = BagCoin::Cat(cats[0]); + assert_eq!(root.coin().puzzle_hash, bag.root_puzzle_hash()); + + // Spend every node and get back the payment coins. + let coins = bag.unroll(ctx, root)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + assert_eq!(coins.len(), 6); + + for payment in bag.payments() { + let coin = coins + .iter() + .find(|coin| coin.p2_puzzle_hash() == payment.coin_puzzle_hash()) + .unwrap(); + + assert_eq!(coin.coin().amount, payment.amount); + assert!(sim.coin_state(coin.coin().coin_id()).is_some()); + + // Every payment, including the clawback, is hinted to its recipient. + assert!(sim.hinted_coins(payment.puzzle_hash).contains(&coin.coin().coin_id())); + } + + Ok(()) +} +``` + +## Secured NFT mints + +A bag can also secure an NFT bulk mint. Each mint becomes a one mojo **pre-launcher** coin, whose puzzle +is the secure the mint launcher from +[mintgarden-io/secure-the-mint](https://github.com/mintgarden-io/secure-the-mint). The puzzle commits to +the NFT's metadata hash, metadata updater, royalty and owner. When it's spent, it creates a singleton +launcher and asserts that the launcher creates the eve NFT with exactly those values. The eve NFT then +sends itself to the owner with a hint. Nobody can change what gets minted once the bag is funded, and +anyone can do the minting. + +`SecuredMint::new(metadata, metadata_updater_puzzle_hash, royalty_puzzle_hash, royalty_basis_points, p2_puzzle_hash)` +describes one mint. `bag_payment()` is the payment that creates its pre-launcher in a bag, +`mint(ctx, pre_launcher)` spends the pre-launcher, launcher and eve NFT, and `output(pre_launcher)` +calculates the same `SecuredMintOutput` (pre-launcher, launcher, eve NFT and final NFT) without spending +anything. The curried arguments are `SecureTheMintLauncherArgs` in `chia_wallet_sdk::types::puzzles`. + +The pre-launcher must have an amount of exactly 1 (`InvalidPreLauncherAmount` otherwise), since anything +more would go to the fee. With `with_melt_public_key`, the pre-launcher can also be melted without minting +(`melt`), which requires a signature from that key on the message `0x01` with the `AGG_SIG_ME` additional +data. Cloud Wallet doesn't set a melt key, and the key changes the pre-launcher's puzzle hash. + +NFT bulk mints keep the mints in order, so they use an unsorted bag: + +```rust unreleased +use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; +use chia_wallet_sdk::driver::{BagAsset, BagCoin, BagFormat, SecureTheBag, SecuredMint}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::puzzles::NFT_METADATA_UPDATER_DEFAULT_HASH; + +#[test] +fn mint_nfts_from_a_bag() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let mut mints = Vec::new(); + + for i in 0..7u8 { + let metadata = ctx.alloc_hashed(&NftMetadata { + edition_number: u64::from(i) + 1, + edition_total: 7, + data_uris: vec![format!("https://example.com/{i}.png")], + data_hash: Some(Bytes32::new([i; 32])), + ..Default::default() + })?; + + mints.push(SecuredMint::new( + metadata, + NFT_METADATA_UPDATER_DEFAULT_HASH.into(), + Bytes32::new([200; 32]), + 300, + Bytes32::new([100 + i; 32]), + )); + } + + let format = BagFormat::Legacy { leaf_width: Some(3), sorted: false }; + let bag = SecureTheBag::build(format, BagAsset::Xch, mints.iter().map(SecuredMint::bag_payment))?; + assert_eq!(bag.total_amount(), 7); + + let alice = sim.bls(bag.total_amount()); + let hint = ctx.hint(bag.root_hash())?; + StandardLayer::new(alice.pk).spend( + ctx, + alice.coin, + Conditions::new().create_coin(bag.root_hash(), bag.total_amount(), hint), + )?; + + let root = BagCoin::Xch(Coin::new(alice.coin.coin_id(), bag.root_hash(), bag.total_amount())); + let pre_launchers = bag.unroll(ctx, root)?; + + for mint in &mints { + let pre_launcher = pre_launchers + .iter() + .find(|coin| coin.p2_puzzle_hash() == mint.pre_launcher_puzzle_hash()) + .unwrap(); + + let output = mint.mint(ctx, pre_launcher.coin())?; + assert_eq!(output.nft.info.p2_puzzle_hash, mint.p2_puzzle_hash); + } + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + for mint in &mints { + assert_eq!(sim.hinted_coins(mint.p2_puzzle_hash).len(), 1); + } + + Ok(()) +} +``` + +Here the root is created, unrolled and minted in one transaction. In practice you'd create the root +first, and unroll and mint later, possibly in many transactions. + +## Clear signing + +When a [vault](/sdk/primitives/vaults) funds a bag, the root is just a coin sent to an unknown puzzle +hash. Reveal the bag with `Reveals::reveal_bag` and [clear signing](/sdk/clear-signing#secure-the-bag-payments) +shows the root as the bag's payments instead. + +## Gotchas + +- The bag isn't stored on chain. Only the root hash is, so you must keep the payment list, format, width + and order to unroll the bag later. If you lose them, the funds are stuck in the root coin. +- The node puzzles have no signature and no fee. Whoever unrolls the bag pays the fees, from a separate + spend that can assert the node's `"$"` coin announcement. +- `unroll` spends every node in one transaction. A bag with many payments won't fit in a block, so unroll + large bags a level or a node at a time with `spend_node`, or let each recipient use `spend_path`. +- Once a node is spent, start later claims from the closest unspent node on the path. Spending the root + again would be a double spend, and `coin_path` returns `NotBagAncestor` if the start isn't on the path. +- A revocable CAT bag can still be revoked by the issuer until it's unrolled, just like any revocable CAT. +- An unsorted bag's root depends on the order of the payments. Rebuild it in exactly the same order. diff --git a/docs/sdk/primitives/splitter.md b/docs/sdk/primitives/splitter.md new file mode 100644 index 0000000..fecf543 --- /dev/null +++ b/docs/sdk/primitives/splitter.md @@ -0,0 +1,232 @@ +--- +title: Splitter +--- + +# Splitter + +A splitter coin divides its amount between a fixed list of recipients. It uses the puzzle from +[CHIP-0008 (Splitter Puzzle)](https://github.com/Chia-Network/chips/blob/main/CHIPs/chip-0008.md). The +recipients and their shares are curried into the puzzle, so they can't change once funds are sent to +it. Spending the coin needs no signature: anyone can trigger the split, and the outputs are always the +same. The usual use is splitting NFT royalties between several creators, or with a charity, by using a +splitter's puzzle hash as the NFT's royalty puzzle hash. It works the same way for any asset whose outer +puzzle wraps a p2 puzzle, such as a CAT. + +:::info Unreleased +This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). +To try it, depend on the git repository with the `chip-0008` feature: +`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["chip-0008"] }` +::: + +## How the puzzle works + +The curried argument is the payout scheme, a list of `(puzzle_hash share)` pairs. The solution is the +coin's amount and the sum of all shares. When the coin is spent, the puzzle outputs: + +1. `CREATE_COIN_ANNOUNCEMENT` with an empty message, so other spends can depend on the split. +2. `ASSERT_MY_AMOUNT` for the amount in the solution. +3. One `CREATE_COIN` per recipient, in the order of the scheme. Each pays `amount * share / total_shares`, + rounded down, except the last recipient, who receives whatever is left. Each coin is hinted with the + recipient's puzzle hash. + +The puzzle fails if the total in the solution doesn't match the sum of the shares. Shares are relative, +so `80/20`, `4/1` and `8000/2000` split coins the same way. They aren't percentages. + +## Types + +| Type | Path | Purpose | +| ----------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------- | +| `SplitterLayer` | `chia_wallet_sdk::driver` | The [layer](/sdk/concepts/layers) for the puzzle: validation, payouts, spends, parsing | +| `SplitterPayout` | `chia_wallet_sdk::driver` | A recipient's puzzle hash and the amount it receives from a given coin | +| `SplitterShare` | `chia_wallet_sdk::types::puzzles` | A recipient in the payout scheme: puzzle hash and share | +| `SplitterArgs`, `SplitterSolution` | `chia_wallet_sdk::types::puzzles` | The curried arguments and the solution | +| `SPLITTER_PUZZLE`, `SPLITTER_PUZZLE_HASH` | `chia_wallet_sdk::types::puzzles` | The compiled puzzle (from the CHIP's reference implementation) and its mod hash | + +`SplitterLayer::new(shares)` validates the scheme and returns `DriverError::InvalidSplitter` unless there +is at least one recipient, every share is nonzero, the puzzle hashes are unique, and the shares sum to at +most `u64::MAX`. The puzzle itself would accept some of these schemes, but its spends would always fail +(two recipients with the same puzzle hash and amount would create the same coin twice, for example), so +coins sent to them would be stuck. + +The layer's puzzle hash, `splitter.tree_hash()`, is the address you send funds to, or the royalty puzzle +hash you mint NFTs with. The order of the recipients is part of the puzzle hash. + +## Splitting XCH + +The splitter's spend reissues the whole amount of the coin, so it can't pay a fee itself. Any fee comes +from another coin, which should assert `SplitterLayer::announcement_id(coin_id)` so that the fee is only +paid if the split happens in the same block. + +`payouts(amount)` calculates what each recipient gets, using the same rounding as the puzzle, and +`inner_spend(ctx, amount)` builds the spend. For an XCH coin, the inner spend is the whole spend: + +```rust unreleased +use chia_wallet_sdk::driver::SplitterLayer; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::SplitterShare; + +#[test] +fn split_xch() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let artist = sim.bls(0); + let charity = sim.bls(0); + + // 80% to the artist and 20% to the charity. + let splitter = SplitterLayer::new(vec![ + SplitterShare::new(artist.puzzle_hash, 80), + SplitterShare::new(charity.puzzle_hash, 20), + ])?; + let splitter_puzzle_hash: Bytes32 = splitter.tree_hash().into(); + + // Alice funds the splitter, and pays the fee for splitting it in the same transaction. + let alice = sim.bls(1_004); + let alice_p2 = StandardLayer::new(alice.pk); + let coin = Coin::new(alice.coin.coin_id(), splitter_puzzle_hash, 1_003); + + let memos = ctx.hint(splitter_puzzle_hash)?; + alice_p2.spend( + ctx, + alice.coin, + Conditions::new() + .create_coin(splitter_puzzle_hash, 1_003, memos) + .reserve_fee(1) + .assert_coin_announcement(SplitterLayer::announcement_id(coin.coin_id())), + )?; + + // Anyone can split the coin, since no signature is needed. + let spend = splitter.inner_spend(ctx, coin.amount)?; + ctx.spend(coin, spend)?; + + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // 1003 * 80 / 100 = 802 (rounded down), and the last recipient gets the remaining 201. + let payouts = splitter.payouts(coin.amount); + assert_eq!(payouts[0].amount, 802); + assert_eq!(payouts[1].amount, 201); + + for payout in payouts { + let child = Coin::new(coin.coin_id(), payout.puzzle_hash, payout.amount); + assert!(sim.coin_state(child.coin_id()).is_some()); + assert_eq!(sim.hinted_coins(payout.puzzle_hash), [child.coin_id()]); + } + + Ok(()) +} +``` + +## Splitting a CAT + +To split a CAT, use the splitter as the CAT's inner puzzle. Pass the inner spend to +[`Cat::spend_all`](/sdk/primitives/cat), which wraps it in the CAT layer. The children are CATs of the +same asset with the recipients' puzzle hashes as their p2 puzzle hashes: + +```rust unreleased +use chia_wallet_sdk::driver::SplitterLayer; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::SplitterShare; + +#[test] +fn split_cat() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let bob = sim.bls(0); + let carol = sim.bls(0); + let dave = sim.bls(0); + + let splitter = SplitterLayer::new(vec![ + SplitterShare::new(bob.puzzle_hash, 1), + SplitterShare::new(carol.puzzle_hash, 1), + SplitterShare::new(dave.puzzle_hash, 1), + ])?; + let splitter_puzzle_hash: Bytes32 = splitter.tree_hash().into(); + + // Issue a CAT straight to the splitter's puzzle hash. + let alice = sim.bls(100); + let alice_p2 = StandardLayer::new(alice.pk); + let memos = ctx.hint(splitter_puzzle_hash)?; + let (issue_cat, cats) = Cat::single_issuance( + ctx, + alice.coin.coin_id(), + None, + 100, + Conditions::new().create_coin(splitter_puzzle_hash, 100, memos), + )?; + alice_p2.spend(ctx, alice.coin, issue_cat)?; + sim.spend_coins(ctx.take(), &[alice.sk])?; + + // Split the CAT coin. + let spend = splitter.inner_spend(ctx, cats[0].coin.amount)?; + let children = Cat::spend_all(ctx, &[CatSpend::new(cats[0], spend)])?; + sim.spend_coins(ctx.take(), &[])?; + + let amounts: Vec = children.iter().map(|cat| cat.coin.amount).collect(); + assert_eq!(amounts, [33, 33, 34]); + assert_eq!(children[2].info.p2_puzzle_hash, dave.puzzle_hash); + + for child in children { + assert!(sim.coin_state(child.coin.coin_id()).is_some()); + } + + Ok(()) +} +``` + +## Parsing a splitter + +`SplitterLayer` implements `Layer`, so `SplitterLayer::parse_puzzle` recognizes the puzzle in a coin +spend's puzzle reveal (or inside a CAT's inner puzzle) and recovers the scheme. It returns `Ok(None)` for +other puzzles, and `DriverError::InvalidSplitter` if the scheme is one `SplitterLayer::new` would reject, +or if it doesn't serialize back to the same puzzle hash (for example, a share encoded as a non-canonical +integer, or extra values after a share, which the puzzle ignores but are part of the puzzle hash): + +```rust unreleased +use chia_wallet_sdk::driver::SplitterLayer; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::types::puzzles::SplitterShare; + +#[test] +fn parse_splitter() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + let splitter = SplitterLayer::new(vec![ + SplitterShare::new(Bytes32::new([1; 32]), 3), + SplitterShare::new(Bytes32::new([2; 32]), 7), + ])?; + + let coin = sim.new_coin(splitter.tree_hash().into(), 1_000); + let spend = splitter.inner_spend(ctx, coin.amount)?; + ctx.spend(coin, spend)?; + sim.spend_coins(ctx.take(), &[])?; + + // Later, from the confirmed coin spend alone. + let coin_spend = sim.coin_spend(coin.coin_id()).expect("splitter spend"); + let puzzle = ctx.alloc(&coin_spend.puzzle_reveal)?; + let puzzle = Puzzle::parse(ctx, puzzle); + + let parsed = SplitterLayer::parse_puzzle(ctx, puzzle)?.expect("a splitter"); + assert_eq!(parsed, splitter); + assert_eq!(parsed.total_shares(), 10); + + Ok(()) +} +``` + +## Gotchas + +- **Keep the payout scheme.** A splitter coin can only be spent by revealing its puzzle, and the scheme + can't be recovered from the puzzle hash. Until a coin with that puzzle hash has been spent once (so + its reveal is on chain), whoever wants to trigger splits needs the scheme from somewhere else. +- **Recipient count is limited by cost.** `SplitterLayer::new` doesn't cap the number of recipients, but + a spend with more than roughly 2,300 to 2,400 recipients exceeds the mempool's cost limit for a single + transaction (fewer for CATs and for large amounts, and fewer still if the transaction also pays a + fee). +- **Small amounts round to zero.** Every recipient except the last is paid a rounded-down amount, so a + coin smaller than the total shares can give recipients nothing (a 1 mojo coin split 50/50 pays 0 and 1). + The full amount always goes somewhere. +- **The recipients are puzzle hashes.** A recipient can be another splitter, a vault, or any other + puzzle. For CATs and other wrapped assets, the recipient's puzzle hash is the child's inner (p2) + puzzle hash. diff --git a/docs/sdk/primitives/vaults.md b/docs/sdk/primitives/vaults.md index 5f1a1cf..9f83170 100644 --- a/docs/sdk/primitives/vaults.md +++ b/docs/sdk/primitives/vaults.md @@ -540,6 +540,248 @@ The vault path is `P2ConditionsOrSingleton::p2_singleton_spend`, which takes the `P2Singleton::spend`. When the vault spends one of these coins, include `ASSERT_MY_COIN_ID` in the coin's delegated conditions; [clear signing](/sdk/clear-signing) requires it. +## Signed messages + +:::info Unreleased +Vault message signing is on the SDK's `main` branch and isn't in a release yet (the latest release is +0.37.1). To try it, depend on the git repository: +`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk" }` +::: + +A vault has no single key, so it can't sign an off-chain message (such as a login challenge or an +attestation) the way a BLS or secp key does. Instead, the vault's signers sign a vault spend that can +never be valid on chain. The spend carries the messages, and the signed coin spend is the proof. +Anyone who knows the vault's launcher id and current custody hash can verify it offline, without +knowing what kinds of members the vault has. + +How it works: + +1. `vault_message_envelope(ctx, &vault, &messages)` builds a delegated spend that recreates the vault + with the same custody hash and amount, and sends each message (`SEND_MESSAGE` mode `0x17`, at most + 1,024 bytes each) to `VAULT_MESSAGE_COIN_ID`. That coin has a parent id of all zeros, so it can never + exist, and neither can a valid spend that sends it a message. +2. The vault's signers sign the envelope like any other vault spend (the same `MipsSpend` and + `Vault::spend`). Wrap the resulting coin spend and aggregated BLS signature (the default signature if + no BLS signatures are needed) in a `VaultMessageProof`. +3. Send the proof as bytes (`encode` / `decode`) or as a bech32m string with the `vaultsig` prefix + (`encode_string` / `decode_string`). +4. The verifier calls `verify_vault_signed_message(&proof, launcher_id, custody_hash, genesis_challenge, max_cost)`. + It checks that the coin is the expected vault, and that the delegated spend is exactly an envelope. + Then it runs the spend with consensus rules, alongside a spend of the message coin that receives every + message. It returns the messages in order. The vault's own puzzle checks the signatures, thresholds and + restrictions, so this works for BLS, secp256r1, passkey and m-of-n custody alike. + +Before the signers approve an envelope, [clear signing](/sdk/clear-signing#signed-messages) shows it as +`VaultTransaction::signed_messages` instead of a transaction. A delegated spend can't both sign messages +and do something else (`MixedVaultMessageEnvelope`). + +```rust unreleased +use chia_wallet_sdk::driver::{ + InnerPuzzleSpend, MipsSpend, Reveals, VaultMessageProof, mips_puzzle_hash, + parse_vault_transaction, vault_message_envelope, verify_vault_signed_message, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::sign_transaction; +use chia_wallet_sdk::types::{TESTNET11_CONSTANTS, puzzles::BlsMember}; + +#[test] +fn sign_a_message_with_a_vault() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + // A vault with a single BLS member. + let alice = sim.bls(1); + let member = BlsMember::new(alice.pk); + let custody_hash = mips_puzzle_hash(0, vec![], member.curry_tree_hash(), true); + let (conditions, vault) = + Launcher::new(alice.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(alice.pk).spend(ctx, alice.coin, conditions)?; + sim.spend_coins(ctx.take(), &[alice.sk.clone()])?; + + // Include a domain, a nonce from the verifier and an expiration, since proofs can be replayed. + let messages = vec![Bytes::new( + b"Sign in to example.com\nNonce: 4f9a2c\nExpires: 2026-11-01".to_vec(), + )]; + let envelope = vault_message_envelope(ctx, &vault, &messages)?; + + // Clear signing shows the envelope as signed messages, not a transaction. + let tx = parse_vault_transaction(Reveals::default(), ctx, vault.info.launcher_id, envelope)?; + assert!(tx.signs_messages()); + assert_eq!(tx.signed_messages, messages); + assert!(tx.spends.is_empty()); + + // Sign the envelope like any other vault spend. + let mut spend = MipsSpend::new(envelope); + let member_puzzle = ctx.curry(member)?; + spend.members.insert( + custody_hash, + InnerPuzzleSpend::new(0, vec![], Spend::new(member_puzzle, NodePtr::NIL)), + ); + vault.spend(ctx, &spend)?; + + let coin_spends = ctx.take(); + let signature = sign_transaction(&coin_spends, &[alice.sk])?; + let proof = VaultMessageProof::new(coin_spends[0].clone(), signature); + + // The proof can be shared as a string. + let encoded = proof.encode_string()?; + assert!(encoded.starts_with("vaultsig1")); + let decoded = VaultMessageProof::decode_string(&encoded)?; + + // The verifier only needs the launcher id and a trusted custody hash. + let genesis_challenge = TESTNET11_CONSTANTS.genesis_challenge; + let verified = verify_vault_signed_message( + &decoded, + vault.info.launcher_id, + custody_hash.into(), + genesis_challenge, + None, + )?; + assert_eq!(verified, messages); + + // With any other custody hash, it's rejected. + let wrong_custody_hash = Bytes32::new([1; 32]); + assert!( + verify_vault_signed_message( + &decoded, + vault.info.launcher_id, + wrong_custody_hash, + genesis_challenge, + None, + ) + .is_err() + ); + + Ok(()) +} +``` + +### What the proof does and doesn't prove + +`verify_vault_signed_message` checks everything that consensus would check about the spend itself: +the puzzle reveal, the custody's signatures, `AGG_SIG_ME` against the given genesis challenge, and +assertions about the coin. It also requires the vault's actual output to be exactly the recreated vault, +and rejects any height or time assertion (`VaultMessageTimelocked`). Without the chain it can't check +those, and a timelocked member such as a recovery path could otherwise sign at any time. It doesn't check +that the vault coin exists or is unspent, so: + +- **The custody hash must come from a trusted source**, such as the vault's current coin on chain. + Anyone could make up a custody puzzle for any launcher id that needs no signature. Rekeying the vault + changes its custody hash, and with it the signers the proof is checked against. +- **Proofs can be replayed.** A proof doesn't expire and isn't tied to an application. It stays valid for + as long as the custody hash doesn't change. Put a domain, a verifier-chosen nonce and an expiration time + in the messages, and check them after verifying. +- **Verification costs CPU.** The proof is untrusted, and a proof with an invalid signature can still use + the whole cost limit before it fails. `max_cost` defaults to `VAULT_MESSAGE_PROOF_DEFAULT_MAX_COST` + (1,000,000,000; a 3-of-4 passkey vault costs about 214 million). Services that verify proofs from anyone + should rate limit them or pass a lower limit. +- Consensus runs with the rules from before hard fork 2, in mempool mode. Members that depend on newer + operators can't sign messages yet. +- Don't use the `ForceCoinMessageMod` wrapper (it's rejected) or custom restrictions that require + messages for members that should be able to sign messages. + +### Vaults controlled by other vaults + +A vault's custody can include another vault as a `SingletonMember`, so the controlling vault's signers +approve its spends. To sign messages with the controlled vault, its envelope is authorized by the +controller. The controller signs `vault_controller_envelope(ctx, &controller, controlled_coin, delegated_puzzle_hash, fast_forward)`, +which recreates the controller and sends the envelope's delegated puzzle hash to the controlled vault. +It also sends the same message to `VAULT_CONTROLLER_MESSAGE_COIN_ID`, a second coin that can never exist, +so the controller's spend can't be valid on chain either. Set `fast_forward` if the controlled vault's +member is a fast-forwardable `SingletonMemberWithMode` (mode `0x12`), which receives the message by +puzzle hash rather than coin id. + +The proof then includes the controller spends (`VaultMessageProof::with_controllers`), and is encoded +with a second version byte. Verify it with `verify_controlled_vault_signed_message`, which takes the +`VaultInfo` of every controller, since their custody hashes must also be trusted. A controller can itself be +controlled, up to 16 controller spends in total. `verify_vault_signed_message` rejects proofs with +controller spends. + +```rust unreleased +use chia_wallet_sdk::driver::{ + InnerPuzzleSpend, MipsSpend, VaultMessageProof, mips_puzzle_hash, vault_controller_envelope, + vault_message_envelope, verify_controlled_vault_signed_message, +}; +use chia_wallet_sdk::prelude::*; +use chia_wallet_sdk::test::sign_transaction; +use chia_wallet_sdk::types::{ + TESTNET11_CONSTANTS, + puzzles::{BlsMember, SingletonMember, SingletonMemberSolution}, +}; + +fn mint_vault(sim: &mut Simulator, ctx: &mut SpendContext, custody_hash: TreeHash) -> anyhow::Result { + let funder = sim.bls(1); + let (conditions, vault) = + Launcher::new(funder.coin.coin_id(), 1).mint_vault(ctx, custody_hash, ())?; + StandardLayer::new(funder.pk).spend(ctx, funder.coin, conditions)?; + sim.spend_coins(ctx.take(), &[funder.sk])?; + Ok(vault) +} + +#[test] +fn sign_a_message_with_a_controlled_vault() -> anyhow::Result<()> { + let mut sim = Simulator::new(); + let ctx = &mut SpendContext::new(); + + // The controller has a BLS member, and the controlled vault's only member is the controller. + let alice = BlsPair::new(0); + let bls_member = BlsMember::new(alice.pk); + let controller_custody = mips_puzzle_hash(0, vec![], bls_member.curry_tree_hash(), true); + let controller = mint_vault(&mut sim, ctx, controller_custody)?; + + let singleton_member = SingletonMember::new(controller.info.launcher_id); + let controlled_custody = mips_puzzle_hash(0, vec![], singleton_member.curry_tree_hash(), true); + let controlled = mint_vault(&mut sim, ctx, controlled_custody)?; + + // The controlled vault's envelope is approved by its singleton member. + let messages = vec![Bytes::new(b"example.com nonce 81c3".to_vec())]; + let envelope = vault_message_envelope(ctx, &controlled, &messages)?; + + let mut spend = MipsSpend::new(envelope); + let member_puzzle = ctx.curry(singleton_member)?; + let member_solution = ctx.alloc(&SingletonMemberSolution::new( + controller.info.custody_hash.into(), + controller.coin.amount, + ))?; + spend.members.insert( + controlled_custody, + InnerPuzzleSpend::new(0, vec![], Spend::new(member_puzzle, member_solution)), + ); + controlled.spend(ctx, &spend)?; + let vault_spend = ctx.take().remove(0); + + // The controller authorizes the envelope's delegated puzzle hash, which its BLS key signs. + let delegated_puzzle_hash = ctx.tree_hash(envelope.puzzle).into(); + let controller_envelope = + vault_controller_envelope(ctx, &controller, controlled.coin, delegated_puzzle_hash, false)?; + + let mut spend = MipsSpend::new(controller_envelope); + let member_puzzle = ctx.curry(bls_member)?; + spend.members.insert( + controller_custody, + InnerPuzzleSpend::new(0, vec![], Spend::new(member_puzzle, NodePtr::NIL)), + ); + controller.spend(ctx, &spend)?; + let controller_spend = ctx.take().remove(0); + + let signature = sign_transaction(&[vault_spend.clone(), controller_spend.clone()], &[alice.sk])?; + let proof = VaultMessageProof::with_controllers(vault_spend, vec![controller_spend], signature); + + // The verifier trusts the custody hashes of both the vault and its controller. + let verified = verify_controlled_vault_signed_message( + &proof, + controlled.info.launcher_id, + controlled_custody.into(), + &[controller.info], + TESTNET11_CONSTANTS.genesis_challenge, + None, + )?; + assert_eq!(verified, messages); + + Ok(()) +} +``` + ## CHIP-0037 puzzles With the `chip-0037` feature, the SDK adds puzzles for controlling Chia coins from Ethereum-style diff --git a/sidebars.ts b/sidebars.ts index 56d03a6..285515c 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -80,6 +80,25 @@ const sidebars: SidebarsConfig = { id: "sdk/primitives/action-layer", label: "Action Layer", }, + { + type: "category", + label: "Unreleased", + items: [ + { + type: "doc", + id: "sdk/primitives/partial-offers", + label: "Partial Offers", + }, + { type: "doc", id: "sdk/primitives/auctions", label: "Auctions" }, + { type: "doc", id: "sdk/primitives/splitter", label: "Splitter" }, + { type: "doc", id: "sdk/primitives/fusion", label: "NFT Fusion" }, + { + type: "doc", + id: "sdk/primitives/secure-the-bag", + label: "Secure the Bag", + }, + ], + }, ], }, { type: "doc", id: "sdk/offers", label: "Offers" }, From bff89c6924342a50926b1338417f8a84a0c550aa Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Nov 2026 23:48:13 -0500 Subject: [PATCH 4/7] Correct unreleased feature docs against main --- docs/sdk/clear-signing.md | 5 +++-- docs/sdk/primitives/partial-offers.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/sdk/clear-signing.md b/docs/sdk/clear-signing.md index b413673..17a12d2 100644 --- a/docs/sdk/clear-signing.md +++ b/docs/sdk/clear-signing.md @@ -292,7 +292,7 @@ Each child (`ParsedChild`) has a `transfer_type`: `Sent`, `Burned` (sent to the `Offered` (sent to the settlement puzzle), or `OfferPreSplit` (locked into a revealed `P2ConditionsOrSingleton`). Its `memos` field (`ParsedMemos`) has the effective recipient p2 puzzle hash, a decoded `ClawbackV2` if present, any human-readable memos, and the fixed conditions of an offer -pre-split. On `main` there's also `SecureTheBag` (see [below](#secure-the-bag-payments)). A child with a +pre-split. On `main` there's also `SecureTheBag` (see [below](#secure-the-bag-payments)). Also on `main`, a child with a clawback is always `Sent`. Its p2 puzzle hash is the receiver's, but the sender can still claw it back, so it isn't shown as burned, offered, an offer pre-split or a bag. @@ -320,7 +320,8 @@ assertions) are therefore only trusted once the transaction's time bounds rule o timeout. That means the vault (or a message-bound spend that isn't a clawback) asserts `ASSERT_SECONDS_ABSOLUTE` of at least the clawback's `seconds`. Otherwise parsing fails with `UnguaranteedClawbackExpiry`. A receiver spend without a vault message is only shown if the clawback - has expired in the same sense. + has expired in the same sense, and its conditions are never trusted as guarantees about the rest of the + transaction (only a receiver spend that receives a vault message and has expired is). - **Push-through**: once a clawback has expired, anyone can push it through to the receiver without a signature. If the vault asserts the clawback coin's spend (`ASSERT_CONCURRENT_SPEND`) and that the clawback has expired (`ASSERT_SECONDS_ABSOLUTE`), the push-through is shown as a verified spend with diff --git a/docs/sdk/primitives/partial-offers.md b/docs/sdk/primitives/partial-offers.md index f858455..1e9a606 100644 --- a/docs/sdk/primitives/partial-offers.md +++ b/docs/sdk/primitives/partial-offers.md @@ -403,5 +403,5 @@ If the bundle contains the maker's spends, `from_spend_bundle` checks that they - **One partial offer per parent coin.** The payment's nonce is the coin's parent id, so two partial offer coins with the same parent, maker and requested asset could both be filled by one payment, even if their prices differ. `from_maker_spend_bundle` rejects this with `DriverError::PartialOfferSiblings`; if you create partial offers some other way, give each its own parent. - **Look up the latest coin before filling.** A shared partial offer describes the coin at the time it was shared. Once it's been filled or clawed back, the maker's spends in it can no longer be included, so check the coin on chain and follow `parse_child` to its latest descendant. - **The payout must be asserted.** The partial offer solution isn't signed, so a payout that isn't asserted by the taker could be redirected by anyone who sees the transaction. `fill` sends it to a settlement coin that the taker's spends assert, and the CHIP-0052 puzzle's optional taker `CREATE_COIN` should not be used without an assertion. -- **Only known persistent conditions are accepted.** Parsing fails with `DriverError::UnsupportedPartialOfferConditions` if the persistent conditions contain anything other than one expiration and one fee, so that a wallet never hides a term it can't display. +- **Only known persistent conditions are accepted.** Parsing fails with `DriverError::UnsupportedPartialOfferConditions` if the persistent conditions contain anything other than an optional expiration followed by an optional fee, so that a wallet never hides a term it can't display. - **Revocable CATs are a different asset.** As with normal offers, a CAT with a `hidden_puzzle_hash` can be clawed back by its issuer, and isn't interchangeable with a non-revocable CAT of the same asset id. Check `PartialOfferAsset` on both sides before filling. From 333cde98f0b98ff77ea2c5e939ce8bb321fd6e00 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Nov 2026 23:52:00 -0500 Subject: [PATCH 5/7] Describe the Wallet SDK's newer features as released --- docs/sdk/bindings.md | 7 +- docs/sdk/clear-signing.md | 137 +++++++++----------- docs/sdk/concepts/layers.md | 52 ++++---- docs/sdk/index.md | 22 +--- docs/sdk/offers.md | 180 +++++--------------------- docs/sdk/primitives/action-layer.md | 26 ++-- docs/sdk/primitives/auctions.md | 6 - docs/sdk/primitives/did.md | 5 - docs/sdk/primitives/fusion.md | 6 - docs/sdk/primitives/index.md | 17 +-- docs/sdk/primitives/partial-offers.md | 6 - docs/sdk/primitives/secure-the-bag.md | 6 - docs/sdk/primitives/splitter.md | 6 - docs/sdk/primitives/vaults.md | 11 -- sidebars.ts | 28 ++-- 15 files changed, 147 insertions(+), 368 deletions(-) diff --git a/docs/sdk/bindings.md b/docs/sdk/bindings.md index bc3c30a..1d490e9 100644 --- a/docs/sdk/bindings.md +++ b/docs/sdk/bindings.md @@ -277,10 +277,9 @@ asyncio.run(main()) The Python package has the same networking classes as Node.js: `RpcClient`, `Peer`, `Connector`, `Certificate` and `FullNodeSimulator.start_server`. -On the SDK's `main` branch (unreleased), the bindings also include partial offers, auctions, the -splitter, NFT fusion, Secure the Bag and vault signed message verification, with the same names as the -Rust types (for example `PartialOffer`, `Auction`, `Fusion` and `SecureTheBag`). The binding packages -enable every feature, so there's nothing to turn on. +The binding packages are built with every feature enabled, so partial offers, auctions, the splitter, +NFT fusion, Secure the Bag and vault signed message verification are all available, with the same names +as the Rust types (for example `PartialOffer`, `Auction`, `Fusion` and `SecureTheBag`). ## What isn't in the bindings diff --git a/docs/sdk/clear-signing.md b/docs/sdk/clear-signing.md index 17a12d2..7fac3ca 100644 --- a/docs/sdk/clear-signing.md +++ b/docs/sdk/clear-signing.md @@ -20,12 +20,9 @@ asserting that it's spent), and only if the coin's conditions can't be changed w that commitment. Anything that could be swapped out by whoever assembles the final spend bundle is either left out or causes an error. -:::note Unreleased -The SDK's `main` branch (not in a release yet; the latest release is 0.37.1) adds several clear signing -features: signed messages, vaults controlled by other vaults, Secure the Bag payments and expired -clawback push-throughs. It's also stricter in a few places. These are marked "On `main`" below. -Everything else describes 0.37.1, which `main` still does the same way. -::: +Besides ordinary spends, it understands clawback push-throughs, [Secure the Bag](#secure-the-bag-payments) +payments, [signed messages](#signed-messages), [vaults controlled by other vaults](#vaults-controlled-by-other-vaults) +and [linked offers](#linked-offers). ## How it works @@ -34,30 +31,27 @@ Everything else describes 0.37.1, which `main` still does the same way. because a delegated puzzle that computes its conditions from the solution would be malleable. 2. From those conditions it reads the vault's own effects: the odd `CREATE_COIN` that recreates the vault (`vault_child`), any even "drop" coins (`drop_coins`), `RESERVE_FEE`, expiration - (`ASSERT_BEFORE_SECONDS_ABSOLUTE`), and every `SEND_MESSAGE`. + (`ASSERT_BEFORE_SECONDS_ABSOLUTE`), and every `SEND_MESSAGE`. The vault must be recreated with an + amount of 1 (`VAULT_AMOUNT`), or parsing fails with `InvalidVaultAmount`. Clear signing doesn't know + the vault coin's actual amount, so if the vault could have more, recreating it with 1 would hide that + the rest goes to the fee. 3. Each `SEND_MESSAGE` must have the vault format: sender committed by puzzle, receiver committed by coin id. The message names the coin the vault is authorizing. That coin's spend must be revealed, its p2 puzzle must be one of the vault's (`P2Singleton`, `P2ConditionsOrSingleton`, or a clawback around one of those), and the message must equal the tree hash of the coin's delegated conditions. 4. The children of every verified spend are parsed: XCH, CATs (including revocable CATs), NFTs, and bulletins, with their memos, hints and clawbacks decoded. Spends of those children that are - asserted by the vault (`ASSERT_CONCURRENT_SPEND`) are verified recursively. + asserted by the vault (`ASSERT_CONCURRENT_SPEND`) are verified recursively. A coin that's visited + before anything asserts its spend is visited again once a later spend asserts it, so the result + doesn't depend on the order the spends are traversed in. 5. Settlement payments that the transaction asserts are matched against revealed requested payments, which is how offers are summarized. -On `main` (unreleased), there are a few differences: - -- The vault must be recreated with an amount of 1 (`VAULT_AMOUNT`), or parsing fails with - `InvalidVaultAmount`. Clear signing doesn't know the vault coin's actual amount, so if the vault could - have more, recreating it with 1 would hide that the rest goes to the fee. -- A revealed spend that can't be parsed is ignored if nothing links it to the vault. On 0.37.1 any - unparsable revealed spend fails the whole transaction. On `main` the error is still returned if the spend - receives a vault message or the transaction asserts it. -- If the delegated spend authorizes a revealed controlled vault, or is a message envelope, it's parsed - as described in [Vaults controlled by other vaults](#vaults-controlled-by-other-vaults) and - [Signed messages](#signed-messages). -- Coins that are visited before anything asserts their spend are visited again once a later spend - asserts them. The result no longer depends on the order the spends are traversed in. +A revealed spend that can't be parsed is ignored if nothing links it to the vault. If the spend receives +a vault message or the transaction asserts it, the parse error is returned. If the delegated spend +authorizes a revealed controlled vault, or is a message envelope, it's parsed as described in +[Vaults controlled by other vaults](#vaults-controlled-by-other-vaults) and +[Signed messages](#signed-messages). ## Example @@ -247,15 +241,15 @@ p2 spend (see [Custom Spends](/sdk/action-system/custom-spends)), then pass the | `reveal_clawback` | A `ClawbackV2` p2 puzzle, so spends of coins locked in it can be recognized | | `reveal_p2_conditions_or_singleton` | A `P2ConditionsOrSingleton`, so children locked to it are recognized as offer pre-splits | | `reveal_vault_nonce` | An extra `P2Singleton` nonce the vault uses (nonce 0 is always included) | -| `reveal_bag` (`main`) | A `SecureTheBag`, so a coin created with its root is shown as the bag's payments | -| `reveal_signing_vault` (`main`) | The current custody hash (and optionally the coin) of the vault being signed for | -| `reveal_controlled_vault` (`main`) | The spend of a vault controlled by the vault being signed for (`RevealedControlledVault`) | +| `reveal_bag` | A `SecureTheBag`, so a coin created with its root is shown as the bag's payments | +| `reveal_signing_vault` | The current custody hash (and optionally the coin) of the vault being signed for | +| `reveal_controlled_vault` | The spend of a vault controlled by the vault being signed for (`RevealedControlledVault`) | Coins created by the transaction are only recognized as returning to the vault if their puzzle hash is one of the vault's p2 puzzle hashes. These are calculated from the revealed nonces and reported in `VaultTransaction::p2_puzzle_hashes`. -On `main`, `reveal_signing_vault(custody_hash, coin)` tells the parser the signing vault's current +`reveal_signing_vault(custody_hash, coin)` tells the parser the signing vault's current custody hash. Clear signing can't look it up, so it's only checked by the signature. Calculate the message that the members sign (`calculate_vault_puzzle_message` or `calculate_vault_coin_message`) from the returned `VaultTransaction::signing_vault`, not from anything else in the request. Then a wrong @@ -277,24 +271,20 @@ vault's puzzle hash (`WrongSigningVaultPuzzleHash`) and an amount of 1. | `linked_offer` | Summary of the offer this transaction pre-splits coins for, if any | | `reserved_fee` | Total `RESERVE_FEE` of the vault and its verified spends | | `delegated_puzzle_hash` | The hash the signers are signing | - -On `main`, `VaultTransaction` also has: - -| Field | Meaning | -| -------------------- | ----------------------------------------------------------------------------------------------------- | -| `launcher_id` | The vault whose transaction it is: the signing vault, or the last vault in `authorization_path` | -| `custody_hash` | That vault's current custody hash, if known. Compare it with `vault_child` to detect a custody change | -| `signed_messages` | The messages signed by a message envelope, in order. `signs_messages()` is true if there are any | -| `authorization_path` | The controlled vaults that the signing vault authorizes, down to the vault whose transaction it is | -| `signing_vault` | The `SigningVault` (custody hash, puzzle hash, coin) revealed with `reveal_signing_vault` | +| `launcher_id` | The vault whose transaction it is: the signing vault, or the last vault in `authorization_path` | +| `custody_hash` | That vault's current custody hash, if known. Compare it with `vault_child` to detect a custody change | +| `signed_messages` | The messages signed by a message envelope, in order. `signs_messages()` is true if there are any | +| `authorization_path` | The controlled vaults that the signing vault authorizes, down to the vault whose transaction it is | +| `signing_vault` | The `SigningVault` (custody hash, puzzle hash, coin) revealed with `reveal_signing_vault` | Each child (`ParsedChild`) has a `transfer_type`: `Sent`, `Burned` (sent to the burn puzzle hash), -`Offered` (sent to the settlement puzzle), or `OfferPreSplit` (locked into a revealed -`P2ConditionsOrSingleton`). Its `memos` field (`ParsedMemos`) has the effective recipient p2 puzzle -hash, a decoded `ClawbackV2` if present, any human-readable memos, and the fixed conditions of an offer -pre-split. On `main` there's also `SecureTheBag` (see [below](#secure-the-bag-payments)). Also on `main`, a child with a -clawback is always `Sent`. Its p2 puzzle hash is the receiver's, but the sender can still claw it back, so -it isn't shown as burned, offered, an offer pre-split or a bag. +`Offered` (sent to the settlement puzzle), `OfferPreSplit` (locked into a revealed +`P2ConditionsOrSingleton`), or `SecureTheBag` (the root of a revealed bag, see +[below](#secure-the-bag-payments)). A child with a clawback is always `Sent`: its p2 puzzle hash is the +receiver's, but the sender can still claw it back, so it isn't shown as burned, offered, an offer +pre-split or a bag. Its `memos` field (`ParsedMemos`) has the effective recipient p2 puzzle hash, a +decoded `ClawbackV2` if present, any human-readable memos, and the fixed conditions of an offer +pre-split. To validate a custody change, compare `vault_child.custody_hash` against a [`MipsMemo`](/sdk/primitives/vaults#recovering-a-vaults-configuration-from-memos) for the new configuration. @@ -302,20 +292,16 @@ for the new configuration. ## Clawbacks and expiration Coins in a [`ClawbackV2`](/sdk/primitives/clawback) puzzle can be spent by the sender before the -timeout and by the receiver after it. If the transaction uses the sender path, it is only valid until -the clawback expires. The parser requires the vault to commit to an expiration -(`ASSERT_BEFORE_SECONDS_ABSOLUTE`) no later than that, and otherwise fails with -`DriverError::UnguaranteedClawBack`, since a transaction that could be held and replayed after the -timeout would do something different. - -On `main` (unreleased), clawback spends are handled more carefully. A vault message is received by a -coin, not by a puzzle, so another path of the same clawback could consume the message without outputting -the conditions it commits to. A clawback spend's conditions (its fees, announcement and concurrent spend -assertions) are therefore only trusted once the transaction's time bounds rule out the other paths: - -- **Sender path**: trusted once the transaction expires no later than the clawback's timeout. If a spend - found later in the traversal lowers the expiration, the sender spend is trusted after the fact. - Without that, it still fails with `UnguaranteedClawBack`. +timeout and by the receiver after it, and pushed through to the receiver by anyone once it has expired. +A vault message is received by a coin, not by a puzzle, so another path of the same clawback could +consume the message without outputting the conditions it commits to. A clawback spend's conditions (its +fees, announcement and concurrent spend assertions) are therefore only trusted once the transaction's +time bounds rule out the other paths: + +- **Sender path**: the transaction must expire (`ASSERT_BEFORE_SECONDS_ABSOLUTE`) no later than the + clawback's timeout, or parsing fails with `DriverError::UnguaranteedClawBack`, since a transaction that + could be held and replayed after the timeout would do something different. If a spend found later in + the traversal lowers the expiration, the sender spend is trusted after the fact. - **Receiver path** with a vault message: the transaction must also be unable to be valid before the timeout. That means the vault (or a message-bound spend that isn't a clawback) asserts `ASSERT_SECONDS_ABSOLUTE` of at least the clawback's `seconds`. Otherwise parsing fails with @@ -331,7 +317,7 @@ assertions) are therefore only trusted once the transaction's time bounds rule o The time bounds only come from the vault's own conditions and from message-bound spends that aren't clawbacks, never from clawback spends themselves. -```rust unreleased +```rust use chia_wallet_sdk::driver::{ ClawbackInfo, ClawbackPath, ClawbackV2, InnerPuzzleSpend, MipsSpend, P2Singleton, Reveals, mips_puzzle_hash, parse_vault_transaction, @@ -418,8 +404,7 @@ fn clear_sign_push_through() -> anyhow::Result<()> { ## Secure the Bag payments -On `main` (unreleased), a vault can fund a [Secure the Bag](/sdk/primitives/secure-the-bag). Without -help, the root coin looks like a payment to an unknown puzzle hash. Reveal the bag with +A vault can fund a [Secure the Bag](/sdk/primitives/secure-the-bag). Without help, the root coin looks like a payment to an unknown puzzle hash. Reveal the bag with `Reveals::reveal_bag(bag)` and a child created with the bag's root hash and the same asset (including the CAT asset id and a revocable CAT's hidden puzzle hash) gets `TransferType::SecureTheBag(SecureTheBagInfo)`. The child must also have exactly the bag's total amount. `SecureTheBagInfo` has the bag's format and its @@ -432,7 +417,7 @@ to the vault, since they're only paid when someone unrolls the bag. Compare the with `p2_puzzle_hashes` to show which ones return to the vault. A Legacy bag with a single payment and no clawback has no nodes, so `reveal_bag` ignores it and the payment is shown as a normal child. -```rust unreleased +```rust use chia_wallet_sdk::driver::{ BURN_PUZZLE_HASH, BagAsset, BagFormat, BagPayment, InnerPuzzleSpend, MipsSpend, P2Singleton, Reveals, SecureTheBag, TransferType, mips_puzzle_hash, parse_vault_transaction, @@ -533,14 +518,13 @@ fn clear_sign_secure_the_bag() -> anyhow::Result<()> { A verified CAT spend that runs its TAIL is reported in `issuances` as an `Issuance`, with the coin id, asset id, `extra_delta` and `IssuanceKind`. The `extra_delta` comes from the spend's solution, which the -vault's message doesn't cover. On `main` (unreleased), `Issuance::verified` is true only if the vault also -sends a message that authorizes the extra delta (for a TAIL of kind `EverythingWithSingleton`). Only +vault's message doesn't cover, so `Issuance::verified` is true only if the vault also sends a message that authorizes the extra delta (for a TAIL of kind `EverythingWithSingleton`). Only verified extra deltas count toward the issued and melted amounts in `asset_flows`. An unverified one could be different when the transaction is included in a block. ## Signed messages -On `main` (unreleased), a vault can [sign off-chain messages](/sdk/primitives/vaults#signed-messages) +A vault can [sign off-chain messages](/sdk/primitives/vaults#signed-messages) with a `vault_message_envelope`. When the delegated spend is an envelope, `parse_vault_transaction` returns the messages in `signed_messages` (and `signs_messages()` is true), and nothing else. The only other condition is `vault_child`, which recreates the vault. If the signing vault's custody hash is @@ -551,7 +535,7 @@ fails with `MixedVaultMessageEnvelope`, so a transaction can't pass as a message ## Vaults controlled by other vaults -On `main` (unreleased), a vault's custody can include another vault as a singleton member, so one vault +A vault's custody can include another vault as a singleton member, so one vault (the controller) approves the spends of another. When the vault being signed for sends a message to a vault that's revealed with `reveal_controlled_vault`, clear signing follows it down to that vault. That vault can control another vault in turn, up to `MAX_CONTROLLED_VAULT_DEPTH` (8) vaults. The returned @@ -592,9 +576,9 @@ When the `P2ConditionsOrSingleton` puzzles are revealed, `VaultTransaction::link the future offer: its reserved fee and the payments it requests (split into ones paid back to the vault and external ones). For making and taking offers in general, see [Offers](/sdk/offers). -On `main` (unreleased), the action system builds offer takes that clear signing can verify when a vault -is the taker. Settlement coins return only their own surplus, and the rest of the change comes from the -taker's own coins. Set `ConditionConfig::return_unplaced_conditions` on the `Spends`. Then, if no coin +When a vault is the taker, build the take with the action system so that clear signing can verify it. +Settlement coins return only their own surplus, and the rest of the change comes from the taker's own +coins. Set `ConditionConfig::return_unplaced_conditions` on the `Spends`. Then, if no coin spend can emit the transaction-wide conditions (such as the assertions of the settlement payments, when the vault spends none of its coins), they're returned in `Spends::unplaced_conditions` and no intermediate coin is created. Add them to the vault's delegated spend. `finish_with_keys` rejects unplaced @@ -616,21 +600,16 @@ external signers: Parsing is conservative, so many unusual but valid transactions are rejected. Common errors: -| Error | Cause | -| ------------------------------------------- | -------------------------------------------------------------------------------------- | -| `InvalidDelegatedSpendFormat` | The vault's delegated puzzle isn't a quoted list of conditions | -| `InvalidVaultMessageFormat` | A `SEND_MESSAGE` from the vault doesn't use the puzzle-to-coin format with one coin id | -| `MissingSpend` | A coin the vault sends a message to isn't revealed | -| `InvalidLinkedCustody` | A messaged coin's p2 puzzle isn't a recognized vault custody puzzle | -| `UnmatchedVaultMessage` / `WrongConditions` | A message doesn't match the coin's delegated conditions | -| `DuplicateVaultMessage` | The same coin is authorized twice | -| `MissingP2ConditionsOrSingletonAssertion` | A vault spend of a `P2ConditionsOrSingleton` coin doesn't include `ASSERT_MY_COIN_ID` | -| `UnguaranteedClawBack` | A clawback sender path is used without a matching expiration | - -On `main` (unreleased), these are added: - | Error | Cause | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `InvalidDelegatedSpendFormat` | The vault's delegated puzzle isn't a quoted list of conditions | +| `InvalidVaultMessageFormat` | A `SEND_MESSAGE` from the vault doesn't use the puzzle-to-coin format with one coin id | +| `MissingSpend` | A coin the vault sends a message to isn't revealed | +| `InvalidLinkedCustody` | A messaged coin's p2 puzzle isn't a recognized vault custody puzzle | +| `UnmatchedVaultMessage` / `WrongConditions` | A message doesn't match the coin's delegated conditions | +| `DuplicateVaultMessage` | The same coin is authorized twice | +| `MissingP2ConditionsOrSingletonAssertion` | A vault spend of a `P2ConditionsOrSingleton` coin doesn't include `ASSERT_MY_COIN_ID` | +| `UnguaranteedClawBack` | A clawback sender path is used without a matching expiration | | `InvalidVaultAmount` | A vault is recreated, or revealed, with an amount other than 1 | | `UnguaranteedClawbackExpiry` | A clawback receiver path is used without asserting that the clawback has expired | | `MixedVaultMessageEnvelope` | A delegated spend signs messages and also does something else | diff --git a/docs/sdk/concepts/layers.md b/docs/sdk/concepts/layers.md index 62798be..9dddd3e 100644 --- a/docs/sdk/concepts/layers.md +++ b/docs/sdk/concepts/layers.md @@ -365,32 +365,32 @@ If you only need to curry a puzzle and don't need parsing, `ctx.curry(args)` and ## Built-in layers -| Layer | Puzzle | Typically wraps / used by | -| -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `StandardLayer` (alias `P2DelegatedOrHiddenLayer`) | `p2_delegated_puzzle_or_hidden_puzzle`, the standard XCH puzzle | Ownership of most coins; [Standard](/sdk/primitives/standard) | -| `P2DelegatedConditionsLayer` | `p2_delegated_conditions`: a key signs a list of conditions | Simple key ownership | -| `CatLayer` | CAT v2 | [CAT](/sdk/primitives/cat) | -| `RevocationLayer` | Revocation layer for revocable CATs | [CAT](/sdk/primitives/cat) | -| `SingletonLayer` | Singleton top layer v1.1 | [Singletons](/sdk/primitives/singletons), NFTs, DIDs, vaults | -| `NftStateLayer` | NFT state layer (metadata and updater) | [NFT](/sdk/primitives/nft) | -| `NftOwnershipLayer` | NFT ownership layer (current DID owner and transfer program) | [NFT](/sdk/primitives/nft) | -| `RoyaltyTransferLayer` | NFT royalty transfer program | [NFT](/sdk/primitives/nft) | -| `DidLayer` | DID inner puzzle (metadata and recovery) | [DID](/sdk/primitives/did) | -| `SettlementLayer` | `settlement_payments`, used by offers | [Offers](/sdk/offers) | -| `OptionContractLayer` | Option contract | [Option Contracts](/sdk/primitives/options) | -| `P2SingletonLayer` | Coin spendable only alongside a singleton | [Singletons](/sdk/primitives/singletons), [Clear Signing](/sdk/clear-signing) | -| `P2OneOfManyLayer` | Pick one of many delegated puzzles from a merkle root | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options), [MerkleTree](/sdk/utilities#merkle-trees) | -| `P2CurriedLayer` | Spend by revealing a puzzle that hashes to a curried puzzle hash | [Clawback](/sdk/primitives/clawback) | -| `AugmentedConditionLayer` | Adds a fixed condition to the inner puzzle's output | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options) | -| `StreamLayer` | Streamed payments | [Streamed Assets](/sdk/primitives/streaming) | -| `BulletinLayer` | Nonce wrapper that distinguishes bulletin coins | [Bulletins](/sdk/primitives/bulletin) | -| `DelegatedPuzzleFeederLayer`, `IndexWrapperLayer`, `SingletonMemberLayer` | Meta Inner Puzzle Spec (MIPS) building blocks | [Vaults & MIPS](/sdk/primitives/vaults) | -| `DelegationLayer`, `WriterLayer`, `OracleLayer` | DataLayer delegation (feature `chip-0035`) | [DataLayer](/sdk/primitives/datalayer) | -| `P2ControllerPuzzleLayer`, `P2Eip712MessageLayer` | Coins controlled by another puzzle or by an EIP-712 signature (feature `chip-0037`) | Ethereum-style (secp256k1) wallets | -| `ActionLayer`, `PrecommitLayer`, `StateSchedulerLayer`, `MOfNLayer`, `P2DelegatedBySingletonLayer`, `ConditionsLayer`, `P2ParentLayer` | Action layer singletons (feature `action-layer`) | [Action Layer](/sdk/primitives/action-layer) | -| `SplitterLayer` | CHIP-0008 splitter: divides a coin between fixed recipients (feature `chip-0008`, unreleased) | [Splitter](/sdk/primitives/splitter) | -| `FusionLayer` | CHIP-0021 fusion singleton inner puzzle (feature `chip-0021`, unreleased) | [NFT Fusion](/sdk/primitives/fusion) | -| `P2FusionLayer` | p2 puzzle of NFTs locked in a fusion (feature `chip-0021`, unreleased) | [NFT Fusion](/sdk/primitives/fusion) | +| Layer | Puzzle | Typically wraps / used by | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `StandardLayer` (alias `P2DelegatedOrHiddenLayer`) | `p2_delegated_puzzle_or_hidden_puzzle`, the standard XCH puzzle | Ownership of most coins; [Standard](/sdk/primitives/standard) | +| `P2DelegatedConditionsLayer` | `p2_delegated_conditions`: a key signs a list of conditions | Simple key ownership | +| `CatLayer` | CAT v2 | [CAT](/sdk/primitives/cat) | +| `RevocationLayer` | Revocation layer for revocable CATs | [CAT](/sdk/primitives/cat) | +| `SingletonLayer` | Singleton top layer v1.1 | [Singletons](/sdk/primitives/singletons), NFTs, DIDs, vaults | +| `NftStateLayer` | NFT state layer (metadata and updater) | [NFT](/sdk/primitives/nft) | +| `NftOwnershipLayer` | NFT ownership layer (current DID owner and transfer program) | [NFT](/sdk/primitives/nft) | +| `RoyaltyTransferLayer` | NFT royalty transfer program | [NFT](/sdk/primitives/nft) | +| `DidLayer` | DID inner puzzle (metadata and recovery) | [DID](/sdk/primitives/did) | +| `SettlementLayer` | `settlement_payments`, used by offers | [Offers](/sdk/offers) | +| `OptionContractLayer` | Option contract | [Option Contracts](/sdk/primitives/options) | +| `P2SingletonLayer` | Coin spendable only alongside a singleton | [Singletons](/sdk/primitives/singletons), [Clear Signing](/sdk/clear-signing) | +| `P2OneOfManyLayer` | Pick one of many delegated puzzles from a merkle root | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options), [MerkleTree](/sdk/utilities#merkle-trees) | +| `P2CurriedLayer` | Spend by revealing a puzzle that hashes to a curried puzzle hash | [Clawback](/sdk/primitives/clawback) | +| `AugmentedConditionLayer` | Adds a fixed condition to the inner puzzle's output | [Clawback](/sdk/primitives/clawback), [Option Contracts](/sdk/primitives/options) | +| `StreamLayer` | Streamed payments | [Streamed Assets](/sdk/primitives/streaming) | +| `BulletinLayer` | Nonce wrapper that distinguishes bulletin coins | [Bulletins](/sdk/primitives/bulletin) | +| `DelegatedPuzzleFeederLayer`, `IndexWrapperLayer`, `SingletonMemberLayer` | Meta Inner Puzzle Spec (MIPS) building blocks | [Vaults & MIPS](/sdk/primitives/vaults) | +| `DelegationLayer`, `WriterLayer`, `OracleLayer` | DataLayer delegation (feature `chip-0035`) | [DataLayer](/sdk/primitives/datalayer) | +| `P2ControllerPuzzleLayer`, `P2Eip712MessageLayer` | Coins controlled by another puzzle or by an EIP-712 signature (feature `chip-0037`) | Ethereum-style (secp256k1) wallets | +| `ActionLayer`, `PrecommitLayer`, `StateSchedulerLayer`, `MOfNLayer`, `P2DelegatedBySingletonLayer`, `ConditionsLayer`, `P2ParentLayer` | Action layer singletons (feature `action-layer`) | [Action Layer](/sdk/primitives/action-layer) | +| `SplitterLayer` | CHIP-0008 splitter: divides a coin between fixed recipients (feature `chip-0008`) | [Splitter](/sdk/primitives/splitter) | +| `FusionLayer` | CHIP-0021 fusion singleton inner puzzle (feature `chip-0021`) | [NFT Fusion](/sdk/primitives/fusion) | +| `P2FusionLayer` | p2 puzzle of NFTs locked in a fusion (feature `chip-0021`) | [NFT Fusion](/sdk/primitives/fusion) | Besides these, any type that implements both `ToClvm` and `FromClvm` is a layer, whose puzzle is the value itself and whose solution is a `NodePtr`. `Puzzle` is a layer too, as shown above. Both are useful as placeholder inner layers in tests and when parsing. diff --git a/docs/sdk/index.md b/docs/sdk/index.md index 56210ef..4174590 100644 --- a/docs/sdk/index.md +++ b/docs/sdk/index.md @@ -45,28 +45,14 @@ chia-wallet-sdk = { version = "0.37", features = ["rustls"] } | `native-tls` | The same, using the platform's native TLS library | | `chip-0035` | [DataLayer stores](/sdk/primitives/datalayer) (CHIP-0035) | | `chip-0037` | The CHIP-0037 controller puzzle and EIP-712 message layers used with [vaults](/sdk/primitives/vaults) | +| `chip-0008` | The [splitter](/sdk/primitives/splitter) puzzle (CHIP-0008) | +| `chip-0021` | [NFT fusion](/sdk/primitives/fusion) (CHIP-0021) | +| `chip-0052` | [Partial offers](/sdk/primitives/partial-offers) (CHIP-0052) | | `action-layer` | [Action layer singletons](/sdk/primitives/action-layer) such as CATalog and XCHandles (implies `chip-0035`) | +| `auction` | [Auctions](/sdk/primitives/auctions) (implies `action-layer`) | | `offer-compression` | Compressing and decompressing [offer](/sdk/offers) strings | | `peer-simulator` | The [peer simulator](/sdk/testing/peer-simulator), an in-process full node for networking tests | -The SDK's `main` branch adds four more feature flags that aren't in a release yet: - -| Feature | Enables | -| ----------- | ------------------------------------------------------------- | -| `chip-0008` | The [splitter](/sdk/primitives/splitter) puzzle (CHIP-0008) | -| `chip-0021` | [NFT fusion](/sdk/primitives/fusion) (CHIP-0021) | -| `chip-0052` | [Partial offers](/sdk/primitives/partial-offers) (CHIP-0052) | -| `auction` | [Auctions](/sdk/primitives/auctions) (implies `action-layer`) | - -To use them before they're released, depend on the git repository: - -```toml -[dependencies] -chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["rustls", "chip-0052"] } -``` - -Pages and sections that cover unreleased features say so at the top. - Everything else, including the [simulator](/sdk/testing), is always available. ## How the crate is organized diff --git a/docs/sdk/offers.md b/docs/sdk/offers.md index 0447dfe..b7cf89e 100644 --- a/docs/sdk/offers.md +++ b/docs/sdk/offers.md @@ -9,7 +9,7 @@ An offer is a partially complete transaction that trades assets without trusting The SDK represents an offer as [`Offer`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/struct.Offer.html), and both sides are built with the [action system](/sdk/action-system), which is how the SDK's own tests make and take offers. The offer types are in the prelude; the royalty helpers and compression functions are in `chia_wallet_sdk::driver`. -For selling one fungible asset for another in any amount at a fixed price, see [Partial Offers](/sdk/primitives/partial-offers) (CHIP-0052, unreleased), which can be filled many times and are taken as ordinary offers. +For selling one fungible asset for another in any amount at a fixed price, see [Partial Offers](/sdk/primitives/partial-offers) (CHIP-0052), which can be filled many times and are taken as ordinary offers. ## How offers work @@ -45,14 +45,12 @@ To take an offer: 1. Decode it with `decode_offer`, and parse it with `Offer::from_spend_bundle(&mut ctx, &spend_bundle)?`. This checks every puzzle reveal against its coin, and separates the maker's spends from the requested payments. 2. Inspect what's offered and requested (see [Inspecting offers](#inspecting-offers)) before going any further. 3. Add `offer.offered_coins().clone()` and your own coins to `Spends`. The settlement coins are spent as settlement spends automatically. -4. Apply `offer.requested_payments().actions()`, which is an `Action::settle` for each requested notarized payment, plus anything else you want (a fee, royalties). The offered assets go to your change puzzle hash, unless you send them elsewhere. +4. Apply `offer.requested_payments().actions()`, which is an `Action::settle` for each requested notarized payment, plus anything else you want (a fee, and the royalties from `offer.requested_royalty_payments(&mut ctx)?.actions()` if the offer sells royalty NFTs). The offered assets go to your change puzzle hash, unless you send them elsewhere. 5. Finish and sign, then combine your spend bundle with the maker's with `offer.take(spend_bundle)`. -:::note Unreleased -On the SDK's `main` branch, change is split by who has value left over when you take an offer. Settlement coins return whatever they have left after their payments, and the rest of the change is created by one of your own coins' conditions spends, rather than by the first settlement coin. Usually that means a single change coin, from your own coin, while the offered amount is still paid and asserted by the settlement coins. +Change is split by who has value left over. Settlement coins return whatever they have left after their payments, and the rest of the change is created by one of your own coins' conditions spends. Usually that means a single change coin, from your own coin, while the offered amount is still paid and asserted by the settlement coins. -`main` also adds `ConditionConfig::return_unplaced_conditions` (`spends.conditions.return_unplaced_conditions = true`). When none of the spends can emit the transaction-wide conditions (for example, a vault taking a one-sided offer without spending any of its own coins), they're returned in `Spends::unplaced_conditions` instead of being emitted by an intermediate coin, so the caller can emit them from its own spend, such as a vault's delegated spend. `finish_with_keys` fails with `DriverError::CannotEmitConditions` if any are left. Together these make a vault's offer take something [clear signing](/sdk/clear-signing) can verify. -::: +When none of the spends can emit the transaction-wide conditions (for example, a vault taking a one-sided offer without spending any of its own coins), set `spends.conditions.return_unplaced_conditions = true` (`ConditionConfig::return_unplaced_conditions`). The conditions are then returned in `Spends::unplaced_conditions` instead of being emitted by an intermediate coin, so you can emit them from your own spend, such as a vault's delegated spend. `finish_with_keys` fails with `DriverError::CannotEmitConditions` if any are left. This is what makes a vault's offer take something [clear signing](/sdk/clear-signing) can verify. Here's a complete trade of a CAT for XCH, including encoding the offer as a string and parsing it on the taker's side: @@ -171,7 +169,7 @@ Before taking an offer (or showing it to a user), look at what it does: - `offer.arbitrage()`: an `Arbitrage` with `offered` and `requested` sides, each an `ArbitrageSide` with `xch`, `cats`, `nfts` and `options`. `requested` is what the offer gives you beyond what it asks for of the same asset, and `offered` is what you need to supply. - `offer.fee()`: the fee the maker's spends leave for the farmer, as an `i128` (negative if the spends create more than they spend). `offered_coins().fee` is the fee they asserted with `RESERVE_FEE`. - `offer.offered_royalties()` and `offer.requested_royalties()`: the `RoyaltyInfo` of requested and offered NFTs (see below). -- On `main` (unreleased), `offered_coins().nft_trade_prices` has the trade prices each offered NFT revealed when it was spent into settlement, and `offered_coins().prepaid_royalties` has the royalty payments the maker's settlement spends made up front for requested NFTs. +- `offered_coins().nft_trade_prices`: the trade prices each offered NFT revealed when it was spent into settlement. `offered_coins().prepaid_royalties`: the royalty payments the maker's settlement spends made up front for requested NFTs. To cancel an offer you made, spend any of the coins it spends. `offer.cancellable_coin_spends()` returns the maker's spends of coins that weren't created within the offer, which are the coins to spend. Several offers can be combined into one with `offer.extend(other)`, as long as they don't spend the same coins (`DriverError::ConflictingOfferInputs`). @@ -179,148 +177,33 @@ To cancel an offer you made, spend any of the coins it spends. `offer.cancellabl NFTs with a royalty require a royalty payment whenever they're traded through an offer. The NFT's transfer program asserts that, for each trade price it's told about, the royalty was paid by a settlement coin, with the NFT's launcher id as the nonce. A trade price is an amount and the settlement puzzle hash of the asset it's paid in (`TradePrice`). -The maker of an offer that sells royalty NFTs splits the requested price evenly between them when it commits to trade prices, and a maker bidding for royalty NFTs splits the offered price the same way when it pays their royalties up front. These helpers do the math: - -| Function | What it does | -| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `calculate_nft_trade_price(amount, royalty_nft_count)` | `amount / royalty_nft_count`, or zero if there are no royalty NFTs. | -| `calculate_trade_price_amounts(&amounts, royalty_nft_count)` | The per-NFT trade price of each asset in an `OfferAmounts`. | -| `calculate_trade_prices(&trade_price_amounts, &asset_info)` | Converts them into `TradePrice`s for `TransferNftById`. Needs `AssetInfo` for CATs. | -| `calculate_nft_royalty(trade_price, basis_points)` | `trade_price * basis_points / 10000`. | -| `calculate_royalty_amounts(&trade_prices, &royalties)` | The total royalty owed in each asset, for a list of `RoyaltyInfo`. | -| `calculate_royalty_payments(&mut ctx, &trade_prices, &royalties)` | The royalty payments as `RequestedPayments`, whose `.actions()` are `settle` actions that pay them. | - -There are two cases, depending on which side the NFT is on. - -In 0.37.1, `offer.requested_royalty_amounts()` and `offer.offered_royalty_amounts()` are computed the same way, by splitting the offer's requested (or offered) totals evenly between its royalty NFTs. That's correct for a single maker's offer, but not for aggregated offers whose makers committed to different trade prices. - -:::note Unreleased -On the SDK's `main` branch, royalties come from what's actually in the offer rather than an even split: +The royalties owed are read from the offer itself, so they're correct even for aggregated offers whose makers committed to different trade prices: - **Offered NFTs.** `OfferCoins::nft_trade_prices` records the trade prices each offered NFT revealed when its maker spent it into settlement. `offer.requested_royalty_payments(&mut ctx)?` returns the royalty payment each NFT asserts for each of its trade prices, as `RequestedPayments` whose `.actions()` pay them, and `requested_royalty_amounts()` is their total. Identical royalties are only paid once, since one payment satisfies every identical assertion, and royalties the offer already prepaid are left out. Both fail with `DriverError::ZeroRoyaltyPayment(launcher_id)` if a trade price's royalty rounds down to zero (settlement can never pay it, so the offer can't be taken), `DriverError::UnknownSettlementAsset` if a trade price is in an asset the offer doesn't know, or `DriverError::AmountOverflow`. - **Requested NFTs.** `OfferCoins::prepaid_royalties` holds the royalty payments the maker made up front, in the form an NFT's royalty assertion accepts (a single payment whose memos are exactly the royalty puzzle hash, notarized with the launcher id). `offered_royalty_amounts()` totals them, and `offer.requested_nft_trade_prices(launcher_id)` returns the trade prices the taker should reveal on the NFT they supply so that it asserts exactly those payments. -- **New helpers.** `payable_trade_prices(&trade_prices, royalty_basis_points)` drops trade prices whose royalty would round down to zero, so a maker never commits to an unpayable trade price (a small sale with only zero royalties can be taken without any royalty payment). `AssetInfo::settlement_puzzle_hash(asset_id)` and `settlement_asset_id(puzzle_hash)` convert between a CAT asset id (`None` for XCH) and its settlement puzzle hash, `OfferAmounts::add_amount(asset_id, amount)` adds to a total, and `RequestedPayments::fungible()`/`fungible_mut(asset_id)` iterate over or extend the XCH and CAT payments together. - ::: - -### Offering an NFT - -The maker offering an NFT tells it the trade prices with `Action::update_nft(nft_id, vec![], Some(TransferNftById::new(None, trade_prices)))`, computed from the requested amounts. The taker pays the royalty from what they're paying anyway: `offer.requested_royalty_amounts()?` is the total, and `Action::settle_royalty` pays each NFT's royalty. If the taker doesn't pay it, the NFT's spend fails and the offer can't be taken. - -```rust -use chia_wallet_sdk::chia::puzzle_types::{ - Memos, - offer::{NotarizedPayment, Payment}, -}; -use chia_wallet_sdk::driver::{ - TransferNftById, calculate_trade_price_amounts, calculate_trade_prices, coin_amount, -}; -use chia_wallet_sdk::prelude::*; -use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; -use chia_wallet_sdk::test::sign_transaction; -use indexmap::indexmap; - -#[test] -fn nft_for_xch_with_royalty() -> anyhow::Result<()> { - let mut sim = Simulator::new(); - let mut ctx = SpendContext::new(); - - let alice = sim.bls(1); - let bob = sim.bls(1_030); - let carol = BlsPair::new(2); - let alice_keys = indexmap! { alice.puzzle_hash => alice.pk }; - let alice_hint = ctx.hint(alice.puzzle_hash)?; - - // Alice mints an NFT with a 3% royalty to Carol - let mut spends = Spends::new(alice.puzzle_hash); - spends.add(alice.coin); - let deltas = spends.apply(&mut ctx, &[Action::mint_empty_royalty_nft(carol.puzzle_hash, 300)])?; - let outputs = spends.finish_with_keys(&mut ctx, &deltas, Relation::None, &alice_keys)?; - sim.spend_coins(ctx.take(), std::slice::from_ref(&alice.sk))?; - - let nft = outputs.nfts[&Id::New(0)]; - let nft_id = Id::Existing(nft.info.launcher_id); - - // Maker: offer the NFT for 1000 mojos, telling the NFT its trade price - let mut requested_payments = RequestedPayments::new(); - requested_payments.xch.push(NotarizedPayment::new( - Offer::nonce(vec![nft.coin.coin_id()]), - vec![Payment::new(alice.puzzle_hash, 1_000, alice_hint)], - )); - let asset_info = AssetInfo::new(); - - let trade_prices = calculate_trade_prices( - &calculate_trade_price_amounts(&requested_payments.amounts(), 1), - &asset_info, - )?; - - let mut spends = Spends::new(alice.puzzle_hash); - spends.add(nft); - spends.conditions.required = spends - .conditions - .required - .extend(requested_payments.assertions(&mut ctx, &asset_info)?); - let deltas = spends.apply( - &mut ctx, - &[ - Action::update_nft(nft_id, vec![], Some(TransferNftById::new(None, trade_prices))), - Action::send(nft_id, SETTLEMENT_PAYMENT_HASH.into(), 1, Memos::None), - ], - )?; - spends.finish_with_keys(&mut ctx, &deltas, Relation::AssertConcurrent, &alice_keys)?; +These helpers build trade prices and royalty payments on the maker's side: - let coin_spends = ctx.take(); - let signature = sign_transaction(&coin_spends, &[alice.sk])?; - let offer = Offer::from_input_spend_bundle( - &mut ctx, - SpendBundle::new(coin_spends, signature), - requested_payments, - asset_info, - )?; - - // Taker: pay the price and the 30 mojo royalty - let royalties = offer.requested_royalty_amounts()?; - assert_eq!(royalties.xch, 30); +| Function | What it does | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `calculate_nft_trade_price(amount, royalty_nft_count)` | `amount / royalty_nft_count`, or zero if there are no royalty NFTs. | +| `calculate_trade_price_amounts(&amounts, royalty_nft_count)` | The per-NFT trade price of each asset in an `OfferAmounts`, splitting it between the royalty NFTs. | +| `calculate_trade_prices(&trade_price_amounts, &asset_info)` | Converts them into `TradePrice`s for `TransferNftById`. Needs `AssetInfo` for CATs. | +| `payable_trade_prices(&trade_prices, basis_points)` | Drops trade prices whose royalty would round down to zero, so an NFT never commits to an unpayable one. | +| `calculate_nft_royalty(trade_price, basis_points)` | `trade_price * basis_points / 10000`. | +| `calculate_royalty_amounts(&trade_prices, &royalties)` | The total royalty owed in each asset, for a list of `RoyaltyInfo`. | +| `calculate_royalty_payments(&mut ctx, &trade_prices, &royalties)` | The royalty payments as `RequestedPayments`, whose `.actions()` are `settle` actions that pay them. | +| `RoyaltyInfo::payment(&mut ctx, amount)` | A single royalty payment, notarized with the NFT's launcher id. | - let mut spends = Spends::new(bob.puzzle_hash); - spends.add(offer.offered_coins().clone()); - spends.add(bob.coin); +With `payable_trade_prices`, a small sale whose royalties would all round down to zero commits to no trade prices, and can be taken without any royalty payment. `AssetInfo::settlement_puzzle_hash(asset_id)` and `settlement_asset_id(puzzle_hash)` convert between a CAT asset id (`None` for XCH) and its settlement puzzle hash, `OfferAmounts::add_amount(asset_id, amount)` adds to a total, and `RequestedPayments::fungible()`/`fungible_mut(asset_id)` iterate over or extend the XCH and CAT payments together. - let mut actions = offer.requested_payments().actions(); - for royalty in offer.requested_royalties() { - actions.push(Action::settle_royalty( - &mut ctx, - Id::Xch, - royalty.launcher_id, - royalty.puzzle_hash, - coin_amount(royalties.xch)?, - )?); - } - - let deltas = spends.apply(&mut ctx, &actions)?; - let outputs = spends.finish_with_keys( - &mut ctx, - &deltas, - Relation::AssertConcurrent, - &indexmap! { bob.puzzle_hash => bob.pk }, - )?; - - let coin_spends = ctx.take(); - let signature = sign_transaction(&coin_spends, &[bob.sk])?; - sim.new_transaction(offer.take(SpendBundle::new(coin_spends, signature)))?; - - assert_eq!(outputs.nfts[&nft_id].info.p2_puzzle_hash, bob.puzzle_hash); - assert_eq!(sim.unspent_coins(carol.puzzle_hash, false)[0].amount, 30); - - Ok(()) -} -``` +### Offering an NFT -With several royalty NFTs on 0.37.1, compute the royalty for each one separately (`calculate_nft_royalty(trade_price, royalty.basis_points)` with the per-NFT trade price), and pay one `settle_royalty` per NFT and asset. +The maker offering an NFT tells it the trade prices with `Action::update_nft(nft_id, vec![], Some(TransferNftById::new(None, trade_prices)))`, computed from the requested amounts and filtered with `payable_trade_prices`. The taker pays the royalties with `offer.requested_royalty_payments(&mut ctx)?.actions()`, from what they're paying anyway. If the taker doesn't pay them, the NFT's spend fails and the offer can't be taken. -On `main` (unreleased), the maker should filter each NFT's trade prices with `payable_trade_prices`, and the taker should pay `offer.requested_royalty_payments(&mut ctx)?.actions()` instead of computing royalties itself. That's what makes aggregated offers work: here two makers sell NFTs with different royalties at different prices, and the taker of the combined offer pays each royalty on the price its NFT committed to, rather than on half of the total: +Here two makers sell NFTs with different royalties at different prices, and the taker of the combined offer pays each royalty on the price its NFT committed to: -```rust unreleased +```rust use chia_wallet_sdk::chia::puzzle_types::{ Memos, offer::{NotarizedPayment, Payment}, @@ -407,7 +290,7 @@ fn aggregated_royalties() -> anyhow::Result<()> { let second = sell_nft(&mut sim, ctx, royalty_b, 1_000, 30_000)?; let offer = Offer::from_spend_bundle(ctx, &SpendBundle::aggregate(&[first, second]))?; - // 5% of 10,000 plus 10% of 30,000, rather than royalties on 20,000 each + // 5% of 10,000 plus 10% of 30,000 assert_eq!(offer.requested_royalty_amounts()?.xch, 500 + 3_000); let taker = sim.bls(43_500); @@ -439,7 +322,7 @@ fn aggregated_royalties() -> anyhow::Result<()> { ### Requesting an NFT -When the maker requests an NFT, they pay its royalty up front, since the taker can't be trusted to. The maker adds `NftAssetInfo` for the NFT (which includes its royalty), and adds the royalty payments with `calculate_royalty_payments(...)?.actions()` alongside the offered XCH. The taker reveals the trade prices to the NFT when they spend it, with `update_nft`. On 0.37.1, compute them from the offered amounts as below. On `main` (unreleased), use `offer.requested_nft_trade_prices(launcher_id)` instead, which derives them from the royalties the maker actually prepaid, so they match even when bids are aggregated or a bid requests several NFTs: +When the maker requests an NFT, they pay its royalty up front, since the taker can't be trusted to. The maker adds `NftAssetInfo` for the NFT (which includes its royalty), and adds the royalty payments with `calculate_royalty_payments(...)?.actions()` alongside the offered XCH. The taker reveals the trade prices to the NFT when they spend it, with `update_nft`, using `offer.requested_nft_trade_prices(launcher_id)`. That derives them from the royalties the maker actually prepaid, so they match even when bids are aggregated or a bid requests several NFTs: ```rust use chia_wallet_sdk::chia::puzzle_types::{ @@ -448,7 +331,6 @@ use chia_wallet_sdk::chia::puzzle_types::{ }; use chia_wallet_sdk::driver::{ TransferNftById, calculate_royalty_payments, calculate_trade_price_amounts, - calculate_trade_prices, }; use chia_wallet_sdk::prelude::*; use chia_wallet_sdk::puzzles::SETTLEMENT_PAYMENT_HASH; @@ -532,11 +414,9 @@ fn xch_for_royalty_nft() -> anyhow::Result<()> { )?; assert_eq!(offer.offered_royalty_amounts()?.xch, 30); - // Taker: reveal the trade price to the NFT, and send it to Alice - let trade_prices = calculate_trade_prices( - &calculate_trade_price_amounts(&offer.offered_coins().amounts(), 1), - offer.asset_info(), - )?; + // Taker: reveal the trade price the royalty was paid on, and send the NFT to Alice + let trade_prices = offer.requested_nft_trade_prices(launcher_id); + assert_eq!(trade_prices, [TradePrice::new(1_000, SETTLEMENT_PAYMENT_HASH.into())]); let mut spends = Spends::new(bob.puzzle_hash); spends.add(offer.offered_coins().clone()); @@ -564,8 +444,6 @@ fn xch_for_royalty_nft() -> anyhow::Result<()> { } ``` -On `main`, `offer.offered_royalty_amounts()` in this example is the 30 mojos the maker prepaid, read from its settlement spend, rather than a recomputed even split, and the taker could replace its `calculate_trade_prices(...)` call with `offer.requested_nft_trade_prices(launcher_id)` (which returns the same 1000 mojo trade price here). - The royalty math on its own: ```rust @@ -612,7 +490,7 @@ Offers are shared as bech32m strings with the `offer` prefix, containing a zlib- | `decompress_offer(&bytes)` | Compressed bytes to `SpendBundle` | | `compress_offer_bytes` / `decompress_offer_bytes` | Raw serialized bytes, without parsing | -On `main` (unreleased), the `chip-0052` feature adds `encode_partial_offer` and `decode_partial_offer`, which use the same compression with the `partial` prefix (see [Partial Offers](/sdk/primitives/partial-offers)). +With the `chip-0052` feature, `encode_partial_offer` and `decode_partial_offer` use the same compression with the `partial` prefix (see [Partial Offers](/sdk/primitives/partial-offers)). Decompression supports every version of the format the reference wallet has used, and output is capped at 6 MB (`DriverError::DecompressionTooLarge`), matching chia-blockchain. Encode the result of `offer.to_spend_bundle(&mut ctx)?`, not the maker's input spend bundle, since the offer file needs the requested payments. @@ -624,6 +502,6 @@ Decompression supports every version of the format the reference wallet has used - **Revocable CATs are a different asset.** A revocable CAT can be clawed back by its issuer, and has a different settlement puzzle hash than a CAT with the same asset id but no revocation layer. Check the offered CAT's `hidden_puzzle_hash` in `asset_info().cat(asset_id)` before treating an offered CAT as the one you expect. Mixing both kinds under one asset id fails with `DriverError::IncompatibleAssetInfo`. - **Settlement spends are asserted.** When taking an offer, every payment a settlement coin makes is asserted by one of your conditions spends, so a third party can't strip the settlement spends out of the bundle and claim the coins. Don't set `disable_settlement_assertions` unless something else ties them to your spend. - **Amounts can overflow.** Offered and requested totals are `u128`. A malicious offer can have totals that don't fit in a coin, which fail with `DriverError::AmountOverflow` (or `InsufficientFunds`) rather than wrapping around. Use `coin_amount` to convert totals. -- **Royalties are enforced on chain.** An NFT's royalty must be paid for an offer involving it to succeed. Use `requested_royalty_amounts` and `offered_royalty_amounts` to show users the real cost. `offered_royalties` fails with `DriverError::MissingAssetInfo` if a requested NFT has no asset info, rather than silently skipping its royalty. +- **Royalties are enforced on chain.** An NFT's royalty must be paid for an offer involving it to succeed. Use `requested_royalty_amounts` and `offered_royalty_amounts` to show users the real cost, and pay royalties with `requested_royalty_payments` rather than computing them yourself. `offered_royalties` fails with `DriverError::MissingAssetInfo` if a requested NFT has no asset info, rather than silently skipping its royalty. - **Verify option contracts.** An offered option only commits to hashes of its terms. Check it with `OptionInfo::is_backed_by` before paying for it. See [Option Contracts](/sdk/primitives/options). - **Relate your spends.** Use `Relation::AssertConcurrent` for both sides, so neither the maker's nor the taker's spends can be split apart. diff --git a/docs/sdk/primitives/action-layer.md b/docs/sdk/primitives/action-layer.md index cd9bcbd..ee5981b 100644 --- a/docs/sdk/primitives/action-layer.md +++ b/docs/sdk/primitives/action-layer.md @@ -19,7 +19,7 @@ coins and reserves. All of it is behind the `action-layer` feature, which also e chia-wallet-sdk = { version = "...", features = ["action-layer"] } ``` -On `main` (unreleased), the SDK also has an NFT [auction](/sdk/primitives/auctions) built on the action +The SDK also has an NFT [auction](/sdk/primitives/auctions) built on the action layer, behind the `auction` feature (which implies `action-layer`). :::note @@ -70,14 +70,14 @@ To follow a singleton on chain, use `from_parent_spend` / `from_spend` on its co ## Applications -| Application | Types | What it does | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| CATalog | `CatalogRegistry`, `CatalogRegistryInfo`, `CatalogRegistryState`, `CatalogRegistryConstants` | A registry of CATs. Registering a CAT (by TAIL hash) mints an NFT that holds its metadata (`CatNftMetadata`). Each TAIL can only be registered once, enforced by slots | -| XCHandles | `XchandlesRegistry`, `XchandlesRegistryInfo`, `XchandlesRegistryState`, `XchandlesConstants` | A registry of handles (names) with expiration, priced by a pricing puzzle. Handles can be registered, extended, expired and re-registered, and updated | -| Reward distributor | `RewardDistributor`, `RewardDistributorInfo`, `RewardDistributorState`, `RewardDistributorConstants` | Distributes rewards (a CAT held in a `Reserve`) to entries over epochs. Entries are managed by a manager singleton, or are NFTs from a collection, NFTs from a curated DataLayer store, or staked CATs (`RewardDistributorType`) | -| Medieval vault | `MedievalVault`, `MedievalVaultInfo` | A plain m-of-n BLS multisig singleton, used to administer other singletons | -| State scheduler | `StateScheduler`, `StateSchedulerInfo` | A singleton that sends a pre-committed sequence of state updates to another singleton at given times (for example to schedule price changes) | -| Auction (unreleased, `auction` feature) | `Auction`, `AuctionInfo`, `AuctionSettings`, `AuctionState`, `AuctionReserve` | An English auction for an NFT, with bids in XCH or a CAT held in a reserve coin. It has its own driver (`Auction::spend`) rather than the `pending_spend` flow below. See [Auctions](/sdk/primitives/auctions) | +| Application | Types | What it does | +| --------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CATalog | `CatalogRegistry`, `CatalogRegistryInfo`, `CatalogRegistryState`, `CatalogRegistryConstants` | A registry of CATs. Registering a CAT (by TAIL hash) mints an NFT that holds its metadata (`CatNftMetadata`). Each TAIL can only be registered once, enforced by slots | +| XCHandles | `XchandlesRegistry`, `XchandlesRegistryInfo`, `XchandlesRegistryState`, `XchandlesConstants` | A registry of handles (names) with expiration, priced by a pricing puzzle. Handles can be registered, extended, expired and re-registered, and updated | +| Reward distributor | `RewardDistributor`, `RewardDistributorInfo`, `RewardDistributorState`, `RewardDistributorConstants` | Distributes rewards (a CAT held in a `Reserve`) to entries over epochs. Entries are managed by a manager singleton, or are NFTs from a collection, NFTs from a curated DataLayer store, or staked CATs (`RewardDistributorType`) | +| Medieval vault | `MedievalVault`, `MedievalVaultInfo` | A plain m-of-n BLS multisig singleton, used to administer other singletons | +| State scheduler | `StateScheduler`, `StateSchedulerInfo` | A singleton that sends a pre-committed sequence of state updates to another singleton at given times (for example to schedule price changes) | +| Auction (`auction` feature) | `Auction`, `AuctionInfo`, `AuctionSettings`, `AuctionState`, `AuctionReserve` | An English auction for an NFT, with bids in XCH or a CAT held in a reserve coin. It has its own driver (`Auction::spend`) rather than the `pending_spend` flow below. See [Auctions](/sdk/primitives/auctions) | Each registry's actions are separate types: @@ -360,12 +360,8 @@ computes the next generation. - **`CatMaker`** and `DefaultCatMakerArgs`: small puzzles that compute a CAT puzzle hash from an inner puzzle hash, so actions can verify payments in a CAT chosen by the registry's state. `CatMaker` has `Default`, `Revocable` and `Xch` variants (with `DefaultCatMakerArgs`, `RevocableCatMakerArgs` and - `XchCatMaker` in `chia_wallet_sdk::types::puzzles`). In 0.37.1 they're part of the action layer and - need the `action-layer` feature. On `main` (unreleased), they moved out of the action layer (to the - driver's `cat_maker` module and the types crate's `cat_makers` module) and are no longer - feature-gated, so other primitives such as [partial offers](/sdk/primitives/partial-offers) can use - them. The public paths (`chia_wallet_sdk::driver::CatMaker`, - `chia_wallet_sdk::types::puzzles::DefaultCatMakerArgs`, ...) are the same in both. + `XchCatMaker` in `chia_wallet_sdk::types::puzzles`). They aren't specific to the action layer and don't + need the `action-layer` feature; [partial offers](/sdk/primitives/partial-offers) use them too. - **`P2ParentCoin`**: a coin (XCH or CAT) that can be spent by whoever controls its parent's inner puzzle. Its solution reveals the parent's inner puzzle with a new solution, the puzzle checks it against the parent coin id, and the conditions it outputs become the coin's conditions. diff --git a/docs/sdk/primitives/auctions.md b/docs/sdk/primitives/auctions.md index 6551a77..7f1f2e8 100644 --- a/docs/sdk/primitives/auctions.md +++ b/docs/sdk/primitives/auctions.md @@ -12,12 +12,6 @@ is paid out to the seller, the fee recipients and the NFT's royalty address. The the [action layer](/sdk/primitives/action-layer), so no one controls it once it's launched, the seller included. -:::info Unreleased -This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). -To try it, depend on the git repository with the `auction` feature: -`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["auction"] }` -::: - The `auction` feature implies `action-layer`. The drivers are in `chia_wallet_sdk::driver` (`Auction`, `AuctionInfo`, `AuctionSettings`, ...) and the puzzle types are in `chia_wallet_sdk::types::puzzles` (`AuctionBid`, `AuctionState`, `AuctionTimings`, `AuctionPayments`, ...). diff --git a/docs/sdk/primitives/did.md b/docs/sdk/primitives/did.md index a3aa52e..ab164a7 100644 --- a/docs/sdk/primitives/did.md +++ b/docs/sdk/primitives/did.md @@ -325,11 +325,6 @@ The hint isn't always the p2 puzzle hash. A DID sent with a [ClawbackV2](/sdk/pr hinted to the receiver, but its p2 puzzle is the clawback. In that case, decode the clawback from the memos and replace `did.info.p2_puzzle_hash` with the clawback's puzzle hash if that produces the coin. -:::note -Version 0.37.0 verified the hint inside `parse_child` and returned `None` on a mismatch. 0.37.1 -reverted this, because it rejected DIDs sent with a clawback. The check above works on every version. -::: - The other parsers: - `Did::parse(allocator, coin, puzzle, solution)` parses a DID coin's own spend, returning the `Did` and diff --git a/docs/sdk/primitives/fusion.md b/docs/sdk/primitives/fusion.md index 2ea9a00..8af18ab 100644 --- a/docs/sdk/primitives/fusion.md +++ b/docs/sdk/primitives/fusion.md @@ -12,12 +12,6 @@ take that offer against the fusion. The swap can be reversed the same way, any n combine several NFTs into one, divide one into several, or upgrade an NFT to a new version while keeping the original recoverable. -:::info Unreleased -This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). -To try it, depend on the git repository with the `chip-0021` feature: -`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["chip-0021"] }` -::: - ## Concepts - **Sides.** A fusion has two fixed lists of NFT launcher IDs, side A and side B, curried into its puzzle. diff --git a/docs/sdk/primitives/index.md b/docs/sdk/primitives/index.md index b777fa3..393a092 100644 --- a/docs/sdk/primitives/index.md +++ b/docs/sdk/primitives/index.md @@ -234,18 +234,11 @@ In practice you rarely build proofs by hand: | [Bulletins](/sdk/primitives/bulletin) | `Bulletin`, `BulletinMessage` | | | [DataLayer](/sdk/primitives/datalayer) | `Datastore`, `DatastoreInfo` | `chip-0035` | | [Action Layer](/sdk/primitives/action-layer) | `CatalogRegistry`, `XchandlesRegistry`, `RewardDistributor`, `MedievalVault`, `StateScheduler`, `Slot` | `action-layer` | - -These primitives are on the SDK's `main` branch and aren't in a release yet: - -| Page | Types | Feature | -| ------------------------------------------------ | -------------------------------------------------------- | ----------- | -| [Partial Offers](/sdk/primitives/partial-offers) | `PartialOffer`, `PartialOfferInfo`, `PartialOfferFill` | `chip-0052` | -| [Auctions](/sdk/primitives/auctions) | `Auction`, `AuctionInfo`, `AuctionSettings` | `auction` | -| [Splitter](/sdk/primitives/splitter) | `SplitterLayer`, `SplitterArgs`, `SplitterShare` | `chip-0008` | -| [NFT Fusion](/sdk/primitives/fusion) | `Fusion`, `FusionSwap`, `P2FusionLayer` | `chip-0021` | -| [Secure the Bag](/sdk/primitives/secure-the-bag) | `SecureTheBag`, `BagFormat`, `BagPayment`, `SecuredMint` | | - -Vault [signed messages](/sdk/primitives/vaults#signed-messages) are also unreleased. +| [Partial Offers](/sdk/primitives/partial-offers) | `PartialOffer`, `PartialOfferInfo`, `PartialOfferFill` | `chip-0052` | +| [Auctions](/sdk/primitives/auctions) | `Auction`, `AuctionInfo`, `AuctionSettings` | `auction` | +| [Splitter](/sdk/primitives/splitter) | `SplitterLayer`, `SplitterArgs`, `SplitterShare` | `chip-0008` | +| [NFT Fusion](/sdk/primitives/fusion) | `Fusion`, `FusionSwap`, `P2FusionLayer` | `chip-0021` | +| [Secure the Bag](/sdk/primitives/secure-the-bag) | `SecureTheBag`, `BagFormat`, `BagPayment`, `SecuredMint` | | All primitive types live in `chia_wallet_sdk::driver`, and the most common ones are re-exported from `chia_wallet_sdk::prelude`. See the diff --git a/docs/sdk/primitives/partial-offers.md b/docs/sdk/primitives/partial-offers.md index 1e9a606..5bf5005 100644 --- a/docs/sdk/primitives/partial-offers.md +++ b/docs/sdk/primitives/partial-offers.md @@ -8,12 +8,6 @@ A partial offer ([CHIP-0052](https://github.com/Chia-Network/chips/blob/main/CHI Use a partial offer when you want order-book style trading of a single pair, for example "1000 CATs at 0.4 XCH each, in any amount". For trading several assets at once, NFTs or options, or anything that has to be taken all at once, use a normal [offer](/sdk/offers). -:::info Unreleased -This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). -To try it, depend on the git repository with the `chip-0052` feature: -`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["chip-0052"] }` -::: - The driver types are in [`chia_wallet_sdk::driver`](https://docs.rs/chia-wallet-sdk/latest/chia_wallet_sdk/driver/index.html) and the puzzle types in `chia_wallet_sdk::types::puzzles`. None of them are in the prelude. ## Partial offers and normal offers diff --git a/docs/sdk/primitives/secure-the-bag.md b/docs/sdk/primitives/secure-the-bag.md index 973f893..3bba6c0 100644 --- a/docs/sdk/primitives/secure-the-bag.md +++ b/docs/sdk/primitives/secure-the-bag.md @@ -17,12 +17,6 @@ to be guaranteed now and let the recipients (or anyone else) pay the fees to cla works for XCH, CATs and revocable CATs. It can also secure NFT bulk mints, through the secure the mint pre-launcher puzzle (`SecuredMint`). -:::info Unreleased -This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). -To try it, depend on the git repository: -`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk" }` -::: - There's no feature flag. Everything on this page is in `chia_wallet_sdk::driver`. ## The types diff --git a/docs/sdk/primitives/splitter.md b/docs/sdk/primitives/splitter.md index fecf543..a0df101 100644 --- a/docs/sdk/primitives/splitter.md +++ b/docs/sdk/primitives/splitter.md @@ -12,12 +12,6 @@ same. The usual use is splitting NFT royalties between several creators, or with splitter's puzzle hash as the NFT's royalty puzzle hash. It works the same way for any asset whose outer puzzle wraps a p2 puzzle, such as a CAT. -:::info Unreleased -This feature is on the SDK's `main` branch and isn't in a release yet (the latest release is 0.37.1). -To try it, depend on the git repository with the `chip-0008` feature: -`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", features = ["chip-0008"] }` -::: - ## How the puzzle works The curried argument is the payout scheme, a list of `(puzzle_hash share)` pairs. The solution is the diff --git a/docs/sdk/primitives/vaults.md b/docs/sdk/primitives/vaults.md index 9f83170..55b066a 100644 --- a/docs/sdk/primitives/vaults.md +++ b/docs/sdk/primitives/vaults.md @@ -500,11 +500,6 @@ The struct only stores the hash of the fixed path, so a coin can be reconstructe calculates it from the quoted conditions hash and version. When spending the fixed path, the version is detected from the quoted conditions (`match_quoted_conditions_hash`). -:::note -This changed in 0.37.0. In earlier versions, `new` took the quoted conditions hash and version and -stored them on the struct. -::: - ```rust since-0.37 use chia_wallet_sdk::driver::{P2ConditionsOrSingleton, P2ConditionsOrSingletonVersion}; use chia_wallet_sdk::prelude::*; @@ -542,12 +537,6 @@ The vault path is `P2ConditionsOrSingleton::p2_singleton_spend`, which takes the ## Signed messages -:::info Unreleased -Vault message signing is on the SDK's `main` branch and isn't in a release yet (the latest release is -0.37.1). To try it, depend on the git repository: -`chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk" }` -::: - A vault has no single key, so it can't sign an off-chain message (such as a login challenge or an attestation) the way a BLS or secp key does. Instead, the vault's signers sign a vault spend that can never be valid on chain. The spend carries the messages, and the signed coin spend is the proof. diff --git a/sidebars.ts b/sidebars.ts index 285515c..f4d76a5 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -81,23 +81,17 @@ const sidebars: SidebarsConfig = { label: "Action Layer", }, { - type: "category", - label: "Unreleased", - items: [ - { - type: "doc", - id: "sdk/primitives/partial-offers", - label: "Partial Offers", - }, - { type: "doc", id: "sdk/primitives/auctions", label: "Auctions" }, - { type: "doc", id: "sdk/primitives/splitter", label: "Splitter" }, - { type: "doc", id: "sdk/primitives/fusion", label: "NFT Fusion" }, - { - type: "doc", - id: "sdk/primitives/secure-the-bag", - label: "Secure the Bag", - }, - ], + type: "doc", + id: "sdk/primitives/partial-offers", + label: "Partial Offers", + }, + { type: "doc", id: "sdk/primitives/auctions", label: "Auctions" }, + { type: "doc", id: "sdk/primitives/splitter", label: "Splitter" }, + { type: "doc", id: "sdk/primitives/fusion", label: "NFT Fusion" }, + { + type: "doc", + id: "sdk/primitives/secure-the-bag", + label: "Secure the Bag", }, ], }, From 73939c74d9ae5f627b01b8136f5e8c34b179bfbf Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Nov 2026 23:54:21 -0500 Subject: [PATCH 6/7] List category overview pages as regular sidebar items --- sidebars.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sidebars.ts b/sidebars.ts index f4d76a5..bcaad7f 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -37,8 +37,8 @@ const sidebars: SidebarsConfig = { { type: "category", label: "Action System", - link: { type: "doc", id: "sdk/action-system/index" }, items: [ + { type: "doc", id: "sdk/action-system/index", label: "Overview" }, { type: "doc", id: "sdk/action-system/actions", @@ -54,8 +54,8 @@ const sidebars: SidebarsConfig = { { type: "category", label: "Primitives", - link: { type: "doc", id: "sdk/primitives/index" }, items: [ + { type: "doc", id: "sdk/primitives/index", label: "Overview" }, { type: "doc", id: "sdk/primitives/standard", label: "Standard (XCH)" }, { type: "doc", id: "sdk/primitives/cat", label: "CAT" }, { type: "doc", id: "sdk/primitives/singletons", label: "Singletons" }, @@ -109,8 +109,8 @@ const sidebars: SidebarsConfig = { { type: "category", label: "Testing", - link: { type: "doc", id: "sdk/testing/index" }, items: [ + { type: "doc", id: "sdk/testing/index", label: "Simulator" }, { type: "doc", id: "sdk/testing/peer-simulator", From 0f296e2e8a1385fc725ff4b29b30948b85ea27c3 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Mon, 9 Nov 2026 00:02:24 -0500 Subject: [PATCH 7/7] Check Wallet SDK examples in-repo and add an update-sdk-docs skill --- .claude/skills/update-sdk-docs/SKILL.md | 78 + .gitignore | 4 + .prettierignore | 1 + README.md | 14 + docs/sdk/networking/rpc.md | 2 +- docs/sdk/primitives/auctions.md | 4 +- docs/sdk/primitives/fusion.md | 6 +- docs/sdk/primitives/partial-offers.md | 6 +- docs/sdk/primitives/secure-the-bag.md | 6 +- docs/sdk/primitives/splitter.md | 6 +- docs/sdk/primitives/vaults.md | 6 +- docs/sdk/testing/full-node-simulator.md | 4 +- package.json | 3 +- scripts/check-sdk-examples.py | 111 + sdk-examples/Cargo.lock | 4509 +++++++++++++++++++++++ sdk-examples/Cargo.toml | 37 + sdk-examples/src/lib.rs | 3 + tsconfig.json | 2 +- 18 files changed, 4780 insertions(+), 22 deletions(-) create mode 100644 .claude/skills/update-sdk-docs/SKILL.md create mode 100755 scripts/check-sdk-examples.py create mode 100644 sdk-examples/Cargo.lock create mode 100644 sdk-examples/Cargo.toml create mode 100644 sdk-examples/src/lib.rs diff --git a/.claude/skills/update-sdk-docs/SKILL.md b/.claude/skills/update-sdk-docs/SKILL.md new file mode 100644 index 0000000..f265f28 --- /dev/null +++ b/.claude/skills/update-sdk-docs/SKILL.md @@ -0,0 +1,78 @@ +--- +name: update-sdk-docs +description: Update the Wallet SDK docs (docs/sdk) for a new chia-wallet-sdk release or commit, based on the SDK diff since the commit the docs were last written against. Use when asked to update, sync or refresh the SDK docs for a release. +argument-hint: "" +--- + +# Update the Wallet SDK docs + +The pages in `docs/sdk` document the public API of [chia-wallet-sdk](https://github.com/xch-dev/chia-wallet-sdk). +The SDK commit they're written against is the `rev` in `sdk-examples/Cargo.toml`. Your job is to bring +the docs up to date with a newer SDK commit (the argument, or the latest release tag if none is given), +and open a PR. + +## 1. Get the diff + +1. Clone or update the SDK somewhere outside this repo (or use an existing checkout, read only). +2. Resolve the target to a full commit hash, and diff it against the current `rev`: + `git log --oneline ..` and `git diff .. -- src crates Cargo.toml`. + Ignore the bindings' generated files, CI, and dependency bumps that don't change the public API. +3. Read the PR descriptions of the merged commits (`gh pr view --repo xch-dev/chia-wallet-sdk`) + for intent, especially for behaviour changes. + +## 2. Plan the changes + +Sort every relevant change into one of these, and map it to the pages it affects (`grep -rn` the docs +for the type and method names involved): + +- **New public items** (types, methods, primitives, layers, actions, feature flags): add them to the + relevant page, or write a new page for a new primitive or major feature. +- **Changed signatures or renames**: update the prose and examples. The example check finds most of these. +- **Behaviour changes**: rewrite the affected prose. The example check won't find these, so read the + diff carefully and check every claim on the affected pages against the new source. +- **Removals**: remove or replace them. + +Also check the lists that summarize the API: the feature flag table and crate table in +`docs/sdk/index.md`, the "All primitives" table in `docs/sdk/primitives/index.md`, the built-in +layers table in `docs/sdk/concepts/layers.md`, the action table in `docs/sdk/action-system/actions.md`, +the bindings page, and `sidebars.ts`. + +For a large diff, split independent pages between subagents, giving each this skill's conventions and +its own copy of `sdk-examples` (cargo locks the target directory, so parallel runs in one crate block +each other). + +## 3. Update the docs + +Conventions: + +- The docs describe the SDK as it is at the target commit, as if everything is released. Don't label + anything as new or unreleased, don't mention SDK versions in prose, and don't describe old behaviour. +- Pages are conceptual: what something is, when to use it, how it fits with the rest of the SDK, then + realistic examples, then gotchas. docs.rs is the exhaustive reference, so don't list every method. +- Rust only (the bindings have their own page). Every page needs realistic examples, preferably + `#[test]`s that run against the `Simulator`. Networking examples can be compiled `async fn`s. +- Each ` ```rust ` block is compiled as its own module, so it needs its own `use` lines, using the + `chia_wallet_sdk::...` re-export paths (the prelude plus specific modules). Only use + ` ```rust ignore ` for excerpts such as struct definitions. +- Mention the feature flag for anything that's feature gated. +- Link other SDK pages with absolute paths (`/sdk/concepts/spend-context`). `pnpm build` fails on broken links. +- New pages get an entry in `sidebars.ts`. Category landing pages are regular items inside their + category (for example an "Overview" item), not category links. +- If a page moves or is removed, add a redirect in `docusaurus.config.ts`. +- Plain, direct prose, matching the existing pages. No marketing language or emoji. + +## 4. Verify + +1. Update `rev` in `sdk-examples/Cargo.toml` (both dependencies) to the target commit's full hash. +2. Run `pnpm check:sdk-examples` until every example compiles and passes. Failures point at + `sdk-examples/src/docs/_.rs`, whose first line names the markdown file and line. +3. Have a separate reviewer (a subagent given the changed pages and the SDK source, not your + conclusions) check the prose, tables and comments against the source, and fix what it finds. +4. Run `pnpm format`, `pnpm typecheck` and `pnpm build`, and make sure the build has no broken links + or anchors. + +## 5. Open a PR + +Work on a branch, commit, and open a PR against `main` that lists the SDK range +(`..`), what changed in the docs and why, and anything you couldn't cover. Follow the +user's branch naming and attribution preferences. diff --git a/.gitignore b/.gitignore index 86b0956..98646df 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Wallet SDK example checks (generated by scripts/check-sdk-examples.py) +/sdk-examples/target +/sdk-examples/src/docs diff --git a/.prettierignore b/.prettierignore index de7d40d..73e00cc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ pnpm-lock.yaml src/openapi.json docs/sage/rpc docs/rpc +sdk-examples/target diff --git a/README.md b/README.md index 70ffb56..63e9a05 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,20 @@ pnpm build CI runs the same checks on every pull request. +## Wallet SDK examples + +Every Rust example in `docs/sdk` is compiled and run against the Wallet SDK. The SDK commit the docs are written against is the `rev` in `sdk-examples/Cargo.toml`. To check the examples (this needs a Rust toolchain): + +```sh +pnpm check:sdk-examples # every page +pnpm check:sdk-examples docs/sdk/primitives/cat.md # specific pages +pnpm check:sdk-examples --sdk /path/to/wallet-sdk # against a local SDK checkout +``` + +Each ` ```rust ` block is compiled as its own module, so it needs its own `use` lines. Mark a block ` ```rust ignore ` to skip it, which is only meant for excerpts like struct definitions. CI doesn't run this check yet. + +To update the docs for a new SDK release with Claude Code, run `/update-sdk-docs `. + ## RPC reference The RPC pages are generated from `src/openapi.json`, which comes from Sage. To update it to a Sage release (this needs git and a Rust toolchain): diff --git a/docs/sdk/networking/rpc.md b/docs/sdk/networking/rpc.md index 79016cf..2094011 100644 --- a/docs/sdk/networking/rpc.md +++ b/docs/sdk/networking/rpc.md @@ -271,7 +271,7 @@ every RPC method comes with the trait. The method's bounds use `serde`'s `Serial This wrapper logs every call and then forwards it to another client: -```rust ignore +```rust use chia_wallet_sdk::prelude::*; use serde::{Serialize, de::DeserializeOwned}; diff --git a/docs/sdk/primitives/auctions.md b/docs/sdk/primitives/auctions.md index 7f1f2e8..fa57a28 100644 --- a/docs/sdk/primitives/auctions.md +++ b/docs/sdk/primitives/auctions.md @@ -186,7 +186,7 @@ To launch: singleton amount of `AUCTION_SINGLETON_AMOUNT` (1), since the reserve finalizer always recreates the singleton with an amount of 1. -```rust unreleased +```rust use std::num::NonZeroU64; use chia_wallet_sdk::driver::{ @@ -350,7 +350,7 @@ fn auction_launch_bid_and_settle() -> anyhow::Result<()> { The seller authorizes a cancel by spending a coin with the payout puzzle hash (any coin, even an empty one) that outputs `cancel_authorization`. The NFT goes back to the payout puzzle hash. -```rust unreleased +```rust use std::num::NonZeroU64; use chia_wallet_sdk::driver::{ diff --git a/docs/sdk/primitives/fusion.md b/docs/sdk/primitives/fusion.md index 8af18ab..1ce6907 100644 --- a/docs/sdk/primitives/fusion.md +++ b/docs/sdk/primitives/fusion.md @@ -72,7 +72,7 @@ coin, the NFTs now `locked`, and the NFTs `unlocked` for the maker, ready for th This example has a project lock one NFT on side A, then a user fuses two NFTs into it and defuses it back: -```rust unreleased +```rust use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; use chia_wallet_sdk::chia::puzzle_types::offer::{NotarizedPayment, Payment}; use chia_wallet_sdk::driver::{Fusion, FusionDirection}; @@ -237,7 +237,7 @@ offered NFT into the settlement puzzle must assert the payment it expects in ret Otherwise anyone who sees that spend can pay the NFT to themselves, because settlement coins can be spent by anyone: -```rust unreleased +```rust use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; use chia_wallet_sdk::chia::puzzle_types::offer::{NotarizedPayment, Payment}; use chia_wallet_sdk::driver::{FusionDirection, FusionUnlock}; @@ -333,7 +333,7 @@ indexer can find fusions by that hint. from a confirmed fusion spend, along with the `FusionDirection` of that swap, which tells you which side is locked now: after `Fuse` side B is locked, after `Defuse` side A is. -```rust unreleased +```rust use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; use chia_wallet_sdk::chia::puzzle_types::offer::{NotarizedPayment, Payment}; use chia_wallet_sdk::driver::{Fusion, FusionDirection, FusionUnlock}; diff --git a/docs/sdk/primitives/partial-offers.md b/docs/sdk/primitives/partial-offers.md index 5bf5005..cf4641a 100644 --- a/docs/sdk/primitives/partial-offers.md +++ b/docs/sdk/primitives/partial-offers.md @@ -66,7 +66,7 @@ To take it, decode it with `decode_partial_offer` and parse it with `PartialOffe This example sells 1000 CAT mojos for 400 XCH mojos, with a minimum fill, an expiration and a required fee. Bob fills part of it, then takes the rest from the recreated coin: -```rust unreleased +```rust use std::num::NonZeroU64; use chia_wallet_sdk::driver::{ @@ -206,7 +206,7 @@ Each fill pays `requested_amount` and releases `requested_amount * price_precisi - `PartialOffer::max_requested_amount()` is the smallest payment that releases as much of the coin as possible. Because of rounding, a tiny remainder can be impossible to release, in which case only the maker can reclaim it. - `PartialOffer::child(requested)` is the coin that a fill recreates, or `None` if it takes everything. -```rust unreleased +```rust use std::num::NonZeroU64; use chia_wallet_sdk::driver::{PartialOffer, PartialOfferAsset, PartialOfferInfo}; @@ -267,7 +267,7 @@ To cancel a partial offer, the maker claws back its latest coin with `partial_of This example creates the partial offer coin on chain right away, has Bob fill part of it, then has Alice find the latest coin from Bob's spend and claw it back: -```rust unreleased +```rust use std::num::NonZeroU64; use chia_wallet_sdk::driver::{ diff --git a/docs/sdk/primitives/secure-the-bag.md b/docs/sdk/primitives/secure-the-bag.md index 3bba6c0..095491b 100644 --- a/docs/sdk/primitives/secure-the-bag.md +++ b/docs/sdk/primitives/secure-the-bag.md @@ -107,7 +107,7 @@ second claim starts from the closest unspent node on its path. This example buil payments with a width of three. It funds the root, claims one payment, then claims a second payment from a node that the first claim created: -```rust unreleased +```rust use chia_wallet_sdk::driver::{BagAsset, BagCoin, BagFormat, BagPayment, SecureTheBag}; use chia_wallet_sdk::prelude::*; @@ -168,7 +168,7 @@ sender can still claw it back before the timeout, as with any [clawback](/sdk/pr This example issues a CAT straight into a bag of five payments and one clawback payment, then unrolls all of it in one transaction: -```rust unreleased +```rust use chia_wallet_sdk::chia::puzzle_types::cat::GenesisByCoinIdTailArgs; use chia_wallet_sdk::driver::{BagAsset, BagFormat, BagPayment, BagCoin, ClawbackV2, SecureTheBag}; use chia_wallet_sdk::prelude::*; @@ -254,7 +254,7 @@ data. Cloud Wallet doesn't set a melt key, and the key changes the pre-launcher' NFT bulk mints keep the mints in order, so they use an unsorted bag: -```rust unreleased +```rust use chia_wallet_sdk::chia::puzzle_types::nft::NftMetadata; use chia_wallet_sdk::driver::{BagAsset, BagCoin, BagFormat, SecureTheBag, SecuredMint}; use chia_wallet_sdk::prelude::*; diff --git a/docs/sdk/primitives/splitter.md b/docs/sdk/primitives/splitter.md index a0df101..1df4807 100644 --- a/docs/sdk/primitives/splitter.md +++ b/docs/sdk/primitives/splitter.md @@ -54,7 +54,7 @@ paid if the split happens in the same block. `payouts(amount)` calculates what each recipient gets, using the same rounding as the puzzle, and `inner_spend(ctx, amount)` builds the spend. For an XCH coin, the inner spend is the whole spend: -```rust unreleased +```rust use chia_wallet_sdk::driver::SplitterLayer; use chia_wallet_sdk::prelude::*; use chia_wallet_sdk::types::puzzles::SplitterShare; @@ -116,7 +116,7 @@ To split a CAT, use the splitter as the CAT's inner puzzle. Pass the inner spend [`Cat::spend_all`](/sdk/primitives/cat), which wraps it in the CAT layer. The children are CATs of the same asset with the recipients' puzzle hashes as their p2 puzzle hashes: -```rust unreleased +```rust use chia_wallet_sdk::driver::SplitterLayer; use chia_wallet_sdk::prelude::*; use chia_wallet_sdk::types::puzzles::SplitterShare; @@ -176,7 +176,7 @@ other puzzles, and `DriverError::InvalidSplitter` if the scheme is one `Splitter or if it doesn't serialize back to the same puzzle hash (for example, a share encoded as a non-canonical integer, or extra values after a share, which the puzzle ignores but are part of the puzzle hash): -```rust unreleased +```rust use chia_wallet_sdk::driver::SplitterLayer; use chia_wallet_sdk::prelude::*; use chia_wallet_sdk::types::puzzles::SplitterShare; diff --git a/docs/sdk/primitives/vaults.md b/docs/sdk/primitives/vaults.md index 55b066a..0aae49d 100644 --- a/docs/sdk/primitives/vaults.md +++ b/docs/sdk/primitives/vaults.md @@ -500,7 +500,7 @@ The struct only stores the hash of the fixed path, so a coin can be reconstructe calculates it from the quoted conditions hash and version. When spending the fixed path, the version is detected from the quoted conditions (`match_quoted_conditions_hash`). -```rust since-0.37 +```rust use chia_wallet_sdk::driver::{P2ConditionsOrSingleton, P2ConditionsOrSingletonVersion}; use chia_wallet_sdk::prelude::*; @@ -564,7 +564,7 @@ Before the signers approve an envelope, [clear signing](/sdk/clear-signing#signe `VaultTransaction::signed_messages` instead of a transaction. A delegated spend can't both sign messages and do something else (`MixedVaultMessageEnvelope`). -```rust unreleased +```rust use chia_wallet_sdk::driver::{ InnerPuzzleSpend, MipsSpend, Reveals, VaultMessageProof, mips_puzzle_hash, parse_vault_transaction, vault_message_envelope, verify_vault_signed_message, @@ -686,7 +686,7 @@ with a second version byte. Verify it with `verify_controlled_vault_signed_messa controlled, up to 16 controller spends in total. `verify_vault_signed_message` rejects proofs with controller spends. -```rust unreleased +```rust use chia_wallet_sdk::driver::{ InnerPuzzleSpend, MipsSpend, VaultMessageProof, mips_puzzle_hash, vault_controller_envelope, vault_message_envelope, verify_controlled_vault_signed_message, diff --git a/docs/sdk/testing/full-node-simulator.md b/docs/sdk/testing/full-node-simulator.md index 68dc6c9..49939f6 100644 --- a/docs/sdk/testing/full-node-simulator.md +++ b/docs/sdk/testing/full-node-simulator.md @@ -318,7 +318,7 @@ return `success: false`), plus `get_aggsig_additional_data` and a few control en :::info Feature flag The server needs the `full-node-simulator-http` feature of `chia-sdk-test`. `chia-wallet-sdk` doesn't have a feature that enables it, so add `chia-sdk-test` as a direct dependency (at the same version) with -the feature turned on. The example below is not compiled as part of these docs for that reason. +the feature turned on. ```toml [dev-dependencies] @@ -331,7 +331,7 @@ chia-sdk-test = { version = "0.37", features = ["full-node-simulator-http"] } the test as well, create it yourself as an `Arc>` and pass it to `with_simulator`. The server stops when it's dropped. -```rust ignore +```rust use std::sync::{Arc, Mutex}; use chia_sdk_test::{FullNodeSimulator, FullNodeSimulatorServer}; diff --git a/package.json b/package.json index 6f6d730..e78f7ac 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "typecheck": "pnpm gen-api-docs && tsc", "format": "prettier --write .", "format:check": "prettier --check .", - "sync-openapi": "./scripts/sync-openapi.sh" + "sync-openapi": "./scripts/sync-openapi.sh", + "check:sdk-examples": "python3 scripts/check-sdk-examples.py" }, "dependencies": { "@docusaurus/core": "3.10.2", diff --git a/scripts/check-sdk-examples.py b/scripts/check-sdk-examples.py new file mode 100755 index 0000000..c0976e8 --- /dev/null +++ b/scripts/check-sdk-examples.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Compile and run the Rust examples in the Wallet SDK docs. + +Every ```rust block in docs/sdk becomes its own module in the sdk-examples crate (so each block needs +its own `use` lines), and `cargo test` runs them. Blocks marked ```rust ignore are skipped. + +Usage: + scripts/check-sdk-examples.py [PAGE.md ...] [--sdk PATH] [-- CARGO_TEST_ARGS ...] + + PAGE.md Only check these pages (default: every page in docs/sdk). + --sdk PATH Build against a local chia-wallet-sdk checkout instead of the `rev` in + sdk-examples/Cargo.toml, for example to check the docs against an unreleased change. + +Compiler errors point at sdk-examples/src/docs/_.rs, whose first line names the markdown +file and line the block came from. +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DOCS = ROOT / "docs" / "sdk" +CRATE = ROOT / "sdk-examples" +OUT = CRATE / "src" / "docs" +SDK_GIT = "https://github.com/xch-dev/chia-wallet-sdk" +FENCE = re.compile(r"^(\s*)```rust\b(.*)$") + + +def parse_args(argv): + pages, sdk, cargo_args = [], None, [] + if "--" in argv: + index = argv.index("--") + argv, cargo_args = argv[:index], argv[index + 1 :] + i = 0 + while i < len(argv): + if argv[i] == "--sdk": + sdk = Path(argv[i + 1]).resolve() + i += 2 + else: + pages.append(Path(argv[i]).resolve()) + i += 1 + return pages or sorted(DOCS.rglob("*.md")), sdk, cargo_args + + +def extract(page): + """Yields (line number, code) for every checked rust block in a page.""" + lines = page.read_text().split("\n") + i = 0 + while i < len(lines): + match = FENCE.match(lines[i]) + i += 1 + if not match: + continue + indent, info = match.groups() + start, body = i, [] + while i < len(lines) and not lines[i].strip().startswith("```"): + line = lines[i] + body.append(line[len(indent) :] if line.startswith(indent) else line) + i += 1 + i += 1 + if "ignore" not in info.split(): + yield start, "\n".join(body) + + +def main(): + pages, sdk, cargo_args = parse_args(sys.argv[1:]) + + shutil.rmtree(OUT, ignore_errors=True) + OUT.mkdir(parents=True) + + modules = [] + for page in pages: + relative = page.relative_to(DOCS).with_suffix("") + slug = re.sub(r"[^a-z0-9]+", "_", str(relative).lower()).strip("_") + for n, (line, code) in enumerate(extract(page), start=1): + name = f"{slug}_{n}" + source = f"// {page.relative_to(ROOT)}:{line}\n{code}\n" + (OUT / f"{name}.rs").write_text(source) + modules.append(name) + + (OUT / "mod.rs").write_text( + "".join(f'#[path = "{name}.rs"]\nmod {name};\n' for name in modules) + ) + print(f"Extracted {len(modules)} examples from {len(pages)} pages") + + command = ["cargo", "test", "--quiet"] + if sdk: + for name, path in [ + ("chia-wallet-sdk", sdk), + ("chia-sdk-test", sdk / "crates" / "chia-sdk-test"), + ]: + command += ["--config", f'patch."{SDK_GIT}".{name}.path="{path}"'] + command += cargo_args + + # Patching the SDK rewrites Cargo.lock to point at the local checkout, so put it back afterwards. + lockfile = CRATE / "Cargo.lock" + original_lock = lockfile.read_bytes() if sdk and lockfile.exists() else None + try: + status = subprocess.call(command, cwd=CRATE) + finally: + if original_lock is not None: + lockfile.write_bytes(original_lock) + sys.exit(status) + + +if __name__ == "__main__": + main() diff --git a/sdk-examples/Cargo.lock b/sdk-examples/Cargo.lock new file mode 100644 index 0000000..1fca45c --- /dev/null +++ b/sdk-examples/Cargo.lock @@ -0,0 +1,4509 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-fips-sys" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b744eed18cd75d814effdf2aba2880f912abe0fe1c327b361341d31aba9a7005" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", + "regex", +] + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-fips-sys", + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex", + "syn 3.0.9", +] + +[[package]] +name = "bip39" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4559bb9225f8401dcea601cf8dc9ed81ea0dadd9cf7755fdefdd93ef7961e19e" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "blst" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chia-bls" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfe8540688eaed5bdecffd51c26df489b8bc610890b613b81461411f90cc9" +dependencies = [ + "blst", + "chia-sha2 0.38.2", + "chia-traits 0.38.2", + "hex", + "hkdf", + "linked-hash-map", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "chia-bls" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde18a35ddd6ef44d4216a56f37f62388d202d069740c6a12258dff5fc50c1de" +dependencies = [ + "blst", + "chia-serde", + "chia-sha2 0.53.0", + "chia-traits 0.53.0", + "hex", + "hkdf", + "linked-hash-map", + "serde", + "sha2 0.10.9", + "thiserror 2.0.21", +] + +[[package]] +name = "chia-consensus" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf32e23f5a05c556efd2e1161ac8620f05b9b22ae2e1243ef0364d92cdf724b3" +dependencies = [ + "bitflags", + "chia-bls 0.53.0", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sha2 0.53.0", + "chia-traits 0.53.0", + "chia_streamable_macro 0.53.0", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", + "hex", + "hex-literal 0.4.1", + "thiserror 2.0.21", +] + +[[package]] +name = "chia-protocol" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad3e7a0bc4e8dea908d7a5e976afc311da024109890bb083b27c558a8958c523" +dependencies = [ + "chia-bls 0.53.0", + "chia-serde", + "chia-sha2 0.53.0", + "chia-traits 0.53.0", + "chia_streamable_macro 0.53.0", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", + "hex", + "serde", + "serde_arrays", +] + +[[package]] +name = "chia-puzzle-types" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b210faac5f6e306deda7097825f665f8050a270b3409cc904296db0df40b841" +dependencies = [ + "chia-bls 0.53.0", + "chia-protocol", + "chia-puzzles", + "chia-sha2 0.53.0", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", + "hex-literal 0.4.1", + "num-bigint", +] + +[[package]] +name = "chia-puzzles" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553363ce9550cfde0ae1306a0a79bf9afd36ea09e0222b874b6287b44ad9d178" +dependencies = [ + "hex", + "hex-literal 0.4.1", +] + +[[package]] +name = "chia-sdk-client" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "aws-lc-rs", + "chia-protocol", + "chia-sdk-types", + "chia-ssl", + "chia-traits 0.53.0", + "futures-util", + "rustls", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite", + "tracing", + "tungstenite", +] + +[[package]] +name = "chia-sdk-coinset" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "chia-protocol", + "hex", + "hex-literal 1.1.0", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "chia-sdk-daemon" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "chia-sdk-client", + "chia-sdk-coinset", + "futures-util", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite", + "tracing", + "tungstenite", +] + +[[package]] +name = "chia-sdk-derive" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "convert_case 0.12.0", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "chia-sdk-driver" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "bip39", + "chia-bls 0.53.0", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-signer", + "chia-sdk-types", + "chia-sdk-utils", + "chia-secp", + "chia-sha2 0.53.0", + "chia-traits 0.53.0", + "chia_streamable_macro 0.53.0", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", + "flate2", + "getrandom 0.3.4", + "hex", + "hex-literal 1.1.0", + "indexmap", + "num-bigint", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "chia-sdk-signer" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "chia-bls 0.53.0", + "chia-consensus", + "chia-protocol", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.53.0", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", + "k256", + "rue-lir", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "chia-sdk-test" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "anyhow", + "axum", + "bincode", + "bip39", + "chia-bls 0.53.0", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-sdk-client", + "chia-sdk-coinset", + "chia-sdk-signer", + "chia-sdk-types", + "chia-secp", + "chia-sha2 0.53.0", + "chia-traits 0.53.0", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", + "futures-channel", + "futures-util", + "hex", + "hex-literal 1.1.0", + "indexmap", + "itertools 0.15.0", + "prettytable-rs", + "rand 0.10.3", + "rand_chacha 0.10.0", + "serde", + "serde_json", + "signature", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite", + "tracing", +] + +[[package]] +name = "chia-sdk-types" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "chia-bls 0.53.0", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-derive", + "chia-secp", + "chia-sha2 0.53.0", + "chialisp", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", + "hex-literal 1.1.0", + "rue-compiler", + "rue-lir", + "rue-options", + "serde", + "sha3 0.12.0", + "thiserror 2.0.21", +] + +[[package]] +name = "chia-sdk-utils" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "bech32", + "chia-protocol", + "hex", + "indexmap", + "rand 0.10.3", + "rand_chacha 0.10.0", + "thiserror 2.0.21", +] + +[[package]] +name = "chia-secp" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2f995b03bc7e9c78ec0da994b647f032e76bb4014bc4c555b845fef4f3177" +dependencies = [ + "chia-sha2 0.53.0", + "hex", + "k256", + "p256", +] + +[[package]] +name = "chia-serde" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37250dfcf6072d1867812c8f6b3eb76356ff77e1e02e94ee15357d9ed6aca5de" +dependencies = [ + "hex", + "serde", +] + +[[package]] +name = "chia-sha2" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a57be484b5abb4481a3ea8b2e6fc0404f41222e0cfb35b81269c2404b64107a" +dependencies = [ + "sha2 0.10.9", +] + +[[package]] +name = "chia-sha2" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f2141a6e5ab2f923f95a96c8ee99a707a0e4fd8f7e10bc0bdde99c6fd4e0e5" +dependencies = [ + "sha2 0.10.9", +] + +[[package]] +name = "chia-ssl" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6a2cff84aa0ed3fbd4bbf3f9531275b966c48dfab0b3688eaf2b845d24b663" +dependencies = [ + "getrandom 0.4.3", + "rcgen", + "rsa", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "chia-traits" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13ea36e3ae5ede1d015d873fdfa91ea4d7a8790c6859c78b6b74065c7ddbbbd" +dependencies = [ + "chia-sha2 0.38.2", + "chia_streamable_macro 0.38.2", + "thiserror 1.0.69", +] + +[[package]] +name = "chia-traits" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1222a2cf43296036af72e84197798e724bdd8df7b213da0c81aecd2508ada12" +dependencies = [ + "chia-sha2 0.53.0", + "chia_streamable_macro 0.53.0", + "thiserror 2.0.21", +] + +[[package]] +name = "chia-wallet-sdk" +version = "0.37.1" +source = "git+https://github.com/xch-dev/chia-wallet-sdk?rev=ad7630cbae5bc2d3c1481eb2fc797249048a16a8#ad7630cbae5bc2d3c1481eb2fc797249048a16a8" +dependencies = [ + "chia-bls 0.53.0", + "chia-consensus", + "chia-protocol", + "chia-puzzle-types", + "chia-puzzles", + "chia-sdk-client", + "chia-sdk-coinset", + "chia-sdk-daemon", + "chia-sdk-driver", + "chia-sdk-signer", + "chia-sdk-test", + "chia-sdk-types", + "chia-sdk-utils", + "chia-secp", + "chia-serde", + "chia-sha2 0.53.0", + "chia-ssl", + "chia-traits 0.53.0", + "clvm-traits 0.53.0", + "clvm-utils", + "clvmr", +] + +[[package]] +name = "chia_streamable_macro" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4450a65b83cd89f8ccad2b4d5f8dc23e89ab0b6ae86d8c535ffde9fdc9d9c6c5" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chia_streamable_macro" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "079db85b6a5c9cde487852552f70ae935c664cfcf024db35de2b67e2341aaef1" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "chialisp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1df7ff70d12893bd5292a36255a55e7fac691911abd5f073be17dbe3b0a435" +dependencies = [ + "binascii", + "chia-bls 0.38.2", + "clvmr", + "do-notation", + "hashlink", + "hex", + "indoc", + "js-sys", + "lazy_static", + "num", + "num-bigint", + "num-traits", + "pyo3-build-config", + "regex", + "serde", + "serde_json", + "sha2 0.11.1", + "tempfile", + "unicode-segmentation", + "wasm-bindgen", + "yaml-rust2", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clvm-derive" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "159c7547311ea58839b808805d12ac9b75138b77a9806feabbef060de8f448c9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "clvm-traits" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ea3eeec6cc601989e4b59e4a447d9b73698808e99dc9836651d3d33dab9d72" +dependencies = [ + "clvmr", + "num-bigint", + "thiserror 2.0.21", +] + +[[package]] +name = "clvm-traits" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40d6cf5eb03c53fd6464e97f152057791d46515bd51da003bad30bf86fd4150" +dependencies = [ + "chia-bls 0.53.0", + "chia-secp", + "clvm-derive", + "clvmr", + "num-bigint", + "thiserror 2.0.21", +] + +[[package]] +name = "clvm-utils" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4c9faab28996a13f72da5470f953bf95f8b9d0b228cdee155db55254c4ce80" +dependencies = [ + "chia-sha2 0.53.0", + "clvm-traits 0.53.0", + "clvmr", + "hex", + "hex-literal 0.4.1", +] + +[[package]] +name = "clvmr" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13f0990d3f7f4e88d8f8a49a0082ecdd42de4f2e5206d571c02fac9519c649" +dependencies = [ + "bitflags", + "bitvec", + "bumpalo", + "chia-bls 0.38.2", + "chia-sha2 0.38.2", + "hex", + "hex-literal 0.4.1", + "k256", + "lazy_static", + "malachite-bigint", + "num-bigint", + "num-integer", + "num-traits", + "p256", + "rand 0.9.5", + "sha1 0.10.7", + "sha3 0.10.9", + "thiserror 2.0.21", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914c195c4d7fbc5ed7f88d14cbf85914d5c664967658d43404b263913a477e82" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "do-notation" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e16a80c1dda2cf52fa07106427d3d798b6331dca8155fcb8c39f7fc78f6dd2" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "libz-sys", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8" +dependencies = [ + "cpubits", + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.11.1", + "signature", + "wnaf", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libz-sys" +version = "1.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69965d2409bff8c8cea3829009fff66a86f05a0570656552ef70f616e8e4bd85" +dependencies = [ + "cc", + "cmake", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "malachite-base" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f44099731f17094b07825c88ccb5fbd1bfa1f82fafff7daa33e8b8652db16e" +dependencies = [ + "hashbrown 0.16.1", + "itertools 0.14.0", + "libm", + "ryu", +] + +[[package]] +name = "malachite-bigint" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc58206ba15e9c406e20c95c5f86efa07b12f94080945908e910b3a0faa23fef" +dependencies = [ + "malachite-base", + "malachite-nz", + "num-integer", + "num-traits", + "paste", +] + +[[package]] +name = "malachite-nz" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a137660cdba20f136c8a223125f08088adb4e0b72fbb8466f08c43e31cc0427d" +dependencies = [ + "itertools 0.14.0", + "libm", + "malachite-base", + "wide", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.1", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6250dd7321adbe93d12b750aee0ced159fd13e77f625edcdf1cb6f83cd99f650" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.9", +] + +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.19+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", + "serde", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash 1.1.0", + "text-size", +] + +[[package]] +name = "rsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e77fd77e0dea28b3011fd62d15445b7de7959333b0276d930e03be0f755699" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-common 0.2.2", + "crypto-primes", + "digest 0.11.3", + "hmac 0.13.0", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.1", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "rue-ast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc09c3acb6e22a6cd4c9753fd9731c3ac34e1c702a8b7e33e2e4353b596a3c5" +dependencies = [ + "paste", + "rue-parser", +] + +[[package]] +name = "rue-clvm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba2ef740266638545f8e01f8cdf339918be036914db4126b6728dc2e803dff4" +dependencies = [ + "clvmr", + "hex", + "num-bigint", + "thiserror 2.0.21", +] + +[[package]] +name = "rue-compiler" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be0f52d8339e47a585cb4628b2c7f831a3a7604e320bda9092e33da17020d1e" +dependencies = [ + "clvmr", + "hex", + "id-arena", + "include_dir", + "indexmap", + "log", + "num-bigint", + "num-traits", + "rowan", + "rue-ast", + "rue-diagnostic", + "rue-hir", + "rue-lexer", + "rue-lir", + "rue-options", + "rue-parser", + "rue-types", + "thiserror 2.0.21", +] + +[[package]] +name = "rue-diagnostic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3ab3d2df2516ce737c04c5368dbcf4a35b89c134f14409ff68a89cc1ab61bc" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "rue-hir" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f0fe68f25d6c8da44940493135336561dad25af386a67bc9d2c66a9bd0cfcf" +dependencies = [ + "derive_more", + "hex", + "id-arena", + "indexmap", + "log", + "num-bigint", + "rue-diagnostic", + "rue-lir", + "rue-options", + "rue-types", +] + +[[package]] +name = "rue-lexer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec04ea96023172cc18bd65ce90ce6e10d4cd36e6c9d77c2feddecf605540cdf6" + +[[package]] +name = "rue-lir" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772d75acdfe6e0d4b878c677bf9bb38b80d509196432523905d273badfdffab3" +dependencies = [ + "clvm-traits 0.51.0", + "clvmr", + "colored", + "id-arena", + "num-bigint", + "num-integer", + "rue-clvm", + "sha2 0.10.9", + "sha3 0.10.9", + "thiserror 2.0.21", +] + +[[package]] +name = "rue-options" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e374576b0229324f2ca59eaa229d2de7d2987b707720e2ccabd6e47e38df52b" +dependencies = [ + "serde", + "thiserror 2.0.21", + "toml", +] + +[[package]] +name = "rue-parser" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc34761757362a6eee943331c2994e9373f297f90640e9e231572a3d811437ba" +dependencies = [ + "derive_more", + "indexmap", + "num-derive", + "num-traits", + "rowan", + "rue-diagnostic", + "rue-lexer", +] + +[[package]] +name = "rue-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2cdca4a1383eb86233371dd828ee46860cbe0d736b36b2499046bfb20b6296" +dependencies = [ + "clvmr", + "derive_more", + "hex", + "id-arena", + "indexmap", + "log", + "rstest", + "rue-diagnostic", + "thiserror 2.0.21", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdk-examples" +version = "0.0.0" +dependencies = [ + "anyhow", + "chia-sdk-test", + "chia-wallet-sdk", + "hex", + "hex-literal 1.1.0", + "indexmap", + "serde", + "tokio", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_arrays" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a16b99c5ea4fe3daccd14853ad260ec00ea043b2708d1fd1da3106dcd8d9df" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", + "sponge-cursor", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.19+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" +dependencies = [ + "indexmap", + "toml_datetime 1.1.3+spec-1.1.0", + "toml_parser", + "winnow 1.0.5", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.5", +] + +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "rustls", + "rustls-pki-types", + "sha1 0.11.0", + "thiserror 2.0.21", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "serde", + "serde_json", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yaml-rust2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sdk-examples/Cargo.toml b/sdk-examples/Cargo.toml new file mode 100644 index 0000000..db9a240 --- /dev/null +++ b/sdk-examples/Cargo.toml @@ -0,0 +1,37 @@ +# Compiles and runs the Rust examples in docs/sdk. Run it with `pnpm check:sdk-examples`, which +# extracts the examples into src/docs before running `cargo test`. +# +# `rev` is the Wallet SDK commit the docs are written against. Update it (and Cargo.lock) whenever the +# docs are updated for a newer SDK. + +[package] +name = "sdk-examples" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +chia-wallet-sdk = { git = "https://github.com/xch-dev/chia-wallet-sdk", rev = "ad7630cbae5bc2d3c1481eb2fc797249048a16a8", features = [ + "rustls", + "chip-0008", + "chip-0021", + "chip-0035", + "chip-0037", + "chip-0052", + "action-layer", + "auction", + "offer-compression", + "peer-simulator", +] } +# The HTTP full node simulator isn't exposed by a chia-wallet-sdk feature. +chia-sdk-test = { git = "https://github.com/xch-dev/chia-wallet-sdk", rev = "ad7630cbae5bc2d3c1481eb2fc797249048a16a8", features = [ + "full-node-simulator-http", +] } +anyhow = "1" +hex = "0.4" +hex-literal = "1" +indexmap = "2" +serde = "1" +tokio = { version = "1", features = ["full"] } + +[workspace] diff --git a/sdk-examples/src/lib.rs b/sdk-examples/src/lib.rs new file mode 100644 index 0000000..625ee47 --- /dev/null +++ b/sdk-examples/src/lib.rs @@ -0,0 +1,3 @@ +// The modules in `docs` are generated from docs/sdk by scripts/check-sdk-examples.py. +#![allow(dead_code, unused_imports, unused_variables, unused_mut, clippy::all)] +mod docs; diff --git a/tsconfig.json b/tsconfig.json index 920d7a6..51836d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,5 @@ "compilerOptions": { "baseUrl": "." }, - "exclude": [".docusaurus", "build"] + "exclude": [".docusaurus", "build", "sdk-examples"] }