Skip to main content

chip35_dl_coin/
store.rs

1//! CHIP-0035 DataLayer store spend builders.
2//!
3//! Copied from DataLayer-Driver/src/wallet.rs (399-484, 696-929) and
4//! src/lib.rs (136-148). The only edits vs. the source: the error type is
5//! `crate::Error` (aliased as `WalletError`), and the two serialization
6//! helpers map their errors explicitly into `Error::Parse`.
7
8use chia_bls::PublicKey;
9use chia_protocol::{Bytes, Bytes32, Coin, CoinSpend, SpendBundle};
10use chia_puzzle_types::standard::StandardArgs;
11use chia_puzzles::SINGLETON_LAUNCHER_HASH;
12use chia_sdk_driver::{
13    get_merkle_tree, Datastore as DataStore, DatastoreMetadata as DataStoreMetadata,
14    DelegatedPuzzle, DriverError, Launcher, Layer, OracleLayer, SpendContext, SpendWithConditions,
15    StandardLayer, WriterLayer,
16};
17use chia_sdk_types::{
18    announcement_id,
19    conditions::{CreateCoin, MeltSingleton, UpdateDatastoreMerkleRoot},
20    Condition, Conditions,
21};
22use clvm_traits::ToClvm;
23use hex_literal::hex;
24
25use crate::error::{Error, WalletError};
26use crate::types::SuccessResponse;
27
28/* echo -n 'datastore' | sha256sum */
29pub const DATASTORE_LAUNCHER_HINT: Bytes32 = Bytes32::new(hex!(
30    "aa7e5b234e1d55967bf0a316395a2eab6cb3370332c0f251f0e44a5afb84fc68"
31));
32
33/// Domain tag for the digstore-scoped owner DISCOVERY hint. Scoping the hint to digstores
34/// (rather than hinting the raw owner puzzle hash) means a coinset
35/// `get_coin_records_by_hint(digstore_owner_hint(owner_ph))` query returns ONLY the owner's
36/// DataLayer store launcher coins — never their ordinary XCH coins, which are hinted with
37/// the raw puzzle hash. Versioned so the derivation can evolve without ambiguity.
38///
39/// This enables OWNER DISCOVERY BY CAPSULE LINEAGE. A capsule is one immutable store
40/// generation = the pair `(storeId, rootHash)` (written `storeId:rootHash`); a store is a
41/// sequence of capsules, one per commit. Each launcher coin returned by the hint query is a
42/// store's genesis launcher = its FIRST capsule `(storeId, rootHash_0)`, from which the
43/// singleton lineage (the store's full sequence of capsules) is followed forward.
44///
45/// CONTRACT: these bytes (`b"dig:datastore:owner:v1"`) are byte-identical across
46/// chip35_dl_coin and digstore — a mutual byte-identical contract. A mismatched hint silently
47/// causes enumeration misses (owned stores never surface in the query).
48pub const DIGSTORE_OWNER_HINT_DOMAIN: &[u8] = b"dig:datastore:owner:v1";
49
50/// Derive the digstore-scoped owner discovery hint = `sha256(DOMAIN || owner_puzzle_hash)`.
51/// It is emitted as the FIRST (indexed) memo on the launch CREATE_COIN so the store is
52/// discoverable on-chain by owner. The client (JS) and the `digstore` CLI MUST compute this
53/// identically (same domain tag, same byte order) or detection misses stores.
54///
55/// This is what ENABLES OWNER DISCOVERY BY CAPSULE LINEAGE: querying coinset by this hint
56/// yields each owned store's genesis launcher coin = the store's FIRST capsule
57/// `(storeId, rootHash_0)` (a capsule = one immutable store generation, the pair
58/// `(storeId, rootHash)`). From that genesis the singleton lineage — the store's full sequence
59/// of capsules, one per commit — is followed forward.
60///
61/// This is the SAME derivation `digstore` uses: the domain [`DIGSTORE_OWNER_HINT_DOMAIN`]
62/// (`b"dig:datastore:owner:v1"`) is byte-identical across chip35_dl_coin and digstore (a mutual
63/// byte-identical contract). A mismatched hint causes enumeration misses.
64pub fn digstore_owner_hint(owner_puzzle_hash: Bytes32) -> Bytes32 {
65    let mut h = chia_sha2::Sha256::new();
66    h.update(DIGSTORE_OWNER_HINT_DOMAIN);
67    h.update(owner_puzzle_hash);
68    Bytes32::new(h.finalize())
69}
70
71// ---------------------------------------------------------------------------
72// Delegated-puzzle constructors (the on-chain primitive for hub Teams #43 and
73// revocable deploy tokens #17).
74//
75// A DataStore singleton carries a list of `DelegatedPuzzle`s beside its owner.
76// Each grants a delegate one of three roles:
77//   - Admin  — update the store AND change delegation (add/remove admins/writers).
78//   - Writer — create new generations (advance the root = deploy) but NOT change
79//              delegation. A revocable deploy token (#17) IS a writer delegate.
80//   - Oracle — anyone may spend for a fixed fee.
81//
82// The owner grants/revokes these by replacing the delegated-puzzle set via
83// `update_store_ownership` (the Teams add-member / deploy-token issue+revoke op).
84// These three builders mirror DataLayer-Driver's shapes byte-for-byte so a store
85// minted here is interchangeable with one minted by DataLayer-Driver.
86// ---------------------------------------------------------------------------
87
88/// Build the **Admin** delegated puzzle for a synthetic key (hub Teams #43 — a team admin).
89///
90/// An admin delegate may update the store's metadata/root AND change the delegated-puzzle set
91/// (add/remove admins and writers — i.e. revoke a deploy token), but cannot transfer ownership
92/// outright. The puzzle is curried only with the standard puzzle of `synthetic_key`
93/// (`StandardArgs::curry_tree_hash`), so the same key authorizes whatever role the owner granted it.
94///
95/// Mirrors DataLayer-Driver's `admin_delegated_puzzle_from_key`.
96pub fn admin_delegated_puzzle_from_key(synthetic_key: &PublicKey) -> DelegatedPuzzle {
97    DelegatedPuzzle::Admin(StandardArgs::curry_tree_hash(*synthetic_key))
98}
99
100/// Build the **Writer** delegated puzzle for a synthetic key (a revocable deploy token #17 / a
101/// hub Teams writer #43).
102///
103/// A writer delegate may create new store generations — advance the root, i.e. DEPLOY a new
104/// capsule — but may NOT change the delegated-puzzle set or transfer ownership. This is exactly the
105/// least-privilege credential a CI deploy bot or a team member needs: it can deploy but can never
106/// grant itself more authority. A **deploy token is a writer delegate**; the owner revokes it by
107/// replacing the delegated-puzzle set (see [`update_store_ownership`]). The on-chain DIG spend-cap
108/// that would further bound a deploy token is the only non-native extra and is future work.
109///
110/// Mirrors DataLayer-Driver's `writer_delegated_puzzle_from_key`.
111pub fn writer_delegated_puzzle_from_key(synthetic_key: &PublicKey) -> DelegatedPuzzle {
112    DelegatedPuzzle::Writer(StandardArgs::curry_tree_hash(*synthetic_key))
113}
114
115/// Build the **Oracle** delegated puzzle: anyone may spend the store for the fixed `oracle_fee`,
116/// paid to `oracle_puzzle_hash`. Unlike admin/writer it is keyed by a payment puzzle hash, not a
117/// public key — there is no signer; the fee is the gate.
118///
119/// Mirrors DataLayer-Driver's `oracle_delegated_puzzle`.
120pub fn oracle_delegated_puzzle(oracle_puzzle_hash: Bytes32, oracle_fee: u64) -> DelegatedPuzzle {
121    DelegatedPuzzle::Oracle(oracle_puzzle_hash, oracle_fee)
122}
123
124/// Build the spend bundle that LAUNCHES a new DataLayer store singleton.
125///
126/// Spends the minter's `selected_coins` (the first is the lead coin; the rest assert concurrent
127/// spend with it), mints the launcher coin via [`Launcher::mint_datastore`], and returns the
128/// resulting [`DataStore`] state for chaining into the next update. The launcher CREATE_COIN
129/// carries two memos: the digstore-scoped [`digstore_owner_hint`] (first, for owner discovery)
130/// then the global [`DATASTORE_LAUNCHER_HINT`]. Any coin value above `fee + 1` mojo is returned
131/// to the minter as change, hinted to their own puzzle hash.
132///
133/// `program_hash` is the optional size-proof field of the metadata. `delegated_puzzles` grants
134/// admin/writer/oracle delegated authority on the store.
135///
136/// # Errors
137/// [`WalletError::Parse`] if `selected_coins` is empty; [`WalletError::Driver`] for any
138/// underlying spend-construction failure.
139#[allow(clippy::too_many_arguments)]
140pub fn mint_store(
141    minter_synthetic_key: PublicKey,
142    selected_coins: Vec<Coin>,
143    root_hash: Bytes32,
144    label: Option<String>,
145    description: Option<String>,
146    bytes: Option<u64>,
147    program_hash: Option<String>,
148    owner_puzzle_hash: Bytes32,
149    delegated_puzzles: Vec<DelegatedPuzzle>,
150    fee: u64,
151) -> Result<SuccessResponse, WalletError> {
152    if selected_coins.is_empty() {
153        return Err(WalletError::Parse("selected_coins is empty".to_string()));
154    }
155
156    let minter_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(minter_synthetic_key).into();
157    let total_amount_from_coins = selected_coins.iter().map(|c| c.amount).sum::<u64>();
158
159    let total_amount = fee + 1;
160
161    let mut ctx = SpendContext::new();
162
163    let p2 = StandardLayer::new(minter_synthetic_key);
164
165    let lead_coin = selected_coins[0];
166    let lead_coin_name = lead_coin.coin_id();
167
168    for coin in selected_coins.into_iter().skip(1) {
169        p2.spend(
170            &mut ctx,
171            coin,
172            Conditions::new().assert_concurrent_spend(lead_coin_name),
173        )?;
174    }
175
176    let (launch_singleton, datastore) = Launcher::new(lead_coin_name, 1).mint_datastore(
177        &mut ctx,
178        DataStoreMetadata {
179            root_hash,
180            label,
181            description,
182            bytes,
183            size_proof: program_hash,
184        },
185        owner_puzzle_hash.into(),
186        delegated_puzzles,
187    )?;
188
189    let launch_singleton = Conditions::new().extend(
190        launch_singleton
191            .into_iter()
192            .map(|cond| {
193                if let Condition::CreateCoin(cc) = cond {
194                    if cc.puzzle_hash == SINGLETON_LAUNCHER_HASH.into() {
195                        // First (indexed) memo = the digstore-scoped owner discovery hint, so
196                        // the launcher coin (coin_id == launcher_id == store_id) is found by
197                        // get_coin_records_by_hint(digstore_owner_hint(owner_ph)). The global
198                        // DATASTORE_LAUNCHER_HINT is kept as a second memo for compatibility.
199                        let hint = ctx.memos(&[
200                            digstore_owner_hint(owner_puzzle_hash),
201                            DATASTORE_LAUNCHER_HINT,
202                        ])?;
203
204                        return Ok(Condition::CreateCoin(CreateCoin {
205                            puzzle_hash: cc.puzzle_hash,
206                            amount: cc.amount,
207                            memos: hint,
208                        }));
209                    }
210
211                    return Ok(Condition::CreateCoin(cc));
212                }
213
214                Ok(cond)
215            })
216            .collect::<Result<Vec<_>, WalletError>>()?,
217    );
218
219    let lead_coin_conditions = if total_amount_from_coins > total_amount {
220        let hint = ctx.hint(minter_puzzle_hash)?;
221
222        launch_singleton.create_coin(
223            minter_puzzle_hash,
224            total_amount_from_coins - total_amount,
225            hint,
226        )
227    } else {
228        launch_singleton
229    };
230    p2.spend(&mut ctx, lead_coin, lead_coin_conditions)?;
231
232    Ok(SuccessResponse {
233        coin_spends: ctx.take(),
234        new_datastore: datastore,
235    })
236}
237
238/// Reconstruct a DataStore from the coin spend that CREATED its current coin. For an eve
239/// store (no updates) this is the launcher coin's spend; for an updated store it is the
240/// latest update spend. Lets a client MELT a store it did not mint in-session (it fetches
241/// the creating spend from a full node, then rebuilds the DataStore here). `prev_delegated`
242/// is the parent's delegated-puzzle set ([] for an eve/owner-only store). Returns the
243/// reconstructed DataStore, or a Parse error if the spend is not a DataStore spend.
244pub fn datastore_from_spend(
245    parent_spend: CoinSpend,
246    prev_delegated: Vec<DelegatedPuzzle>,
247) -> Result<DataStore, WalletError> {
248    let ctx = &mut SpendContext::new();
249    DataStore::<DataStoreMetadata>::from_spend(ctx, &parent_spend, &prev_delegated)?
250        .ok_or_else(|| WalletError::Parse("coin spend is not a DataStore spend".to_string()))
251}
252
253/// Which delegated role authorizes a store update, carrying that role's public key.
254///
255/// The role determines what the spend may do: an [`Owner`](Self::Owner) can change metadata
256/// and ownership; an [`Admin`](Self::Admin) can update metadata and the delegated-puzzle set; a
257/// [`Writer`](Self::Writer) can only update metadata. There is intentionally no `Oracle` variant
258/// — oracle spends pay a fee but cannot change metadata or owners.
259#[derive(Clone, Debug)]
260pub enum DataStoreInnerSpend {
261    /// Spend authorized by the store owner (full authority).
262    Owner(PublicKey),
263    /// Spend authorized by an admin delegated puzzle.
264    Admin(PublicKey),
265    /// Spend authorized by a writer delegated puzzle (metadata only).
266    Writer(PublicKey),
267}
268
269fn update_store_with_conditions(
270    ctx: &mut SpendContext,
271    conditions: Conditions,
272    datastore: DataStore,
273    inner_spend_info: DataStoreInnerSpend,
274    allow_admin: bool,
275    allow_writer: bool,
276) -> Result<SuccessResponse, WalletError> {
277    let inner_datastore_spend = match inner_spend_info {
278        DataStoreInnerSpend::Owner(pk) => {
279            StandardLayer::new(pk).spend_with_conditions(ctx, conditions)?
280        }
281        DataStoreInnerSpend::Admin(pk) => {
282            if !allow_admin {
283                return Err(WalletError::Permission);
284            }
285
286            StandardLayer::new(pk).spend_with_conditions(ctx, conditions)?
287        }
288        DataStoreInnerSpend::Writer(pk) => {
289            if !allow_writer {
290                return Err(WalletError::Permission);
291            }
292
293            WriterLayer::new(StandardLayer::new(pk)).spend(ctx, conditions)?
294        }
295    };
296
297    let parent_delegated_puzzles = datastore.info.delegated_puzzles.clone();
298    let new_spend = datastore.spend(ctx, inner_datastore_spend)?;
299
300    let new_datastore =
301        DataStore::<DataStoreMetadata>::from_spend(ctx, &new_spend, &parent_delegated_puzzles)?
302            .ok_or(WalletError::Parse("Store from spend is None".to_string()))?;
303
304    Ok(SuccessResponse {
305        coin_spends: vec![new_spend],
306        new_datastore,
307    })
308}
309
310/// Build the spend that transfers store ownership and/or replaces its delegated-puzzle set.
311///
312/// An [`Owner`](DataStoreInnerSpend::Owner) spend re-creates the singleton under
313/// `new_owner_puzzle_hash` with `new_delegated_puzzles`. An [`Admin`](DataStoreInnerSpend::Admin)
314/// spend cannot move ownership directly, so it instead emits an `UpdateDatastoreMerkleRoot`
315/// condition over the new delegated-puzzle set (recreation memos pin the owner). Returns the new
316/// [`DataStore`] state.
317///
318/// # Errors
319/// [`WalletError::Permission`] if authorized by a [`Writer`](DataStoreInnerSpend::Writer)
320/// (writers cannot change ownership); [`WalletError::Driver`]/[`WalletError::Parse`] on spend
321/// construction or re-parse failure.
322pub fn update_store_ownership(
323    datastore: DataStore,
324    new_owner_puzzle_hash: Bytes32,
325    new_delegated_puzzles: Vec<DelegatedPuzzle>,
326    inner_spend_info: DataStoreInnerSpend,
327) -> Result<SuccessResponse, WalletError> {
328    let ctx = &mut SpendContext::new();
329
330    let update_condition: Condition = match inner_spend_info {
331        DataStoreInnerSpend::Owner(_) => {
332            DataStore::<DataStoreMetadata>::owner_create_coin_condition(
333                ctx,
334                datastore.info.launcher_id,
335                new_owner_puzzle_hash,
336                new_delegated_puzzles,
337                true,
338            )?
339        }
340        DataStoreInnerSpend::Admin(_) => {
341            let merkle_tree = get_merkle_tree(ctx, new_delegated_puzzles.clone())?;
342
343            let new_merkle_root_condition = UpdateDatastoreMerkleRoot {
344                new_merkle_root: merkle_tree.root(),
345                memos: DataStore::<DataStoreMetadata>::get_recreation_memos(
346                    datastore.info.launcher_id,
347                    new_owner_puzzle_hash.into(),
348                    new_delegated_puzzles,
349                ),
350            }
351            .to_clvm(&mut **ctx)
352            .map_err(DriverError::ToClvm)?;
353
354            Condition::Other(new_merkle_root_condition)
355        }
356        _ => return Err(WalletError::Permission),
357    };
358
359    let update_conditions = Conditions::new().with(update_condition);
360
361    update_store_with_conditions(
362        ctx,
363        update_conditions,
364        datastore,
365        inner_spend_info,
366        true,
367        false,
368    )
369}
370
371/// Build the spend that updates a store's metadata (root hash, label, description, size, and
372/// optional size-proof `program_hash`).
373///
374/// Emits a `new_metadata` condition with the supplied fields. An
375/// [`Owner`](DataStoreInnerSpend::Owner) spend additionally re-creates the singleton coin under
376/// the existing owner and delegated puzzles (so the owner keeps control); admin/writer spends
377/// rely on the store's own recreation. Returns the new [`DataStore`] state.
378///
379/// # Errors
380/// [`WalletError::Permission`] if the role is not allowed; [`WalletError::Driver`]/
381/// [`WalletError::Parse`] on spend construction or re-parse failure.
382pub fn update_store_metadata(
383    datastore: DataStore,
384    new_root_hash: Bytes32,
385    new_label: Option<String>,
386    new_description: Option<String>,
387    new_bytes: Option<u64>,
388    new_program_hash: Option<String>,
389    inner_spend_info: DataStoreInnerSpend,
390) -> Result<SuccessResponse, WalletError> {
391    let ctx = &mut SpendContext::new();
392
393    let new_metadata = DataStoreMetadata {
394        root_hash: new_root_hash,
395        label: new_label,
396        description: new_description,
397        bytes: new_bytes,
398        size_proof: new_program_hash,
399    };
400    let mut new_metadata_condition = Conditions::new().with(
401        DataStore::<DataStoreMetadata>::new_metadata_condition(ctx, new_metadata)?,
402    );
403
404    if let DataStoreInnerSpend::Owner(_) = inner_spend_info {
405        new_metadata_condition = new_metadata_condition.with(
406            DataStore::<DataStoreMetadata>::owner_create_coin_condition(
407                ctx,
408                datastore.info.launcher_id,
409                datastore.info.owner_puzzle_hash,
410                datastore.info.delegated_puzzles.clone(),
411                false,
412            )?,
413        );
414    }
415
416    update_store_with_conditions(
417        ctx,
418        new_metadata_condition,
419        datastore,
420        inner_spend_info,
421        true,
422        true,
423    )
424}
425
426/// Build the spend that BURNS (melts) a store singleton, removing it from the chain.
427///
428/// Owner-authorized only: the inner spend reserves a 1-mojo fee and emits the `MeltSingleton`
429/// condition. Returns the single melt coin spend (no resulting [`DataStore`], since the store
430/// ceases to exist).
431///
432/// # Errors
433/// [`WalletError::Driver`] on spend-construction failure.
434pub fn melt_store(
435    datastore: DataStore,
436    owner_pk: PublicKey,
437) -> Result<Vec<CoinSpend>, WalletError> {
438    let ctx = &mut SpendContext::new();
439
440    let melt_conditions = Conditions::new()
441        .with(Condition::reserve_fee(1))
442        .with(Condition::Other(
443            MeltSingleton {}
444                .to_clvm(&mut **ctx)
445                .map_err(DriverError::ToClvm)?,
446        ));
447
448    let inner_datastore_spend =
449        StandardLayer::new(owner_pk).spend_with_conditions(ctx, melt_conditions)?;
450
451    let new_spend = datastore.spend(ctx, inner_datastore_spend)?;
452
453    Ok(vec![new_spend])
454}
455
456/// Build the spend that exercises a store's ORACLE delegated puzzle.
457///
458/// Anyone may oracle-spend a store that carries an [`Oracle`](DelegatedPuzzle::Oracle) delegated
459/// puzzle: the spender pays `oracle_fee` (defined by that puzzle) plus the network `fee` from
460/// their `selected_coins`, asserts the store's oracle puzzle announcement, and re-creates the
461/// singleton unchanged. Change above the total is returned to the spender. Returns the new
462/// [`DataStore`] state.
463///
464/// # Errors
465/// [`WalletError::Parse`] if `selected_coins` is empty; [`WalletError::Permission`] if the store
466/// has no oracle delegated puzzle; [`WalletError::Driver`] on spend-construction failure
467/// (including an odd oracle fee).
468pub fn oracle_spend(
469    spender_synthetic_key: PublicKey,
470    selected_coins: Vec<Coin>,
471    datastore: DataStore,
472    fee: u64,
473) -> Result<SuccessResponse, WalletError> {
474    if selected_coins.is_empty() {
475        return Err(WalletError::Parse("selected_coins is empty".to_string()));
476    }
477
478    let Some(DelegatedPuzzle::Oracle(oracle_ph, oracle_fee)) = datastore
479        .info
480        .delegated_puzzles
481        .iter()
482        .find(|dp| matches!(dp, DelegatedPuzzle::Oracle(_, _)))
483    else {
484        return Err(WalletError::Permission);
485    };
486
487    let spender_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(spender_synthetic_key).into();
488
489    let total_amount = oracle_fee + fee;
490
491    let ctx = &mut SpendContext::new();
492
493    let p2 = StandardLayer::new(spender_synthetic_key);
494
495    let lead_coin = selected_coins[0];
496    let lead_coin_name = lead_coin.coin_id();
497
498    let total_amount_from_coins = selected_coins.iter().map(|c| c.amount).sum::<u64>();
499    for coin in selected_coins.into_iter().skip(1) {
500        p2.spend(
501            ctx,
502            coin,
503            Conditions::new().assert_concurrent_spend(lead_coin_name),
504        )?;
505    }
506
507    let assert_oracle_conds = Conditions::new().assert_puzzle_announcement(announcement_id(
508        datastore.coin.puzzle_hash,
509        Bytes::new("$".into()),
510    ));
511
512    let mut lead_coin_conditions = assert_oracle_conds;
513    if total_amount_from_coins > total_amount {
514        let hint = ctx.hint(spender_puzzle_hash)?;
515
516        lead_coin_conditions = lead_coin_conditions.create_coin(
517            spender_puzzle_hash,
518            total_amount_from_coins - total_amount,
519            hint,
520        );
521    }
522    if fee > 0 {
523        lead_coin_conditions = lead_coin_conditions.reserve_fee(fee);
524    }
525    p2.spend(ctx, lead_coin, lead_coin_conditions)?;
526
527    let inner_datastore_spend = OracleLayer::new(*oracle_ph, *oracle_fee)
528        .ok_or(DriverError::OddOracleFee)?
529        .construct_spend(ctx, ())?;
530
531    let parent_delegated_puzzles = datastore.info.delegated_puzzles.clone();
532    let new_spend = datastore.spend(ctx, inner_datastore_spend)?;
533
534    let new_datastore = DataStore::from_spend(ctx, &new_spend, &parent_delegated_puzzles)?
535        .ok_or(WalletError::Parse("Store from spend is None".to_string()))?;
536    ctx.insert(new_spend.clone());
537
538    Ok(SuccessResponse {
539        coin_spends: ctx.take(),
540        new_datastore,
541    })
542}
543
544/// Build coin spends that reserve `fee` mojos from the spender's own XCH coins,
545/// asserting concurrent spend of `coin_ids` (e.g. the singleton being updated/melted),
546/// returning change to the spender. Lets a singleton-only op (update/melt) carry a fee.
547pub fn add_fee(
548    spender_synthetic_key: PublicKey,
549    selected_coins: Vec<Coin>,
550    coin_ids: Vec<Bytes32>,
551    fee: u64,
552) -> Result<Vec<CoinSpend>, WalletError> {
553    if selected_coins.is_empty() {
554        return Err(WalletError::Parse("selected_coins is empty".to_string()));
555    }
556    let spender_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(spender_synthetic_key).into();
557    let total_amount_from_coins = selected_coins.iter().map(|c| c.amount).sum::<u64>();
558
559    let mut ctx = SpendContext::new();
560    let p2 = StandardLayer::new(spender_synthetic_key);
561
562    let lead_coin = selected_coins[0];
563    let lead_coin_name = lead_coin.coin_id();
564
565    for coin in selected_coins.into_iter().skip(1) {
566        p2.spend(
567            &mut ctx,
568            coin,
569            Conditions::new().assert_concurrent_spend(lead_coin_name),
570        )?;
571    }
572
573    let mut lead_coin_conditions = Conditions::new().reserve_fee(fee);
574    if total_amount_from_coins > fee {
575        let hint = ctx.hint(spender_puzzle_hash)?;
576        lead_coin_conditions = lead_coin_conditions.create_coin(
577            spender_puzzle_hash,
578            total_amount_from_coins - fee,
579            hint,
580        );
581    }
582    for coin_id in coin_ids {
583        lead_coin_conditions = lead_coin_conditions.assert_concurrent_spend(coin_id);
584    }
585
586    p2.spend(&mut ctx, lead_coin, lead_coin_conditions)?;
587
588    Ok(ctx.take())
589}
590
591/// Decode a hex-encoded spend bundle into its coin spends (keyless).
592pub fn hex_spend_bundle_to_coin_spends(hex_str: &str) -> Result<Vec<CoinSpend>, Error> {
593    use chia_traits::Streamable;
594    let bytes = hex::decode(hex_str).map_err(|e| Error::Parse(format!("hex decode: {e}")))?;
595    let spend_bundle =
596        SpendBundle::from_bytes(&bytes).map_err(|e| Error::Parse(format!("bundle parse: {e}")))?;
597    Ok(spend_bundle.coin_spends)
598}
599
600/// Serialize a spend bundle to hex (keyless).
601pub fn spend_bundle_to_hex(spend_bundle: &SpendBundle) -> Result<String, Error> {
602    use chia_traits::Streamable;
603    let bytes = spend_bundle
604        .to_bytes()
605        .map_err(|e| Error::Parse(format!("bundle serialize: {e}")))?;
606    Ok(hex::encode(bytes))
607}