chip35-dl-coin 0.11.0

Isolated CHIP-0035 Chia DataLayer store coin driver (offline spend-bundle construction).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
//! CHIP-0035 DataLayer store spend builders.
//!
//! Copied from DataLayer-Driver/src/wallet.rs (399-484, 696-929) and
//! src/lib.rs (136-148). The only edits vs. the source: the error type is
//! `crate::Error` (aliased as `WalletError`), and the two serialization
//! helpers map their errors explicitly into `Error::Parse`.

use chia_bls::PublicKey;
use chia_protocol::{Bytes, Bytes32, Coin, CoinSpend, SpendBundle};
use chia_puzzle_types::standard::StandardArgs;
use chia_puzzles::SINGLETON_LAUNCHER_HASH;
use chia_sdk_driver::{
    get_merkle_tree, Datastore as DataStore, DatastoreMetadata as DataStoreMetadata,
    DelegatedPuzzle, DriverError, Launcher, Layer, OracleLayer, SpendContext, SpendWithConditions,
    StandardLayer, WriterLayer,
};
use chia_sdk_types::{
    announcement_id,
    conditions::{CreateCoin, MeltSingleton, UpdateDatastoreMerkleRoot},
    Condition, Conditions,
};
use clvm_traits::ToClvm;
use hex_literal::hex;

use crate::error::{Error, WalletError};
use crate::types::SuccessResponse;

/* echo -n 'datastore' | sha256sum */
pub const DATASTORE_LAUNCHER_HINT: Bytes32 = Bytes32::new(hex!(
    "aa7e5b234e1d55967bf0a316395a2eab6cb3370332c0f251f0e44a5afb84fc68"
));

/// Domain tag for the digstore-scoped owner DISCOVERY hint. Scoping the hint to digstores
/// (rather than hinting the raw owner puzzle hash) means a coinset
/// `get_coin_records_by_hint(digstore_owner_hint(owner_ph))` query returns ONLY the owner's
/// DataLayer store launcher coins — never their ordinary XCH coins, which are hinted with
/// the raw puzzle hash. Versioned so the derivation can evolve without ambiguity.
///
/// This enables OWNER DISCOVERY BY CAPSULE LINEAGE. A capsule is one immutable store
/// generation = the pair `(storeId, rootHash)` (written `storeId:rootHash`); a store is a
/// sequence of capsules, one per commit. Each launcher coin returned by the hint query is a
/// store's genesis launcher = its FIRST capsule `(storeId, rootHash_0)`, from which the
/// singleton lineage (the store's full sequence of capsules) is followed forward.
///
/// CONTRACT: these bytes (`b"dig:datastore:owner:v1"`) are byte-identical across
/// chip35_dl_coin and digstore — a mutual byte-identical contract. A mismatched hint silently
/// causes enumeration misses (owned stores never surface in the query).
pub const DIGSTORE_OWNER_HINT_DOMAIN: &[u8] = b"dig:datastore:owner:v1";

/// Derive the digstore-scoped owner discovery hint = `sha256(DOMAIN || owner_puzzle_hash)`.
/// It is emitted as the FIRST (indexed) memo on the launch CREATE_COIN so the store is
/// discoverable on-chain by owner. The client (JS) and the `digstore` CLI MUST compute this
/// identically (same domain tag, same byte order) or detection misses stores.
///
/// This is what ENABLES OWNER DISCOVERY BY CAPSULE LINEAGE: querying coinset by this hint
/// yields each owned store's genesis launcher coin = the store's FIRST capsule
/// `(storeId, rootHash_0)` (a capsule = one immutable store generation, the pair
/// `(storeId, rootHash)`). From that genesis the singleton lineage — the store's full sequence
/// of capsules, one per commit — is followed forward.
///
/// This is the SAME derivation `digstore` uses: the domain [`DIGSTORE_OWNER_HINT_DOMAIN`]
/// (`b"dig:datastore:owner:v1"`) is byte-identical across chip35_dl_coin and digstore (a mutual
/// byte-identical contract). A mismatched hint causes enumeration misses.
pub fn digstore_owner_hint(owner_puzzle_hash: Bytes32) -> Bytes32 {
    let mut h = chia_sha2::Sha256::new();
    h.update(DIGSTORE_OWNER_HINT_DOMAIN);
    h.update(owner_puzzle_hash);
    Bytes32::new(h.finalize())
}

// ---------------------------------------------------------------------------
// Delegated-puzzle constructors (the on-chain primitive for hub Teams #43 and
// revocable deploy tokens #17).
//
// A DataStore singleton carries a list of `DelegatedPuzzle`s beside its owner.
// Each grants a delegate one of three roles:
//   - Admin  — update the store AND change delegation (add/remove admins/writers).
//   - Writer — create new generations (advance the root = deploy) but NOT change
//              delegation. A revocable deploy token (#17) IS a writer delegate.
//   - Oracle — anyone may spend for a fixed fee.
//
// The owner grants/revokes these by replacing the delegated-puzzle set via
// `update_store_ownership` (the Teams add-member / deploy-token issue+revoke op).
// These three builders mirror DataLayer-Driver's shapes byte-for-byte so a store
// minted here is interchangeable with one minted by DataLayer-Driver.
// ---------------------------------------------------------------------------

/// Build the **Admin** delegated puzzle for a synthetic key (hub Teams #43 — a team admin).
///
/// An admin delegate may update the store's metadata/root AND change the delegated-puzzle set
/// (add/remove admins and writers — i.e. revoke a deploy token), but cannot transfer ownership
/// outright. The puzzle is curried only with the standard puzzle of `synthetic_key`
/// (`StandardArgs::curry_tree_hash`), so the same key authorizes whatever role the owner granted it.
///
/// Mirrors DataLayer-Driver's `admin_delegated_puzzle_from_key`.
pub fn admin_delegated_puzzle_from_key(synthetic_key: &PublicKey) -> DelegatedPuzzle {
    DelegatedPuzzle::Admin(StandardArgs::curry_tree_hash(*synthetic_key))
}

/// Build the **Writer** delegated puzzle for a synthetic key (a revocable deploy token #17 / a
/// hub Teams writer #43).
///
/// A writer delegate may create new store generations — advance the root, i.e. DEPLOY a new
/// capsule — but may NOT change the delegated-puzzle set or transfer ownership. This is exactly the
/// least-privilege credential a CI deploy bot or a team member needs: it can deploy but can never
/// grant itself more authority. A **deploy token is a writer delegate**; the owner revokes it by
/// replacing the delegated-puzzle set (see [`update_store_ownership`]). The on-chain DIG spend-cap
/// that would further bound a deploy token is the only non-native extra and is future work.
///
/// Mirrors DataLayer-Driver's `writer_delegated_puzzle_from_key`.
pub fn writer_delegated_puzzle_from_key(synthetic_key: &PublicKey) -> DelegatedPuzzle {
    DelegatedPuzzle::Writer(StandardArgs::curry_tree_hash(*synthetic_key))
}

/// Build the **Oracle** delegated puzzle: anyone may spend the store for the fixed `oracle_fee`,
/// paid to `oracle_puzzle_hash`. Unlike admin/writer it is keyed by a payment puzzle hash, not a
/// public key — there is no signer; the fee is the gate.
///
/// Mirrors DataLayer-Driver's `oracle_delegated_puzzle`.
pub fn oracle_delegated_puzzle(oracle_puzzle_hash: Bytes32, oracle_fee: u64) -> DelegatedPuzzle {
    DelegatedPuzzle::Oracle(oracle_puzzle_hash, oracle_fee)
}

/// Build the spend bundle that LAUNCHES a new DataLayer store singleton.
///
/// Spends the minter's `selected_coins` (the first is the lead coin; the rest assert concurrent
/// spend with it), mints the launcher coin via [`Launcher::mint_datastore`], and returns the
/// resulting [`DataStore`] state for chaining into the next update. The launcher CREATE_COIN
/// carries two memos: the digstore-scoped [`digstore_owner_hint`] (first, for owner discovery)
/// then the global [`DATASTORE_LAUNCHER_HINT`]. Any coin value above `fee + 1` mojo is returned
/// to the minter as change, hinted to their own puzzle hash.
///
/// `program_hash` is the optional size-proof field of the metadata. `delegated_puzzles` grants
/// admin/writer/oracle delegated authority on the store.
///
/// # Errors
/// [`WalletError::Parse`] if `selected_coins` is empty; [`WalletError::Driver`] for any
/// underlying spend-construction failure.
#[allow(clippy::too_many_arguments)]
pub fn mint_store(
    minter_synthetic_key: PublicKey,
    selected_coins: Vec<Coin>,
    root_hash: Bytes32,
    label: Option<String>,
    description: Option<String>,
    bytes: Option<u64>,
    program_hash: Option<String>,
    owner_puzzle_hash: Bytes32,
    delegated_puzzles: Vec<DelegatedPuzzle>,
    fee: u64,
) -> Result<SuccessResponse, WalletError> {
    if selected_coins.is_empty() {
        return Err(WalletError::Parse("selected_coins is empty".to_string()));
    }

    let minter_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(minter_synthetic_key).into();
    let total_amount_from_coins = selected_coins.iter().map(|c| c.amount).sum::<u64>();

    let total_amount = fee + 1;

    let mut ctx = SpendContext::new();

    let p2 = StandardLayer::new(minter_synthetic_key);

    let lead_coin = selected_coins[0];
    let lead_coin_name = lead_coin.coin_id();

    for coin in selected_coins.into_iter().skip(1) {
        p2.spend(
            &mut ctx,
            coin,
            Conditions::new().assert_concurrent_spend(lead_coin_name),
        )?;
    }

    let (launch_singleton, datastore) = Launcher::new(lead_coin_name, 1).mint_datastore(
        &mut ctx,
        DataStoreMetadata {
            root_hash,
            label,
            description,
            bytes,
            size_proof: program_hash,
        },
        owner_puzzle_hash.into(),
        delegated_puzzles,
    )?;

    let launch_singleton = Conditions::new().extend(
        launch_singleton
            .into_iter()
            .map(|cond| {
                if let Condition::CreateCoin(cc) = cond {
                    if cc.puzzle_hash == SINGLETON_LAUNCHER_HASH.into() {
                        // First (indexed) memo = the digstore-scoped owner discovery hint, so
                        // the launcher coin (coin_id == launcher_id == store_id) is found by
                        // get_coin_records_by_hint(digstore_owner_hint(owner_ph)). The global
                        // DATASTORE_LAUNCHER_HINT is kept as a second memo for compatibility.
                        let hint = ctx.memos(&[
                            digstore_owner_hint(owner_puzzle_hash),
                            DATASTORE_LAUNCHER_HINT,
                        ])?;

                        return Ok(Condition::CreateCoin(CreateCoin {
                            puzzle_hash: cc.puzzle_hash,
                            amount: cc.amount,
                            memos: hint,
                        }));
                    }

                    return Ok(Condition::CreateCoin(cc));
                }

                Ok(cond)
            })
            .collect::<Result<Vec<_>, WalletError>>()?,
    );

    let lead_coin_conditions = if total_amount_from_coins > total_amount {
        let hint = ctx.hint(minter_puzzle_hash)?;

        launch_singleton.create_coin(
            minter_puzzle_hash,
            total_amount_from_coins - total_amount,
            hint,
        )
    } else {
        launch_singleton
    };
    p2.spend(&mut ctx, lead_coin, lead_coin_conditions)?;

    Ok(SuccessResponse {
        coin_spends: ctx.take(),
        new_datastore: datastore,
    })
}

/// Reconstruct a DataStore from the coin spend that CREATED its current coin. For an eve
/// store (no updates) this is the launcher coin's spend; for an updated store it is the
/// latest update spend. Lets a client MELT a store it did not mint in-session (it fetches
/// the creating spend from a full node, then rebuilds the DataStore here). `prev_delegated`
/// is the parent's delegated-puzzle set ([] for an eve/owner-only store). Returns the
/// reconstructed DataStore, or a Parse error if the spend is not a DataStore spend.
pub fn datastore_from_spend(
    parent_spend: CoinSpend,
    prev_delegated: Vec<DelegatedPuzzle>,
) -> Result<DataStore, WalletError> {
    let ctx = &mut SpendContext::new();
    DataStore::<DataStoreMetadata>::from_spend(ctx, &parent_spend, &prev_delegated)?
        .ok_or_else(|| WalletError::Parse("coin spend is not a DataStore spend".to_string()))
}

/// Which delegated role authorizes a store update, carrying that role's public key.
///
/// The role determines what the spend may do: an [`Owner`](Self::Owner) can change metadata
/// and ownership; an [`Admin`](Self::Admin) can update metadata and the delegated-puzzle set; a
/// [`Writer`](Self::Writer) can only update metadata. There is intentionally no `Oracle` variant
/// — oracle spends pay a fee but cannot change metadata or owners.
#[derive(Clone, Debug)]
pub enum DataStoreInnerSpend {
    /// Spend authorized by the store owner (full authority).
    Owner(PublicKey),
    /// Spend authorized by an admin delegated puzzle.
    Admin(PublicKey),
    /// Spend authorized by a writer delegated puzzle (metadata only).
    Writer(PublicKey),
}

fn update_store_with_conditions(
    ctx: &mut SpendContext,
    conditions: Conditions,
    datastore: DataStore,
    inner_spend_info: DataStoreInnerSpend,
    allow_admin: bool,
    allow_writer: bool,
) -> Result<SuccessResponse, WalletError> {
    let inner_datastore_spend = match inner_spend_info {
        DataStoreInnerSpend::Owner(pk) => {
            StandardLayer::new(pk).spend_with_conditions(ctx, conditions)?
        }
        DataStoreInnerSpend::Admin(pk) => {
            if !allow_admin {
                return Err(WalletError::Permission);
            }

            StandardLayer::new(pk).spend_with_conditions(ctx, conditions)?
        }
        DataStoreInnerSpend::Writer(pk) => {
            if !allow_writer {
                return Err(WalletError::Permission);
            }

            WriterLayer::new(StandardLayer::new(pk)).spend(ctx, conditions)?
        }
    };

    let parent_delegated_puzzles = datastore.info.delegated_puzzles.clone();
    let new_spend = datastore.spend(ctx, inner_datastore_spend)?;

    let new_datastore =
        DataStore::<DataStoreMetadata>::from_spend(ctx, &new_spend, &parent_delegated_puzzles)?
            .ok_or(WalletError::Parse("Store from spend is None".to_string()))?;

    Ok(SuccessResponse {
        coin_spends: vec![new_spend],
        new_datastore,
    })
}

/// Build the spend that transfers store ownership and/or replaces its delegated-puzzle set.
///
/// An [`Owner`](DataStoreInnerSpend::Owner) spend re-creates the singleton under
/// `new_owner_puzzle_hash` with `new_delegated_puzzles`. An [`Admin`](DataStoreInnerSpend::Admin)
/// spend cannot move ownership directly, so it instead emits an `UpdateDatastoreMerkleRoot`
/// condition over the new delegated-puzzle set (recreation memos pin the owner). Returns the new
/// [`DataStore`] state.
///
/// # Errors
/// [`WalletError::Permission`] if authorized by a [`Writer`](DataStoreInnerSpend::Writer)
/// (writers cannot change ownership); [`WalletError::Driver`]/[`WalletError::Parse`] on spend
/// construction or re-parse failure.
pub fn update_store_ownership(
    datastore: DataStore,
    new_owner_puzzle_hash: Bytes32,
    new_delegated_puzzles: Vec<DelegatedPuzzle>,
    inner_spend_info: DataStoreInnerSpend,
) -> Result<SuccessResponse, WalletError> {
    let ctx = &mut SpendContext::new();

    let update_condition: Condition = match inner_spend_info {
        DataStoreInnerSpend::Owner(_) => {
            DataStore::<DataStoreMetadata>::owner_create_coin_condition(
                ctx,
                datastore.info.launcher_id,
                new_owner_puzzle_hash,
                new_delegated_puzzles,
                true,
            )?
        }
        DataStoreInnerSpend::Admin(_) => {
            let merkle_tree = get_merkle_tree(ctx, new_delegated_puzzles.clone())?;

            let new_merkle_root_condition = UpdateDatastoreMerkleRoot {
                new_merkle_root: merkle_tree.root(),
                memos: DataStore::<DataStoreMetadata>::get_recreation_memos(
                    datastore.info.launcher_id,
                    new_owner_puzzle_hash.into(),
                    new_delegated_puzzles,
                ),
            }
            .to_clvm(&mut **ctx)
            .map_err(DriverError::ToClvm)?;

            Condition::Other(new_merkle_root_condition)
        }
        _ => return Err(WalletError::Permission),
    };

    let update_conditions = Conditions::new().with(update_condition);

    update_store_with_conditions(
        ctx,
        update_conditions,
        datastore,
        inner_spend_info,
        true,
        false,
    )
}

/// Build the spend that updates a store's metadata (root hash, label, description, size, and
/// optional size-proof `program_hash`).
///
/// Emits a `new_metadata` condition with the supplied fields. An
/// [`Owner`](DataStoreInnerSpend::Owner) spend additionally re-creates the singleton coin under
/// the existing owner and delegated puzzles (so the owner keeps control); admin/writer spends
/// rely on the store's own recreation. Returns the new [`DataStore`] state.
///
/// # Errors
/// [`WalletError::Permission`] if the role is not allowed; [`WalletError::Driver`]/
/// [`WalletError::Parse`] on spend construction or re-parse failure.
pub fn update_store_metadata(
    datastore: DataStore,
    new_root_hash: Bytes32,
    new_label: Option<String>,
    new_description: Option<String>,
    new_bytes: Option<u64>,
    new_program_hash: Option<String>,
    inner_spend_info: DataStoreInnerSpend,
) -> Result<SuccessResponse, WalletError> {
    let ctx = &mut SpendContext::new();

    let new_metadata = DataStoreMetadata {
        root_hash: new_root_hash,
        label: new_label,
        description: new_description,
        bytes: new_bytes,
        size_proof: new_program_hash,
    };
    let mut new_metadata_condition = Conditions::new().with(
        DataStore::<DataStoreMetadata>::new_metadata_condition(ctx, new_metadata)?,
    );

    if let DataStoreInnerSpend::Owner(_) = inner_spend_info {
        new_metadata_condition = new_metadata_condition.with(
            DataStore::<DataStoreMetadata>::owner_create_coin_condition(
                ctx,
                datastore.info.launcher_id,
                datastore.info.owner_puzzle_hash,
                datastore.info.delegated_puzzles.clone(),
                false,
            )?,
        );
    }

    update_store_with_conditions(
        ctx,
        new_metadata_condition,
        datastore,
        inner_spend_info,
        true,
        true,
    )
}

/// Build the spend that BURNS (melts) a store singleton, removing it from the chain.
///
/// Owner-authorized only: the inner spend reserves a 1-mojo fee and emits the `MeltSingleton`
/// condition. Returns the single melt coin spend (no resulting [`DataStore`], since the store
/// ceases to exist).
///
/// # Errors
/// [`WalletError::Driver`] on spend-construction failure.
pub fn melt_store(
    datastore: DataStore,
    owner_pk: PublicKey,
) -> Result<Vec<CoinSpend>, WalletError> {
    let ctx = &mut SpendContext::new();

    let melt_conditions = Conditions::new()
        .with(Condition::reserve_fee(1))
        .with(Condition::Other(
            MeltSingleton {}
                .to_clvm(&mut **ctx)
                .map_err(DriverError::ToClvm)?,
        ));

    let inner_datastore_spend =
        StandardLayer::new(owner_pk).spend_with_conditions(ctx, melt_conditions)?;

    let new_spend = datastore.spend(ctx, inner_datastore_spend)?;

    Ok(vec![new_spend])
}

/// Build the spend that exercises a store's ORACLE delegated puzzle.
///
/// Anyone may oracle-spend a store that carries an [`Oracle`](DelegatedPuzzle::Oracle) delegated
/// puzzle: the spender pays `oracle_fee` (defined by that puzzle) plus the network `fee` from
/// their `selected_coins`, asserts the store's oracle puzzle announcement, and re-creates the
/// singleton unchanged. Change above the total is returned to the spender. Returns the new
/// [`DataStore`] state.
///
/// # Errors
/// [`WalletError::Parse`] if `selected_coins` is empty; [`WalletError::Permission`] if the store
/// has no oracle delegated puzzle; [`WalletError::Driver`] on spend-construction failure
/// (including an odd oracle fee).
pub fn oracle_spend(
    spender_synthetic_key: PublicKey,
    selected_coins: Vec<Coin>,
    datastore: DataStore,
    fee: u64,
) -> Result<SuccessResponse, WalletError> {
    if selected_coins.is_empty() {
        return Err(WalletError::Parse("selected_coins is empty".to_string()));
    }

    let Some(DelegatedPuzzle::Oracle(oracle_ph, oracle_fee)) = datastore
        .info
        .delegated_puzzles
        .iter()
        .find(|dp| matches!(dp, DelegatedPuzzle::Oracle(_, _)))
    else {
        return Err(WalletError::Permission);
    };

    let spender_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(spender_synthetic_key).into();

    let total_amount = oracle_fee + fee;

    let ctx = &mut SpendContext::new();

    let p2 = StandardLayer::new(spender_synthetic_key);

    let lead_coin = selected_coins[0];
    let lead_coin_name = lead_coin.coin_id();

    let total_amount_from_coins = selected_coins.iter().map(|c| c.amount).sum::<u64>();
    for coin in selected_coins.into_iter().skip(1) {
        p2.spend(
            ctx,
            coin,
            Conditions::new().assert_concurrent_spend(lead_coin_name),
        )?;
    }

    let assert_oracle_conds = Conditions::new().assert_puzzle_announcement(announcement_id(
        datastore.coin.puzzle_hash,
        Bytes::new("$".into()),
    ));

    let mut lead_coin_conditions = assert_oracle_conds;
    if total_amount_from_coins > total_amount {
        let hint = ctx.hint(spender_puzzle_hash)?;

        lead_coin_conditions = lead_coin_conditions.create_coin(
            spender_puzzle_hash,
            total_amount_from_coins - total_amount,
            hint,
        );
    }
    if fee > 0 {
        lead_coin_conditions = lead_coin_conditions.reserve_fee(fee);
    }
    p2.spend(ctx, lead_coin, lead_coin_conditions)?;

    let inner_datastore_spend = OracleLayer::new(*oracle_ph, *oracle_fee)
        .ok_or(DriverError::OddOracleFee)?
        .construct_spend(ctx, ())?;

    let parent_delegated_puzzles = datastore.info.delegated_puzzles.clone();
    let new_spend = datastore.spend(ctx, inner_datastore_spend)?;

    let new_datastore = DataStore::from_spend(ctx, &new_spend, &parent_delegated_puzzles)?
        .ok_or(WalletError::Parse("Store from spend is None".to_string()))?;
    ctx.insert(new_spend.clone());

    Ok(SuccessResponse {
        coin_spends: ctx.take(),
        new_datastore,
    })
}

/// Build coin spends that reserve `fee` mojos from the spender's own XCH coins,
/// asserting concurrent spend of `coin_ids` (e.g. the singleton being updated/melted),
/// returning change to the spender. Lets a singleton-only op (update/melt) carry a fee.
pub fn add_fee(
    spender_synthetic_key: PublicKey,
    selected_coins: Vec<Coin>,
    coin_ids: Vec<Bytes32>,
    fee: u64,
) -> Result<Vec<CoinSpend>, WalletError> {
    if selected_coins.is_empty() {
        return Err(WalletError::Parse("selected_coins is empty".to_string()));
    }
    let spender_puzzle_hash: Bytes32 = StandardArgs::curry_tree_hash(spender_synthetic_key).into();
    let total_amount_from_coins = selected_coins.iter().map(|c| c.amount).sum::<u64>();

    let mut ctx = SpendContext::new();
    let p2 = StandardLayer::new(spender_synthetic_key);

    let lead_coin = selected_coins[0];
    let lead_coin_name = lead_coin.coin_id();

    for coin in selected_coins.into_iter().skip(1) {
        p2.spend(
            &mut ctx,
            coin,
            Conditions::new().assert_concurrent_spend(lead_coin_name),
        )?;
    }

    let mut lead_coin_conditions = Conditions::new().reserve_fee(fee);
    if total_amount_from_coins > fee {
        let hint = ctx.hint(spender_puzzle_hash)?;
        lead_coin_conditions = lead_coin_conditions.create_coin(
            spender_puzzle_hash,
            total_amount_from_coins - fee,
            hint,
        );
    }
    for coin_id in coin_ids {
        lead_coin_conditions = lead_coin_conditions.assert_concurrent_spend(coin_id);
    }

    p2.spend(&mut ctx, lead_coin, lead_coin_conditions)?;

    Ok(ctx.take())
}

/// Decode a hex-encoded spend bundle into its coin spends (keyless).
pub fn hex_spend_bundle_to_coin_spends(hex_str: &str) -> Result<Vec<CoinSpend>, Error> {
    use chia_traits::Streamable;
    let bytes = hex::decode(hex_str).map_err(|e| Error::Parse(format!("hex decode: {e}")))?;
    let spend_bundle =
        SpendBundle::from_bytes(&bytes).map_err(|e| Error::Parse(format!("bundle parse: {e}")))?;
    Ok(spend_bundle.coin_spends)
}

/// Serialize a spend bundle to hex (keyless).
pub fn spend_bundle_to_hex(spend_bundle: &SpendBundle) -> Result<String, Error> {
    use chia_traits::Streamable;
    let bytes = spend_bundle
        .to_bytes()
        .map_err(|e| Error::Parse(format!("bundle serialize: {e}")))?;
    Ok(hex::encode(bytes))
}